diff --git a/README.md b/README.md index 4932c1f..66c5b3a 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ The dock defaults to the right of the editor workspace. Set `config.file_explore 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. -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. +Floating Tree opens in explorer **INSERT** mode; a new docked Tree opens in **NORMAL**. Press `i` in NORMAL to resume editing the existing 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 ancestor context, and query changes initially select a direct match. Escape in INSERT changes only to NORMAL: the filter, visible results, highlighted item, and explorer focus remain. NORMAL displays the active filter and navigates that filtered tree. Escape from NORMAL returns focus to the buffer (or closes floating search). List always accepts query typing immediately. | File-view input | Behavior | | --- | --- | @@ -108,7 +108,8 @@ Floating Tree opens in explorer **INSERT** mode; a new docked Tree opens in **NO | h (NORMAL); Ctrl+h/Left | NORMAL collapses/selects parent; INSERT selects its visible parent without changing the filter | | Enter | Toggle a folder, or open the selected file using existing buffer deduplication | | `i` (explorer NORMAL) | Enter explorer INSERT/filter mode without inserting `i` | -| Esc | INSERT → explorer NORMAL, retaining focus; NORMAL → buffer focus or floating close | +| Esc | INSERT → NORMAL with the filter and selection retained; NORMAL → buffer focus or floating close | +| Ctrl+u | Clear the filter in NORMAL or INSERT without changing mode/focus; preserve the selected path where possible | | Tab | Return to editor focus from a dock | | Ctrl+w | Close either presentation | | Ctrl+r | Rescan while retaining query, surviving expansion, and selection | @@ -117,6 +118,8 @@ Floating Tree opens in explorer **INSERT** mode; a new docked Tree opens in **NO Opening a file from a dock leaves it visible and returns focus to the buffer. Space e refocuses it. Choosing a folder during filtering returns to explorer NORMAL 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. +Clearing returns to the full catalog and reveals the selected file's ancestry in NORMAL when needed. With no current result, it uses the last available selection; removed paths fall back to a visible ancestor, then the first row. Clearing an already empty query is harmless. Filtered folders can be collapsed/expanded in NORMAL without losing the query. Temporary filtered collapse overrides survive mode/view changes and refresh of surviving directories; editing or clearing the query resets them so new matches can be revealed. Choosing a directory during filtering transitions to NORMAL and toggles it while retaining the query. + 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 diff --git a/src/editor.rs b/src/editor.rs index 91fe0bf..a9e1eaa 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1479,7 +1479,7 @@ impl Editor { match input { Input::Ctrl('w') => self.file_picker = None, Input::Escape if picker.view == FileView::Tree && picker.searching => { - picker.cancel_search() + picker.finish_search() } Input::Escape => self.blur_file_picker(), Input::Ctrl('p') => { @@ -1490,6 +1490,7 @@ impl Editor { } Input::Ctrl('r') => picker.refresh(), Input::Ctrl('t') => picker.toggle_view(), + Input::Ctrl('u') => picker.clear_filter(), Input::Backspace if picker.view == FileView::List || picker.searching => { picker.backspace() } @@ -3366,10 +3367,22 @@ mod tests { editor.handle(Input::Escape); let picker = editor.file_picker().unwrap(); assert!(picker.focused && !picker.searching); + assert_eq!(picker.query, "second.txt"); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, - Path::new("dir") + Path::new("dir/second.txt") ); + editor.handle(Input::Char('i')); + assert!(editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "second.txt"); + editor.handle(Input::Escape); + editor.handle(Input::Char('h')); + editor.handle(Input::Char('h')); + let picker = editor.file_picker().unwrap(); + assert_eq!(picker.entries().len(), 1); + assert_eq!(picker.entries().next().unwrap().path, Path::new("dir")); + assert!(!picker.entries().next().unwrap().expanded); + assert_eq!(picker.query, "second.txt"); editor.handle(Input::Char('l')); assert!( editor @@ -3391,6 +3404,271 @@ mod tests { assert!(editor.file_picker().is_none()); } + #[test] + fn filter_escape_resume_and_clear_do_not_edit_underlying_buffers_or_history() { + let directory = Directory::new(); + fs::create_dir_all(directory.path("src/deep")).unwrap(); + fs::write(directory.path("src/deep/needle.txt"), "needle").unwrap(); + fs::write(directory.path("src/other.txt"), "other").unwrap(); + let selected_path = |editor: &Editor| { + let picker = editor.file_picker().unwrap(); + picker + .entries() + .nth(picker.selected) + .unwrap() + .path + .to_owned() + }; + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + for mode in [ + Mode::Normal, + Mode::Insert, + Mode::Command, + Mode::Search, + Mode::Visual, + ] { + let mut editor = project_editor(&directory); + editor + .apply(vec![Effect::Replace("alpha\nbeta".into())]) + .unwrap(); + editor + .apply(vec![Effect::Replace("changed".into())]) + .unwrap(); + editor.act(Action::Undo); + set_carets(&mut editor, &[(0, 1), (1, 2)], 1); + editor.begin_insert(); + editor.active.mode = mode; + editor.command_line = "original prompt".into(); + editor.open_file_picker(presentation); + if !editor.file_picker().unwrap().searching { + editor.handle(Input::Char('i')); + } + editor.count = Some(7); + let snapshot = + |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); + let lines = editor.lines().to_owned(); + let targets = editor.active.targets.clone(); + let primary = editor.active.primary; + let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); + let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); + let insert = editor.active.insert_start.as_ref().map(snapshot); + let buffer = editor.buffer_id(); + let buffers = editor.buffer_count(); + type_text(&mut editor, "needle"); + let expected = PathBuf::from("src/deep/needle.txt"); + assert_eq!(selected_path(&editor), expected); + editor.handle(Input::Escape); + assert!(!editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "needle"); + assert_eq!(selected_path(&editor), expected); + editor.handle(Input::Char('i')); + assert!(editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "needle"); + assert_eq!(selected_path(&editor), expected); + editor.handle(Input::Ctrl('U')); + assert!(editor.file_picker().unwrap().searching); + assert!(editor.file_picker().unwrap().query.is_empty()); + assert_eq!(selected_path(&editor), expected); + type_text(&mut editor, "needle"); + editor.handle(Input::Escape); + editor.handle(Input::Ctrl('u')); + assert!(!editor.file_picker().unwrap().searching); + assert!(editor.file_picker().unwrap().query.is_empty()); + assert_eq!(selected_path(&editor), expected); + editor.handle(Input::Ctrl('u')); + assert_eq!(selected_path(&editor), expected); + editor.handle(Input::Char('i')); + type_text(&mut editor, "missing"); + editor.handle(Input::Ctrl('j')); + editor.handle(Input::Ctrl('k')); + editor.handle(Input::Escape); + assert_eq!(editor.file_picker().unwrap().entries().len(), 0); + editor.handle(Input::Ctrl('u')); + assert_eq!(selected_path(&editor), expected); + assert!(editor.file_picker().unwrap().focused); + assert!(!editor.file_picker().unwrap().searching); + assert_eq!(editor.mode(), mode); + assert_eq!(editor.lines(), lines); + assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.primary, primary); + assert_eq!(editor.command_line, "original prompt"); + assert_eq!(editor.count(), Some(7)); + assert_eq!(editor.buffer_id(), buffer); + assert_eq!(editor.buffer_count(), buffers); + assert_eq!( + editor.active.undo.iter().map(&snapshot).collect::>(), + undo + ); + assert_eq!( + editor.active.redo.iter().map(&snapshot).collect::>(), + redo + ); + assert_eq!(editor.active.insert_start.as_ref().map(snapshot), insert); + editor.handle(Input::Escape); + match presentation { + FilePresentation::Floating => assert!(editor.file_picker().is_none()), + FilePresentation::Docked => { + assert!(!editor.file_picker().unwrap().focused); + editor.open_file_picker(presentation); + assert!(!editor.file_picker().unwrap().searching); + assert_eq!(selected_path(&editor), expected); + } + } + } + } + } + + #[test] + fn filtered_folder_keys_and_directory_accept_work_in_both_presentations() { + let directory = Directory::new(); + fs::create_dir_all(directory.path("src/deep")).unwrap(); + fs::write(directory.path("src/deep/needle.txt"), "needle").unwrap(); + fs::write(directory.path("src/other.txt"), "other").unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + let mut editor = project_editor(&directory); + editor.open_file_picker(presentation); + if !editor.file_picker().unwrap().searching { + editor.handle(Input::Char('i')); + } + type_text(&mut editor, "needle"); + editor.handle(Input::Ctrl('h')); + let picker = editor.file_picker().unwrap(); + assert!(picker.searching); + assert_eq!(picker.query, "needle"); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new("src/deep") + ); + assert!(picker.entries().nth(picker.selected).unwrap().expanded); + editor.handle(Input::Ctrl('l')); + let picker = editor.file_picker().unwrap(); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new("src/deep/needle.txt") + ); + editor.handle(Input::Escape); + editor.handle(Input::Char('h')); + editor.handle(Input::Char('h')); + let picker = editor.file_picker().unwrap(); + assert!(!picker.searching); + assert_eq!(picker.query, "needle"); + assert_eq!(picker.entries().len(), 2); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new("src/deep") + ); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + editor.handle(Input::Char('l')); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .nth(1) + .unwrap() + .expanded + ); + editor.handle(Input::Char('l')); + editor.handle(Input::Char('h')); + editor.handle(Input::Enter); + assert!( + !editor + .file_picker() + .unwrap() + .entries() + .nth(1) + .unwrap() + .expanded + ); + editor.handle(Input::Enter); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .nth(1) + .unwrap() + .expanded + ); + editor.handle(Input::Char('i')); + editor.handle(Input::Enter); + let picker = editor.file_picker().unwrap(); + assert!(!picker.searching && picker.focused); + assert_eq!(picker.query, "needle"); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new("src/deep") + ); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Ctrl('r')); + editor.handle(Input::Ctrl('t')); + editor.handle(Input::Ctrl('t')); + let picker = editor.file_picker().unwrap(); + assert!(!picker.searching && picker.focused); + assert_eq!(picker.query, "needle"); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + if presentation == FilePresentation::Docked { + editor.handle(Input::Escape); + editor.open_file_picker(presentation); + assert!(!editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "needle"); + } + editor.open_file_picker(FilePresentation::Floating); + assert!(editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "needle"); + } + } + + #[test] + fn filter_clear_respects_navigation_bindings_and_does_not_clear_palette_or_idle_dock() { + let directory = Directory::new(); + for name in ["a.txt", "b.txt"] { + fs::write(directory.path(name), "text").unwrap(); + } + for view in [FileView::List, FileView::Tree] { + let mut editor = project_editor(&directory); + editor.open_file_picker(FilePresentation::Docked); + editor.file_picker.as_mut().unwrap().set_view(view); + if view == FileView::Tree { + editor.handle(Input::Char('i')); + } + type_text(&mut editor, "txt"); + editor + .bind("selection", "ctrl+u", Some("core.down".into())) + .unwrap(); + editor.handle(Input::Ctrl('u')); + assert_eq!(editor.file_picker().unwrap().query, "txt"); + assert_eq!(editor.file_picker().unwrap().selected, 1); + editor.bind("selection", "ctrl+u", None).unwrap(); + editor.handle(Input::Ctrl('u')); + assert_eq!(editor.file_picker().unwrap().query, "txt"); + editor.selection_bindings.remove(&Input::Ctrl('u')); + if view == FileView::Tree { + editor.bind("tree", "ctrl+u", None).unwrap(); + editor.handle(Input::Ctrl('u')); + assert_eq!(editor.file_picker().unwrap().query, "txt"); + editor.tree_bindings.remove(&Input::Ctrl('u')); + } + editor.handle(Input::Ctrl('u')); + assert!(editor.file_picker().unwrap().query.is_empty()); + assert_eq!(editor.file_picker().unwrap().selected, 1); + assert_eq!( + editor.file_picker().unwrap().searching, + view == FileView::Tree + ); + type_text(&mut editor, "txt"); + editor.handle(Input::Char('\t')); + editor.handle(Input::Ctrl('u')); + assert_eq!(editor.file_picker().unwrap().query, "txt"); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core"); + editor.handle(Input::Ctrl('u')); + assert_eq!(editor.palette().unwrap().query, "core"); + assert_eq!(editor.file_picker().unwrap().query, "txt"); + } + } + #[test] fn selection_navigation_wraps_without_touching_any_underlying_mode() { let directory = Directory::new(); @@ -4185,7 +4463,12 @@ mod tests { assert_eq!(editor.file_picker().unwrap().query, "hjkl"); editor.handle(Input::Escape); assert!(editor.file_picker().unwrap().focused); - assert_eq!(selected(&editor), Path::new("dir")); + assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); + assert_eq!(editor.file_picker().unwrap().query, "hjkl"); + editor.handle(Input::Ctrl('u')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); + editor.handle(Input::Char('h')); + editor.handle(Input::Char('h')); assert!( !editor .file_picker() @@ -4265,12 +4548,6 @@ mod tests { 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, "ideep/"); assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "deep/"); @@ -4281,12 +4558,19 @@ mod tests { type_text(&mut editor, "👩‍💻"); editor.handle(Input::Backspace); assert_eq!(editor.file_picker().unwrap().query, "deep"); + let before = editor + .file_picker() + .unwrap() + .entries() + .map(|row| row.path.to_owned()) + .collect::>(); + let selected_before = editor.file_picker().unwrap().selected; 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.query, "deep"); + assert_eq!(picker.selected, selected_before); assert_eq!( picker .entries() @@ -4489,10 +4773,24 @@ mod tests { let picker = editor.file_picker().unwrap(); assert_eq!(editor.buffer_id(), id); assert!(!picker.searching); - assert!(picker.query.is_empty()); + assert_eq!(picker.query, "module"); let selected = picker.entries().nth(picker.selected).unwrap(); assert_eq!(selected.path, Path::new("src/module")); - assert!(selected.expanded); + assert!(!selected.expanded); + assert!( + !picker + .entries() + .any(|row| row.path == Path::new("src/module/needle.txt")) + ); + editor.handle(Input::Enter); + let picker = editor.file_picker().unwrap(); + assert_eq!(picker.query, "module"); + assert!(picker.entries().nth(picker.selected).unwrap().expanded); + assert!( + picker + .entries() + .any(|row| row.path == Path::new("src/module/needle.txt")) + ); assert_eq!(editor.buffer_count(), 2); } } diff --git a/src/files.rs b/src/files.rs index 70f387f..a21239f 100644 --- a/src/files.rs +++ b/src/files.rs @@ -81,9 +81,9 @@ pub struct FilePicker { tree_roots: Vec, rows: Vec, expanded: HashSet, + filter_collapsed: HashSet, saved_selection: [Option; 2], filtered_selection: [Option; 2], - before_search: Option, tree_searching: bool, warning: Option, } @@ -211,9 +211,9 @@ impl FilePicker { tree_roots: Vec::new(), rows: Vec::new(), expanded: HashSet::new(), + filter_collapsed: HashSet::new(), saved_selection: [None, None], filtered_selection: [None, None], - before_search: None, tree_searching: false, warning, }; @@ -221,7 +221,7 @@ impl FilePicker { let tree = picker.compact(root, None); picker.tree_roots.push(tree); } - picker.project(None); + picker.project(None, false); picker.remember_selection(false); Ok(picker) } @@ -280,6 +280,9 @@ impl FilePicker { return; }; self.saved_selection[self.view.index()] = Some(node); + if !self.query.is_empty() { + self.filtered_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() { @@ -288,20 +291,6 @@ impl FilePicker { } } - 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 { @@ -313,6 +302,13 @@ impl FilePicker { } else { self.expanded.remove(&index); } + if !self.query.is_empty() { + if expanded { + self.filter_collapsed.remove(&index); + } else { + self.filter_collapsed.insert(index); + } + } node = self.nodes[index].parent; } } @@ -337,15 +333,16 @@ impl FilePicker { self.tree_searching = self.searching; } self.view = view; - let selected = self.filtered_selection[view.index()] - .or(self.saved_selection[view.index()]) - .or(current); + let selected = if self.query.is_empty() { + self.saved_selection[view.index()] + } else { + 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]; - } + self.searching = self.tree_searching; if !self.searching + && self.query.is_empty() && let Some(node) = selected { self.reveal(node); @@ -353,7 +350,7 @@ impl FilePicker { } else { self.searching = false; } - self.project(selected); + self.project(selected, false); } pub fn toggle_view(&mut self) { @@ -364,24 +361,53 @@ impl FilePicker { } pub(crate) fn start_search(&mut self) { - self.before_search = self.selected_node(); + let selected = self.selected_node(); self.searching = true; self.tree_searching = true; - self.project(self.before_search); + self.project(selected, false); self.clear_error(); } - pub(crate) fn cancel_search(&mut self) { - self.query.clear(); + pub(crate) fn finish_search(&mut self) { + let selected = self + .selected_node() + .or(self.filtered_selection[self.view.index()]) + .or(self.saved_selection[self.view.index()]); 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); + if self.query.is_empty() + && let Some(node) = selected + { + self.reveal(node); + } + self.project(selected, false); + self.remember_selection(false); self.clear_error(); } - fn project(&mut self, selected: Option) { + pub(crate) fn clear_filter(&mut self) { + if self.query.is_empty() { + return; + } + let selected = self + .selected_node() + .or(self.filtered_selection[self.view.index()]) + .or(self.saved_selection[self.view.index()]); + self.query.clear(); + self.filter_collapsed.clear(); + self.filtered_selection = [None, None]; + if self.view == FileView::Tree + && !self.searching + && let Some(node) = selected + { + self.reveal(node); + } + self.project(selected, false); + self.remember_selection(false); + self.clear_error(); + } + + fn project(&mut self, selected: Option, select_match: bool) { 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)); @@ -425,8 +451,20 @@ impl FilePicker { continue; } let entry = &self.tree[tree]; + let mut node = Some(entry.node); + let mut collapsed = false; + let mut saved_expansion = false; + while let Some(index) = node { + if self.tree_for_node[index] != tree { + break; + } + collapsed |= self.filter_collapsed.contains(&index); + saved_expansion |= self.expanded.contains(&index); + node = self.nodes[index].parent; + } let expanded = self.nodes[entry.node].is_dir - && (self.is_expanded(tree) + && !collapsed + && (saved_expansion || (filtered && entry.children.iter().any(|&child| included[child]))); self.rows.push(VisibleRow { node: entry.node, @@ -439,17 +477,17 @@ impl FilePicker { } } } - // Filtering selects a real match, never an ancestor present only as context. - // Browsing falls back to the nearest visible ancestor, then the first row. + // Query edits prefer direct matches; navigation keeps context directories. + // Missing selections fall back to a visible ancestor, then the first row. let mut target = selected; self.selected = self .rows .iter() - .position(|row| matches(&self.nodes[row.node])) + .position(|row| !select_match || 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]) + (!select_match || matches(&self.nodes[row.node])) && (row.node == node || (self.view == FileView::Tree && row.tree == Some(self.tree_for_node[node]))) @@ -463,16 +501,24 @@ impl FilePicker { pub(crate) fn refilter(&mut self) { 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; + let selected = self + .selected_node() + .or(self.filtered_selection[view]) + .or(self.saved_selection[view]); + self.filter_collapsed.clear(); + if self.query.is_empty() { + self.filtered_selection = [None, None]; + if self.view == FileView::Tree + && !self.searching + && let Some(node) = selected + { + self.reveal(node); } - self.filtered_selection[view] - }; - self.project(selected); + } else if self.filtered_selection[view].is_none() { + self.filtered_selection[view] = selected; + } + self.project(selected, !self.query.is_empty()); + self.remember_selection(false); self.clear_error(); } @@ -494,12 +540,7 @@ 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.remember_selection(true); self.clear_error(); } @@ -515,7 +556,7 @@ impl FilePicker { { self.selected += 1; } - self.filtered_selection[self.view.index()] = self.selected_node(); + self.remember_selection(false); self.clear_error(); return; } @@ -534,7 +575,7 @@ impl FilePicker { let node = row.node; let tree = row.tree.expect("directory navigation is tree-only"); self.set_expanded(tree, true); - self.project(Some(node)); + self.project(Some(node), false); } self.remember_selection(true); self.clear_error(); @@ -551,7 +592,7 @@ impl FilePicker { self.selected = index; } } - self.filtered_selection[self.view.index()] = self.selected_node(); + self.remember_selection(false); self.clear_error(); return; } @@ -559,7 +600,7 @@ impl FilePicker { let node = row.node; let tree = row.tree.expect("directory navigation is tree-only"); self.set_expanded(tree, false); - self.project(Some(node)); + self.project(Some(node), false); } 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) { @@ -570,23 +611,18 @@ impl FilePicker { self.clear_error(); } - /// Directories toggle in browsing; files retain the authoritative path for the editor. + /// Directories finish query editing and toggle; files retain the authoritative path. 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); + self.finish_search(); } let tree = self.tree_for_node[node]; - let expanded = !self.is_expanded(tree); + let expanded = !self.rows.get(self.selected).is_some_and(|row| row.expanded); self.set_expanded(tree, expanded); - self.project(Some(node)); + self.project(Some(node), false); self.remember_selection(true); None } else { @@ -627,17 +663,32 @@ impl FilePicker { .filter(|&node| replacement.nodes[node].is_dir) }) .collect(); + replacement.filter_collapsed = self + .filter_collapsed + .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.view = self.view; replacement.searching = self.searching; replacement.tree_searching = self.tree_searching; - replacement.project(self.selected_node().and_then(surviving)); + let selected = self + .selected_node() + .or(self.filtered_selection[self.view.index()]) + .or(self.saved_selection[self.view.index()]) + .and_then(surviving); + replacement.project(selected, false); + replacement.remember_selection(false); *self = replacement; } Err(error) => self.error = Some(format!("Refresh failed: {error}")), @@ -927,7 +978,6 @@ mod tests { 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(); @@ -944,11 +994,12 @@ mod tests { 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")); + picker.left(); + let filtered = paths(&picker); + picker.finish_search(); + assert_eq!(picker.query, "NEEDLE"); + assert_eq!(paths(&picker), filtered); + assert_eq!(selected_path(&picker), Path::new("src/module/deep")); assert_eq!(picker.expanded, expanded); picker.start_search(); picker.query = "deep".into(); @@ -956,10 +1007,13 @@ mod tests { select(&mut picker, "src/module/deep"); assert!(picker.accept().is_none()); assert!(!picker.searching); - assert!(picker.query.is_empty()); + assert_eq!(picker.query, "deep"); assert_eq!(selected_path(&picker), Path::new("src/module/deep")); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + assert_eq!(picker.expanded, expanded); + picker.right(); assert!(picker.entries().nth(picker.selected).unwrap().expanded); - assert!(picker.expanded.is_superset(&expanded)); + assert_eq!(picker.query, "deep"); let chain = Directory::new(); chain.put("one/two/three/a.txt", ""); @@ -1056,10 +1110,190 @@ mod tests { 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")); + let filtered = paths(&picker); + picker.finish_search(); + assert!(!picker.searching); + assert_eq!(picker.query, "a.txt"); + assert_eq!(paths(&picker), filtered); + assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt")); assert!(picker.expanded.is_empty()); + picker.toggle_view(); + picker.refresh(); + picker.toggle_view(); + assert!(!picker.searching); + assert_eq!(picker.query, "a.txt"); + assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt")); + picker.start_search(); + assert!(picker.searching); + assert_eq!(picker.query, "a.txt"); + assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt")); + } + + #[test] + fn clearing_filter_preserves_mode_focus_current_path_and_empty_result_fallback() { + let directory = Directory::new(); + directory.put("src/deep/needle.txt", ""); + directory.put("src/other.txt", ""); + directory.put("other/keep.txt", ""); + for (view, searching) in [ + (FileView::Tree, false), + (FileView::Tree, true), + (FileView::List, false), + ] { + let mut picker = tree(&directory); + select(&mut picker, "other"); + picker.right(); + let expanded = picker.expanded.clone(); + picker.set_view(view); + if view == FileView::Tree { + picker.start_search(); + } + picker.query = "needle".into(); + picker.refilter(); + if !searching && view == FileView::Tree { + picker.finish_search(); + } + let expected = Path::new("src/deep/needle.txt"); + assert_eq!(selected_path(&picker), expected); + picker.clear_filter(); + assert!(picker.query.is_empty()); + assert_eq!(picker.searching, searching); + assert!(picker.focused); + assert_eq!(selected_path(&picker), expected); + assert!(picker.expanded.is_superset(&expanded)); + assert!(paths(&picker).contains(&PathBuf::from("other/keep.txt"))); + let rows = paths(&picker); + let expanded = picker.expanded.clone(); + picker.clear_filter(); + assert_eq!(paths(&picker), rows); + assert_eq!(picker.expanded, expanded); + assert_eq!(selected_path(&picker), expected); + assert_eq!(picker.searching, searching); + + picker.query = "absent".into(); + picker.refilter(); + picker.navigate(true); + picker.navigate(false); + assert!(picker.rows.is_empty()); + picker.refresh(); + assert!(picker.rows.is_empty()); + picker.clear_filter(); + assert_eq!(selected_path(&picker), expected); + assert_eq!(picker.searching, searching); + assert!(picker.focused); + } + + let empty = Directory::new(); + let mut picker = tree(&empty); + picker.start_search(); + picker.query = "absent".into(); + picker.refilter(); + picker.finish_search(); + picker.clear_filter(); + assert!(picker.rows.is_empty()); + assert_eq!(picker.selected, 0); + assert!(!picker.searching); + assert!(picker.focused); + } + + #[test] + fn filtered_navigation_toggles_context_folders_and_query_edits_reveal_matches() { + let directory = Directory::new(); + directory.put("src/deep/needle.txt", ""); + directory.put("src/other.txt", ""); + let mut picker = tree(&directory); + picker.start_search(); + picker.query = "needle".into(); + picker.refilter(); + let expanded = picker.expanded.clone(); + picker.left(); + assert_eq!(selected_path(&picker), Path::new("src/deep")); + picker.right(); + assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt")); + assert_eq!(picker.expanded, expanded); + picker.finish_search(); + picker.left(); + picker.left(); + assert_eq!(selected_path(&picker), Path::new("src/deep")); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + assert_eq!(picker.query, "needle"); + assert!(!paths(&picker).contains(&PathBuf::from("src/deep/needle.txt"))); + picker.start_search(); + picker.finish_search(); + assert_eq!(selected_path(&picker), Path::new("src/deep")); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + picker.right(); + assert!(picker.entries().nth(picker.selected).unwrap().expanded); + picker.right(); + assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt")); + picker.left(); + assert!(picker.accept().is_none()); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + assert!(picker.accept().is_none()); + assert!(picker.entries().nth(picker.selected).unwrap().expanded); + picker.start_search(); + assert!(picker.accept().is_none()); + assert!(!picker.searching); + assert_eq!(picker.query, "needle"); + assert_eq!(selected_path(&picker), Path::new("src/deep")); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + picker.start_search(); + picker.query.push_str(".txt"); + picker.refilter(); + assert!(picker.filter_collapsed.is_empty()); + assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt")); + picker.query.clear(); + picker.refilter(); + picker.finish_search(); + assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt")); + } + + #[test] + fn filtered_collapse_survives_refresh_compaction_and_view_switching_by_raw_path() { + let directory = Directory::new(); + directory.put("one/a.txt", ""); + directory.put("one/two/needle.txt", ""); + let mut picker = tree(&directory); + picker.start_search(); + picker.query = "needle".into(); + picker.refilter(); + picker.finish_search(); + picker.left(); + picker.left(); + picker.left(); + picker.left(); + assert_eq!(selected_path(&picker), Path::new("one")); + assert_eq!(paths(&picker), [PathBuf::from("one")]); + picker.toggle_view(); + assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt")); + picker.toggle_view(); + assert_eq!(paths(&picker), [PathBuf::from("one")]); + assert!(!picker.searching); + fs::remove_file(directory.0.join("one/a.txt")).unwrap(); + picker.focused = false; + picker.refresh(); + assert_eq!(selected_path(&picker), Path::new("one/two")); + assert_eq!(paths(&picker), [PathBuf::from("one/two")]); + assert!(!picker.searching && !picker.focused); + assert_eq!(picker.query, "needle"); + directory.put("one/a.txt", ""); + picker.refresh(); + assert_eq!(selected_path(&picker), Path::new("one")); + assert_eq!(paths(&picker), [PathBuf::from("one")]); + picker.right(); + picker.right(); + assert_eq!(selected_path(&picker), Path::new("one/two")); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + picker.right(); + assert!(picker.entries().nth(picker.selected).unwrap().expanded); + picker.right(); + assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt")); + fs::remove_file(directory.0.join("one/two/needle.txt")).unwrap(); + picker.refresh(); + assert!(picker.entries().len() == 0); + picker.clear_filter(); + assert_eq!(selected_path(&picker), Path::new("one/two")); + assert!(!picker.searching && !picker.focused); } #[test] diff --git a/src/main.rs b/src/main.rs index 89a59c0..c7e4369 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,8 @@ 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 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 + i filters; Esc retains filter and returns NORMAL, then editor; Ctrl+u clears in either mode + 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/session.rs b/src/session.rs index 75e3a56..baff1ea 100644 --- a/src/session.rs +++ b/src/session.rs @@ -261,6 +261,22 @@ end session.handle(Input::Escape); assert!(!session.editor().file_picker().unwrap().searching); assert!(session.editor().file_picker().unwrap().focused); + assert_eq!(session.editor().file_picker().unwrap().query, "dir "); + session.handle(Input::Ctrl('u')); + assert_eq!(session.editor().file_picker().unwrap().query, ""); + assert!(!session.editor().file_picker().unwrap().searching); + session.handle(Input::Char('i')); + for ch in "dir ".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Ctrl('u')); + assert_eq!(session.editor().file_picker().unwrap().query, ""); + assert!(session.editor().file_picker().unwrap().searching); + for ch in "dir ".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Escape); + assert_eq!(session.editor().file_picker().unwrap().query, "dir "); session.handle(Input::Escape); assert!(!session.editor().file_picker().unwrap().focused); session.handle(Input::Char('i')); @@ -278,7 +294,7 @@ end session.editor().file_picker().unwrap().view, crate::files::FileView::Tree ); - assert_eq!(session.editor().file_picker().unwrap().query, ""); + assert_eq!(session.editor().file_picker().unwrap().query, "dir "); assert!(session.editor().file_picker().unwrap().searching); session.handle(Input::Escape); assert!(session.editor().file_picker().unwrap().focused); @@ -328,6 +344,7 @@ end session.handle(Input::Escape); assert!(!session.editor().file_picker().unwrap().searching); assert!(session.editor().file_picker().unwrap().focused); + assert_eq!(session.editor().file_picker().unwrap().query, "x"); session.handle(Input::Ctrl('t')); assert_eq!( session.editor().file_picker().unwrap().view, diff --git a/src/tui.rs b/src/tui.rs index 5f78d27..5606970 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -524,8 +524,10 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio Constraint::Length(u16::from(show_help)), ]) .areas(content); - let query = if browsing { - "NORMAL · i filter · ^T view".into() + let query = if browsing && picker.query.is_empty() { + "NORMAL · i filter · ^U clear · ^T view".into() + } else if browsing { + format!("filter: {} · ^U clear", display_text(&picker.query)) } else { format!("> {}", display_text(&picker.query)) }; @@ -630,7 +632,7 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio (FilePresentation::Floating, false) => "Enter · Esc/^W close · ^R · ^T", (FilePresentation::Docked, false) => "Enter · Esc/Tab edit · ^W · ^R · ^T", }; - format!("{navigation_hint} · {controls}") + format!("^U clear · {navigation_hint} · {controls}") } else { "Editor focus · invoke explorer to browse".into() }; @@ -1005,12 +1007,13 @@ mod tests { 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(); + picker.finish_search(); terminal .draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint())) .unwrap(); let text = screen_text(&terminal); - assert!(text.contains("No project files"), "{text}"); + assert!(text.contains("No matching files"), "{text}"); + assert!(text.contains("filter: missing"), "{text}"); assert_eq!( terminal.get_cursor_position().unwrap(), ratatui::layout::Position::new(1, 1) @@ -1220,6 +1223,7 @@ mod tests { } picker.right(); picker.right(); + let long_query = "long界e\u{301}z".repeat(20); 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( @@ -1248,124 +1252,235 @@ mod tests { 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") - ); + for query in ["missing", long_query.as_str()] { + picker.start_search(); + picker.query = query.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.finish_search(); + assert_eq!(picker.query, query); + assert_eq!(picker.entries().len(), 0); + 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 query_x = area.x + u16::from(width >= 4 && height >= 4); + assert_eq!(cursor.x, query_x); + let query_y = area.y + u16::from(width >= 4 && height >= 4); + let query_width = width - 2 * u16::from(width >= 4 && height >= 4); + let header = format!("filter: {query} · ^U clear"); + let mut expected = + ratatui::buffer::Buffer::empty(Rect::new(query_x, query_y, query_width, 1)); + use ratatui::widgets::Widget; + Paragraph::new(visible_line(&header, 0, usize::from(query_width))) + .render(expected.area, &mut expected); + let mut continuation = 0; + for x in query_x..query_x + query_width { + if continuation > 0 { + continuation -= 1; + continue; + } + continuation = expected[(x, query_y)].symbol().width().saturating_sub(1); + assert_eq!( + terminal.backend().buffer()[(x, query_y)].symbol(), + expected[(x, query_y)].symbol() + ); + } + if width == 100 { + assert!(screen_text(&terminal).contains("filter: "), "{query}"); + assert!( + screen_text(&terminal).contains("No matching files"), + "{query}" + ); + } + picker.clear_filter(); + assert!(!picker.searching); + 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() { + fn tree_filter_escape_resume_and_clear_keep_selection_and_cursor_ownership() { + use std::path::Path; 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('i')); - assert!(editor.file_picker().unwrap().searching); - assert_eq!(editor.file_picker().unwrap().query, ""); - assert!( + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_project_root(directory.0.clone()); + editor.set_file_picker_view("tree").unwrap(); 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") - ); - editor.handle(Input::Ctrl('l')); - let picker = editor.file_picker().unwrap(); - assert_eq!( - picker.entries().nth(picker.selected).unwrap().path, - std::path::Path::new("src/inner/needle.txt") - ); - assert_eq!(picker.query, "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("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 + .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(if presentation == FilePresentation::Floating { + 'o' + } else { + 'e' + })); + if !editor.file_picker().unwrap().searching { + editor.handle(Input::Char('i')); + } + let query = "src/inner/needle.txt"; + for ch in query.chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Ctrl('h')); + let picker = editor.file_picker().unwrap(); + assert_eq!(picker.query, query); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new("src/inner") + ); + editor.handle(Input::Ctrl('l')); + let picker = editor.file_picker().unwrap(); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new(query) + ); + let filtered: Vec<_> = 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"]); + .collect(); + 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 query_cursor = terminal.get_cursor_position().unwrap(); + assert!(screen_text(&terminal).contains(&format!("> {query}"))); + assert!(screen_text(&terminal).contains("^U clear")); + editor.handle(Input::Escape); + let picker = editor.file_picker().unwrap(); + assert!(picker.focused && !picker.searching); + assert_eq!(picker.query, query); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new(query) + ); + assert_eq!( + picker + .entries() + .map(|row| (row.path.to_owned(), row.expanded)) + .collect::>(), + filtered + ); + terminal + .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains(&format!("filter: {query}")), "{text}"); + assert!( + text.contains("needle.txt") && !text.contains("other.txt"), + "{text}" + ); + let selected_cursor = terminal.get_cursor_position().unwrap(); + assert_ne!(selected_cursor.y, query_cursor.y); + assert_eq!( + terminal.backend().buffer()[(selected_cursor.x, selected_cursor.y)].symbol(), + ">" + ); + assert_eq!( + terminal.backend().buffer()[(selected_cursor.x, selected_cursor.y)].bg, + Color::Blue + ); + editor.handle(Input::Char('i')); + assert_eq!(editor.file_picker().unwrap().query, query); + terminal + .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), query_cursor); + editor.handle(Input::Escape); + editor.handle(Input::Ctrl('u')); + let picker = editor.file_picker().unwrap(); + assert!(picker.focused && !picker.searching && picker.query.is_empty()); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new(query) + ); + terminal + .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), + ">" + ); + assert!(screen_text(&terminal).contains("other.txt")); + editor.handle(Input::Char('i')); + for ch in query.chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Ctrl('u')); + let picker = editor.file_picker().unwrap(); + assert!(picker.focused && picker.searching && picker.query.is_empty()); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new(query) + ); + terminal + .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert_eq!(cursor.y, query_cursor.y); + assert_eq!(cursor.x, query_cursor.x - query.len() as u16); + editor.handle(Input::Ctrl('t')); + for ch in query.chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Ctrl('u')); + let picker = editor.file_picker().unwrap(); + assert_eq!(picker.view, FileView::List); + assert!(picker.focused && picker.query.is_empty()); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new(query) + ); + terminal + .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), cursor); + editor.handle(Input::Ctrl('t')); + editor.handle(Input::Escape); + editor.handle(Input::Escape); + if presentation == FilePresentation::Floating { + assert!(editor.file_picker().is_none()); + } else { + assert!(!editor.file_picker().unwrap().focused); + } + assert_eq!(editor.mode(), mode); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.lines(), ["unchanged"]); + } + } + + #[test] + fn explorer_retains_insert_filter_through_focus_and_view_switches() { + let directory = Directory::new(); + directory.file("src/inner/needle.txt", ""); + directory.file("src/other.txt", ""); + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_project_root(directory.0.clone()); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); + let (mut top, mut left) = (0, 0); editor.handle(Input::Char(' ')); editor.handle(Input::Char('e')); editor.handle(Input::Char('i')); @@ -1395,6 +1510,20 @@ mod tests { editor.handle(Input::Escape); assert!(editor.file_picker().unwrap().focused); assert!(!editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "n"); + editor.handle(Input::Ctrl('t')); + editor.handle(Input::Ctrl('t')); + 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("filter: n")); + let cursor = terminal.get_cursor_position().unwrap(); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), + ">" + ); } #[test]