diff --git a/README.md b/README.md index 4fb833d..4932c1f 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 the floating file list; Space e opens/focuses the docked compact tree | +| Normal, Visual | Space o opens the floating tree in INSERT/filter mode; 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,13 +92,13 @@ Switching from Insert commits its pending undo group and parks that buffer in No ### File picker and explorer -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. +Space o opens a floating **Tree** explorer directly in **INSERT/filter** mode, ready for typing; Space e opens or focuses the docked **Tree**, starting in **NORMAL/navigation** mode when new. 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. Floating Tree invocation enters filtering if not already filtering. 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. -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, and basename-only files. Folder names end in `/` and use cyan text; files use the normal foreground color. Fold-state arrows are omitted. Maximal single-child directory chains share one row, such as `src/client/http/`; a directory containing one file still gets its own row. +Both view preferences default to `"tree"`: `config.file_picker_view` and `config.file_explorer_view` independently accept `"list"` or `"tree"`. Set the floating preference to `"list"` to retain the old flat picker. Ctrl+t toggles the current focused instance without rescanning. List retains searchable full relative paths. Tree shows folders first, two-cell indentation, and basename-only files. Folder names end in `/` and use cyan text; files use the normal foreground color. Fold-state arrows are omitted. 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 explorer **NORMAL** mode for navigation. Press `i` to enter explorer **INSERT** mode and filter; the `i` itself is not added to the query. Typing and Backspace then filter full relative paths by case-insensitive whitespace tokens. These are explorer-local modes, not changes to the file buffer's mode. 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 in explorer INSERT clears the temporary filter and restores explorer NORMAL and its previous selection, retaining explorer focus. Escape from explorer NORMAL returns focus to the buffer (or closes floating search). List always accepts query typing immediately. +Floating Tree opens in explorer **INSERT** mode; a new docked Tree opens in **NORMAL**. Press `i` in NORMAL to filter; the `i` itself is not added to the query. Typing and Backspace filter full relative paths by case-insensitive whitespace tokens. These are explorer-local modes, not changes to the file buffer's mode. 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 in explorer INSERT clears the temporary filter and restores explorer NORMAL and its previous selection, retaining explorer focus. Escape from explorer NORMAL returns focus to the buffer (or closes floating search). List always accepts query typing immediately. | File-view input | Behavior | | --- | --- | @@ -201,7 +201,7 @@ Vamp.configure do |config| config.leader = "space" config.file_explorer_side = "right" config.file_explorer_view = "tree" - config.file_picker_view = "list" + config.file_picker_view = "tree" 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" @@ -220,7 +220,7 @@ 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 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.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. 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. diff --git a/examples/config.rb b/examples/config.rb index 52331c2..b75f528 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -2,7 +2,7 @@ Vamp.configure do |config| config.leader = "space" config.file_explorer_side = "right" config.file_explorer_view = "tree" - config.file_picker_view = "list" + config.file_picker_view = "tree" config.bind "ctrl+j", config.core.down, mode: config.modes.selection config.bind "ctrl+k", config.core.up, mode: config.modes.selection config.bind "h", config.core.left, mode: config.modes.tree diff --git a/src/editor.rs b/src/editor.rs index 8b5c763..91fe0bf 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -771,7 +771,7 @@ impl Editor { project_root: std::env::current_dir()?, file_picker: None, file_explorer_side: DockSide::Right, - file_picker_view: FileView::List, + file_picker_view: FileView::Tree, file_explorer_view: FileView::Tree, count: None, }; @@ -1450,6 +1450,13 @@ impl Editor { } } } + if presentation == FilePresentation::Floating + && view == FileView::Tree + && let Some(picker) = &mut self.file_picker + && !picker.searching + { + picker.start_search(); + } self.palette = None; self.count = None; self.clear_leader(); @@ -3340,6 +3347,50 @@ mod tests { editor } + #[test] + fn floating_default_tree_starts_filtering_then_escape_navigates_before_close() { + let directory = Directory::new(); + fs::create_dir(directory.path("dir")).unwrap(); + fs::write(directory.path("dir/first.txt"), "first").unwrap(); + fs::write(directory.path("dir/second.txt"), "second").unwrap(); + let mut editor = project_editor(&directory); + type_text(&mut editor, " osecond.txt"); + let picker = editor.file_picker().unwrap(); + assert_eq!(picker.view, FileView::Tree); + assert!(picker.focused && picker.searching); + assert_eq!(picker.query, "second.txt"); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new("dir/second.txt") + ); + editor.handle(Input::Escape); + let picker = editor.file_picker().unwrap(); + assert!(picker.focused && !picker.searching); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new("dir") + ); + editor.handle(Input::Char('l')); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + editor.handle(Input::Escape); + assert!(editor.file_picker().is_none()); + assert_eq!(editor.lines(), &[String::new()]); + editor.set_file_picker_view("list").unwrap(); + type_text(&mut editor, " osecond.txt"); + assert_eq!(editor.file_picker().unwrap().view, FileView::List); + editor.handle(Input::Enter); + assert_eq!(editor.lines(), &["second"]); + assert!(editor.file_picker().is_none()); + } + #[test] fn selection_navigation_wraps_without_touching_any_underlying_mode() { let directory = Directory::new(); @@ -3705,6 +3756,7 @@ mod tests { fs::write(directory.path(name), "text").unwrap(); } let mut editor = project_editor(&directory); + editor.set_file_picker_view("list").unwrap(); type_text(&mut editor, " o"); editor.handle(Input::Ctrl('K')); assert_eq!(editor.file_picker().unwrap().selected, 2); @@ -3788,6 +3840,8 @@ mod tests { type_text(&mut editor, " o"); assert_eq!(editor.file_picker().unwrap().query, ""); editor.handle(Input::Escape); + assert!(editor.file_picker().unwrap().focused); + editor.handle(Input::Escape); assert!(editor.file_picker().is_none()); } @@ -3867,6 +3921,7 @@ mod tests { assert!(editor.file_picker().unwrap().error.is_none()); editor.handle(Input::Escape); editor.handle(Input::Escape); + editor.handle(Input::Escape); editor.handle(Input::Char('i')); type_text(&mut editor, "X"); editor.open_file_picker(FilePresentation::Docked); @@ -3970,6 +4025,8 @@ mod tests { editor.open_file_picker(FilePresentation::Floating); editor.handle(Input::Paste("ignored".into())); editor.handle(Input::Escape); + assert!(editor.file_picker().unwrap().focused); + editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Insert); assert!(editor.active.insert_start.is_some()); assert_eq!(editor.context().text, "X"); @@ -4023,8 +4080,6 @@ mod tests { 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(); @@ -4066,6 +4121,9 @@ mod tests { let mut editor = project_editor(&directory); editor.set_file_picker_view("tree").unwrap(); editor.open_file_picker(presentation); + if presentation == FilePresentation::Floating { + editor.handle(Input::Escape); + } let selected = |editor: &Editor| { let picker = editor.file_picker().unwrap(); picker @@ -4179,6 +4237,9 @@ mod tests { let undo = editor.active.undo.len(); editor.set_file_picker_view("tree").unwrap(); editor.open_file_picker(presentation); + if presentation == FilePresentation::Floating { + editor.handle(Input::Escape); + } type_text(&mut editor, "123 b xyz"); editor.handle(Input::Paste("ignored".into())); editor.handle(Input::Backspace); @@ -4392,7 +4453,10 @@ mod tests { let mut editor = project_editor(&directory); editor.set_file_picker_view("tree").unwrap(); editor.open_file_picker(presentation); - type_text(&mut editor, "ineedle.txt"); + if presentation == FilePresentation::Docked { + editor.handle(Input::Char('i')); + } + type_text(&mut editor, "needle.txt"); let picker = editor.file_picker().unwrap(); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, @@ -4407,7 +4471,10 @@ mod tests { editor.open_file_picker(presentation); editor.handle(Input::Ctrl('w')); editor.open_file_picker(presentation); - type_text(&mut editor, "imodule"); + if presentation == FilePresentation::Docked { + editor.handle(Input::Char('i')); + } + type_text(&mut editor, "module"); assert!( editor .file_picker() @@ -4453,7 +4520,10 @@ mod tests { editor.edit_buffer(b.clone(), false).unwrap(); fs::write(&a, b"\xff").unwrap(); editor.open_file_picker(presentation); - type_text(&mut editor, "isame"); + if presentation == FilePresentation::Docked { + editor.handle(Input::Char('i')); + } + type_text(&mut editor, "same"); let picker = editor.file_picker.as_mut().unwrap(); let labels = picker .entries() diff --git a/src/main.rs b/src/main.rs index fb154be..89a59c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,7 @@ 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 list; Space e docked compact tree (right by default) +Files: Space o floating tree INSERT; Space e docked tree NORMAL (right by default) tree NORMAL: hjkl navigate; INSERT: type to filter, Ctrl+hjkl navigate; Enter toggles/opens i filters in tree INSERT; Esc returns NORMAL, then editor; Ctrl+t view, Ctrl+w close, Ctrl+r refresh Normal: i/a insert, I/A insert at first nonblank/end, o/O new line diff --git a/src/ruby.rs b/src/ruby.rs index 1741bb9..0f35156 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -30,7 +30,7 @@ impl Default for Config { bindings: Vec::new(), leader: "space".into(), file_explorer_side: "right".into(), - file_picker_view: "list".into(), + file_picker_view: "tree".into(), file_explorer_view: "tree".into(), } } diff --git a/src/ruby_host.rb b/src/ruby_host.rb index e0d3296..ffe3694 100644 --- a/src/ruby_host.rb +++ b/src/ruby_host.rb @@ -53,7 +53,7 @@ module Vamp @bindings = [] @leader = "space" @file_explorer_side = "right" - @file_picker_view = "list" + @file_picker_view = "tree" @file_explorer_view = "tree" end diff --git a/src/session.rs b/src/session.rs index 297fbc4..75e3a56 100644 --- a/src/session.rs +++ b/src/session.rs @@ -276,9 +276,12 @@ end ); assert_eq!( session.editor().file_picker().unwrap().view, - crate::files::FileView::List + crate::files::FileView::Tree ); assert_eq!(session.editor().file_picker().unwrap().query, ""); + assert!(session.editor().file_picker().unwrap().searching); + session.handle(Input::Escape); + assert!(session.editor().file_picker().unwrap().focused); session.handle(Input::Escape); assert!(session.editor().file_picker().is_none()); session.handle(Input::Char('u')); @@ -318,10 +321,7 @@ end 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('i')); + assert!(session.editor().file_picker().unwrap().searching); session.handle(Input::Char('x')); session.handle(Input::Ctrl('h')); assert_eq!(session.editor().file_picker().unwrap().query, "x"); @@ -365,10 +365,6 @@ end 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(), diff --git a/src/tui.rs b/src/tui.rs index 72836e1..5f78d27 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -833,6 +833,7 @@ mod tests { #[test] fn file_list_window_follows_control_j_k_selection() { let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_file_picker_view("list").unwrap(); editor.handle(Input::Char(' ')); editor.handle(Input::Char('o')); let count = editor.file_picker().unwrap().entries().len(); @@ -864,6 +865,7 @@ mod tests { fn list_footers_show_remapped_and_disabled_selection_keys() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_file_explorer_view("list").unwrap(); + editor.set_file_picker_view("list").unwrap(); for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] { editor.bind("selection", key, None).unwrap(); } @@ -1422,6 +1424,10 @@ mod tests { editor.handle(Input::Char(' ')); editor.handle(Input::Char(key)); assert_eq!(editor.file_picker().unwrap().view, expected); + if expected == FileView::Tree { + assert!(editor.file_picker().unwrap().searching); + editor.handle(Input::Escape); + } for view in [ expected, if expected == FileView::Tree { @@ -1640,6 +1646,8 @@ mod tests { assert_eq!(viewports.offsets[&a_id], before); assert!(screen_text(&terminal).contains("Files")); editor.handle(Input::Escape); + assert!(editor.file_picker().unwrap().focused); + editor.handle(Input::Escape); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap();