feat: retain explorer filters in navigation mode

This commit is contained in:
Valdemar Lorenzen
2026-10-09 09:28:20 +02:00
parent 86395cf5ce
commit 4fe3accc56
6 changed files with 890 additions and 208 deletions
+241 -112
View File
@@ -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]