feat: add floating and docked searchable file picker

This commit is contained in:
Valdemar Lorenzen
2026-10-08 14:25:54 +02:00
parent 5a67bf64a3
commit c60e625555
12 changed files with 1820 additions and 34 deletions
+547 -21
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,5 @@
pub mod editor;
pub mod files;
pub mod ruby;
pub mod session;
pub mod tui;
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+92
View File
@@ -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
View File
@@ -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();