feat: default explorer to compact directory tree

This commit is contained in:
Valdemar Lorenzen
2026-10-08 15:51:25 +02:00
parent 9ba8b38898
commit 6d7be0c169
9 changed files with 2036 additions and 145 deletions
+543 -38
View File
@@ -1,4 +1,4 @@
use crate::files::{DockSide, FilePicker, FilePresentation};
use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
use serde::Serialize;
use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap, HashSet};
@@ -443,7 +443,6 @@ fn parse_key(key: &str) -> Result<Input, String> {
fn control_key(ch: char) -> Input {
// Terminals encode these controls as named keys.
match ch.to_ascii_lowercase() {
'h' => Input::Backspace,
'i' => Input::Char('\t'),
'm' => Input::Enter,
ch => Input::Ctrl(ch),
@@ -708,6 +707,8 @@ pub struct Editor {
bindings: HashMap<Mode, HashMap<Input, Binding>>,
selection_bindings: HashMap<Input, Binding>,
selection_navigation_hint: String,
tree_bindings: HashMap<Input, Binding>,
tree_navigation_hint: String,
leader: Input,
leader_bindings: HashMap<Mode, HashMap<Vec<Input>, Binding>>,
leader_suffix: Vec<Input>,
@@ -716,6 +717,8 @@ pub struct Editor {
project_root: PathBuf,
file_picker: Option<FilePicker>,
file_explorer_side: DockSide,
file_picker_view: FileView,
file_explorer_view: FileView,
count: Option<usize>,
}
@@ -744,6 +747,16 @@ impl Editor {
.map(|(key, action)| (key, Binding::Action(action)))
.collect(),
selection_navigation_hint: String::new(),
tree_bindings: [
(Input::Left, Action::Left),
(Input::Ctrl('h'), Action::Left),
(Input::Right, Action::Right),
(Input::Ctrl('l'), Action::Right),
]
.into_iter()
.map(|(key, action)| (key, Binding::Action(action)))
.collect(),
tree_navigation_hint: String::new(),
leader: Input::Char(' '),
leader_bindings: HashMap::new(),
leader_suffix: Vec::new(),
@@ -752,6 +765,8 @@ impl Editor {
project_root: std::env::current_dir()?,
file_picker: None,
file_explorer_side: DockSide::Right,
file_picker_view: FileView::List,
file_explorer_view: FileView::Tree,
count: None,
};
editor.refresh_selection_navigation_hint();
@@ -850,6 +865,47 @@ impl Editor {
};
Ok(())
}
pub fn file_picker_view(&self) -> FileView {
self.file_picker_view
}
pub fn file_explorer_view(&self) -> FileView {
self.file_explorer_view
}
pub fn set_file_picker_view(&mut self, view: &str) -> Result<(), String> {
self.set_file_view(view, FilePresentation::Floating)
}
pub fn set_file_explorer_view(&mut self, view: &str) -> Result<(), String> {
self.set_file_view(view, FilePresentation::Docked)
}
fn set_file_view(&mut self, view: &str, presentation: FilePresentation) -> Result<(), String> {
let view = match view.to_ascii_lowercase().as_str() {
"list" => FileView::List,
"tree" => FileView::Tree,
_ => return Err(format!("Invalid file view: {view:?}")),
};
match presentation {
FilePresentation::Floating => self.file_picker_view = view,
FilePresentation::Docked => self.file_explorer_view = view,
}
if let Some(picker) = self
.file_picker
.as_mut()
.filter(|picker| picker.presentation == presentation)
{
picker.set_view(view);
}
Ok(())
}
#[cfg(test)]
pub(crate) fn set_project_root(&mut self, root: PathBuf) {
self.project_root = root;
}
/// Pending decimal prefix for the next Normal/Visual motion.
pub fn count(&self) -> Option<usize> {
self.count
@@ -1027,18 +1083,24 @@ impl Editor {
self.set_message(format!("Plugin {name} disabled: {reason}"));
}
/// Overrides an editing mode's key/leader route, or a single `selection` key
/// for list navigation. `None` disables it; invalid overrides are atomic.
/// Overrides an editing route or single selection/tree navigation key.
/// `None` disables it; invalid overrides are atomic.
pub fn bind(&mut self, mode: &str, key: &str, target: Option<String>) -> Result<(), String> {
if mode == "selection" {
if matches!(mode, "selection" | "tree") {
let input = parse_key(key)?;
let binding = match target.as_deref() {
None => Binding::Disabled,
Some("core.up") => Binding::Action(Action::Up),
Some("core.down") => Binding::Action(Action::Down),
Some(target) => return Err(format!("Unknown selection action: {target}")),
Some("core.left") if mode == "tree" => Binding::Action(Action::Left),
Some("core.right") if mode == "tree" => Binding::Action(Action::Right),
Some(target) => return Err(format!("Unknown {mode} action: {target}")),
};
self.selection_bindings.insert(input, binding);
if mode == "tree" {
self.tree_bindings.insert(input, binding);
} else {
self.selection_bindings.insert(input, binding);
}
self.refresh_selection_navigation_hint();
return Ok(());
}
@@ -1115,11 +1177,25 @@ impl Editor {
&self.selection_navigation_hint
}
pub fn file_navigation_hint(&self) -> &str {
if self
.file_picker
.as_ref()
.is_some_and(|picker| picker.view == FileView::Tree && !picker.searching)
{
&self.tree_navigation_hint
} else {
&self.selection_navigation_hint
}
}
fn refresh_selection_navigation_hint(&mut self) {
let keys = |action| {
let keys = |action, tree: bool| {
let mut keys: Vec<_> = self
.selection_bindings
.iter()
.filter(|(key, _)| !tree || !self.tree_bindings.contains_key(*key))
.chain(self.tree_bindings.iter().filter(|_| tree))
.filter(
|(_, binding)| matches!(binding, Binding::Action(bound) if *bound == action),
)
@@ -1134,9 +1210,36 @@ impl Editor {
};
self.selection_navigation_hint = format!(
"{} next · {} previous",
keys(Action::Down),
keys(Action::Up)
keys(Action::Down, false),
keys(Action::Up, false)
);
self.tree_navigation_hint = format!(
"{} next · {} previous · {} parent/close · {} open/child",
keys(Action::Down, true),
keys(Action::Up, true),
keys(Action::Left, true),
keys(Action::Right, true)
);
}
fn handle_tree_binding(&mut self, input: &Input) -> bool {
let Some(picker) = self
.file_picker
.as_mut()
.filter(|picker| picker.focused && picker.view == FileView::Tree && !picker.searching)
else {
return false;
};
match self.tree_bindings.get(input) {
Some(Binding::Action(Action::Down)) => picker.navigate(true),
Some(Binding::Action(Action::Up)) => picker.navigate(false),
Some(Binding::Action(Action::Left)) => picker.left(),
Some(Binding::Action(Action::Right)) => picker.right(),
Some(Binding::Disabled) => {}
None => return false,
_ => unreachable!("tree bindings only accept navigation or disabled"),
}
true
}
fn handle_selection_binding(&mut self, input: &Input) -> bool {
@@ -1295,13 +1398,20 @@ impl Editor {
}
fn open_file_picker(&mut self, presentation: FilePresentation) {
let view = match presentation {
FilePresentation::Floating => self.file_picker_view,
FilePresentation::Docked => self.file_explorer_view,
};
if let Some(picker) = &mut self.file_picker {
picker.presentation = presentation;
picker.set_view(view);
picker.focused = true;
picker.clear_error();
} else {
match FilePicker::scan(self.project_root.clone(), presentation) {
Ok(picker) => self.file_picker = Some(picker),
match FilePicker::scan(self.project_root.clone(), presentation, view) {
Ok(picker) => {
self.file_picker = Some(picker);
}
Err(error) => {
self.set_message(format!("File scan failed: {error}"));
return;
@@ -1329,6 +1439,9 @@ impl Editor {
.expect("focused picker receives input");
match input {
Input::Ctrl('w') => self.file_picker = None,
Input::Escape if picker.view == FileView::Tree && picker.searching => {
picker.cancel_search()
}
Input::Escape => self.blur_file_picker(),
Input::Ctrl('p') => {
self.act(Action::Palette);
@@ -1337,20 +1450,21 @@ impl Editor {
picker.focused = false;
}
Input::Ctrl('r') => picker.refresh(),
Input::Backspace => picker.backspace(),
Input::Char(ch) if !ch.is_control() => {
Input::Ctrl('t') => picker.toggle_view(),
Input::Backspace if picker.view == FileView::List || picker.searching => {
picker.backspace()
}
Input::Char('/') if picker.view == FileView::Tree && !picker.searching => {
picker.start_search()
}
Input::Char(ch)
if !ch.is_control() && (picker.view == FileView::List || picker.searching) =>
{
picker.query.push(ch);
picker.refilter();
}
Input::Enter => {
picker.clear_error();
let Some(path) = picker
.entries()
.nth(picker.selected)
.map(|entry| picker.root.join(&entry.path))
else {
return;
};
let Some(path) = picker.accept() else { return };
match self.edit_buffer(path, false) {
Ok(()) => self.blur_file_picker(),
Err(error) => {
@@ -1375,6 +1489,9 @@ impl Editor {
.file_picker
.as_ref()
.is_some_and(|picker| picker.focused);
if picker_focused && self.handle_tree_binding(&input) {
return None;
}
if (picker_focused || self.palette.is_some()) && self.handle_selection_binding(&input) {
return None;
}
@@ -3218,6 +3335,11 @@ mod tests {
editor.command_line = "original prompt".into();
if let Some(presentation) = surface {
editor.open_file_picker(presentation);
editor
.file_picker
.as_mut()
.unwrap()
.set_view(FileView::List);
type_text(&mut editor, "txt");
} else {
editor.handle(Input::Ctrl('p'));
@@ -3345,6 +3467,11 @@ mod tests {
editor.bind("normal", "ctrl+k", None).unwrap();
if let Some(presentation) = surface {
editor.open_file_picker(presentation);
editor
.file_picker
.as_mut()
.unwrap()
.set_view(FileView::List);
} else {
editor.handle(Input::Ctrl('p'));
}
@@ -3402,7 +3529,7 @@ mod tests {
assert_eq!(parse_key("CTRL+J").unwrap(), Input::Ctrl('j'));
for (key, input) in [
("ctrl+m", Input::Enter),
("ctrl+h", Input::Backspace),
("ctrl+h", Input::Ctrl('h')),
("ctrl+i", Input::Char('\t')),
] {
assert_eq!(parse_key(key).unwrap(), input);
@@ -3602,7 +3729,7 @@ mod tests {
let directory = Directory::new();
fs::write(directory.path("keep.txt"), "text").unwrap();
let mut editor = project_editor(&directory);
type_text(&mut editor, " ekeep");
type_text(&mut editor, " e/keep");
editor.handle(Input::Char('\t'));
assert!(!editor.file_picker().unwrap().focused);
fs::write(directory.path("keep new.txt"), "new").unwrap();
@@ -3616,7 +3743,7 @@ mod tests {
editor.handle(Input::Ctrl('R'));
assert_eq!(editor.file_picker().unwrap().entries().len(), 2);
assert_eq!(editor.file_picker().unwrap().query, "keep");
editor.handle(Input::Escape);
editor.handle(Input::Char('\t'));
assert!(!editor.file_picker().unwrap().focused);
type_text(&mut editor, " e");
assert!(editor.file_picker().unwrap().focused);
@@ -3647,7 +3774,7 @@ mod tests {
let id = editor.buffer_id();
editor.execute(&format!("edit {}", b.display()));
fs::write(&a, b"\xff").unwrap();
type_text(&mut editor, " ea space 界");
type_text(&mut editor, " e/a space 界");
editor.handle(Input::Enter);
assert_eq!(editor.buffer_id(), id);
assert_eq!(editor.buffer_count(), 3);
@@ -3759,7 +3886,7 @@ mod tests {
type_text(&mut editor, " o");
editor.execute("buffers");
assert!(editor.file_picker().is_none());
type_text(&mut editor, " efile");
type_text(&mut editor, " e/file");
editor.execute("buffers");
assert!(!editor.file_picker().unwrap().focused);
assert_eq!(editor.file_picker().unwrap().query, "file");
@@ -3776,8 +3903,8 @@ mod tests {
editor
.apply(vec![Effect::Replace("a\nb\nc\nd".into())])
.unwrap();
type_text(&mut editor, " efile");
editor.handle(Input::Escape);
type_text(&mut editor, " e/file");
editor.handle(Input::Char('\t'));
type_text(&mut editor, "2j");
assert_eq!(editor.cursor().row, 2);
assert_eq!(editor.file_picker().unwrap().query, "file");
@@ -3848,7 +3975,7 @@ mod tests {
);
let selected = picker
.entries()
.position(|entry| entry.path == *name)
.position(|entry| entry.path == name.as_path())
.unwrap();
picker.selected = selected;
editor.handle(Input::Enter);
@@ -3858,6 +3985,391 @@ mod tests {
assert_eq!(editor.buffer_count(), 3);
}
#[test]
fn file_view_preferences_are_independent_atomic_and_applied_to_current_presentation() {
let directory = Directory::new();
fs::create_dir(directory.path("folder")).unwrap();
fs::write(directory.path("file.txt"), "text").unwrap();
let mut editor = project_editor(&directory);
assert_eq!(editor.file_picker_view(), FileView::List);
assert_eq!(editor.file_explorer_view(), FileView::Tree);
editor.open_file_picker(FilePresentation::Docked);
assert_eq!(editor.file_picker().unwrap().view, FileView::Tree);
editor.set_file_picker_view("TREE").unwrap();
assert_eq!(editor.file_picker().unwrap().view, FileView::Tree);
editor.set_file_explorer_view("LiSt").unwrap();
assert_eq!(editor.file_picker().unwrap().view, FileView::List);
for invalid in ["", "flat", " tree "] {
assert!(editor.set_file_picker_view(invalid).is_err());
assert!(editor.set_file_explorer_view(invalid).is_err());
assert_eq!(editor.file_picker_view(), FileView::Tree);
assert_eq!(editor.file_explorer_view(), FileView::List);
assert_eq!(editor.file_picker().unwrap().view, FileView::List);
}
editor.handle(Input::Ctrl('t'));
assert_eq!(editor.file_picker().unwrap().view, FileView::Tree);
assert_eq!(editor.file_explorer_view(), FileView::List);
editor.handle(Input::Char('\t'));
editor.open_file_picker(FilePresentation::Docked);
assert_eq!(editor.file_picker().unwrap().view, FileView::List);
editor.open_file_picker(FilePresentation::Floating);
assert_eq!(editor.file_picker().unwrap().view, FileView::Tree);
assert_eq!(
editor.file_picker().unwrap().presentation,
FilePresentation::Floating
);
editor.set_file_explorer_view("tree").unwrap();
editor.set_file_picker_view("list").unwrap();
assert_eq!(editor.file_picker().unwrap().view, FileView::List);
assert_eq!(editor.file_picker().unwrap().entries().len(), 1);
}
#[test]
fn tree_browse_and_search_have_separate_input_and_two_stage_escape() {
let directory = Directory::new();
fs::create_dir_all(directory.path("folder/deep")).unwrap();
fs::write(directory.path("folder/deep/needle.txt"), "text").unwrap();
fs::write(directory.path("folder/other.txt"), "other").unwrap();
for presentation in [FilePresentation::Floating, FilePresentation::Docked] {
for mode in [
Mode::Normal,
Mode::Visual,
Mode::Insert,
Mode::Command,
Mode::Search,
] {
let mut editor = project_editor(&directory);
editor
.apply(vec![Effect::Replace("alpha\nbeta".into())])
.unwrap();
set_carets(&mut editor, &[(0, 1), (1, 2)], 1);
editor.active.mode = mode;
editor.command_line = "underlying".into();
let targets = editor.active.targets.clone();
let text = editor.context().text;
let undo = editor.active.undo.len();
editor.set_file_picker_view("tree").unwrap();
editor.open_file_picker(presentation);
type_text(&mut editor, "123 b xyz");
editor.handle(Input::Paste("ignored".into()));
editor.handle(Input::Backspace);
assert_eq!(editor.file_picker().unwrap().query, "");
assert_eq!(editor.count(), None);
assert_eq!(editor.leader_pending(), None);
editor.handle(Input::Enter);
assert_eq!(editor.buffer_count(), 1);
assert!(
editor
.file_picker()
.unwrap()
.entries()
.next()
.unwrap()
.expanded
);
editor.handle(Input::Ctrl('l'));
assert_eq!(
editor.file_picker().unwrap().entries().nth(1).unwrap().path,
Path::new("folder/deep")
);
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, "/deep/");
assert!(editor.file_picker().unwrap().searching);
assert_eq!(editor.file_picker().unwrap().query, "deep/");
editor.handle(Input::Ctrl('H'));
assert_eq!(editor.file_picker().unwrap().query, "deep/");
editor.handle(Input::Backspace);
assert_eq!(editor.file_picker().unwrap().query, "deep");
type_text(&mut editor, "👩‍💻");
editor.handle(Input::Backspace);
assert_eq!(editor.file_picker().unwrap().query, "deep");
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
.entries()
.map(|row| row.path.to_owned())
.collect::<Vec<_>>(),
before
);
editor.handle(Input::Escape);
match presentation {
FilePresentation::Floating => assert!(editor.file_picker().is_none()),
FilePresentation::Docked => assert!(!editor.file_picker().unwrap().focused),
}
assert_eq!(editor.mode(), mode);
assert_eq!(editor.command_line, "underlying");
assert_eq!(editor.active.targets, targets);
assert_eq!(editor.context().text, text);
assert_eq!(editor.active.undo.len(), undo);
}
}
}
#[test]
fn tree_bindings_override_selection_only_in_browse_and_hints_are_effective() {
let directory = Directory::new();
fs::create_dir(directory.path("folder")).unwrap();
fs::write(directory.path("folder/a.txt"), "a").unwrap();
fs::write(directory.path("folder/b.txt"), "b").unwrap();
fs::write(directory.path("z.txt"), "z").unwrap();
let mut editor = project_editor(&directory);
editor
.bind("selection", "x", Some("core.down".into()))
.unwrap();
editor.bind("tree", "x", Some("core.right".into())).unwrap();
editor
.bind("tree", "ctrl+j", Some("core.left".into()))
.unwrap();
editor.bind("tree", "ctrl+k", None).unwrap();
type_text(&mut editor, "3 b");
let pending = editor.leader_pending().unwrap().to_owned();
let hint = editor.tree_navigation_hint.clone();
for (key, target) in [
("x", Some("core.delete")),
("x", Some("core.palette")),
("x", Some("w")),
("leader x", Some("core.right")),
("x y", Some("core.left")),
("alt+x", None),
] {
assert!(editor.bind("tree", key, target.map(str::to_owned)).is_err());
assert_eq!(editor.tree_navigation_hint, hint);
assert_eq!(editor.count(), Some(3));
assert_eq!(editor.leader_pending(), Some(pending.as_str()));
}
editor.open_file_picker(FilePresentation::Docked);
assert_eq!(
editor.file_navigation_hint(),
"Ctrl+n/Down next · Up previous · Ctrl+h/Ctrl+j/Left parent/close · Ctrl+l/Right/x open/child"
);
editor.handle(Input::Char('x'));
assert_eq!(editor.file_picker().unwrap().selected, 0);
assert!(
editor
.file_picker()
.unwrap()
.entries()
.next()
.unwrap()
.expanded
);
editor.handle(Input::Char('x'));
assert_eq!(editor.file_picker().unwrap().selected, 1);
editor.handle(Input::Ctrl('k'));
assert_eq!(editor.file_picker().unwrap().selected, 1);
editor.handle(Input::Ctrl('j'));
assert_eq!(editor.file_picker().unwrap().selected, 0);
editor.handle(Input::Char('/'));
assert_eq!(
editor.file_navigation_hint(),
editor.selection_navigation_hint()
);
editor.handle(Input::Char('x'));
assert_eq!(editor.file_picker().unwrap().selected, 1);
assert_eq!(editor.file_picker().unwrap().query, "");
editor.handle(Input::Ctrl('j'));
assert_eq!(editor.file_picker().unwrap().selected, 2);
editor.handle(Input::Ctrl('h'));
assert_eq!(editor.file_picker().unwrap().selected, 2);
editor.handle(Input::Ctrl('t'));
assert_eq!(editor.file_picker().unwrap().view, FileView::List);
assert_eq!(
editor.file_navigation_hint(),
editor.selection_navigation_hint()
);
editor.handle(Input::Char('x'));
assert_eq!(editor.file_picker().unwrap().selected, 1);
editor.handle(Input::Ctrl('w'));
editor.handle(Input::Char('x'));
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.context().text, "");
editor.handle(Input::Ctrl('p'));
let selected = editor.palette().unwrap().selected;
editor.handle(Input::Char('x'));
assert_eq!(editor.palette().unwrap().selected, selected + 1);
editor.handle(Input::Ctrl('h'));
assert_eq!(editor.palette().unwrap().query, "");
}
#[test]
fn ctrl_h_is_not_backspace_unless_explicitly_bound() {
assert_eq!(parse_key("CTRL+H").unwrap(), Input::Ctrl('h'));
assert_eq!(parse_key("backspace").unwrap(), Input::Backspace);
for mode in [
Mode::Normal,
Mode::Visual,
Mode::Insert,
Mode::Command,
Mode::Search,
] {
let mut editor = editor();
editor.apply(vec![Effect::Replace("abc".into())]).unwrap();
set_carets(&mut editor, &[(0, 2)], 0);
editor.active.mode = mode;
editor.command_line = "query".into();
let targets = editor.active.targets.clone();
let undo = editor.active.undo.len();
editor.handle(Input::Ctrl('h'));
assert_eq!(editor.context().text, "abc");
assert_eq!(editor.active.targets, targets);
assert_eq!(editor.active.undo.len(), undo);
assert_eq!(editor.command_line, "query");
assert_eq!(editor.mode(), mode);
}
let mut editor = editor();
type_text(&mut editor, "iabc");
editor.handle(Input::Ctrl('h'));
assert_eq!(editor.context().text, "abc");
editor
.bind("insert", "ctrl+h", Some("core.backspace".into()))
.unwrap();
editor.handle(Input::Ctrl('h'));
assert_eq!(editor.context().text, "ab");
editor.bind("insert", "ctrl+h", None).unwrap();
editor.handle(Input::Ctrl('h'));
assert_eq!(editor.context().text, "ab");
editor.handle(Input::Backspace);
assert_eq!(editor.context().text, "a");
let directory = Directory::new();
fs::write(directory.path("a.txt"), "text").unwrap();
let mut editor = project_editor(&directory);
editor.open_file_picker(FilePresentation::Floating);
type_text(&mut editor, "a");
editor.handle(Input::Ctrl('h'));
assert_eq!(editor.file_picker().unwrap().query, "a");
editor.handle(Input::Backspace);
assert_eq!(editor.file_picker().unwrap().query, "");
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "core");
editor.handle(Input::Ctrl('h'));
assert_eq!(editor.palette().unwrap().query, "core");
editor.handle(Input::Backspace);
assert_eq!(editor.palette().unwrap().query, "cor");
}
#[test]
fn tree_leaf_search_opens_direct_match_and_directory_search_enters_browse() {
let directory = Directory::new();
fs::create_dir_all(directory.path("src/module")).unwrap();
fs::write(directory.path("src/module/needle.txt"), "needle").unwrap();
fs::write(directory.path("src/sibling.txt"), "sibling").unwrap();
for presentation in [FilePresentation::Floating, FilePresentation::Docked] {
let mut editor = project_editor(&directory);
editor.set_file_picker_view("tree").unwrap();
editor.open_file_picker(presentation);
type_text(&mut editor, "/needle.txt");
let picker = editor.file_picker().unwrap();
assert_eq!(
picker.entries().nth(picker.selected).unwrap().path,
Path::new("src/module/needle.txt")
);
editor.handle(Input::Enter);
assert_eq!(editor.context().text, "needle");
assert_eq!(
editor.path(),
Some(directory.path("src/module/needle.txt").as_path())
);
editor.open_file_picker(presentation);
editor.handle(Input::Ctrl('w'));
editor.open_file_picker(presentation);
type_text(&mut editor, "/module");
assert!(
editor
.file_picker()
.unwrap()
.entries()
.nth(editor.file_picker().unwrap().selected)
.unwrap()
.is_dir
);
let id = editor.buffer_id();
editor.handle(Input::Enter);
let picker = editor.file_picker().unwrap();
assert_eq!(editor.buffer_id(), id);
assert!(!picker.searching);
assert!(picker.query.is_empty());
let selected = picker.entries().nth(picker.selected).unwrap();
assert_eq!(selected.path, Path::new("src/module"));
assert!(selected.expanded);
assert_eq!(editor.buffer_count(), 2);
}
}
#[cfg(unix)]
#[test]
fn tree_search_opens_raw_nested_paths_and_deduplicates_dirty_buffers() {
let directory = Directory::new();
fs::create_dir_all(directory.path("dir\n/deep")).unwrap();
let a = directory.path("dir\n/deep/same\n.txt");
let b = directory.path("dir\n/deep/same\t.txt");
fs::write(&a, "first").unwrap();
fs::write(&b, "second").unwrap();
for presentation in [FilePresentation::Floating, FilePresentation::Docked] {
fs::write(&a, "first").unwrap();
let mut editor = project_editor(&directory);
editor.set_file_picker_view("tree").unwrap();
editor.edit_buffer(a.clone(), false).unwrap();
editor
.apply(vec![Effect::Replace("dirty first".into())])
.unwrap();
type_text(&mut editor, "vl");
let selections = editor.selections();
let id = editor.buffer_id();
editor.edit_buffer(b.clone(), false).unwrap();
fs::write(&a, b"\xff").unwrap();
editor.open_file_picker(presentation);
type_text(&mut editor, "/same");
let picker = editor.file_picker.as_mut().unwrap();
let labels = picker
.entries()
.filter(|row| !row.is_dir)
.map(|row| row.label)
.collect::<Vec<_>>();
assert_eq!(labels.len(), 2);
assert_eq!(labels[0], labels[1]);
assert!(
picker
.entries()
.all(|row| !row.label.chars().any(char::is_control))
);
let selected = picker
.entries()
.position(|row| picker.root.join(row.path) == a)
.unwrap();
picker.selected = selected;
editor.handle(Input::Enter);
assert_eq!(editor.path(), Some(a.as_path()));
assert_eq!(editor.buffer_id(), id);
assert_eq!(editor.buffer_count(), 3);
assert_eq!(editor.context().text, "dirty first");
assert!(editor.dirty());
assert_eq!(editor.mode(), Mode::Visual);
assert_eq!(editor.selections(), selections);
match presentation {
FilePresentation::Floating => assert!(editor.file_picker().is_none()),
FilePresentation::Docked => {
assert!(!editor.file_picker().unwrap().focused);
assert!(editor.file_picker().unwrap().searching);
assert_eq!(editor.file_picker().unwrap().query, "same");
}
}
}
}
#[test]
fn default_leader_routes_save_switch_list_delete_and_open_palette() {
let directory = Directory::new();
@@ -5115,14 +5627,7 @@ mod tests {
assert_eq!(
palette_entry(&editor, "core.insert").bindings,
vec![
"Backspace",
"Ctrl+j",
"Ctrl+r",
"Enter",
"I",
"Space",
"Tab",
"界"
"Ctrl+h", "Ctrl+j", "Ctrl+r", "Enter", "I", "Space", "Tab", "界"
]
);
assert!(
+787 -47
View File
@@ -1,7 +1,8 @@
use ignore::WalkBuilder;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use unicode_segmentation::UnicodeSegmentation;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -10,6 +11,21 @@ pub enum FilePresentation {
Docked,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FileView {
List,
Tree,
}
impl FileView {
fn index(self) -> usize {
match self {
Self::List => 0,
Self::Tree => 1,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DockSide {
Left,
@@ -18,11 +34,36 @@ pub enum DockSide {
Bottom,
}
pub struct FileEntry {
pub struct FileRow<'a> {
/// The actual project-relative OS path, never reconstructed from the label.
pub path: PathBuf,
pub label: String,
pub path: &'a Path,
pub label: &'a str,
pub is_dir: bool,
pub depth: usize,
pub expanded: bool,
}
struct Node {
path: PathBuf,
label: String,
search_label: String,
is_dir: bool,
parent: Option<usize>,
children: Vec<usize>,
}
struct TreeNode {
node: usize,
label: String,
parent: Option<usize>,
children: Vec<usize>,
}
struct VisibleRow {
node: usize,
tree: Option<usize>,
depth: usize,
expanded: bool,
}
pub struct FilePicker {
@@ -30,22 +71,39 @@ pub struct FilePicker {
pub query: String,
pub selected: usize,
pub presentation: FilePresentation,
pub view: FileView,
pub searching: bool,
pub focused: bool,
pub error: Option<String>,
files: Vec<FileEntry>,
matches: Vec<usize>,
nodes: Vec<Node>,
tree: Vec<TreeNode>,
tree_for_node: Vec<usize>,
tree_roots: Vec<usize>,
rows: Vec<VisibleRow>,
expanded: HashSet<usize>,
saved_selection: [Option<usize>; 2],
filtered_selection: [Option<usize>; 2],
before_search: Option<usize>,
tree_searching: bool,
warning: Option<String>,
}
fn label(path: &Path) -> String {
path.to_string_lossy()
.chars()
.map(|ch| if ch.is_control() { '�' } else { ch })
.collect()
}
impl FilePicker {
pub fn scan(root: PathBuf, presentation: FilePresentation) -> io::Result<Self> {
pub fn scan(root: PathBuf, presentation: FilePresentation, view: FileView) -> io::Result<Self> {
if !fs::metadata(&root)?.is_dir() {
return Err(io::Error::new(
io::ErrorKind::NotADirectory,
"Project root is not a directory",
));
}
// Root failures are fatal; descendant failures leave a visibly incomplete list.
// Root failures are fatal; descendant failures leave a visibly incomplete catalog.
fs::read_dir(&root)?;
// ponytail: synchronous scan; move indexing off the input thread if large projects stall.
let mut walk = WalkBuilder::new(&root);
@@ -57,7 +115,7 @@ impl FilePicker {
.filter_entry(|entry| {
entry.depth() == 0 || (entry.file_name() != ".git" && entry.file_name() != ".jj")
});
let mut files = Vec::new();
let mut nodes = Vec::new();
let mut warning = None;
for entry in walk.build() {
let entry = match entry {
@@ -73,8 +131,12 @@ impl FilePicker {
if let Some(error) = entry.error() {
warning.get_or_insert_with(|| format!("Incomplete scan: {error}"));
}
// Symlinks (including file symlinks) are intentionally excluded.
if !entry.file_type().is_some_and(|kind| kind.is_file()) {
// Symlinks (including file symlinks) and the implicit project root are excluded.
if entry.depth() == 0
|| !entry
.file_type()
.is_some_and(|kind| kind.is_file() || kind.is_dir())
{
continue;
}
let path = entry
@@ -82,49 +144,335 @@ impl FilePicker {
.strip_prefix(&root)
.expect("walk stays within root")
.to_owned();
let label: String = path
.to_string_lossy()
.chars()
.map(|ch| if ch.is_control() { '�' } else { ch })
.collect();
let search_label = label.to_lowercase();
files.push(FileEntry {
let label = label(&path);
nodes.push(Node {
path,
search_label: label.to_lowercase(),
label,
search_label,
is_dir: entry.file_type().unwrap().is_dir(),
parent: None,
children: Vec::new(),
});
}
files.sort_by(|left, right| left.path.cmp(&right.path));
let matches = (0..files.len()).collect();
Ok(Self {
nodes.sort_by(|left, right| left.path.cmp(&right.path));
let by_path: HashMap<_, _> = nodes
.iter()
.enumerate()
.map(|(index, node)| (node.path.clone(), index))
.collect();
let mut roots = Vec::new();
for index in 0..nodes.len() {
let parent = nodes[index]
.path
.parent()
.and_then(|path| by_path.get(path))
.copied();
nodes[index].parent = parent;
if let Some(parent) = parent {
nodes[parent].children.push(index);
} else {
roots.push(index);
}
}
let mut children: Vec<_> = nodes
.iter_mut()
.map(|node| std::mem::take(&mut node.children))
.collect();
let order = |left: &usize, right: &usize| {
nodes[*right]
.is_dir
.cmp(&nodes[*left].is_dir)
.then_with(|| {
nodes[*left]
.path
.file_name()
.cmp(&nodes[*right].path.file_name())
})
};
roots.sort_by(order);
for children in &mut children {
children.sort_by(order);
}
for (node, children) in nodes.iter_mut().zip(children) {
node.children = children;
}
let mut picker = Self {
root,
query: String::new(),
selected: 0,
presentation,
view,
searching: false,
focused: true,
error: warning.clone(),
files,
matches,
tree_for_node: vec![0; nodes.len()],
nodes,
tree: Vec::new(),
tree_roots: Vec::new(),
rows: Vec::new(),
expanded: HashSet::new(),
saved_selection: [None, None],
filtered_selection: [None, None],
before_search: None,
tree_searching: false,
warning,
};
for root in roots {
let tree = picker.compact(root, None);
picker.tree_roots.push(tree);
}
picker.project(None);
picker.remember_selection(false);
Ok(picker)
}
fn compact(&mut self, start: usize, parent: Option<usize>) -> usize {
let tree = self.tree.len();
let mut terminal = start;
let mut name = label(Path::new(self.nodes[start].path.file_name().unwrap()));
self.tree_for_node[start] = tree;
while self.nodes[terminal].children.len() == 1 {
let child = self.nodes[terminal].children[0];
if !self.nodes[terminal].is_dir || !self.nodes[child].is_dir {
break;
}
terminal = child;
self.tree_for_node[terminal] = tree;
name.push('/');
name.push_str(&label(Path::new(
self.nodes[terminal].path.file_name().unwrap(),
)));
}
self.tree.push(TreeNode {
node: terminal,
label: name,
parent,
children: Vec::new(),
});
for index in 0..self.nodes[terminal].children.len() {
let child = self.compact(self.nodes[terminal].children[index], Some(tree));
self.tree[tree].children.push(child);
}
tree
}
pub fn entries(&self) -> impl ExactSizeIterator<Item = FileRow<'_>> + '_ {
self.rows.iter().map(|row| {
let node = &self.nodes[row.node];
FileRow {
path: &node.path,
label: row
.tree
.map_or(node.label.as_str(), |tree| self.tree[tree].label.as_str()),
is_dir: node.is_dir,
depth: row.depth,
expanded: row.expanded,
}
})
}
pub fn entries(&self) -> impl ExactSizeIterator<Item = &FileEntry> + '_ {
self.matches.iter().map(|&index| &self.files[index])
fn selected_node(&self) -> Option<usize> {
self.rows.get(self.selected).map(|row| row.node)
}
fn remember_selection(&mut self, explicit: bool) {
let Some(node) = self.selected_node() else {
return;
};
self.saved_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() {
self.filtered_selection = [Some(node), Some(node)];
}
}
}
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 {
if self.tree_for_node[index] != tree {
break;
}
if expanded {
self.expanded.insert(index);
} else {
self.expanded.remove(&index);
}
node = self.nodes[index].parent;
}
}
fn reveal(&mut self, node: usize) {
let mut parent = self.tree[self.tree_for_node[node]].parent;
while let Some(tree) = parent {
self.set_expanded(tree, true);
parent = self.tree[tree].parent;
}
}
pub fn set_view(&mut self, view: FileView) {
if self.view == view {
return;
}
let current = self.selected_node();
if !self.searching {
self.remember_selection(false);
}
if self.view == FileView::Tree {
self.tree_searching = self.searching;
}
self.view = view;
let selected = 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];
}
if !self.searching
&& let Some(node) = selected
{
self.reveal(node);
}
} else {
self.searching = false;
}
self.project(selected);
}
pub fn toggle_view(&mut self) {
self.set_view(match self.view {
FileView::List => FileView::Tree,
FileView::Tree => FileView::List,
});
}
pub(crate) fn start_search(&mut self) {
self.before_search = self.selected_node();
self.searching = true;
self.tree_searching = true;
self.project(self.before_search);
self.clear_error();
}
pub(crate) fn cancel_search(&mut self) {
self.query.clear();
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);
self.clear_error();
}
fn project(&mut self, selected: Option<usize>) {
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));
self.rows.clear();
if self.view == FileView::List {
self.rows
.extend(self.nodes.iter().enumerate().filter_map(|(node, entry)| {
(!entry.is_dir && matches(entry)).then_some(VisibleRow {
node,
tree: None,
depth: 0,
expanded: false,
})
}));
} else {
let mut included = vec![false; self.tree.len()];
for (index, _) in self
.nodes
.iter()
.enumerate()
.filter(|(_, node)| matches(node))
{
let mut tree = Some(self.tree_for_node[index]);
while let Some(index) = tree {
if included[index] {
break;
}
included[index] = true;
tree = self.tree[index].parent;
}
}
let filtered = self.searching || !tokens.is_empty();
let mut pending: Vec<_> = self
.tree_roots
.iter()
.rev()
.map(|&tree| (tree, 0))
.collect();
while let Some((tree, depth)) = pending.pop() {
if !included[tree] {
continue;
}
let entry = &self.tree[tree];
let expanded = self.nodes[entry.node].is_dir
&& (self.is_expanded(tree)
|| (filtered && entry.children.iter().any(|&child| included[child])));
self.rows.push(VisibleRow {
node: entry.node,
tree: Some(tree),
depth,
expanded,
});
if expanded {
pending.extend(entry.children.iter().rev().map(|&child| (child, depth + 1)));
}
}
}
// Filtering selects a real match, never an ancestor present only as context.
// Browsing falls back to the nearest visible ancestor, then the first row.
let mut target = selected;
self.selected = self
.rows
.iter()
.position(|row| 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])
&& (row.node == node
|| (self.view == FileView::Tree
&& row.tree == Some(self.tree_for_node[node])))
}) {
self.selected = index;
break;
}
target = self.nodes[node].parent;
}
}
pub(crate) fn refilter(&mut self) {
let query = self.query.to_lowercase();
let tokens: Vec<_> = query.split_whitespace().collect();
self.matches.clear();
self.matches
.extend(self.files.iter().enumerate().filter_map(|(index, entry)| {
tokens
.iter()
.all(|token| entry.search_label.contains(token))
.then_some(index)
}));
self.selected = 0;
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;
}
self.filtered_selection[view]
};
self.project(selected);
self.clear_error();
}
@@ -136,7 +484,7 @@ impl FilePicker {
}
pub(crate) fn navigate(&mut self, forward: bool) {
let count = self.matches.len();
let count = self.rows.len();
self.selected = if count == 0 {
0
} else if forward {
@@ -146,19 +494,127 @@ 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.clear_error();
}
pub(crate) fn right(&mut self) {
let Some(row) = self.rows.get(self.selected) else {
return;
};
if !self.nodes[row.node].is_dir {
return;
}
if row.expanded {
if self
.rows
.get(self.selected + 1)
.is_some_and(|child| child.depth > row.depth)
{
self.selected += 1;
}
} else {
let node = row.node;
let tree = row.tree.expect("directory navigation is tree-only");
self.set_expanded(tree, true);
self.project(Some(node));
}
self.remember_selection(true);
self.clear_error();
}
pub(crate) fn left(&mut self) {
let Some(row) = self.rows.get(self.selected) else {
return;
};
if row.expanded {
let node = row.node;
let tree = row.tree.expect("directory navigation is tree-only");
self.set_expanded(tree, false);
self.project(Some(node));
} 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) {
self.selected = index;
}
}
self.remember_selection(true);
self.clear_error();
}
/// Directories toggle in browsing; files retain the authoritative path for the editor.
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);
}
let tree = self.tree_for_node[node];
let expanded = !self.is_expanded(tree);
self.set_expanded(tree, expanded);
self.project(Some(node));
self.remember_selection(true);
None
} else {
self.remember_selection(true);
Some(self.root.join(&self.nodes[node].path))
}
}
pub(crate) fn clear_error(&mut self) {
self.error = self.warning.clone();
}
pub(crate) fn refresh(&mut self) {
match Self::scan(self.root.clone(), self.presentation) {
match Self::scan(self.root.clone(), self.presentation, self.view) {
Ok(mut replacement) => {
let by_path: HashMap<_, _> = replacement
.nodes
.iter()
.enumerate()
.map(|(index, node)| (node.path.clone(), index))
.collect();
let surviving = |node: usize| {
let mut path = self.nodes[node].path.as_path();
loop {
if let Some(&node) = by_path.get(path) {
return Some(node);
}
path = path.parent()?;
}
};
replacement.expanded = self
.expanded
.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.refilter();
replacement.view = self.view;
replacement.searching = self.searching;
replacement.tree_searching = self.tree_searching;
replacement.project(self.selected_node().and_then(surviving));
*self = replacement;
}
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
@@ -187,7 +643,7 @@ mod tests {
fs::write(path, text).unwrap();
}
fn scan(&self) -> FilePicker {
FilePicker::scan(self.0.clone(), FilePresentation::Floating).unwrap()
FilePicker::scan(self.0.clone(), FilePresentation::Floating, FileView::List).unwrap()
}
}
impl Drop for Directory {
@@ -196,7 +652,10 @@ mod tests {
}
}
fn paths(picker: &FilePicker) -> Vec<PathBuf> {
picker.entries().map(|entry| entry.path.clone()).collect()
picker
.entries()
.map(|entry| entry.path.to_owned())
.collect()
}
#[test]
@@ -280,7 +739,14 @@ mod tests {
picker.refresh();
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
assert!(picker.error.as_ref().unwrap().contains("Refresh failed"));
assert!(FilePicker::scan(directory.0.clone(), FilePresentation::Docked).is_err());
assert!(
FilePicker::scan(
directory.0.clone(),
FilePresentation::Docked,
FileView::Tree
)
.is_err()
);
}
#[test]
@@ -306,12 +772,13 @@ mod tests {
directory.put(".gitignore", "*.txt\n");
directory.put("project/visible.txt", "text");
let root = directory.0.join("project");
let picker = FilePicker::scan(root, FilePresentation::Floating).unwrap();
let picker = FilePicker::scan(root, FilePresentation::Floating, FileView::List).unwrap();
assert_eq!(paths(&picker), [PathBuf::from("visible.txt")]);
assert!(
FilePicker::scan(
directory.0.join("project/visible.txt"),
FilePresentation::Docked
FilePresentation::Docked,
FileView::Tree,
)
.is_err()
);
@@ -329,11 +796,284 @@ mod tests {
fs::write(directory.0.join(&raw), "text").unwrap();
let picker = directory.scan();
assert_eq!(picker.entries().len(), 2);
let entry = picker.entries().find(|entry| entry.path == raw).unwrap();
let entry = picker
.entries()
.find(|entry| entry.path == raw.as_path())
.unwrap();
assert!(!entry.label.chars().any(char::is_control));
assert_eq!(
fs::read_to_string(directory.0.join(&entry.path)).unwrap(),
fs::read_to_string(directory.0.join(entry.path)).unwrap(),
"text"
);
}
fn tree(directory: &Directory) -> FilePicker {
FilePicker::scan(
directory.0.clone(),
FilePresentation::Docked,
FileView::Tree,
)
.unwrap()
}
fn select(picker: &mut FilePicker, path: &str) {
let selected = picker
.entries()
.position(|row| row.path == Path::new(path))
.unwrap();
picker.selected = selected;
}
fn selected_path(picker: &FilePicker) -> &Path {
picker.entries().nth(picker.selected).unwrap().path
}
#[test]
fn tree_orders_folders_first_keeps_empty_dirs_and_compacts_only_directory_chains() {
let directory = Directory::new();
directory.put("a.txt", "");
directory.put("folder/only.txt", "");
directory.put("chain/one/two/a.txt", "");
directory.put("chain/one/two/z.txt", "");
fs::create_dir(directory.0.join("empty")).unwrap();
let mut picker = tree(&directory);
assert_eq!(picker.view, FileView::Tree);
assert!(!picker.searching);
assert_eq!(
picker
.entries()
.map(|row| (row.label, row.is_dir, row.depth))
.collect::<Vec<_>>(),
[
("chain/one/two", true, 0),
("empty", true, 0),
("folder", true, 0),
("a.txt", false, 0)
]
);
assert_eq!(selected_path(&picker), Path::new("chain/one/two"));
picker.right();
assert_eq!(
picker
.entries()
.map(|row| (row.label, row.depth))
.collect::<Vec<_>>(),
[
("chain/one/two", 0),
("a.txt", 1),
("z.txt", 1),
("empty", 0),
("folder", 0),
("a.txt", 0)
]
);
picker.right();
assert_eq!(selected_path(&picker), Path::new("chain/one/two/a.txt"));
let rows = paths(&picker);
picker.right();
assert_eq!(paths(&picker), rows);
picker.left();
assert_eq!(selected_path(&picker), Path::new("chain/one/two"));
picker.left();
assert!(!picker.entries().next().unwrap().expanded);
picker.left();
assert_eq!(picker.selected, 0);
select(&mut picker, "folder");
assert!(picker.accept().is_none());
assert_eq!(
picker.entries().nth(picker.selected + 1).unwrap().label,
"only.txt"
);
picker.right();
assert_eq!(picker.accept(), Some(directory.0.join("folder/only.txt")));
select(&mut picker, "empty");
picker.right();
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
picker.right();
assert_eq!(selected_path(&picker), Path::new("empty"));
}
#[test]
fn tree_search_reveals_ancestors_without_mutating_saved_expansion_or_compaction() {
let directory = Directory::new();
directory.put("src/module/deep/needle.txt", "");
directory.put("src/module/sibling.txt", "");
directory.put("src/root.txt", "");
directory.put("other/keep.txt", "");
let mut picker = tree(&directory);
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();
picker.refilter();
assert_eq!(
picker
.entries()
.map(|row| (row.label, row.depth))
.collect::<Vec<_>>(),
[("src", 0), ("module", 1), ("deep", 2), ("needle.txt", 3)]
);
assert_eq!(
selected_path(&picker),
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"));
assert_eq!(picker.expanded, expanded);
picker.start_search();
picker.query = "deep".into();
picker.refilter();
select(&mut picker, "src/module/deep");
assert!(picker.accept().is_none());
assert!(!picker.searching);
assert!(picker.query.is_empty());
assert_eq!(selected_path(&picker), Path::new("src/module/deep"));
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
assert!(picker.expanded.is_superset(&expanded));
let chain = Directory::new();
chain.put("one/two/three/a.txt", "");
chain.put("one/two/three/b.txt", "");
let mut picker = tree(&chain);
picker.start_search();
picker.query = "a.txt".into();
picker.refilter();
assert_eq!(
picker.entries().map(|row| row.label).collect::<Vec<_>>(),
["one/two/three", "a.txt"]
);
}
#[test]
fn view_switches_restore_directory_choices_and_reveal_explicit_file_choices() {
let directory = Directory::new();
directory.put("src/a.txt", "");
directory.put("src/b.txt", "");
directory.put("z.txt", "");
let mut picker = tree(&directory);
picker.right();
let open = paths(&picker);
picker.set_view(FileView::List);
assert_eq!(
paths(&picker),
["src/a.txt", "src/b.txt", "z.txt"].map(PathBuf::from)
);
picker.set_view(FileView::Tree);
assert_eq!(selected_path(&picker), Path::new("src"));
assert_eq!(paths(&picker), open);
picker.left();
picker.toggle_view();
picker.navigate(true);
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
picker.toggle_view();
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
assert!(picker.entries().next().unwrap().expanded);
picker.left();
picker.toggle_view();
picker.toggle_view();
assert_eq!(selected_path(&picker), Path::new("src"));
}
#[test]
fn filtering_and_refresh_preserve_raw_selection_and_surviving_expansion() {
let directory = Directory::new();
directory.put("src/a.txt", "");
directory.put("src/b.txt", "");
directory.put("z.txt", "");
let mut picker = directory.scan();
select(&mut picker, "src/b.txt");
picker.query = "absent".into();
picker.refilter();
assert_eq!(picker.entries().len(), 0);
picker.query.clear();
picker.refilter();
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
picker.set_view(FileView::Tree);
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
fs::remove_file(directory.0.join("src/b.txt")).unwrap();
directory.put("src/c.txt", "");
picker.refresh();
assert_eq!(selected_path(&picker), Path::new("src"));
assert!(picker.entries().next().unwrap().expanded);
assert!(paths(&picker).contains(&PathBuf::from("src/c.txt")));
fs::remove_dir_all(directory.0.join("src")).unwrap();
picker.refresh();
assert_eq!(selected_path(&picker), Path::new("z.txt"));
assert!(picker.expanded.is_empty());
picker.toggle_view();
picker.toggle_view();
assert_eq!(selected_path(&picker), Path::new("z.txt"));
}
#[test]
fn tree_search_subfocus_and_expansion_survive_view_toggles_and_refresh() {
let directory = Directory::new();
directory.put("src/deep/a.txt", "");
directory.put("src/other.txt", "");
let mut picker = tree(&directory);
picker.start_search();
assert!(picker.searching);
assert_eq!(picker.entries().len(), 4);
assert!(picker.expanded.is_empty());
picker.toggle_view();
assert_eq!(picker.view, FileView::List);
picker.toggle_view();
assert!(picker.searching);
assert!(picker.expanded.is_empty());
picker.query = "a.txt".into();
picker.refilter();
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
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"));
assert!(picker.expanded.is_empty());
}
#[test]
fn refresh_preserves_expansion_when_directory_chains_merge_and_split() {
let directory = Directory::new();
directory.put("one/a.txt", "");
directory.put("one/two/b.txt", "");
let mut picker = tree(&directory);
picker.right();
assert_eq!(
paths(&picker),
["one", "one/two", "one/a.txt"].map(PathBuf::from)
);
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
picker.refresh();
assert_eq!(selected_path(&picker), Path::new("one/two"));
assert_eq!(
paths(&picker),
["one/two", "one/two/b.txt"].map(PathBuf::from)
);
assert!(picker.entries().next().unwrap().expanded);
directory.put("one/a.txt", "");
picker.refresh();
assert_eq!(selected_path(&picker), Path::new("one/two"));
assert_eq!(
paths(&picker),
["one", "one/two", "one/a.txt"].map(PathBuf::from)
);
assert!(picker.entries().next().unwrap().expanded);
assert!(!picker.entries().nth(1).unwrap().expanded);
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
picker.refresh();
picker.left();
assert_eq!(paths(&picker), [PathBuf::from("one/two")]);
directory.put("one/a.txt", "");
picker.refresh();
assert_eq!(paths(&picker), [PathBuf::from("one")]);
assert!(!picker.entries().next().unwrap().expanded);
}
}
+3 -2
View File
@@ -10,8 +10,9 @@ Default keys (configurable with config.bind):
All modes: Ctrl+p command palette; type to filter, Ctrl+j/k or arrows select, Enter accept, Esc cancel
Normal/Visual: Space is leader; Space Space opens palette; Esc cancels prefix
Normal leader: Space w saves; Space b n/p/l/d next/previous/list/close buffer
Files: Space o floating picker; Space e docked explorer (right by default)
type to filter, Ctrl+j/k or arrows select, Enter opens, Esc returns, Ctrl+w closes, Ctrl+r refreshes
Files: Space o floating list; Space e docked compact tree (right by default)
Ctrl+j/k select; tree Ctrl+l/h expand/parent, Enter toggles/opens
/ tree search, Esc browse/edit, 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
+13 -3
View File
@@ -19,6 +19,8 @@ pub struct Config {
pub bindings: Vec<Binding>,
pub leader: String,
pub file_explorer_side: String,
pub file_picker_view: String,
pub file_explorer_view: String,
}
impl Default for Config {
@@ -28,6 +30,8 @@ impl Default for Config {
bindings: Vec::new(),
leader: "space".into(),
file_explorer_side: "right".into(),
file_picker_view: "list".into(),
file_explorer_view: "tree".into(),
}
}
}
@@ -44,6 +48,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
bindings,
leader,
file_explorer_side,
file_picker_view,
file_explorer_view,
..
} => {
let mut config = Config::default();
@@ -57,6 +63,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.bindings = bindings;
config.leader = leader;
config.file_explorer_side = file_explorer_side;
config.file_picker_view = file_picker_view;
config.file_explorer_view = file_explorer_view;
Ok(config)
}
Response::Error {
@@ -203,6 +211,8 @@ enum Response {
bindings: Vec<Binding>,
leader: String,
file_explorer_side: String,
file_picker_view: String,
file_explorer_view: String,
},
#[serde(rename = "result")]
Result {
@@ -598,9 +608,9 @@ end
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
}
for frame in [
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"file_explorer_side":"right"}"#,
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":null,"file_explorer_side":"right"}"#,
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":7,"file_explorer_side":"right"}"#,
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree"}"#,
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree"}"#,
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree"}"#,
] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
}
+18 -2
View File
@@ -38,7 +38,7 @@ module Vamp
end
module Modes
%w[normal insert command search visual selection].each do |name|
%w[normal insert command search visual selection tree].each do |name|
mode = Mode.new(name.freeze).freeze
define_singleton_method(name) { mode }
end
@@ -53,6 +53,8 @@ module Vamp
@bindings = []
@leader = "space"
@file_explorer_side = "right"
@file_picker_view = "list"
@file_explorer_view = "tree"
end
def plugin(path)
@@ -74,6 +76,18 @@ module Vamp
@file_explorer_side = side
end
def file_picker_view=(view)
view = Vamp.string!(view, "file picker view")
raise ArgumentError, "file picker view must not be empty" if view.empty?
@file_picker_view = view
end
def file_explorer_view=(view)
view = Vamp.string!(view, "file explorer view")
raise ArgumentError, "file explorer view must not be empty" if view.empty?
@file_explorer_view = view
end
def core
Core
end
@@ -106,7 +120,9 @@ module Vamp
def response
{ "version" => 1, "type" => "config", "plugins" => @plugins,
"bindings" => @bindings, "leader" => @leader,
"file_explorer_side" => @file_explorer_side }
"file_explorer_side" => @file_explorer_side,
"file_picker_view" => @file_picker_view,
"file_explorer_view" => @file_explorer_view }
end
end
+121 -2
View File
@@ -42,6 +42,18 @@ impl EditorSession {
config.file_explorer_side
));
}
if let Err(error) = editor.set_file_picker_view(&config.file_picker_view) {
notices.push(format!(
"file picker view {:?}: {error}",
config.file_picker_view
));
}
if let Err(error) = editor.set_file_explorer_view(&config.file_explorer_view) {
notices.push(format!(
"file explorer view {:?}: {error}",
config.file_explorer_view
));
}
let mut plugins = BTreeMap::new();
for path in config.plugins {
match RubyPlugin::start(&path) {
@@ -233,11 +245,22 @@ end
session.editor().file_picker().unwrap().presentation,
crate::files::FilePresentation::Docked
);
assert_eq!(
session.editor().file_picker().unwrap().view,
crate::files::FileView::Tree
);
assert!(!session.editor().file_picker().unwrap().searching);
session.handle(Input::Char('/'));
for ch in "dir e\u{301}".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Backspace);
assert_eq!(session.editor().file_picker().unwrap().query, "dir ");
session.handle(Input::Ctrl('h'));
assert_eq!(session.editor().file_picker().unwrap().query, "dir ");
session.handle(Input::Escape);
assert!(!session.editor().file_picker().unwrap().searching);
assert!(session.editor().file_picker().unwrap().focused);
session.handle(Input::Escape);
assert!(!session.editor().file_picker().unwrap().focused);
session.handle(Input::Char('i'));
@@ -251,7 +274,11 @@ end
session.editor().file_picker().unwrap().presentation,
crate::files::FilePresentation::Floating
);
assert_eq!(session.editor().file_picker().unwrap().query, "dir ");
assert_eq!(
session.editor().file_picker().unwrap().view,
crate::files::FileView::List
);
assert_eq!(session.editor().file_picker().unwrap().query, "");
session.handle(Input::Escape);
assert!(session.editor().file_picker().is_none());
session.handle(Input::Char('u'));
@@ -259,13 +286,73 @@ end
}
#[test]
fn invalid_explorer_side_preserves_default_and_valid_bindings() {
fn configured_flat_dock_and_tree_picker_keep_control_h_distinct_from_backspace() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "alpha");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.file_explorer_view = "list"
config.file_picker_view = "tree"
config.bind "ctrl+f", config.core.right, mode: config.modes.tree
end
"#,
);
session.handle(Input::Char(' '));
session.handle(Input::Char('e'));
assert_eq!(
session.editor().file_picker().unwrap().view,
crate::files::FileView::List
);
for ch in "e\u{301}".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Ctrl('h'));
assert_eq!(session.editor().file_picker().unwrap().query, "e\u{301}");
session.handle(Input::Backspace);
assert_eq!(session.editor().file_picker().unwrap().query, "");
session.handle(Input::Ctrl('w'));
session.handle(Input::Char(' '));
session.handle(Input::Char('o'));
assert_eq!(
session.editor().file_picker().unwrap().view,
crate::files::FileView::Tree
);
assert!(!session.editor().file_picker().unwrap().searching);
session.handle(Input::Char('x'));
assert_eq!(session.editor().file_picker().unwrap().query, "");
session.handle(Input::Char('/'));
session.handle(Input::Char('x'));
session.handle(Input::Ctrl('h'));
assert_eq!(session.editor().file_picker().unwrap().query, "x");
session.handle(Input::Escape);
assert!(!session.editor().file_picker().unwrap().searching);
assert!(session.editor().file_picker().unwrap().focused);
session.handle(Input::Ctrl('t'));
assert_eq!(
session.editor().file_picker().unwrap().view,
crate::files::FileView::List
);
session.handle(Input::Escape);
assert!(session.editor().file_picker().is_none());
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
session.handle(Input::Ctrl('h'));
assert_eq!(session.editor().lines(), &["xalpha"]);
session.handle(Input::Backspace);
assert_eq!(session.editor().lines(), &["alpha"]);
}
#[test]
fn invalid_explorer_preferences_preserve_defaults_and_valid_bindings() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "abc");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.file_explorer_side = "diagonal"
config.file_explorer_view = "grid"
config.file_picker_view = "grid"
config.bind "n", config.core.right
end
"#,
@@ -274,6 +361,14 @@ end
session.editor().file_explorer_side(),
crate::files::DockSide::Right
);
assert_eq!(
session.editor().file_explorer_view(),
crate::files::FileView::Tree
);
assert_eq!(
session.editor().file_picker_view(),
crate::files::FileView::List
);
session.handle(Input::Char('n'));
assert_eq!(
session.editor().cursor(),
@@ -1101,6 +1196,30 @@ end
r#"config.file_explorer_side = """#,
"file explorer side must not be empty",
),
(
"config.file_picker_view = 7",
"file picker view must be a String",
),
(
"config.file_picker_view = nil",
"file picker view must be a String",
),
(
r#"config.file_picker_view = """#,
"file picker view must not be empty",
),
(
"config.file_explorer_view = 7",
"file explorer view must be a String",
),
(
"config.file_explorer_view = nil",
"file explorer view must be a String",
),
(
r#"config.file_explorer_view = """#,
"file explorer view must not be empty",
),
(r#"config.bind "n", "core.insert""#, "TypeError"),
(r#"config.bind "n", nil, mode: "insert""#, "TypeError"),
(r#"config.bind "n", config.modes.insert"#, "TypeError"),
+505 -32
View File
@@ -28,7 +28,7 @@ use unicode_width::UnicodeWidthStr;
use crate::{
editor::{Editor, Input, Mode, Palette},
files::{DockSide, FilePicker, FilePresentation},
files::{DockSide, FilePicker, FilePresentation, FileView},
session::EditorSession,
};
@@ -427,9 +427,9 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
])
.areas(column);
frame.render_widget(Clear, panel);
draw_file_list(frame, picker, panel, editor.selection_navigation_hint());
draw_file_list(frame, picker, panel, editor.file_navigation_hint());
} else if let Some(panel) = dock {
draw_file_list(frame, picker, panel, editor.selection_navigation_hint());
draw_file_list(frame, picker, panel, editor.file_navigation_hint());
}
}
if let Some(palette) = editor.palette() {
@@ -492,9 +492,14 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio
Color::DarkGray
};
let bordered = panel.width >= 4 && panel.height >= 4;
let title = match picker.presentation {
FilePresentation::Floating => " Files ",
FilePresentation::Docked => " Explorer ",
let browsing = picker.view == FileView::Tree && !picker.searching;
let title = match (picker.presentation, picker.view, browsing) {
(FilePresentation::Floating, FileView::List, _) => " Files · List ",
(FilePresentation::Docked, FileView::List, _) => " Explorer · List ",
(FilePresentation::Floating, FileView::Tree, true) => " Files · Tree browse ",
(FilePresentation::Docked, FileView::Tree, true) => " Explorer · Tree browse ",
(FilePresentation::Floating, FileView::Tree, false) => " Files · Tree search ",
(FilePresentation::Docked, FileView::Tree, false) => " Explorer · Tree search ",
};
let block = Block::default()
.borders(if bordered {
@@ -519,10 +524,18 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio
Constraint::Length(u16::from(show_help)),
])
.areas(content);
let query = format!("> {}", display_text(&picker.query));
let query = if browsing {
"browse · / search · ^T view".into()
} else {
format!("> {}", display_text(&picker.query))
};
let query_width = display_column(&query, usize::MAX);
let width = usize::from(query_area.width);
let offset = query_width.saturating_sub(width.saturating_sub(1));
let offset = if browsing {
0
} else {
query_width.saturating_sub(width.saturating_sub(1))
};
frame.render_widget(
Paragraph::new(visible_line(&query, offset, width)).style(Style::default().fg(
if picker.focused {
@@ -534,7 +547,8 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio
query_area,
);
if picker.focused && query_area.width > 0 && query_area.height > 0 {
frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
let cursor_x = if browsing { 0 } else { query_width - offset };
frame.set_cursor_position((query_area.x + cursor_x as u16, query_area.y));
}
if show_root {
frame.render_widget(
@@ -562,13 +576,14 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio
);
} else if results_area.height > 0 {
let count = usize::from(results_area.height);
let first = picker.selected.saturating_sub(count - 1);
let selected_index = picker.selected.min(entries.len() - 1);
let first = selected_index.saturating_sub(count - 1);
let lines: Vec<Line> = entries
.enumerate()
.skip(first)
.take(count)
.map(|(index, entry)| {
let selected = index == picker.selected;
let selected = index == selected_index;
let style = if selected {
Style::default()
.bg(if picker.focused {
@@ -580,22 +595,47 @@ fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigatio
} else {
Style::default()
};
Line::styled(
format!("{} {}", if selected { ">" } else { " " }, entry.label),
style,
)
let label = match picker.view {
FileView::List => {
format!("{} {}", if selected { ">" } else { " " }, entry.label)
}
FileView::Tree => format!(
"{} {:indent$}{} {}",
if selected { ">" } else { " " },
"",
if entry.is_dir {
if entry.expanded { "▾" } else { "▸" }
} else {
" "
},
entry.label,
indent = entry.depth * 2,
),
};
Line::styled(label, style)
})
.collect();
frame.render_widget(Paragraph::new(lines), results_area);
if browsing && picker.focused && results_area.width > 0 {
frame.set_cursor_position((
results_area.x,
results_area.y + (selected_index - first) as u16,
));
}
}
let help = if picker.focused {
let controls = match picker.presentation {
FilePresentation::Floating => "Enter · Esc/^W close · ^R scan",
FilePresentation::Docked => "Enter · Esc/Tab edit · ^W · ^R",
};
format!("{navigation_hint} · {controls}")
let view_controls = if picker.view == FileView::Tree {
"/ search · ^T view"
} else {
"^T view"
};
format!("{navigation_hint} · {view_controls} · {controls}")
} else {
"Editor focus · invoke explorer to search".into()
"Editor focus · invoke explorer to browse".into()
};
frame.render_widget(
Paragraph::new(help).style(Style::default().fg(Color::DarkGray)),
@@ -722,6 +762,7 @@ mod tests {
fn file(&self, name: &str, text: &str) -> PathBuf {
let path = self.0.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, text).unwrap();
path
}
@@ -764,6 +805,21 @@ mod tests {
KeyModifiers::CONTROL,
Some(Input::Ctrl('k')),
),
(
KeyCode::Char('h'),
KeyModifiers::CONTROL,
Some(Input::Ctrl('h')),
),
(
KeyCode::Char('l'),
KeyModifiers::CONTROL,
Some(Input::Ctrl('l')),
),
(
KeyCode::Backspace,
KeyModifiers::NONE,
Some(Input::Backspace),
),
(KeyCode::Enter, KeyModifiers::NONE, Some(Input::Enter)),
(KeyCode::Enter, KeyModifiers::CONTROL, Some(Input::Enter)),
(
@@ -793,16 +849,11 @@ mod tests {
editor.handle(translate(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)).unwrap());
assert_eq!(editor.file_picker().unwrap().selected, count - 2);
let picker = editor.file_picker().unwrap();
let label = picker.entries().nth(picker.selected).unwrap().label.clone();
let label = picker.entries().nth(picker.selected).unwrap().label;
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 9)).unwrap();
terminal
.draw(|frame| {
draw_file_list(
frame,
picker,
frame.area(),
editor.selection_navigation_hint(),
)
draw_file_list(frame, picker, frame.area(), editor.file_navigation_hint())
})
.unwrap();
let text = screen_text(&terminal);
@@ -815,6 +866,7 @@ mod tests {
#[test]
fn list_footers_show_remapped_and_disabled_selection_keys() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_file_explorer_view("list").unwrap();
for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] {
editor.bind("selection", key, None).unwrap();
}
@@ -854,15 +906,20 @@ mod tests {
}
let special = directory.file("界e\u{301} space\nname", "text");
let editor = Editor::open(None, vec![]).unwrap();
let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Floating).unwrap();
let mut picker = FilePicker::scan(
directory.0.clone(),
FilePresentation::Floating,
FileView::List,
)
.unwrap();
assert!(
picker
.entries()
.any(|entry| directory.0.join(&entry.path) == special)
.any(|entry| directory.0.join(entry.path) == special)
);
let count = picker.entries().len();
picker.selected = count - 1;
let label = picker.entries().last().unwrap().label.clone();
let label = picker.entries().last().unwrap().label;
assert_eq!(label, "界e\u{301} space�name");
picker.query = "long\t界e\u{301}z".into();
let area = Rect::new(3, 2, 24, 9);
@@ -902,7 +959,12 @@ mod tests {
fn file_list_renders_root_empty_filter_errors_and_idle_focus() {
let directory = Directory::new();
let editor = Editor::open(None, vec![]).unwrap();
let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Docked).unwrap();
let mut picker = FilePicker::scan(
directory.0.clone(),
FilePresentation::Docked,
FileView::List,
)
.unwrap();
let area = Rect::new(0, 0, 100, 12);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
terminal
@@ -943,13 +1005,41 @@ mod tests {
assert!(screen_text(&terminal).contains("Files"));
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();
terminal
.draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint()))
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("Tree browse"), "{text}");
assert!(text.contains("No project files"), "{text}");
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(1, 1)
);
picker.focused = false;
terminal
.draw(|frame| {
frame.set_cursor_position((99, 11));
draw_file_list(frame, &picker, area, editor.file_navigation_hint());
})
.unwrap();
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(99, 11)
);
}
#[test]
fn file_query_cursor_and_dock_layout_survive_tiny_offset_resizes() {
let directory = Directory::new();
let editor = Editor::open(None, vec![]).unwrap();
let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Docked).unwrap();
let mut picker = FilePicker::scan(
directory.0.clone(),
FilePresentation::Docked,
FileView::List,
)
.unwrap();
picker.query = "long界e\u{301}z".into();
for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] {
let area = Rect::new(3, 2, width, height);
@@ -1029,6 +1119,378 @@ mod tests {
}
}
#[test]
fn tree_rows_render_directory_chains_hierarchy_and_basename_files_in_both_presentations() {
use std::path::Path;
let directory = Directory::new();
directory.file("chain/a/b/leaf.txt", "");
directory.file("mixed/nested/inner.txt", "");
directory.file("mixed/sibling.txt", "");
directory.file("single/only.txt", "");
directory.file("root.txt", "");
fs::create_dir(directory.0.join("empty")).unwrap();
let editor = Editor::open(None, vec![]).unwrap();
for presentation in [FilePresentation::Floating, FilePresentation::Docked] {
let mut picker =
FilePicker::scan(directory.0.clone(), presentation, FileView::Tree).unwrap();
assert_eq!(picker.entries().next().unwrap().label, "chain/a/b");
for path in ["chain/a/b", "mixed", "mixed/nested", "single"] {
let selected = picker
.entries()
.position(|row| row.path == Path::new(path))
.unwrap();
picker.selected = selected;
picker.right();
}
let area = Rect::new(3, 2, 100, 22);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(110, 28),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
terminal
.draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint()))
.unwrap();
let text = screen_text(&terminal);
for label in [
" ▾ chain/a/b",
" leaf.txt",
" ▸ empty",
" ▾ mixed",
" ▾ nested",
" inner.txt",
" sibling.txt",
"> ▾ single",
" only.txt",
" root.txt",
"/ search · ^T view",
] {
assert!(text.contains(label), "{label}: {text}");
}
assert!(!text.contains("single/only.txt"), "{text}");
assert!(!text.contains("mixed/nested/inner.txt"), "{text}");
let cursor = terminal.get_cursor_position().unwrap();
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
">"
);
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].bg,
Color::Blue
);
picker.set_view(FileView::List);
terminal
.draw(|frame| draw_file_list(frame, &picker, area, editor.file_navigation_hint()))
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("List"), "{text}");
assert!(text.contains("chain/a/b/leaf.txt"), "{text}");
assert!(text.contains("single/only.txt"), "{text}");
assert!(!text.contains("▸"), "{text}");
assert_eq!(terminal.get_cursor_position().unwrap().y, area.y + 1);
}
}
#[test]
fn tree_browse_cursor_tracks_window_and_bounds_empty_search_and_tiny_offset_panels() {
let directory = Directory::new();
for index in 0..18 {
directory.file(&format!("dir-{index:02}/file.txt"), "");
}
let editor = Editor::open(None, vec![]).unwrap();
let mut picker = FilePicker::scan(
directory.0.clone(),
FilePresentation::Docked,
FileView::Tree,
)
.unwrap();
for _ in 0..17 {
picker.navigate(true);
}
picker.right();
picker.right();
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(
ratatui::backend::TestBackend::new(110, 16),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
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 has_results = height > if width >= 4 && height >= 4 { 3 } else { 1 };
if has_results {
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
">"
);
} else {
assert_eq!(cursor, ratatui::layout::Position::new(area.x, area.y));
}
if width == 100 {
let text = screen_text(&terminal);
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")
);
}
}
#[test]
fn default_explorer_tree_search_and_escape_restore_browse_rows_cursor_and_editor_state() {
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('/'));
assert!(editor.file_picker().unwrap().searching);
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/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("Tree search"), "{text}");
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
.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"]);
editor.handle(Input::Char(' '));
editor.handle(Input::Char('e'));
editor.handle(Input::Char('/'));
editor.handle(Input::Char('n'));
editor.handle(Input::Char('\t'));
assert!(!editor.file_picker().unwrap().focused);
editor.handle(Input::Char(' '));
editor.handle(Input::Char('e'));
assert!(editor.file_picker().unwrap().searching);
assert_eq!(editor.file_picker().unwrap().query, "n");
editor.handle(Input::Ctrl('t'));
assert_eq!(editor.file_picker().unwrap().view, FileView::List);
assert_eq!(editor.file_picker().unwrap().query, "n");
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
assert!(screen_text(&terminal).contains("List"));
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
editor.handle(Input::Ctrl('t'));
assert_eq!(editor.file_picker().unwrap().view, FileView::Tree);
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("Tree search"));
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
editor.handle(Input::Escape);
assert!(editor.file_picker().unwrap().focused);
assert!(!editor.file_picker().unwrap().searching);
}
#[test]
fn configured_views_and_view_toggle_render_in_both_presentations_with_scoped_hints() {
let directory = Directory::new();
directory.file("folder/file.txt", "");
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_project_root(directory.0.clone());
editor.set_file_picker_view("tree").unwrap();
editor.set_file_explorer_view("list").unwrap();
editor.set_file_explorer_side("bottom").unwrap();
editor.bind("tree", "ctrl+l", None).unwrap();
editor.bind("tree", "right", None).unwrap();
editor.bind("tree", "x", Some("core.right".into())).unwrap();
for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] {
editor.bind("selection", key, None).unwrap();
}
editor
.bind("selection", "j", Some("core.down".into()))
.unwrap();
editor
.bind("selection", "k", Some("core.up".into()))
.unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(200, 30)).unwrap();
let (mut top, mut left) = (0, 0);
for (key, expected) in [('o', FileView::Tree), ('e', FileView::List)] {
editor.handle(Input::Char(' '));
editor.handle(Input::Char(key));
assert_eq!(editor.file_picker().unwrap().view, expected);
for view in [
expected,
if expected == FileView::Tree {
FileView::List
} else {
FileView::Tree
},
] {
assert_eq!(editor.file_picker().unwrap().view, view);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let text = screen_text(&terminal);
assert!(
text.contains(if view == FileView::Tree {
"Tree browse"
} else {
"List"
}),
"{text}"
);
assert!(text.contains("^T view"), "{text}");
assert!(text.contains(editor.file_navigation_hint()), "{text}");
if view == FileView::Tree {
assert_ne!(
editor.file_navigation_hint(),
editor.selection_navigation_hint()
);
assert!(text.contains("/ search"), "{text}");
let cursor = terminal.get_cursor_position().unwrap();
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
">"
);
editor.handle(Input::Char('x'));
assert_eq!(editor.file_picker().unwrap().query, "");
assert!(
editor
.file_picker()
.unwrap()
.entries()
.next()
.unwrap()
.expanded
);
let picker = editor.file_picker().unwrap();
let was_file = !picker.entries().nth(picker.selected).unwrap().is_dir;
editor.handle(Input::Ctrl('h'));
if was_file {
let picker = editor.file_picker().unwrap();
assert!(picker.entries().nth(picker.selected).unwrap().is_dir);
assert!(picker.entries().next().unwrap().expanded);
editor.handle(Input::Ctrl('h'));
}
assert!(
!editor
.file_picker()
.unwrap()
.entries()
.next()
.unwrap()
.expanded
);
} else {
assert_eq!(
editor.file_navigation_hint(),
editor.selection_navigation_hint()
);
assert!(text.contains("folder/file.txt"), "{text}");
}
editor.handle(Input::Ctrl('t'));
}
editor.handle(Input::Escape);
}
editor.handle(Input::Ctrl('p'));
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
assert!(screen_text(&terminal).contains(editor.selection_navigation_hint()));
}
#[test]
fn all_dock_sides_clip_editor_gutter_selection_carets_and_cursor() {
let mut editor = Editor::open(None, vec![]).unwrap();
@@ -2245,6 +2707,10 @@ mod tests {
Input::Ctrl('k'),
Input::Ctrl('j'),
Input::Ctrl('k'),
Input::Ctrl('h'),
Input::Ctrl('l'),
Input::Backspace,
Input::Backspace,
Input::Enter,
Input::Enter,
]
@@ -2257,6 +2723,10 @@ mod tests {
let input = translate(key).unwrap();
assert_eq!(input, expected);
if index == 4 {
for ch in "ab".chars() {
editor.handle(Input::Char(ch));
}
} else if index == 8 {
for ch in "q!".chars() {
editor.handle(Input::Char(ch));
}
@@ -2265,13 +2735,16 @@ mod tests {
match index {
0 | 2 => assert_eq!(editor.palette().unwrap().selected, 1),
1 | 3 => assert_eq!(editor.palette().unwrap().selected, 0),
4 => {
4 | 5 => assert_eq!(editor.palette().unwrap().query, "ab"),
6 => assert_eq!(editor.palette().unwrap().query, "a"),
7 => assert_eq!(editor.palette().unwrap().query, ""),
8 => {
assert!(editor.palette().is_none());
assert_eq!(editor.mode(), Mode::Command);
assert_eq!(editor.command_line(), "q! ");
assert!(!editor.should_quit());
}
5 => assert!(editor.should_quit()),
9 => assert!(editor.should_quit()),
_ => unreachable!(),
}
assert_eq!(editor.lines(), original);
@@ -2374,7 +2847,7 @@ PTY.spawn(*ARGV) do |output, input, pid|
STDOUT.write(chunk)
captured << chunk
if !sent_keys && captured.include?("READING_KEYS")
input.write("\e[106;5u\e[107;5u\n\v\e[13u\r")
input.write("\e[106;5u\e[107;5u\n\v\b\f\x7f\x7f\e[13u\r")
input.flush
sent_keys = true
end