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() {