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!(
|
||||
|
||||
+787
-47
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user