feat: add floating and docked searchable file picker
This commit is contained in:
+547
-21
@@ -1,3 +1,4 @@
|
||||
use crate::files::{DockSide, FilePicker, FilePresentation};
|
||||
use serde::Serialize;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
@@ -202,6 +203,8 @@ enum Action {
|
||||
SkipOccurrence,
|
||||
SelectAll,
|
||||
Palette,
|
||||
FilePicker,
|
||||
FileExplorer,
|
||||
}
|
||||
|
||||
// Names and mode eligibility are shared by configured bindings and the palette.
|
||||
@@ -353,6 +356,16 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
|
||||
Mode::Visual,
|
||||
],
|
||||
),
|
||||
(
|
||||
"core.file_picker",
|
||||
Action::FilePicker,
|
||||
&[Mode::Normal, Mode::Visual],
|
||||
),
|
||||
(
|
||||
"core.file_explorer",
|
||||
Action::FileExplorer,
|
||||
&[Mode::Normal, Mode::Visual],
|
||||
),
|
||||
];
|
||||
|
||||
impl Action {
|
||||
@@ -625,11 +638,18 @@ struct Buffer {
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
fn open(id: usize, path: Option<PathBuf>, identity: Option<PathBuf>) -> io::Result<Self> {
|
||||
fn open(
|
||||
id: usize,
|
||||
path: Option<PathBuf>,
|
||||
identity: Option<PathBuf>,
|
||||
allow_missing: bool,
|
||||
) -> io::Result<Self> {
|
||||
let text = match &path {
|
||||
Some(path) => match fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(),
|
||||
Err(error) if allow_missing && error.kind() == io::ErrorKind::NotFound => {
|
||||
String::new()
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
},
|
||||
None => String::new(),
|
||||
@@ -683,6 +703,9 @@ pub struct Editor {
|
||||
leader_suffix: Vec<Input>,
|
||||
leader_display: String,
|
||||
palette: Option<Palette>,
|
||||
project_root: PathBuf,
|
||||
file_picker: Option<FilePicker>,
|
||||
file_explorer_side: DockSide,
|
||||
count: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -690,7 +713,7 @@ impl Editor {
|
||||
pub fn open(path: Option<PathBuf>, capabilities: Vec<String>) -> io::Result<Self> {
|
||||
let identity = path.as_deref().map(path_identity).transpose()?;
|
||||
let mut editor = Self {
|
||||
active: Buffer::open(1, path, identity)?,
|
||||
active: Buffer::open(1, path, identity, true)?,
|
||||
parked: BTreeMap::new(),
|
||||
next_buffer_id: 2,
|
||||
command_line: String::new(),
|
||||
@@ -705,6 +728,9 @@ impl Editor {
|
||||
leader_suffix: Vec::new(),
|
||||
leader_display: String::new(),
|
||||
palette: None,
|
||||
project_root: std::env::current_dir()?,
|
||||
file_picker: None,
|
||||
file_explorer_side: DockSide::Right,
|
||||
count: None,
|
||||
};
|
||||
editor
|
||||
@@ -727,6 +753,10 @@ impl Editor {
|
||||
for (mode, key, target) in [
|
||||
("normal", "leader space", "core.palette"),
|
||||
("visual", "leader space", "core.palette"),
|
||||
("normal", "leader o", "core.file_picker"),
|
||||
("visual", "leader o", "core.file_picker"),
|
||||
("normal", "leader e", "core.file_explorer"),
|
||||
("visual", "leader e", "core.file_explorer"),
|
||||
("normal", "leader w", "w"),
|
||||
("normal", "leader b n", "bnext"),
|
||||
("normal", "leader b p", "bprev"),
|
||||
@@ -779,6 +809,25 @@ impl Editor {
|
||||
pub fn palette(&self) -> Option<&Palette> {
|
||||
self.palette.as_ref()
|
||||
}
|
||||
|
||||
pub fn file_picker(&self) -> Option<&FilePicker> {
|
||||
self.file_picker.as_ref()
|
||||
}
|
||||
|
||||
pub fn file_explorer_side(&self) -> DockSide {
|
||||
self.file_explorer_side
|
||||
}
|
||||
|
||||
pub fn set_file_explorer_side(&mut self, side: &str) -> Result<(), String> {
|
||||
self.file_explorer_side = match side.to_ascii_lowercase().as_str() {
|
||||
"left" => DockSide::Left,
|
||||
"right" => DockSide::Right,
|
||||
"top" => DockSide::Top,
|
||||
"bottom" => DockSide::Bottom,
|
||||
_ => return Err(format!("Invalid file explorer side: {side:?}")),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
/// Pending decimal prefix for the next Normal/Visual motion.
|
||||
pub fn count(&self) -> Option<usize> {
|
||||
self.count
|
||||
@@ -1169,7 +1218,92 @@ impl Editor {
|
||||
None
|
||||
}
|
||||
|
||||
fn open_file_picker(&mut self, presentation: FilePresentation) {
|
||||
if let Some(picker) = &mut self.file_picker {
|
||||
picker.presentation = presentation;
|
||||
picker.focused = true;
|
||||
picker.clear_error();
|
||||
} else {
|
||||
match FilePicker::scan(self.project_root.clone(), presentation) {
|
||||
Ok(picker) => self.file_picker = Some(picker),
|
||||
Err(error) => {
|
||||
self.set_message(format!("File scan failed: {error}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.palette = None;
|
||||
self.count = None;
|
||||
self.clear_leader();
|
||||
}
|
||||
|
||||
fn blur_file_picker(&mut self) {
|
||||
match self.file_picker.as_mut() {
|
||||
Some(picker) if picker.presentation == FilePresentation::Docked => {
|
||||
picker.focused = false;
|
||||
}
|
||||
_ => self.file_picker = None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_file_picker(&mut self, input: Input) {
|
||||
let picker = self
|
||||
.file_picker
|
||||
.as_mut()
|
||||
.expect("focused picker receives input");
|
||||
match input {
|
||||
Input::Ctrl('w') => self.file_picker = None,
|
||||
Input::Escape => self.blur_file_picker(),
|
||||
Input::Ctrl('p') => {
|
||||
self.act(Action::Palette);
|
||||
}
|
||||
Input::Char('\t') if picker.presentation == FilePresentation::Docked => {
|
||||
picker.focused = false;
|
||||
}
|
||||
Input::Up | Input::Ctrl('k') => picker.navigate(false),
|
||||
Input::Down | Input::Ctrl('n') => picker.navigate(true),
|
||||
Input::Ctrl('r') => picker.refresh(),
|
||||
Input::Backspace => picker.backspace(),
|
||||
Input::Char(ch) if !ch.is_control() => {
|
||||
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;
|
||||
};
|
||||
match self.edit_buffer(path, false) {
|
||||
Ok(()) => self.blur_file_picker(),
|
||||
Err(error) => {
|
||||
self.file_picker
|
||||
.as_mut()
|
||||
.expect("failed open retains picker")
|
||||
.error = Some(format!("Open failed: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, input: Input) -> Option<Invocation> {
|
||||
if self
|
||||
.file_picker
|
||||
.as_ref()
|
||||
.is_some_and(|picker| picker.focused)
|
||||
{
|
||||
let input = match input {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
self.handle_file_picker(input);
|
||||
return None;
|
||||
}
|
||||
if self.palette.is_some() {
|
||||
let input = match input {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
@@ -1292,6 +1426,7 @@ impl Editor {
|
||||
self.clear_leader();
|
||||
match action {
|
||||
Action::Palette => {
|
||||
self.blur_file_picker();
|
||||
self.palette = Some(Palette {
|
||||
query: String::new(),
|
||||
entries: Vec::new(),
|
||||
@@ -1299,6 +1434,8 @@ impl Editor {
|
||||
});
|
||||
self.refresh_palette();
|
||||
}
|
||||
Action::FilePicker => self.open_file_picker(FilePresentation::Floating),
|
||||
Action::FileExplorer => self.open_file_picker(FilePresentation::Docked),
|
||||
Action::Insert => self.begin_insert(),
|
||||
Action::InsertFirstNonblank | Action::AppendLineEnd => {
|
||||
if self.active.mode == Mode::Visual {
|
||||
@@ -1548,29 +1685,25 @@ impl Editor {
|
||||
.map_or(id, |(id, _)| *id)
|
||||
}
|
||||
|
||||
fn edit_buffer(&mut self, path: PathBuf) {
|
||||
let identity = match path_identity(&path) {
|
||||
Ok(identity) => identity,
|
||||
Err(error) => {
|
||||
self.set_message(format!("Open failed: {error}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
fn edit_buffer(&mut self, path: PathBuf, allow_missing: bool) -> io::Result<()> {
|
||||
let identity = path_identity(&path)?;
|
||||
let loaded = std::iter::once(&self.active)
|
||||
.chain(self.parked.values())
|
||||
.find(|buffer| buffer.identity.as_ref() == Some(&identity))
|
||||
.map(|buffer| buffer.id);
|
||||
if let Some(id) = loaded {
|
||||
self.switch_buffer(id);
|
||||
return;
|
||||
}
|
||||
match Buffer::open(self.next_buffer_id, Some(path), Some(identity)) {
|
||||
Ok(buffer) => {
|
||||
self.next_buffer_id += 1;
|
||||
self.activate(buffer);
|
||||
}
|
||||
Err(error) => self.set_message(format!("Open failed: {error}")),
|
||||
return Ok(());
|
||||
}
|
||||
let buffer = Buffer::open(
|
||||
self.next_buffer_id,
|
||||
Some(path),
|
||||
Some(identity),
|
||||
allow_missing,
|
||||
)?;
|
||||
self.next_buffer_id += 1;
|
||||
self.activate(buffer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_buffer(&mut self, id: usize, force: bool) {
|
||||
@@ -1592,7 +1725,7 @@ impl Editor {
|
||||
if id == self.active.id {
|
||||
let next = self.adjacent_buffer(true);
|
||||
let replacement = if next == id {
|
||||
let buffer = Buffer::open(self.next_buffer_id, None, None)
|
||||
let buffer = Buffer::open(self.next_buffer_id, None, None, true)
|
||||
.expect("an unnamed empty buffer is valid");
|
||||
self.next_buffer_id += 1;
|
||||
buffer
|
||||
@@ -1614,7 +1747,11 @@ impl Editor {
|
||||
fn buffer_command(&mut self, name: &str, args: &str) -> bool {
|
||||
match name {
|
||||
"edit" if args.is_empty() => self.set_message("Usage: :edit path"),
|
||||
"edit" => self.edit_buffer(PathBuf::from(args)),
|
||||
"edit" => {
|
||||
if let Err(error) = self.edit_buffer(PathBuf::from(args), true) {
|
||||
self.set_message(format!("Open failed: {error}"));
|
||||
}
|
||||
}
|
||||
"buffers" | "bnext" | "bprev" if !args.is_empty() => {
|
||||
self.set_message(format!("The {name} command takes no arguments"));
|
||||
}
|
||||
@@ -1670,6 +1807,7 @@ impl Editor {
|
||||
self.count = None;
|
||||
self.clear_leader();
|
||||
self.palette = None;
|
||||
self.blur_file_picker();
|
||||
let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
|
||||
let (name, args) = command
|
||||
.split_once(char::is_whitespace)
|
||||
@@ -2978,6 +3116,394 @@ mod tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
fn project_editor(directory: &Directory) -> Editor {
|
||||
let mut editor = editor();
|
||||
editor.project_root = directory.0.clone();
|
||||
editor
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_actions_default_leaders_palette_and_side_configuration() {
|
||||
let directory = Directory::new();
|
||||
fs::write(directory.path("file.txt"), "abcdef").unwrap();
|
||||
for visual in [false, true] {
|
||||
let mut editor = project_editor(&directory);
|
||||
editor
|
||||
.apply(vec![Effect::Replace("abcdef".into())])
|
||||
.unwrap();
|
||||
if visual {
|
||||
type_text(&mut editor, "vl");
|
||||
}
|
||||
let mode = editor.mode();
|
||||
let selections = editor.selections();
|
||||
let carets = editor.carets();
|
||||
for (suffix, presentation) in [
|
||||
("3 o", FilePresentation::Floating),
|
||||
("2 e", FilePresentation::Docked),
|
||||
] {
|
||||
type_text(&mut editor, suffix);
|
||||
let picker = editor.file_picker().unwrap();
|
||||
assert_eq!(picker.presentation, presentation);
|
||||
assert!(picker.focused);
|
||||
assert_eq!(editor.mode(), mode);
|
||||
assert_eq!(editor.selections(), selections);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
assert_eq!(editor.count(), None);
|
||||
assert_eq!(editor.leader_pending(), None);
|
||||
editor.handle(Input::Ctrl('w'));
|
||||
assert!(editor.file_picker().is_none());
|
||||
}
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
for (name, binding) in [
|
||||
("core.file_picker", "Space o"),
|
||||
("core.file_explorer", "Space e"),
|
||||
] {
|
||||
let entry = palette_entry(&editor, name);
|
||||
assert!(entry.enabled);
|
||||
assert_eq!(entry.bindings, vec![binding]);
|
||||
}
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.file_explorer_side(), DockSide::Right);
|
||||
for (name, side) in [
|
||||
("LEFT", DockSide::Left),
|
||||
("rIgHt", DockSide::Right),
|
||||
("top", DockSide::Top),
|
||||
("BOTTOM", DockSide::Bottom),
|
||||
] {
|
||||
editor.set_file_explorer_side(name).unwrap();
|
||||
assert_eq!(editor.file_explorer_side(), side);
|
||||
}
|
||||
assert!(editor.set_file_explorer_side("middle").is_err());
|
||||
assert_eq!(editor.file_explorer_side(), DockSide::Bottom);
|
||||
assert!(
|
||||
editor
|
||||
.bind("insert", "ctrl+o", Some("core.file_picker".into()))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
editor
|
||||
.bind("command", "ctrl+e", Some("core.file_explorer".into()))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_focus_literal_input_navigation_and_empty_selection() {
|
||||
let directory = Directory::new();
|
||||
for name in ["a space 界[e\u{301}].txt", "b.txt", "c.txt"] {
|
||||
fs::write(directory.path(name), "text").unwrap();
|
||||
}
|
||||
let mut editor = project_editor(&directory);
|
||||
type_text(&mut editor, " o");
|
||||
editor.handle(Input::Ctrl('K'));
|
||||
assert_eq!(editor.file_picker().unwrap().selected, 2);
|
||||
editor.handle(Input::Ctrl('N'));
|
||||
assert_eq!(editor.file_picker().unwrap().selected, 0);
|
||||
editor.handle(Input::Up);
|
||||
editor.handle(Input::Down);
|
||||
assert_eq!(editor.file_picker().unwrap().selected, 0);
|
||||
type_text(&mut editor, "界 SPACE [E\u{301}]");
|
||||
assert_eq!(editor.file_picker().unwrap().entries().len(), 1);
|
||||
assert_eq!(editor.context().text, "");
|
||||
assert_eq!(editor.count(), None);
|
||||
assert_eq!(editor.leader_pending(), None);
|
||||
let query = editor.file_picker().unwrap().query.clone();
|
||||
editor.handle(Input::Paste("ignored 5 b n".into()));
|
||||
assert_eq!(editor.file_picker().unwrap().query, query);
|
||||
editor.handle(Input::Enter);
|
||||
assert!(editor.file_picker().is_none());
|
||||
assert_eq!(
|
||||
editor.path(),
|
||||
Some(directory.path("a space 界[e\u{301}].txt").as_path())
|
||||
);
|
||||
assert_eq!(editor.context().text, "text");
|
||||
type_text(&mut editor, " o");
|
||||
type_text(&mut editor, "e\u{301}👩💻");
|
||||
editor.handle(Input::Backspace);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "e\u{301}");
|
||||
editor.handle(Input::Backspace);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "");
|
||||
editor.handle(Input::Backspace);
|
||||
type_text(&mut editor, "missing 123");
|
||||
let id = editor.buffer_id();
|
||||
editor.handle(Input::Up);
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_id(), id);
|
||||
assert_eq!(editor.file_picker().unwrap().selected, 0);
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
editor.handle(Input::Escape);
|
||||
let empty = Directory::new();
|
||||
editor.project_root = empty.0.clone();
|
||||
type_text(&mut editor, " e");
|
||||
editor.handle(Input::Up);
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_id(), id);
|
||||
assert_eq!(editor.file_picker().unwrap().entries().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_conversion_reuses_query_and_scan_refreshes_only_on_request() {
|
||||
let directory = Directory::new();
|
||||
fs::write(directory.path("keep.txt"), "text").unwrap();
|
||||
let mut editor = project_editor(&directory);
|
||||
type_text(&mut editor, " ekeep");
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert!(!editor.file_picker().unwrap().focused);
|
||||
fs::write(directory.path("keep new.txt"), "new").unwrap();
|
||||
type_text(&mut editor, " o");
|
||||
let picker = editor.file_picker().unwrap();
|
||||
assert_eq!(picker.presentation, FilePresentation::Floating);
|
||||
assert_eq!(picker.query, "keep");
|
||||
assert_eq!(picker.entries().len(), 1);
|
||||
editor.open_file_picker(FilePresentation::Docked);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "keep");
|
||||
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);
|
||||
assert!(!editor.file_picker().unwrap().focused);
|
||||
type_text(&mut editor, " e");
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "keep");
|
||||
fs::remove_file(directory.path("keep new.txt")).unwrap();
|
||||
editor.handle(Input::Ctrl('r'));
|
||||
assert_eq!(editor.file_picker().unwrap().entries().len(), 1);
|
||||
editor.handle(Input::Ctrl('w'));
|
||||
assert!(editor.file_picker().is_none());
|
||||
type_text(&mut editor, " o");
|
||||
assert_eq!(editor.file_picker().unwrap().query, "");
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.file_picker().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_open_reuses_loaded_dirty_buffer_and_dock_survives_lifecycle() {
|
||||
let directory = Directory::new();
|
||||
let a = directory.path("a space 界.txt");
|
||||
let b = directory.path("b.txt");
|
||||
fs::write(&a, "original").unwrap();
|
||||
fs::write(&b, "other").unwrap();
|
||||
let mut editor = project_editor(&directory);
|
||||
editor.execute(&format!("edit {}", a.display()));
|
||||
editor.apply(vec![Effect::Replace("dirty".into())]).unwrap();
|
||||
type_text(&mut editor, "vl");
|
||||
let selections = editor.selections();
|
||||
let id = editor.buffer_id();
|
||||
editor.execute(&format!("edit {}", b.display()));
|
||||
fs::write(&a, b"\xff").unwrap();
|
||||
type_text(&mut editor, " ea space 界");
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_id(), id);
|
||||
assert_eq!(editor.buffer_count(), 3);
|
||||
assert_eq!(editor.context().text, "dirty");
|
||||
assert!(editor.dirty());
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.selections(), selections);
|
||||
assert!(!editor.file_picker().unwrap().focused);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "a space 界");
|
||||
editor.execute("bnext");
|
||||
assert!(editor.file_picker().is_some());
|
||||
editor.execute("bdelete");
|
||||
assert!(editor.file_picker().is_some());
|
||||
editor.execute("buffer 1");
|
||||
assert_eq!(
|
||||
editor.file_picker().unwrap().presentation,
|
||||
FilePresentation::Docked
|
||||
);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "a space 界");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_picker_selection_preserves_visual_state_and_insert_group() {
|
||||
let directory = Directory::new();
|
||||
let bad = directory.path("bad.txt");
|
||||
fs::write(&bad, b"\xff").unwrap();
|
||||
let mut editor = project_editor(&directory);
|
||||
editor
|
||||
.apply(vec![Effect::Replace("abcdef".into())])
|
||||
.unwrap();
|
||||
type_text(&mut editor, "llvhh");
|
||||
let selections = editor.selections();
|
||||
let carets = editor.carets();
|
||||
let undo = editor.active.undo.len();
|
||||
type_text(&mut editor, " obad");
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.selections(), selections);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
assert_eq!(editor.context().text, "abcdef");
|
||||
assert_eq!(editor.active.undo.len(), undo);
|
||||
assert_eq!(editor.next_buffer_id, 2);
|
||||
assert_eq!(editor.buffer_count(), 1);
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
assert!(
|
||||
editor
|
||||
.file_picker()
|
||||
.unwrap()
|
||||
.error
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("Open failed")
|
||||
);
|
||||
editor.handle(Input::Down);
|
||||
assert!(editor.file_picker().unwrap().error.is_none());
|
||||
editor.handle(Input::Enter);
|
||||
editor.handle(Input::Backspace);
|
||||
assert!(editor.file_picker().unwrap().error.is_none());
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Char('i'));
|
||||
type_text(&mut editor, "X");
|
||||
editor.open_file_picker(FilePresentation::Docked);
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
assert!(editor.active.insert_start.is_some());
|
||||
assert!(editor.file_picker().unwrap().error.is_some());
|
||||
fs::remove_file(&bad).unwrap();
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_id(), 1);
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
assert!(editor.file_picker().unwrap().error.is_some());
|
||||
fs::create_dir(&bad).unwrap();
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_count(), 1);
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
editor.handle(Input::Escape);
|
||||
assert!(!editor.file_picker().unwrap().focused);
|
||||
type_text(&mut editor, "Y");
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "abcdef");
|
||||
assert!(editor.file_picker().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_palette_and_external_commands_are_mutually_exclusive() {
|
||||
let directory = Directory::new();
|
||||
fs::write(directory.path("file.txt"), "text").unwrap();
|
||||
let mut editor = project_editor(&directory);
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
editor.open_file_picker(FilePresentation::Docked);
|
||||
assert!(editor.palette().is_none());
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
assert!(editor.palette().is_some());
|
||||
assert!(!editor.file_picker().unwrap().focused);
|
||||
type_text(&mut editor, "file picker");
|
||||
editor.handle(Input::Enter);
|
||||
assert!(editor.palette().is_none());
|
||||
assert_eq!(
|
||||
editor.file_picker().unwrap().presentation,
|
||||
FilePresentation::Floating
|
||||
);
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
assert!(editor.file_picker().is_none());
|
||||
assert!(editor.palette().is_some());
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, " o");
|
||||
editor.execute("buffers");
|
||||
assert!(editor.file_picker().is_none());
|
||||
type_text(&mut editor, " efile");
|
||||
editor.execute("buffers");
|
||||
assert!(!editor.file_picker().unwrap().focused);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "file");
|
||||
type_text(&mut editor, " e");
|
||||
editor.handle(Input::Ctrl('w'));
|
||||
assert!(editor.file_picker().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfocused_dock_keeps_editor_counts_leaders_palette_and_literal_input() {
|
||||
let directory = Directory::new();
|
||||
fs::write(directory.path("file.txt"), "text").unwrap();
|
||||
let mut editor = project_editor(&directory);
|
||||
editor
|
||||
.apply(vec![Effect::Replace("a\nb\nc\nd".into())])
|
||||
.unwrap();
|
||||
type_text(&mut editor, " efile");
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, "2j");
|
||||
assert_eq!(editor.cursor().row, 2);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "file");
|
||||
type_text(&mut editor, "3 b");
|
||||
assert_eq!(editor.leader_pending(), Some("Space b"));
|
||||
assert_eq!(editor.count(), Some(3));
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, "i o e123");
|
||||
editor.handle(Input::Paste(" literal".into()));
|
||||
assert!(editor.context().text.contains(" o e123 literal"));
|
||||
assert_eq!(editor.file_picker().unwrap().query, "file");
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, ": o e123");
|
||||
assert_eq!(editor.mode(), Mode::Command);
|
||||
assert_eq!(editor.command_line(), " o e123");
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, "/ o e123");
|
||||
assert_eq!(editor.mode(), Mode::Search);
|
||||
assert_eq!(editor.command_line(), " o e123");
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, " ");
|
||||
assert!(editor.palette().is_some());
|
||||
assert!(!editor.file_picker().unwrap().focused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceling_picker_does_not_commit_insert_and_root_failure_is_not_empty_success() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = project_editor(&directory);
|
||||
type_text(&mut editor, "iX");
|
||||
editor.open_file_picker(FilePresentation::Floating);
|
||||
editor.handle(Input::Paste("ignored".into()));
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
assert!(editor.active.insert_start.is_some());
|
||||
assert_eq!(editor.context().text, "X");
|
||||
type_text(&mut editor, "Y");
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "");
|
||||
fs::remove_dir_all(&directory.0).unwrap();
|
||||
type_text(&mut editor, " o");
|
||||
assert!(editor.file_picker().is_none());
|
||||
assert!(editor.message().contains("File scan failed"));
|
||||
assert_eq!(editor.buffer_count(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn picker_opens_raw_paths_even_when_sanitized_display_labels_collide() {
|
||||
let directory = Directory::new();
|
||||
let names = [PathBuf::from("same\n.txt"), PathBuf::from("same\t.txt")];
|
||||
fs::write(directory.0.join(&names[0]), "first").unwrap();
|
||||
fs::write(directory.0.join(&names[1]), "second").unwrap();
|
||||
let mut editor = project_editor(&directory);
|
||||
for (name, text) in names.iter().zip(["first", "second"]) {
|
||||
type_text(&mut editor, " osame");
|
||||
let picker = editor.file_picker.as_mut().unwrap();
|
||||
assert_eq!(picker.entries().len(), 2);
|
||||
assert_eq!(
|
||||
picker.entries().next().unwrap().label,
|
||||
picker.entries().last().unwrap().label,
|
||||
);
|
||||
assert!(
|
||||
picker
|
||||
.entries()
|
||||
.all(|entry| !entry.label.chars().any(char::is_control))
|
||||
);
|
||||
let selected = picker
|
||||
.entries()
|
||||
.position(|entry| entry.path == *name)
|
||||
.unwrap();
|
||||
picker.selected = selected;
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.path(), Some(directory.0.join(name).as_path()));
|
||||
assert_eq!(editor.context().text, text);
|
||||
}
|
||||
assert_eq!(editor.buffer_count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_leader_routes_save_switch_list_delete_and_open_palette() {
|
||||
|
||||
Reference in New Issue
Block a user