feat: retain explorer filters in navigation mode
This commit is contained in:
@@ -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.
|
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 |
|
| 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 |
|
| 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 |
|
| Enter | Toggle a folder, or open the selected file using existing buffer deduplication |
|
||||||
| `i` (explorer NORMAL) | Enter explorer INSERT/filter mode without inserting `i` |
|
| `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 |
|
| Tab | Return to editor focus from a dock |
|
||||||
| Ctrl+w | Close either presentation |
|
| Ctrl+w | Close either presentation |
|
||||||
| Ctrl+r | Rescan while retaining query, surviving expansion, and selection |
|
| 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.
|
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.
|
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
|
### Command palette
|
||||||
|
|||||||
+311
-13
@@ -1479,7 +1479,7 @@ impl Editor {
|
|||||||
match input {
|
match input {
|
||||||
Input::Ctrl('w') => self.file_picker = None,
|
Input::Ctrl('w') => self.file_picker = None,
|
||||||
Input::Escape if picker.view == FileView::Tree && picker.searching => {
|
Input::Escape if picker.view == FileView::Tree && picker.searching => {
|
||||||
picker.cancel_search()
|
picker.finish_search()
|
||||||
}
|
}
|
||||||
Input::Escape => self.blur_file_picker(),
|
Input::Escape => self.blur_file_picker(),
|
||||||
Input::Ctrl('p') => {
|
Input::Ctrl('p') => {
|
||||||
@@ -1490,6 +1490,7 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
Input::Ctrl('r') => picker.refresh(),
|
Input::Ctrl('r') => picker.refresh(),
|
||||||
Input::Ctrl('t') => picker.toggle_view(),
|
Input::Ctrl('t') => picker.toggle_view(),
|
||||||
|
Input::Ctrl('u') => picker.clear_filter(),
|
||||||
Input::Backspace if picker.view == FileView::List || picker.searching => {
|
Input::Backspace if picker.view == FileView::List || picker.searching => {
|
||||||
picker.backspace()
|
picker.backspace()
|
||||||
}
|
}
|
||||||
@@ -3366,10 +3367,22 @@ mod tests {
|
|||||||
editor.handle(Input::Escape);
|
editor.handle(Input::Escape);
|
||||||
let picker = editor.file_picker().unwrap();
|
let picker = editor.file_picker().unwrap();
|
||||||
assert!(picker.focused && !picker.searching);
|
assert!(picker.focused && !picker.searching);
|
||||||
|
assert_eq!(picker.query, "second.txt");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
picker.entries().nth(picker.selected).unwrap().path,
|
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'));
|
editor.handle(Input::Char('l'));
|
||||||
assert!(
|
assert!(
|
||||||
editor
|
editor
|
||||||
@@ -3391,6 +3404,271 @@ mod tests {
|
|||||||
assert!(editor.file_picker().is_none());
|
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]
|
#[test]
|
||||||
fn selection_navigation_wraps_without_touching_any_underlying_mode() {
|
fn selection_navigation_wraps_without_touching_any_underlying_mode() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
@@ -4185,7 +4463,12 @@ mod tests {
|
|||||||
assert_eq!(editor.file_picker().unwrap().query, "hjkl");
|
assert_eq!(editor.file_picker().unwrap().query, "hjkl");
|
||||||
editor.handle(Input::Escape);
|
editor.handle(Input::Escape);
|
||||||
assert!(editor.file_picker().unwrap().focused);
|
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!(
|
assert!(
|
||||||
!editor
|
!editor
|
||||||
.file_picker()
|
.file_picker()
|
||||||
@@ -4265,12 +4548,6 @@ mod tests {
|
|||||||
assert_eq!(editor.file_picker().unwrap().selected, 1);
|
assert_eq!(editor.file_picker().unwrap().selected, 1);
|
||||||
editor.handle(Input::Ctrl('h'));
|
editor.handle(Input::Ctrl('h'));
|
||||||
assert_eq!(editor.file_picker().unwrap().selected, 0);
|
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/");
|
type_text(&mut editor, "ideep/");
|
||||||
assert!(editor.file_picker().unwrap().searching);
|
assert!(editor.file_picker().unwrap().searching);
|
||||||
assert_eq!(editor.file_picker().unwrap().query, "deep/");
|
assert_eq!(editor.file_picker().unwrap().query, "deep/");
|
||||||
@@ -4281,12 +4558,19 @@ mod tests {
|
|||||||
type_text(&mut editor, "👩💻");
|
type_text(&mut editor, "👩💻");
|
||||||
editor.handle(Input::Backspace);
|
editor.handle(Input::Backspace);
|
||||||
assert_eq!(editor.file_picker().unwrap().query, "deep");
|
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);
|
editor.handle(Input::Escape);
|
||||||
let picker = editor.file_picker().unwrap();
|
let picker = editor.file_picker().unwrap();
|
||||||
assert!(picker.focused);
|
assert!(picker.focused);
|
||||||
assert!(!picker.searching);
|
assert!(!picker.searching);
|
||||||
assert!(picker.query.is_empty());
|
assert_eq!(picker.query, "deep");
|
||||||
assert_eq!(picker.selected, 0);
|
assert_eq!(picker.selected, selected_before);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
picker
|
picker
|
||||||
.entries()
|
.entries()
|
||||||
@@ -4489,10 +4773,24 @@ mod tests {
|
|||||||
let picker = editor.file_picker().unwrap();
|
let picker = editor.file_picker().unwrap();
|
||||||
assert_eq!(editor.buffer_id(), id);
|
assert_eq!(editor.buffer_id(), id);
|
||||||
assert!(!picker.searching);
|
assert!(!picker.searching);
|
||||||
assert!(picker.query.is_empty());
|
assert_eq!(picker.query, "module");
|
||||||
let selected = picker.entries().nth(picker.selected).unwrap();
|
let selected = picker.entries().nth(picker.selected).unwrap();
|
||||||
assert_eq!(selected.path, Path::new("src/module"));
|
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);
|
assert_eq!(editor.buffer_count(), 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+311
-77
@@ -81,9 +81,9 @@ pub struct FilePicker {
|
|||||||
tree_roots: Vec<usize>,
|
tree_roots: Vec<usize>,
|
||||||
rows: Vec<VisibleRow>,
|
rows: Vec<VisibleRow>,
|
||||||
expanded: HashSet<usize>,
|
expanded: HashSet<usize>,
|
||||||
|
filter_collapsed: HashSet<usize>,
|
||||||
saved_selection: [Option<usize>; 2],
|
saved_selection: [Option<usize>; 2],
|
||||||
filtered_selection: [Option<usize>; 2],
|
filtered_selection: [Option<usize>; 2],
|
||||||
before_search: Option<usize>,
|
|
||||||
tree_searching: bool,
|
tree_searching: bool,
|
||||||
warning: Option<String>,
|
warning: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -211,9 +211,9 @@ impl FilePicker {
|
|||||||
tree_roots: Vec::new(),
|
tree_roots: Vec::new(),
|
||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
expanded: HashSet::new(),
|
expanded: HashSet::new(),
|
||||||
|
filter_collapsed: HashSet::new(),
|
||||||
saved_selection: [None, None],
|
saved_selection: [None, None],
|
||||||
filtered_selection: [None, None],
|
filtered_selection: [None, None],
|
||||||
before_search: None,
|
|
||||||
tree_searching: false,
|
tree_searching: false,
|
||||||
warning,
|
warning,
|
||||||
};
|
};
|
||||||
@@ -221,7 +221,7 @@ impl FilePicker {
|
|||||||
let tree = picker.compact(root, None);
|
let tree = picker.compact(root, None);
|
||||||
picker.tree_roots.push(tree);
|
picker.tree_roots.push(tree);
|
||||||
}
|
}
|
||||||
picker.project(None);
|
picker.project(None, false);
|
||||||
picker.remember_selection(false);
|
picker.remember_selection(false);
|
||||||
Ok(picker)
|
Ok(picker)
|
||||||
}
|
}
|
||||||
@@ -280,6 +280,9 @@ impl FilePicker {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
self.saved_selection[self.view.index()] = Some(node);
|
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 {
|
if explicit && !self.nodes[node].is_dir {
|
||||||
self.saved_selection = [Some(node), Some(node)];
|
self.saved_selection = [Some(node), Some(node)];
|
||||||
if !self.query.is_empty() {
|
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) {
|
fn set_expanded(&mut self, tree: usize, expanded: bool) {
|
||||||
let mut node = Some(self.tree[tree].node);
|
let mut node = Some(self.tree[tree].node);
|
||||||
while let Some(index) = node {
|
while let Some(index) = node {
|
||||||
@@ -313,6 +302,13 @@ impl FilePicker {
|
|||||||
} else {
|
} else {
|
||||||
self.expanded.remove(&index);
|
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;
|
node = self.nodes[index].parent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,15 +333,16 @@ impl FilePicker {
|
|||||||
self.tree_searching = self.searching;
|
self.tree_searching = self.searching;
|
||||||
}
|
}
|
||||||
self.view = view;
|
self.view = view;
|
||||||
let selected = self.filtered_selection[view.index()]
|
let selected = if self.query.is_empty() {
|
||||||
.or(self.saved_selection[view.index()])
|
self.saved_selection[view.index()]
|
||||||
|
} else {
|
||||||
|
self.filtered_selection[view.index()].or(self.saved_selection[view.index()])
|
||||||
|
}
|
||||||
.or(current);
|
.or(current);
|
||||||
if view == FileView::Tree {
|
if view == FileView::Tree {
|
||||||
self.searching = self.tree_searching || !self.query.is_empty();
|
self.searching = self.tree_searching;
|
||||||
if self.searching && self.before_search.is_none() {
|
|
||||||
self.before_search = self.saved_selection[1];
|
|
||||||
}
|
|
||||||
if !self.searching
|
if !self.searching
|
||||||
|
&& self.query.is_empty()
|
||||||
&& let Some(node) = selected
|
&& let Some(node) = selected
|
||||||
{
|
{
|
||||||
self.reveal(node);
|
self.reveal(node);
|
||||||
@@ -353,7 +350,7 @@ impl FilePicker {
|
|||||||
} else {
|
} else {
|
||||||
self.searching = false;
|
self.searching = false;
|
||||||
}
|
}
|
||||||
self.project(selected);
|
self.project(selected, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_view(&mut self) {
|
pub fn toggle_view(&mut self) {
|
||||||
@@ -364,24 +361,53 @@ impl FilePicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn start_search(&mut self) {
|
pub(crate) fn start_search(&mut self) {
|
||||||
self.before_search = self.selected_node();
|
let selected = self.selected_node();
|
||||||
self.searching = true;
|
self.searching = true;
|
||||||
self.tree_searching = true;
|
self.tree_searching = true;
|
||||||
self.project(self.before_search);
|
self.project(selected, false);
|
||||||
self.clear_error();
|
self.clear_error();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn cancel_search(&mut self) {
|
pub(crate) fn finish_search(&mut self) {
|
||||||
self.query.clear();
|
let selected = self
|
||||||
|
.selected_node()
|
||||||
|
.or(self.filtered_selection[self.view.index()])
|
||||||
|
.or(self.saved_selection[self.view.index()]);
|
||||||
self.searching = false;
|
self.searching = false;
|
||||||
self.tree_searching = false;
|
self.tree_searching = false;
|
||||||
self.filtered_selection = [None, None];
|
if self.query.is_empty()
|
||||||
let selected = self.before_search.take().or(self.saved_selection[1]);
|
&& let Some(node) = selected
|
||||||
self.project(selected);
|
{
|
||||||
|
self.reveal(node);
|
||||||
|
}
|
||||||
|
self.project(selected, false);
|
||||||
|
self.remember_selection(false);
|
||||||
self.clear_error();
|
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 query = self.query.to_lowercase();
|
||||||
let tokens: Vec<_> = query.split_whitespace().collect();
|
let tokens: Vec<_> = query.split_whitespace().collect();
|
||||||
let matches = |node: &Node| tokens.iter().all(|token| node.search_label.contains(token));
|
let matches = |node: &Node| tokens.iter().all(|token| node.search_label.contains(token));
|
||||||
@@ -425,8 +451,20 @@ impl FilePicker {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let entry = &self.tree[tree];
|
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
|
let expanded = self.nodes[entry.node].is_dir
|
||||||
&& (self.is_expanded(tree)
|
&& !collapsed
|
||||||
|
&& (saved_expansion
|
||||||
|| (filtered && entry.children.iter().any(|&child| included[child])));
|
|| (filtered && entry.children.iter().any(|&child| included[child])));
|
||||||
self.rows.push(VisibleRow {
|
self.rows.push(VisibleRow {
|
||||||
node: entry.node,
|
node: entry.node,
|
||||||
@@ -439,17 +477,17 @@ impl FilePicker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Filtering selects a real match, never an ancestor present only as context.
|
// Query edits prefer direct matches; navigation keeps context directories.
|
||||||
// Browsing falls back to the nearest visible ancestor, then the first row.
|
// Missing selections fall back to a visible ancestor, then the first row.
|
||||||
let mut target = selected;
|
let mut target = selected;
|
||||||
self.selected = self
|
self.selected = self
|
||||||
.rows
|
.rows
|
||||||
.iter()
|
.iter()
|
||||||
.position(|row| matches(&self.nodes[row.node]))
|
.position(|row| !select_match || matches(&self.nodes[row.node]))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
while let Some(node) = target {
|
while let Some(node) = target {
|
||||||
if let Some(index) = self.rows.iter().position(|row| {
|
if let Some(index) = self.rows.iter().position(|row| {
|
||||||
matches(&self.nodes[row.node])
|
(!select_match || matches(&self.nodes[row.node]))
|
||||||
&& (row.node == node
|
&& (row.node == node
|
||||||
|| (self.view == FileView::Tree
|
|| (self.view == FileView::Tree
|
||||||
&& row.tree == Some(self.tree_for_node[node])))
|
&& row.tree == Some(self.tree_for_node[node])))
|
||||||
@@ -463,16 +501,24 @@ impl FilePicker {
|
|||||||
|
|
||||||
pub(crate) fn refilter(&mut self) {
|
pub(crate) fn refilter(&mut self) {
|
||||||
let view = self.view.index();
|
let view = self.view.index();
|
||||||
let current = self.selected_node();
|
let selected = self
|
||||||
let selected = if self.query.is_empty() {
|
.selected_node()
|
||||||
self.filtered_selection[view].take().or(current)
|
.or(self.filtered_selection[view])
|
||||||
} else {
|
.or(self.saved_selection[view]);
|
||||||
if self.filtered_selection[view].is_none() {
|
self.filter_collapsed.clear();
|
||||||
self.filtered_selection[view] = current;
|
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]
|
} else if self.filtered_selection[view].is_none() {
|
||||||
};
|
self.filtered_selection[view] = selected;
|
||||||
self.project(selected);
|
}
|
||||||
|
self.project(selected, !self.query.is_empty());
|
||||||
|
self.remember_selection(false);
|
||||||
self.clear_error();
|
self.clear_error();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,12 +540,7 @@ impl FilePicker {
|
|||||||
} else {
|
} else {
|
||||||
self.selected - 1
|
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();
|
self.clear_error();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,7 +556,7 @@ impl FilePicker {
|
|||||||
{
|
{
|
||||||
self.selected += 1;
|
self.selected += 1;
|
||||||
}
|
}
|
||||||
self.filtered_selection[self.view.index()] = self.selected_node();
|
self.remember_selection(false);
|
||||||
self.clear_error();
|
self.clear_error();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -534,7 +575,7 @@ impl FilePicker {
|
|||||||
let node = row.node;
|
let node = row.node;
|
||||||
let tree = row.tree.expect("directory navigation is tree-only");
|
let tree = row.tree.expect("directory navigation is tree-only");
|
||||||
self.set_expanded(tree, true);
|
self.set_expanded(tree, true);
|
||||||
self.project(Some(node));
|
self.project(Some(node), false);
|
||||||
}
|
}
|
||||||
self.remember_selection(true);
|
self.remember_selection(true);
|
||||||
self.clear_error();
|
self.clear_error();
|
||||||
@@ -551,7 +592,7 @@ impl FilePicker {
|
|||||||
self.selected = index;
|
self.selected = index;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.filtered_selection[self.view.index()] = self.selected_node();
|
self.remember_selection(false);
|
||||||
self.clear_error();
|
self.clear_error();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -559,7 +600,7 @@ impl FilePicker {
|
|||||||
let node = row.node;
|
let node = row.node;
|
||||||
let tree = row.tree.expect("directory navigation is tree-only");
|
let tree = row.tree.expect("directory navigation is tree-only");
|
||||||
self.set_expanded(tree, false);
|
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) {
|
} else if let Some(parent) = row.tree.and_then(|tree| self.tree[tree].parent) {
|
||||||
let node = self.tree[parent].node;
|
let node = self.tree[parent].node;
|
||||||
if let Some(index) = self.rows.iter().position(|row| row.node == node) {
|
if let Some(index) = self.rows.iter().position(|row| row.node == node) {
|
||||||
@@ -570,23 +611,18 @@ impl FilePicker {
|
|||||||
self.clear_error();
|
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> {
|
pub(crate) fn accept(&mut self) -> Option<PathBuf> {
|
||||||
self.clear_error();
|
self.clear_error();
|
||||||
let node = self.selected_node()?;
|
let node = self.selected_node()?;
|
||||||
if self.nodes[node].is_dir {
|
if self.nodes[node].is_dir {
|
||||||
if self.searching {
|
if self.searching {
|
||||||
self.query.clear();
|
self.finish_search();
|
||||||
self.searching = false;
|
|
||||||
self.tree_searching = false;
|
|
||||||
self.before_search = None;
|
|
||||||
self.filtered_selection = [None, None];
|
|
||||||
self.reveal(node);
|
|
||||||
}
|
}
|
||||||
let tree = self.tree_for_node[node];
|
let 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.set_expanded(tree, expanded);
|
||||||
self.project(Some(node));
|
self.project(Some(node), false);
|
||||||
self.remember_selection(true);
|
self.remember_selection(true);
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -627,17 +663,32 @@ impl FilePicker {
|
|||||||
.filter(|&node| replacement.nodes[node].is_dir)
|
.filter(|&node| replacement.nodes[node].is_dir)
|
||||||
})
|
})
|
||||||
.collect();
|
.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 =
|
replacement.saved_selection =
|
||||||
self.saved_selection.map(|node| node.and_then(surviving));
|
self.saved_selection.map(|node| node.and_then(surviving));
|
||||||
replacement.filtered_selection =
|
replacement.filtered_selection =
|
||||||
self.filtered_selection.map(|node| node.and_then(surviving));
|
self.filtered_selection.map(|node| node.and_then(surviving));
|
||||||
replacement.before_search = self.before_search.and_then(surviving);
|
|
||||||
replacement.query = self.query.clone();
|
replacement.query = self.query.clone();
|
||||||
replacement.focused = self.focused;
|
replacement.focused = self.focused;
|
||||||
replacement.view = self.view;
|
replacement.view = self.view;
|
||||||
replacement.searching = self.searching;
|
replacement.searching = self.searching;
|
||||||
replacement.tree_searching = self.tree_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;
|
*self = replacement;
|
||||||
}
|
}
|
||||||
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
|
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
|
||||||
@@ -927,7 +978,6 @@ mod tests {
|
|||||||
select(&mut picker, "other");
|
select(&mut picker, "other");
|
||||||
picker.right();
|
picker.right();
|
||||||
select(&mut picker, "src");
|
select(&mut picker, "src");
|
||||||
let browsing = paths(&picker);
|
|
||||||
let expanded = picker.expanded.clone();
|
let expanded = picker.expanded.clone();
|
||||||
picker.start_search();
|
picker.start_search();
|
||||||
picker.query = "NEEDLE".into();
|
picker.query = "NEEDLE".into();
|
||||||
@@ -944,11 +994,12 @@ mod tests {
|
|||||||
Path::new("src/module/deep/needle.txt")
|
Path::new("src/module/deep/needle.txt")
|
||||||
);
|
);
|
||||||
assert_eq!(picker.expanded, expanded);
|
assert_eq!(picker.expanded, expanded);
|
||||||
select(&mut picker, "src/module/deep/needle.txt");
|
picker.left();
|
||||||
picker.navigate(false);
|
let filtered = paths(&picker);
|
||||||
picker.cancel_search();
|
picker.finish_search();
|
||||||
assert_eq!(paths(&picker), browsing);
|
assert_eq!(picker.query, "NEEDLE");
|
||||||
assert_eq!(selected_path(&picker), Path::new("src"));
|
assert_eq!(paths(&picker), filtered);
|
||||||
|
assert_eq!(selected_path(&picker), Path::new("src/module/deep"));
|
||||||
assert_eq!(picker.expanded, expanded);
|
assert_eq!(picker.expanded, expanded);
|
||||||
picker.start_search();
|
picker.start_search();
|
||||||
picker.query = "deep".into();
|
picker.query = "deep".into();
|
||||||
@@ -956,10 +1007,13 @@ mod tests {
|
|||||||
select(&mut picker, "src/module/deep");
|
select(&mut picker, "src/module/deep");
|
||||||
assert!(picker.accept().is_none());
|
assert!(picker.accept().is_none());
|
||||||
assert!(!picker.searching);
|
assert!(!picker.searching);
|
||||||
assert!(picker.query.is_empty());
|
assert_eq!(picker.query, "deep");
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/module/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.entries().nth(picker.selected).unwrap().expanded);
|
||||||
assert!(picker.expanded.is_superset(&expanded));
|
assert_eq!(picker.query, "deep");
|
||||||
|
|
||||||
let chain = Directory::new();
|
let chain = Directory::new();
|
||||||
chain.put("one/two/three/a.txt", "");
|
chain.put("one/two/three/a.txt", "");
|
||||||
@@ -1056,10 +1110,190 @@ mod tests {
|
|||||||
picker.refresh();
|
picker.refresh();
|
||||||
assert!(picker.searching);
|
assert!(picker.searching);
|
||||||
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
||||||
picker.cancel_search();
|
let filtered = paths(&picker);
|
||||||
assert_eq!(paths(&picker), [PathBuf::from("src")]);
|
picker.finish_search();
|
||||||
assert_eq!(selected_path(&picker), Path::new("src"));
|
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());
|
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]
|
#[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
|
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)
|
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
|
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
|
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
|
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
|
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
|
||||||
|
|||||||
+18
-1
@@ -261,6 +261,22 @@ end
|
|||||||
session.handle(Input::Escape);
|
session.handle(Input::Escape);
|
||||||
assert!(!session.editor().file_picker().unwrap().searching);
|
assert!(!session.editor().file_picker().unwrap().searching);
|
||||||
assert!(session.editor().file_picker().unwrap().focused);
|
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);
|
session.handle(Input::Escape);
|
||||||
assert!(!session.editor().file_picker().unwrap().focused);
|
assert!(!session.editor().file_picker().unwrap().focused);
|
||||||
session.handle(Input::Char('i'));
|
session.handle(Input::Char('i'));
|
||||||
@@ -278,7 +294,7 @@ end
|
|||||||
session.editor().file_picker().unwrap().view,
|
session.editor().file_picker().unwrap().view,
|
||||||
crate::files::FileView::Tree
|
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);
|
assert!(session.editor().file_picker().unwrap().searching);
|
||||||
session.handle(Input::Escape);
|
session.handle(Input::Escape);
|
||||||
assert!(session.editor().file_picker().unwrap().focused);
|
assert!(session.editor().file_picker().unwrap().focused);
|
||||||
@@ -328,6 +344,7 @@ end
|
|||||||
session.handle(Input::Escape);
|
session.handle(Input::Escape);
|
||||||
assert!(!session.editor().file_picker().unwrap().searching);
|
assert!(!session.editor().file_picker().unwrap().searching);
|
||||||
assert!(session.editor().file_picker().unwrap().focused);
|
assert!(session.editor().file_picker().unwrap().focused);
|
||||||
|
assert_eq!(session.editor().file_picker().unwrap().query, "x");
|
||||||
session.handle(Input::Ctrl('t'));
|
session.handle(Input::Ctrl('t'));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
session.editor().file_picker().unwrap().view,
|
session.editor().file_picker().unwrap().view,
|
||||||
|
|||||||
+195
-66
@@ -524,8 +524,10 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio
|
|||||||
Constraint::Length(u16::from(show_help)),
|
Constraint::Length(u16::from(show_help)),
|
||||||
])
|
])
|
||||||
.areas(content);
|
.areas(content);
|
||||||
let query = if browsing {
|
let query = if browsing && picker.query.is_empty() {
|
||||||
"NORMAL · i filter · ^T view".into()
|
"NORMAL · i filter · ^U clear · ^T view".into()
|
||||||
|
} else if browsing {
|
||||||
|
format!("filter: {} · ^U clear", display_text(&picker.query))
|
||||||
} else {
|
} else {
|
||||||
format!("> {}", display_text(&picker.query))
|
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::Floating, false) => "Enter · Esc/^W close · ^R · ^T",
|
||||||
(FilePresentation::Docked, false) => "Enter · Esc/Tab edit · ^W · ^R · ^T",
|
(FilePresentation::Docked, false) => "Enter · Esc/Tab edit · ^W · ^R · ^T",
|
||||||
};
|
};
|
||||||
format!("{navigation_hint} · {controls}")
|
format!("^U clear · {navigation_hint} · {controls}")
|
||||||
} else {
|
} else {
|
||||||
"Editor focus · invoke explorer to browse".into()
|
"Editor focus · invoke explorer to browse".into()
|
||||||
};
|
};
|
||||||
@@ -1005,12 +1007,13 @@ mod tests {
|
|||||||
assert!(screen_text(&terminal).contains("Esc/^W"));
|
assert!(screen_text(&terminal).contains("Esc/^W"));
|
||||||
assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::Cyan);
|
assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::Cyan);
|
||||||
picker.set_view(FileView::Tree);
|
picker.set_view(FileView::Tree);
|
||||||
picker.cancel_search();
|
picker.finish_search();
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint()))
|
.draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let text = screen_text(&terminal);
|
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!(
|
assert_eq!(
|
||||||
terminal.get_cursor_position().unwrap(),
|
terminal.get_cursor_position().unwrap(),
|
||||||
ratatui::layout::Position::new(1, 1)
|
ratatui::layout::Position::new(1, 1)
|
||||||
@@ -1220,6 +1223,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
picker.right();
|
picker.right();
|
||||||
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)] {
|
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 area = Rect::new(3, 2, width, height);
|
||||||
let mut terminal = Terminal::with_options(
|
let mut terminal = Terminal::with_options(
|
||||||
@@ -1248,31 +1252,78 @@ mod tests {
|
|||||||
assert!(text.contains("> file.txt"), "{text}");
|
assert!(text.contains("> file.txt"), "{text}");
|
||||||
assert!(!text.contains("dir-00"), "{text}");
|
assert!(!text.contains("dir-00"), "{text}");
|
||||||
}
|
}
|
||||||
|
for query in ["missing", long_query.as_str()] {
|
||||||
picker.start_search();
|
picker.start_search();
|
||||||
picker.query = "missing".into();
|
picker.query = query.into();
|
||||||
picker.refilter();
|
picker.refilter();
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint()))
|
.draw(|frame| {
|
||||||
|
draw_file_list(frame, &picker, area, editor.file_navigation_hint())
|
||||||
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cursor = terminal.get_cursor_position().unwrap();
|
let cursor = terminal.get_cursor_position().unwrap();
|
||||||
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
|
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
|
||||||
assert_eq!(cursor.y, area.y + u16::from(width >= 4 && height >= 4));
|
assert_eq!(cursor.y, area.y + u16::from(width >= 4 && height >= 4));
|
||||||
picker.cancel_search();
|
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!(
|
assert_eq!(
|
||||||
picker.entries().nth(picker.selected).unwrap().path,
|
picker.entries().nth(picker.selected).unwrap().path,
|
||||||
std::path::Path::new("dir-17/file.txt")
|
std::path::Path::new("dir-17/file.txt")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let directory = Directory::new();
|
||||||
directory.file("src/inner/needle.txt", "");
|
directory.file("src/inner/needle.txt", "");
|
||||||
directory.file("src/other.txt", "");
|
directory.file("src/other.txt", "");
|
||||||
directory.file("root.txt", "");
|
directory.file("root.txt", "");
|
||||||
|
for presentation in [FilePresentation::Floating, FilePresentation::Docked] {
|
||||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||||
editor.set_project_root(directory.0.clone());
|
editor.set_project_root(directory.0.clone());
|
||||||
|
editor.set_file_picker_view("tree").unwrap();
|
||||||
editor
|
editor
|
||||||
.apply(vec![crate::editor::Effect::Replace("unchanged".into())])
|
.apply(vec![crate::editor::Effect::Replace("unchanged".into())])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1280,92 +1331,156 @@ mod tests {
|
|||||||
let mode = editor.mode();
|
let mode = editor.mode();
|
||||||
let selections = editor.selections();
|
let selections = editor.selections();
|
||||||
editor.handle(Input::Char(' '));
|
editor.handle(Input::Char(' '));
|
||||||
editor.handle(Input::Char('e'));
|
editor.handle(Input::Char(if presentation == FilePresentation::Floating {
|
||||||
assert_eq!(editor.file_picker().unwrap().view, FileView::Tree);
|
'o'
|
||||||
editor.handle(Input::Char('z'));
|
} else {
|
||||||
assert_eq!(editor.file_picker().unwrap().query, "");
|
'e'
|
||||||
editor.handle(Input::Ctrl('l'));
|
}));
|
||||||
|
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'));
|
editor.handle(Input::Ctrl('l'));
|
||||||
let picker = editor.file_picker().unwrap();
|
let picker = editor.file_picker().unwrap();
|
||||||
let selected = picker
|
assert_eq!(
|
||||||
.entries()
|
picker.entries().nth(picker.selected).unwrap().path,
|
||||||
.nth(picker.selected)
|
Path::new(query)
|
||||||
.unwrap()
|
);
|
||||||
.path
|
let filtered: Vec<_> = picker
|
||||||
.to_owned();
|
|
||||||
let before: Vec<_> = picker
|
|
||||||
.entries()
|
.entries()
|
||||||
.map(|row| (row.path.to_owned(), row.expanded))
|
.map(|row| (row.path.to_owned(), row.expanded))
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(selected, std::path::Path::new("src/inner"));
|
|
||||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
|
||||||
let (mut top, mut left) = (0, 0);
|
let (mut top, mut left) = (0, 0);
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let browse_cursor = terminal.get_cursor_position().unwrap();
|
let query_cursor = terminal.get_cursor_position().unwrap();
|
||||||
editor.handle(Input::Char('i'));
|
assert!(screen_text(&terminal).contains(&format!("> {query}")));
|
||||||
assert!(editor.file_picker().unwrap().searching);
|
assert!(screen_text(&terminal).contains("^U clear"));
|
||||||
assert_eq!(editor.file_picker().unwrap().query, "");
|
|
||||||
assert!(
|
|
||||||
editor
|
|
||||||
.file_picker()
|
|
||||||
.unwrap()
|
|
||||||
.entries()
|
|
||||||
.any(|row| row.path == std::path::Path::new("src/inner/needle.txt"))
|
|
||||||
);
|
|
||||||
editor.handle(Input::Ctrl('t'));
|
|
||||||
assert_eq!(editor.file_picker().unwrap().view, FileView::List);
|
|
||||||
editor.handle(Input::Ctrl('t'));
|
|
||||||
assert_eq!(editor.file_picker().unwrap().view, FileView::Tree);
|
|
||||||
assert!(editor.file_picker().unwrap().searching);
|
|
||||||
for ch in "src/inner/needle.txt".chars() {
|
|
||||||
editor.handle(Input::Char(ch));
|
|
||||||
}
|
|
||||||
editor.handle(Input::Ctrl('h'));
|
|
||||||
assert_eq!(editor.file_picker().unwrap().query, "src/inner/needle.txt");
|
|
||||||
let picker = editor.file_picker().unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
picker.entries().nth(picker.selected).unwrap().path,
|
|
||||||
std::path::Path::new("src/inner")
|
|
||||||
);
|
|
||||||
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);
|
editor.handle(Input::Escape);
|
||||||
let picker = editor.file_picker().unwrap();
|
let picker = editor.file_picker().unwrap();
|
||||||
assert!(picker.focused && !picker.searching && picker.query.is_empty());
|
assert!(picker.focused && !picker.searching);
|
||||||
|
assert_eq!(picker.query, query);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
picker.entries().nth(picker.selected).unwrap().path,
|
picker.entries().nth(picker.selected).unwrap().path,
|
||||||
selected.as_path()
|
Path::new(query)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
picker
|
picker
|
||||||
.entries()
|
.entries()
|
||||||
.map(|row| (row.path.to_owned(), row.expanded))
|
.map(|row| (row.path.to_owned(), row.expanded))
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
before
|
filtered
|
||||||
);
|
);
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(terminal.get_cursor_position().unwrap(), browse_cursor);
|
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::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!(!editor.file_picker().unwrap().focused);
|
||||||
|
}
|
||||||
assert_eq!(editor.mode(), mode);
|
assert_eq!(editor.mode(), mode);
|
||||||
assert_eq!(editor.selections(), selections);
|
assert_eq!(editor.selections(), selections);
|
||||||
assert_eq!(editor.lines(), ["unchanged"]);
|
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(' '));
|
||||||
editor.handle(Input::Char('e'));
|
editor.handle(Input::Char('e'));
|
||||||
editor.handle(Input::Char('i'));
|
editor.handle(Input::Char('i'));
|
||||||
@@ -1395,6 +1510,20 @@ mod tests {
|
|||||||
editor.handle(Input::Escape);
|
editor.handle(Input::Escape);
|
||||||
assert!(editor.file_picker().unwrap().focused);
|
assert!(editor.file_picker().unwrap().focused);
|
||||||
assert!(!editor.file_picker().unwrap().searching);
|
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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user