feat: default explorer to compact directory tree
This commit is contained in:
+543
-38
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user