feat: retain explorer filters in navigation mode
This commit is contained in:
+311
-13
@@ -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::<Vec<_>>(),
|
||||
undo
|
||||
);
|
||||
assert_eq!(
|
||||
editor.active.redo.iter().map(&snapshot).collect::<Vec<_>>(),
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+313
-79
@@ -81,9 +81,9 @@ pub struct FilePicker {
|
||||
tree_roots: Vec<usize>,
|
||||
rows: Vec<VisibleRow>,
|
||||
expanded: HashSet<usize>,
|
||||
filter_collapsed: HashSet<usize>,
|
||||
saved_selection: [Option<usize>; 2],
|
||||
filtered_selection: [Option<usize>; 2],
|
||||
before_search: Option<usize>,
|
||||
tree_searching: bool,
|
||||
warning: Option<String>,
|
||||
}
|
||||
@@ -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<usize>) {
|
||||
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<usize>, 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<PathBuf> {
|
||||
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]
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+18
-1
@@ -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,
|
||||
|
||||
+241
-112
@@ -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::<Vec<_>>(),
|
||||
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::<Vec<_>>(),
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user