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() {
|
||||
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
use ignore::WalkBuilder;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum FilePresentation {
|
||||
Floating,
|
||||
Docked,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum DockSide {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
pub struct FileEntry {
|
||||
/// The actual project-relative OS path, never reconstructed from the label.
|
||||
pub path: PathBuf,
|
||||
pub label: String,
|
||||
search_label: String,
|
||||
}
|
||||
|
||||
pub struct FilePicker {
|
||||
pub root: PathBuf,
|
||||
pub query: String,
|
||||
pub selected: usize,
|
||||
pub presentation: FilePresentation,
|
||||
pub focused: bool,
|
||||
pub error: Option<String>,
|
||||
files: Vec<FileEntry>,
|
||||
matches: Vec<usize>,
|
||||
warning: Option<String>,
|
||||
}
|
||||
|
||||
impl FilePicker {
|
||||
pub fn scan(root: PathBuf, presentation: FilePresentation) -> 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.
|
||||
fs::read_dir(&root)?;
|
||||
// ponytail: synchronous scan; move indexing off the input thread if large projects stall.
|
||||
let mut walk = WalkBuilder::new(&root);
|
||||
walk.require_git(false)
|
||||
.parents(false)
|
||||
.hidden(false)
|
||||
.git_global(false)
|
||||
.follow_links(false)
|
||||
.filter_entry(|entry| {
|
||||
entry.depth() == 0 || (entry.file_name() != ".git" && entry.file_name() != ".jj")
|
||||
});
|
||||
let mut files = Vec::new();
|
||||
let mut warning = None;
|
||||
for entry in walk.build() {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(error) => {
|
||||
if error.depth() == Some(0) {
|
||||
return Err(io::Error::other(error));
|
||||
}
|
||||
warning.get_or_insert_with(|| format!("Incomplete scan: {error}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
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()) {
|
||||
continue;
|
||||
}
|
||||
let path = entry
|
||||
.path()
|
||||
.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 {
|
||||
path,
|
||||
label,
|
||||
search_label,
|
||||
});
|
||||
}
|
||||
files.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
let matches = (0..files.len()).collect();
|
||||
Ok(Self {
|
||||
root,
|
||||
query: String::new(),
|
||||
selected: 0,
|
||||
presentation,
|
||||
focused: true,
|
||||
error: warning.clone(),
|
||||
files,
|
||||
matches,
|
||||
warning,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl ExactSizeIterator<Item = &FileEntry> + '_ {
|
||||
self.matches.iter().map(|&index| &self.files[index])
|
||||
}
|
||||
|
||||
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;
|
||||
self.clear_error();
|
||||
}
|
||||
|
||||
pub(crate) fn backspace(&mut self) {
|
||||
if let Some((byte, _)) = self.query.grapheme_indices(true).next_back() {
|
||||
self.query.truncate(byte);
|
||||
self.refilter();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn navigate(&mut self, forward: bool) {
|
||||
let count = self.matches.len();
|
||||
self.selected = if count == 0 {
|
||||
0
|
||||
} else if forward {
|
||||
(self.selected + 1) % count
|
||||
} else if self.selected == 0 {
|
||||
count - 1
|
||||
} else {
|
||||
self.selected - 1
|
||||
};
|
||||
self.clear_error();
|
||||
}
|
||||
|
||||
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) {
|
||||
Ok(mut replacement) => {
|
||||
replacement.query = self.query.clone();
|
||||
replacement.focused = self.focused;
|
||||
replacement.refilter();
|
||||
*self = replacement;
|
||||
}
|
||||
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("vamp-files-test-{}-{id}", std::process::id()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn put(&self, name: &str, text: &str) {
|
||||
let path = self.0.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
fn scan(&self) -> FilePicker {
|
||||
FilePicker::scan(self.0.clone(), FilePresentation::Floating).unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn paths(picker: &FilePicker) -> Vec<PathBuf> {
|
||||
picker.entries().map(|entry| entry.path.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_ignore_negation_hidden_files_and_vcs_metadata() {
|
||||
let directory = Directory::new();
|
||||
directory.put(".gitignore", "*.log\nignored/\n");
|
||||
directory.put("nested/.gitignore", "!keep.log\nlocal.txt\n");
|
||||
for name in [
|
||||
"gone.log",
|
||||
"nested/gone.log",
|
||||
"nested/keep.log",
|
||||
"nested/local.txt",
|
||||
"nested/ok.txt",
|
||||
"ignored/hidden.txt",
|
||||
".hidden",
|
||||
".git/objects/secret",
|
||||
"nested/.git/secret",
|
||||
".jj/secret",
|
||||
"nested/.jj/secret",
|
||||
] {
|
||||
directory.put(name, "text");
|
||||
}
|
||||
let picker = directory.scan();
|
||||
assert_eq!(
|
||||
paths(&picker),
|
||||
[
|
||||
".gitignore",
|
||||
".hidden",
|
||||
"nested/.gitignore",
|
||||
"nested/keep.log",
|
||||
"nested/ok.txt"
|
||||
]
|
||||
.map(PathBuf::from)
|
||||
);
|
||||
assert!(picker.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_unicode_tokens_grapheme_deletion_and_navigation() {
|
||||
let directory = Directory::new();
|
||||
for name in ["a space 界[e\u{301}].txt", "b.txt", "c.txt"] {
|
||||
directory.put(name, "text");
|
||||
}
|
||||
let mut picker = directory.scan();
|
||||
picker.navigate(false);
|
||||
assert_eq!(picker.selected, 2);
|
||||
picker.navigate(true);
|
||||
assert_eq!(picker.selected, 0);
|
||||
picker.query = "界 SPACE [E\u{301}]".into();
|
||||
picker.refilter();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("a space 界[e\u{301}].txt")]);
|
||||
picker.query = "e\u{301}👩💻".into();
|
||||
picker.backspace();
|
||||
assert_eq!(picker.query, "e\u{301}");
|
||||
picker.backspace();
|
||||
assert_eq!(picker.query, "");
|
||||
picker.backspace();
|
||||
assert_eq!(picker.entries().len(), 3);
|
||||
picker.query = "no match".into();
|
||||
picker.refilter();
|
||||
for forward in [true, false] {
|
||||
picker.navigate(forward);
|
||||
assert_eq!(picker.selected, 0);
|
||||
assert_eq!(picker.entries().len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_scan_refresh_and_failure_preserve_reachable_state() {
|
||||
let directory = Directory::new();
|
||||
let mut picker = directory.scan();
|
||||
picker.navigate(false);
|
||||
picker.backspace();
|
||||
assert_eq!(picker.entries().len(), 0);
|
||||
directory.put("new.txt", "text");
|
||||
picker.query = "new".into();
|
||||
picker.refresh();
|
||||
assert_eq!(picker.query, "new");
|
||||
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
|
||||
fs::remove_dir_all(&directory.0).unwrap();
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ignore_warning_survives_query_and_navigation() {
|
||||
let directory = Directory::new();
|
||||
directory.put(".gitignore", "[z-a]\n");
|
||||
directory.put("ok.txt", "text");
|
||||
let mut picker = directory.scan();
|
||||
assert!(picker.error.as_ref().unwrap().contains("Incomplete scan"));
|
||||
picker.query = "ok".into();
|
||||
picker.refilter();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("ok.txt")]);
|
||||
picker.navigate(true);
|
||||
assert!(picker.error.as_ref().unwrap().contains("Incomplete scan"));
|
||||
directory.put(".gitignore", "");
|
||||
picker.refresh();
|
||||
assert!(picker.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_project_ignore_rules_apply_and_non_directory_roots_fail() {
|
||||
let directory = Directory::new();
|
||||
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();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("visible.txt")]);
|
||||
assert!(
|
||||
FilePicker::scan(
|
||||
directory.0.join("project/visible.txt"),
|
||||
FilePresentation::Docked
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_loops_and_file_links_are_not_followed_and_raw_paths_survive() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let directory = Directory::new();
|
||||
directory.put("real.txt", "text");
|
||||
symlink(&directory.0, directory.0.join("loop")).unwrap();
|
||||
symlink(directory.0.join("real.txt"), directory.0.join("alias.txt")).unwrap();
|
||||
let raw = PathBuf::from("raw界\n.txt");
|
||||
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();
|
||||
assert!(!entry.label.chars().any(char::is_control));
|
||||
assert_eq!(
|
||||
fs::read_to_string(directory.0.join(&entry.path)).unwrap(),
|
||||
"text"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod editor;
|
||||
pub mod files;
|
||||
pub mod ruby;
|
||||
pub mod session;
|
||||
pub mod tui;
|
||||
|
||||
@@ -10,6 +10,8 @@ Default keys (configurable with config.bind):
|
||||
All modes: Ctrl+p command palette; type to filter, Up/Down 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, Up/Down select, Enter opens, Esc returns, Ctrl+w closes, Ctrl+r refreshes
|
||||
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
|
||||
|
||||
+8
-3
@@ -18,6 +18,7 @@ pub struct Config {
|
||||
pub plugins: Vec<PathBuf>,
|
||||
pub bindings: Vec<Binding>,
|
||||
pub leader: String,
|
||||
pub file_explorer_side: String,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -26,6 +27,7 @@ impl Default for Config {
|
||||
plugins: Vec::new(),
|
||||
bindings: Vec::new(),
|
||||
leader: "space".into(),
|
||||
file_explorer_side: "right".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +43,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
plugins,
|
||||
bindings,
|
||||
leader,
|
||||
file_explorer_side,
|
||||
..
|
||||
} => {
|
||||
let mut config = Config::default();
|
||||
@@ -53,6 +56,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
}
|
||||
config.bindings = bindings;
|
||||
config.leader = leader;
|
||||
config.file_explorer_side = file_explorer_side;
|
||||
Ok(config)
|
||||
}
|
||||
Response::Error {
|
||||
@@ -198,6 +202,7 @@ enum Response {
|
||||
plugins: Vec<String>,
|
||||
bindings: Vec<Binding>,
|
||||
leader: String,
|
||||
file_explorer_side: String,
|
||||
},
|
||||
#[serde(rename = "result")]
|
||||
Result {
|
||||
@@ -593,9 +598,9 @@ end
|
||||
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||||
}
|
||||
for frame in [
|
||||
r#"{"type":"config","version":1,"plugins":[],"bindings":[]}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":null}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":7}"#,
|
||||
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"}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
||||
}
|
||||
|
||||
+10
-1
@@ -30,6 +30,7 @@ module Vamp
|
||||
word_next word_previous word_end big_word_next big_word_previous big_word_end
|
||||
select copy cut change paste_before paste_after
|
||||
select_lines clone_above clone_below select_next skip_occurrence select_all
|
||||
file_picker file_explorer
|
||||
].each do |name|
|
||||
action = Action.new("core.#{name}".freeze).freeze
|
||||
define_singleton_method(name) { action }
|
||||
@@ -51,6 +52,7 @@ module Vamp
|
||||
@plugins = []
|
||||
@bindings = []
|
||||
@leader = "space"
|
||||
@file_explorer_side = "right"
|
||||
end
|
||||
|
||||
def plugin(path)
|
||||
@@ -66,6 +68,12 @@ module Vamp
|
||||
@leader = key
|
||||
end
|
||||
|
||||
def file_explorer_side=(side)
|
||||
side = Vamp.string!(side, "file explorer side")
|
||||
raise ArgumentError, "file explorer side must not be empty" if side.empty?
|
||||
@file_explorer_side = side
|
||||
end
|
||||
|
||||
def core
|
||||
Core
|
||||
end
|
||||
@@ -97,7 +105,8 @@ module Vamp
|
||||
|
||||
def response
|
||||
{ "version" => 1, "type" => "config", "plugins" => @plugins,
|
||||
"bindings" => @bindings, "leader" => @leader }
|
||||
"bindings" => @bindings, "leader" => @leader,
|
||||
"file_explorer_side" => @file_explorer_side }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -36,6 +36,12 @@ impl EditorSession {
|
||||
if let Err(error) = editor.set_leader(&config.leader) {
|
||||
notices.push(format!("leader {:?}: {error}", config.leader));
|
||||
}
|
||||
if let Err(error) = editor.set_file_explorer_side(&config.file_explorer_side) {
|
||||
notices.push(format!(
|
||||
"file explorer side {:?}: {error}",
|
||||
config.file_explorer_side
|
||||
));
|
||||
}
|
||||
let mut plugins = BTreeMap::new();
|
||||
for path in config.plugins {
|
||||
match RubyPlugin::start(&path) {
|
||||
@@ -158,6 +164,80 @@ Vamp.command("healthy") { |editor, args| editor.replace "healthy #{args}" }
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_picker_presentations_preserve_editing_and_docked_focus() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "alpha");
|
||||
let mut session = fixture.open(
|
||||
r#"
|
||||
Vamp.configure do |config|
|
||||
config.file_explorer_side = "left"
|
||||
config.bind "ctrl+o", config.core.file_picker
|
||||
config.bind "ctrl+e", config.core.file_explorer
|
||||
end
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
session.editor().file_explorer_side(),
|
||||
crate::files::DockSide::Left
|
||||
);
|
||||
session.handle(Input::Ctrl('e'));
|
||||
assert_eq!(
|
||||
session.editor().file_picker().unwrap().presentation,
|
||||
crate::files::FilePresentation::Docked
|
||||
);
|
||||
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::Escape);
|
||||
assert!(!session.editor().file_picker().unwrap().focused);
|
||||
session.handle(Input::Char('i'));
|
||||
for ch in " a b ".chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(session.editor().lines(), &[" a b alpha"]);
|
||||
session.handle(Input::Ctrl('o'));
|
||||
assert_eq!(
|
||||
session.editor().file_picker().unwrap().presentation,
|
||||
crate::files::FilePresentation::Floating
|
||||
);
|
||||
assert_eq!(session.editor().file_picker().unwrap().query, "dir ");
|
||||
session.handle(Input::Escape);
|
||||
assert!(session.editor().file_picker().is_none());
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), &["alpha"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_explorer_side_preserves_default_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.bind "n", config.core.right
|
||||
end
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
session.editor().file_explorer_side(),
|
||||
crate::files::DockSide::Right
|
||||
);
|
||||
session.handle(Input::Char('n'));
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
crate::editor::Cursor { row: 0, column: 1 }
|
||||
);
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('!'));
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(session.editor().lines(), &["a!bc"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_config_uses_space_leader_for_palette_and_write() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -962,6 +1042,18 @@ end
|
||||
("config.leader = 7", "leader key must be a String"),
|
||||
("config.leader = nil", "leader key must be a String"),
|
||||
(r#"config.leader = """#, "leader key must not be empty"),
|
||||
(
|
||||
"config.file_explorer_side = 7",
|
||||
"file explorer side must be a String",
|
||||
),
|
||||
(
|
||||
"config.file_explorer_side = nil",
|
||||
"file explorer side must be a String",
|
||||
),
|
||||
(
|
||||
r#"config.file_explorer_side = """#,
|
||||
"file explorer side 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"),
|
||||
|
||||
+671
-7
@@ -18,7 +18,7 @@ use crossterm::{
|
||||
use ratatui::{
|
||||
Frame, Terminal,
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Layout},
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::Line,
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
@@ -28,6 +28,7 @@ use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::{
|
||||
editor::{Editor, Input, Mode, Palette},
|
||||
files::{DockSide, FilePicker, FilePresentation},
|
||||
session::EditorSession,
|
||||
};
|
||||
|
||||
@@ -219,12 +220,21 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
return;
|
||||
}
|
||||
let prompting = matches!(editor.mode(), Mode::Command | Mode::Search);
|
||||
let [viewport, status_area, prompt_area] = Layout::vertical([
|
||||
let dock_focused = editor
|
||||
.file_picker()
|
||||
.is_some_and(|picker| picker.focused && picker.presentation == FilePresentation::Docked);
|
||||
let [workspace, status_area, prompt_area] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(u16::from(area.height >= 3)),
|
||||
Constraint::Length(u16::from(area.height >= 2 || prompting)),
|
||||
Constraint::Length(u16::from(area.height >= 2 || (prompting && !dock_focused))),
|
||||
])
|
||||
.areas(area);
|
||||
let (viewport, dock) = match editor.file_picker() {
|
||||
Some(picker) if picker.presentation == FilePresentation::Docked => {
|
||||
dock_layout(workspace, editor.file_explorer_side(), picker.focused)
|
||||
}
|
||||
_ => (workspace, None),
|
||||
};
|
||||
let digits = editor.lines().len().ilog10() as usize + 1;
|
||||
let gutter_width = (digits + 1) as u16;
|
||||
let [gutter, viewport] = Layout::horizontal([
|
||||
@@ -239,7 +249,7 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
let height = usize::from(viewport.height);
|
||||
let width = usize::from(viewport.width);
|
||||
let cursor = editor.cursor();
|
||||
if height > 0 {
|
||||
if height > 0 && width > 0 {
|
||||
if cursor.row < *top {
|
||||
*top = cursor.row;
|
||||
} else if cursor.row >= top.saturating_add(height) {
|
||||
@@ -372,7 +382,7 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)),
|
||||
status_area,
|
||||
);
|
||||
if prompting && prompt_area.height > 0 {
|
||||
if prompting && prompt_area.height > 0 && prompt_area.width > 0 {
|
||||
let prefix = if editor.mode() == Mode::Search {
|
||||
'/'
|
||||
} else {
|
||||
@@ -381,7 +391,7 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
let command = format!("{prefix}{}", editor.command_line());
|
||||
let command_width = display_column(&command, usize::MAX);
|
||||
let prompt_width = usize::from(prompt_area.width);
|
||||
let offset = command_width.saturating_sub(prompt_width - 1);
|
||||
let offset = command_width.saturating_sub(prompt_width.saturating_sub(1));
|
||||
frame.render_widget(
|
||||
Paragraph::new(visible_line(&command, offset, prompt_width)),
|
||||
prompt_area,
|
||||
@@ -392,7 +402,7 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
));
|
||||
} else {
|
||||
frame.render_widget(Paragraph::new(editor.message()), prompt_area);
|
||||
if height > 0 {
|
||||
if height > 0 && width > 0 {
|
||||
let column = display_column(&editor.lines()[cursor.row], cursor.column);
|
||||
frame.set_cursor_position((
|
||||
viewport.x + (column - *left) as u16,
|
||||
@@ -400,11 +410,191 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(picker) = editor.file_picker() {
|
||||
if picker.presentation == FilePresentation::Floating {
|
||||
let [_, column, _] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.width.min(84)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(area);
|
||||
let [_, panel, _] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.height.min(18)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(column);
|
||||
frame.render_widget(Clear, panel);
|
||||
draw_file_list(frame, picker, panel);
|
||||
} else if let Some(panel) = dock {
|
||||
draw_file_list(frame, picker, panel);
|
||||
}
|
||||
}
|
||||
if let Some(palette) = editor.palette() {
|
||||
draw_palette(frame, palette, editor.mode());
|
||||
}
|
||||
}
|
||||
|
||||
fn dock_layout(area: Rect, side: DockSide, focused: bool) -> (Rect, Option<Rect>) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return (area, None);
|
||||
}
|
||||
let horizontal = matches!(side, DockSide::Left | DockSide::Right);
|
||||
let available = if horizontal { area.width } else { area.height };
|
||||
if !focused && available < if horizontal { 24 } else { 8 } {
|
||||
return (area, None);
|
||||
}
|
||||
let extent = if available < if horizontal { 20 } else { 6 } {
|
||||
available
|
||||
} else if horizontal {
|
||||
(available / 3).clamp(12, 38).min(available - 8)
|
||||
} else {
|
||||
(available / 3).clamp(4, 12).min(available - 2)
|
||||
};
|
||||
let leading = matches!(side, DockSide::Left | DockSide::Top);
|
||||
let constraints = if leading {
|
||||
[Constraint::Length(extent), Constraint::Fill(1)]
|
||||
} else {
|
||||
[Constraint::Fill(1), Constraint::Length(extent)]
|
||||
};
|
||||
let [first, second] = if horizontal {
|
||||
Layout::horizontal(constraints).areas(area)
|
||||
} else {
|
||||
Layout::vertical(constraints).areas(area)
|
||||
};
|
||||
if leading {
|
||||
(second, Some(first))
|
||||
} else {
|
||||
(first, Some(second))
|
||||
}
|
||||
}
|
||||
|
||||
fn display_text(text: &str) -> String {
|
||||
text.chars()
|
||||
.map(|ch| if ch.is_control() { '�' } else { ch })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect) {
|
||||
if panel.width == 0 || panel.height == 0 {
|
||||
return;
|
||||
}
|
||||
let color = if picker.focused {
|
||||
Color::Cyan
|
||||
} else {
|
||||
Color::DarkGray
|
||||
};
|
||||
let bordered = panel.width >= 4 && panel.height >= 4;
|
||||
let title = match picker.presentation {
|
||||
FilePresentation::Floating => " Files ",
|
||||
FilePresentation::Docked => " Explorer ",
|
||||
};
|
||||
let block = Block::default()
|
||||
.borders(if bordered {
|
||||
Borders::ALL
|
||||
} else {
|
||||
Borders::NONE
|
||||
})
|
||||
.border_style(Style::default().fg(color))
|
||||
.title(title);
|
||||
let content = if bordered { block.inner(panel) } else { panel };
|
||||
if bordered {
|
||||
frame.render_widget(block, panel);
|
||||
}
|
||||
let show_root = content.height >= 4;
|
||||
let show_error = picker.error.is_some() && content.height >= 2;
|
||||
let show_help = content.height >= 5 + u16::from(show_error);
|
||||
let [query_area, root_area, error_area, results_area, help_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(u16::from(show_root)),
|
||||
Constraint::Length(u16::from(show_error)),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(u16::from(show_help)),
|
||||
])
|
||||
.areas(content);
|
||||
let query = 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));
|
||||
frame.render_widget(
|
||||
Paragraph::new(visible_line(&query, offset, width)).style(Style::default().fg(
|
||||
if picker.focused {
|
||||
Color::Yellow
|
||||
} else {
|
||||
Color::DarkGray
|
||||
},
|
||||
)),
|
||||
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));
|
||||
}
|
||||
if show_root {
|
||||
frame.render_widget(
|
||||
Paragraph::new(display_text(&picker.root.to_string_lossy()))
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
root_area,
|
||||
);
|
||||
}
|
||||
if show_error {
|
||||
frame.render_widget(
|
||||
Paragraph::new(display_text(picker.error.as_deref().unwrap()))
|
||||
.style(Style::default().fg(Color::Red)),
|
||||
error_area,
|
||||
);
|
||||
}
|
||||
let entries = picker.entries();
|
||||
if entries.len() == 0 {
|
||||
frame.render_widget(
|
||||
Paragraph::new(if picker.query.is_empty() {
|
||||
"No project files"
|
||||
} else {
|
||||
"No matching files"
|
||||
}),
|
||||
results_area,
|
||||
);
|
||||
} else if results_area.height > 0 {
|
||||
let count = usize::from(results_area.height);
|
||||
let first = picker.selected.saturating_sub(count - 1);
|
||||
let lines: Vec<Line> = entries
|
||||
.enumerate()
|
||||
.skip(first)
|
||||
.take(count)
|
||||
.map(|(index, entry)| {
|
||||
let selected = index == picker.selected;
|
||||
let style = if selected {
|
||||
Style::default()
|
||||
.bg(if picker.focused {
|
||||
Color::Blue
|
||||
} else {
|
||||
Color::DarkGray
|
||||
})
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
Line::styled(
|
||||
format!("{} {}", if selected { ">" } else { " " }, entry.label),
|
||||
style,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(lines), results_area);
|
||||
}
|
||||
let help = if picker.focused {
|
||||
match picker.presentation {
|
||||
FilePresentation::Floating => "↑↓ Enter · Esc/^W close · ^R scan",
|
||||
FilePresentation::Docked => "↑↓ Enter · Esc/Tab edit · ^W · ^R",
|
||||
}
|
||||
} else {
|
||||
"Editor focus · invoke explorer to search"
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(help).style(Style::default().fg(Color::DarkGray)),
|
||||
help_area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode) {
|
||||
let area = frame.area();
|
||||
if area.width == 0 || area.height == 0 {
|
||||
@@ -548,6 +738,480 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_list_scrolls_selection_and_sanitizes_display_without_losing_paths() {
|
||||
let directory = Directory::new();
|
||||
for index in 0..18 {
|
||||
directory.file(&format!("file-{index:02}"), "text");
|
||||
}
|
||||
let special = directory.file("界e\u{301} space\nname", "text");
|
||||
let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Floating).unwrap();
|
||||
assert!(
|
||||
picker
|
||||
.entries()
|
||||
.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();
|
||||
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);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(40, 15),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(!text.contains("file-00"), "{text}");
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(area.x + 7, area.y + 1)].symbol(), "�");
|
||||
assert_eq!(buffer[(area.x + 8, area.y + 1)].symbol(), "界");
|
||||
assert_eq!(buffer[(area.x + 10, area.y + 1)].symbol(), "e\u{301}");
|
||||
let selected_y = (area.y + 2..area.bottom() - 1)
|
||||
.find(|&y| buffer[(area.x + 1, y)].symbol() == ">")
|
||||
.unwrap();
|
||||
assert_eq!(buffer[(area.x + 3, selected_y)].symbol(), "界");
|
||||
assert_eq!(buffer[(area.x + 5, selected_y)].symbol(), "e\u{301}");
|
||||
assert_eq!(buffer[(area.x + 1, selected_y)].bg, Color::Blue);
|
||||
assert!(
|
||||
buffer[(area.x + 1, selected_y)]
|
||||
.modifier
|
||||
.contains(Modifier::BOLD)
|
||||
);
|
||||
assert_eq!(cursor.y, area.y + 1);
|
||||
assert_eq!(buffer[(0, 0)].symbol(), " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_list_renders_root_empty_filter_errors_and_idle_focus() {
|
||||
let directory = Directory::new();
|
||||
let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Docked).unwrap();
|
||||
let area = Rect::new(0, 0, 100, 12);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("Explorer"), "{text}");
|
||||
assert!(
|
||||
text.contains(&directory.0.to_string_lossy().to_string()),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("No project files"), "{text}");
|
||||
assert!(text.contains("Esc/Tab"), "{text}");
|
||||
picker.query = "missing".into();
|
||||
picker.error = Some("Unreadable\npath\u{1b}".into());
|
||||
picker.focused = false;
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
frame.set_cursor_position((99, 11));
|
||||
draw_file_list(frame, &picker, area);
|
||||
})
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("No matching files"), "{text}");
|
||||
assert!(text.contains("Unreadable�path�"), "{text}");
|
||||
assert!(text.contains("Editor focus"), "{text}");
|
||||
assert_eq!(terminal.backend().buffer()[(1, 3)].fg, Color::Red);
|
||||
assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::DarkGray);
|
||||
assert_eq!(
|
||||
terminal.get_cursor_position().unwrap(),
|
||||
ratatui::layout::Position::new(99, 11)
|
||||
);
|
||||
picker.presentation = FilePresentation::Floating;
|
||||
picker.focused = true;
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Files"));
|
||||
assert!(screen_text(&terminal).contains("Esc/^W"));
|
||||
assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::Cyan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_query_cursor_and_dock_layout_survive_tiny_offset_resizes() {
|
||||
let directory = Directory::new();
|
||||
let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Docked).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);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(24, 12),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.unwrap();
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
|
||||
let bordered = width >= 4 && height >= 4;
|
||||
let query_x = area.x + u16::from(bordered);
|
||||
let query_y = area.y + u16::from(bordered);
|
||||
let query_width = width - 2 * u16::from(bordered);
|
||||
assert_eq!(cursor.x, query_x + query_width - 1);
|
||||
assert_eq!(cursor.y, query_y);
|
||||
let query = format!("> {}", picker.query);
|
||||
let offset =
|
||||
display_column(&query, usize::MAX).saturating_sub(usize::from(query_width) - 1);
|
||||
let mut expected =
|
||||
ratatui::buffer::Buffer::empty(Rect::new(query_x, query_y, query_width, 1));
|
||||
use ratatui::widgets::Widget;
|
||||
Paragraph::new(visible_line(&query, offset, usize::from(query_width)))
|
||||
.render(expected.area, &mut expected);
|
||||
for x in query_x..query_x + query_width {
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(x, query_y)].symbol(),
|
||||
expected[(x, query_y)].symbol()
|
||||
);
|
||||
}
|
||||
assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), " ");
|
||||
for side in [
|
||||
DockSide::Left,
|
||||
DockSide::Right,
|
||||
DockSide::Top,
|
||||
DockSide::Bottom,
|
||||
] {
|
||||
for focused in [false, true] {
|
||||
let (editor, panel) = dock_layout(area, side, focused);
|
||||
for rect in [Some(editor), panel].into_iter().flatten() {
|
||||
assert!(rect.x >= area.x && rect.y >= area.y);
|
||||
assert!(rect.right() <= area.right() && rect.bottom() <= area.bottom());
|
||||
}
|
||||
if focused {
|
||||
assert!(panel.is_some());
|
||||
} else {
|
||||
assert!(panel.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (width, height) in [(0, 1), (1, 0), (0, 0)] {
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, frame.area()))
|
||||
.unwrap();
|
||||
assert!(
|
||||
dock_layout(Rect::new(3, 2, width, height), DockSide::Right, true)
|
||||
.1
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_dock_sides_clip_editor_gutter_selection_carets_and_cursor() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
vec!["word ".repeat(40); 20].join("\n"),
|
||||
)])
|
||||
.unwrap();
|
||||
editor.handle(Input::Char('v'));
|
||||
editor.handle(Input::Ctrl('a'));
|
||||
assert!(editor.selection_count() > 1);
|
||||
let area = Rect::new(3, 2, 90, 26);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(100, 32),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
for (name, side) in [
|
||||
("left", DockSide::Left),
|
||||
("right", DockSide::Right),
|
||||
("top", DockSide::Top),
|
||||
("bottom", DockSide::Bottom),
|
||||
] {
|
||||
editor.set_file_explorer_side(name).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let workspace = Rect::new(area.x, area.y, area.width, area.height - 2);
|
||||
let (editor_area, panel) = dock_layout(workspace, side, true);
|
||||
let panel = panel.unwrap();
|
||||
assert_eq!(editor_area.area() + panel.area(), workspace.area());
|
||||
assert_eq!(editor_area.intersection(panel).area(), 0);
|
||||
match side {
|
||||
DockSide::Left => assert_eq!(panel.right(), editor_area.x),
|
||||
DockSide::Right => assert_eq!(editor_area.right(), panel.x),
|
||||
DockSide::Top => assert_eq!(panel.bottom(), editor_area.y),
|
||||
DockSide::Bottom => assert_eq!(editor_area.bottom(), panel.y),
|
||||
}
|
||||
assert!(panel.contains(terminal.get_cursor_position().unwrap()));
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
assert!(editor_area.contains(terminal.get_cursor_position().unwrap()));
|
||||
let buffer = terminal.backend().buffer();
|
||||
for y in panel.y..panel.bottom() {
|
||||
for x in panel.x..panel.right() {
|
||||
assert!(
|
||||
!matches!(
|
||||
buffer[(x, y)].bg,
|
||||
Color::Blue | Color::Magenta | Color::Yellow
|
||||
),
|
||||
"{side:?}: {x},{y}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(buffer[(editor_area.x + 3, editor_area.y)].symbol(), "w");
|
||||
assert!(matches!(
|
||||
buffer[(editor_area.x + 3, editor_area.y)].bg,
|
||||
Color::Blue | Color::Magenta
|
||||
));
|
||||
for y in editor_area.y..editor_area.bottom() {
|
||||
for x in editor_area.x..editor_area.x + 3 {
|
||||
assert_eq!(buffer[(x, y)].bg, Color::Reset);
|
||||
}
|
||||
}
|
||||
for x in area.x..area.right() {
|
||||
assert_eq!(buffer[(x, area.bottom() - 2)].bg, Color::Cyan);
|
||||
}
|
||||
assert_eq!(buffer[(0, 0)].symbol(), " ");
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_picker_clears_a_centered_overlay_without_moving_editor_regions() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
vec!["x".repeat(120); 30].join("\n"),
|
||||
)])
|
||||
.unwrap();
|
||||
let area = Rect::new(3, 2, 110, 26);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(120, 32),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let before = (top, left);
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('o'));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let panel = Rect::new(area.x + 13, area.y + 4, 84, 18);
|
||||
assert_eq!((top, left), before);
|
||||
assert_eq!(
|
||||
terminal.get_cursor_position().unwrap(),
|
||||
ratatui::layout::Position::new(panel.x + 3, panel.y + 1)
|
||||
);
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(panel.x, panel.y)].fg, Color::Cyan);
|
||||
assert_eq!(buffer[(panel.right() - 2, panel.y + 1)].symbol(), " ");
|
||||
assert_eq!(buffer[(area.x + 3, area.y)].symbol(), "x");
|
||||
assert_eq!(buffer[(area.x, area.bottom() - 2)].bg, Color::Cyan);
|
||||
assert_eq!(buffer[(0, 0)].symbol(), " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_conversion_focus_palette_and_closure_preserve_buffer_viewport_cache() {
|
||||
let directory = Directory::new();
|
||||
let a = directory.file("a", &vec!["a".repeat(80); 30].join("\n"));
|
||||
let b = directory.file("b", "other");
|
||||
let mut editor = Editor::open(Some(a), vec![]).unwrap();
|
||||
let a_id = editor.buffer_id();
|
||||
for _ in 0..20 {
|
||||
editor.handle(Input::Down);
|
||||
}
|
||||
editor.handle(Input::End);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 10)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let before = viewports.offsets[&a_id];
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('o'));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(viewports.offsets[&a_id], before);
|
||||
assert!(screen_text(&terminal).contains("Files"));
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(viewports.offsets[&a_id], before);
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Explorer"));
|
||||
editor.handle(Input::Escape);
|
||||
let docked = editor.file_picker().unwrap();
|
||||
assert!(!docked.focused);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let dock_offsets = viewports.offsets[&a_id];
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Commands"));
|
||||
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
|
||||
assert_eq!(viewports.offsets[&a_id], dock_offsets);
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute(&format!("edit {}", b.display()));
|
||||
let b_id = editor.buffer_id();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(editor.file_picker().is_some());
|
||||
assert_eq!(viewports.offsets[&b_id], (0, 0));
|
||||
editor.execute(&format!("buffer {a_id}"));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(viewports.offsets[&a_id], dock_offsets);
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('o'));
|
||||
assert_eq!(
|
||||
editor.file_picker().unwrap().presentation,
|
||||
FilePresentation::Floating
|
||||
);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Files"));
|
||||
editor.handle(Input::Ctrl('w'));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(editor.file_picker().is_none());
|
||||
assert_eq!(viewports.offsets[&b_id], (0, 0));
|
||||
assert!(screen_text(&terminal).contains("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docked_search_highlight_and_cursor_survive_focus_and_offset_resizes() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
"0123456789界e\u{301}z\nsecond".into(),
|
||||
)])
|
||||
.unwrap();
|
||||
editor.handle(Input::Char('/'));
|
||||
editor.handle(Input::Char('界'));
|
||||
editor.handle(Input::Enter);
|
||||
assert!(editor.search_match().is_some());
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(100, 32),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(Rect::new(3, 2, 90, 26)),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
for (name, side) in [
|
||||
("left", DockSide::Left),
|
||||
("right", DockSide::Right),
|
||||
("top", DockSide::Top),
|
||||
("bottom", DockSide::Bottom),
|
||||
] {
|
||||
editor.set_file_explorer_side(name).unwrap();
|
||||
for (width, height) in [
|
||||
(90, 26),
|
||||
(12, 6),
|
||||
(8, 2),
|
||||
(1, 1),
|
||||
(1, 2),
|
||||
(0, 1),
|
||||
(1, 0),
|
||||
(90, 26),
|
||||
] {
|
||||
let area = Rect::new(3, 2, width, height);
|
||||
terminal.resize(area).unwrap();
|
||||
let area = terminal.get_frame().area();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
if width > 0 && height > 0 {
|
||||
let workspace = Rect::new(
|
||||
area.x,
|
||||
area.y,
|
||||
width,
|
||||
height - u16::from(height >= 3) - u16::from(height >= 2),
|
||||
);
|
||||
let (_, panel) = dock_layout(workspace, side, true);
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(
|
||||
panel.unwrap().contains(cursor),
|
||||
"{side:?} {area:?} {panel:?}: {cursor:?}"
|
||||
);
|
||||
}
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
if width > 0 && height > 0 {
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(area.contains(cursor), "{side:?} {area:?}: {cursor:?}");
|
||||
let workspace = Rect::new(
|
||||
area.x,
|
||||
area.y,
|
||||
width,
|
||||
height - u16::from(height >= 3) - u16::from(height >= 2),
|
||||
);
|
||||
let (editor_area, panel) = dock_layout(workspace, side, false);
|
||||
assert!(editor_area.contains(cursor), "{side:?}: {cursor:?}");
|
||||
let buffer = terminal.backend().buffer();
|
||||
if width == 90 {
|
||||
let mut yellow = 0;
|
||||
for y in editor_area.y..editor_area.bottom() {
|
||||
for x in editor_area.x..editor_area.right() {
|
||||
yellow += usize::from(buffer[(x, y)].bg == Color::Yellow);
|
||||
}
|
||||
}
|
||||
assert!(yellow > 0);
|
||||
}
|
||||
if let Some(panel) = panel {
|
||||
for y in panel.y..panel.bottom() {
|
||||
for x in panel.x..panel.right() {
|
||||
assert_ne!(buffer[(x, y)].bg, Color::Yellow);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(buffer[(0, 0)].symbol(), " ");
|
||||
}
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_leader_is_visible_before_long_paths_and_unknown_keys_do_not_edit() {
|
||||
let directory = Directory::new();
|
||||
|
||||
Reference in New Issue
Block a user