feat: add a searchable buffer picker
This commit is contained in:
+182
-41
@@ -110,9 +110,10 @@ impl fmt::Display for Mode {
|
||||
|
||||
pub struct Palette {
|
||||
pub query: String,
|
||||
/// Current filtered results, sorted by label then name.
|
||||
/// Buffer results use ascending IDs; command results sort by label then name.
|
||||
pub entries: Vec<PaletteEntry>,
|
||||
pub selected: usize,
|
||||
pub buffers: bool,
|
||||
}
|
||||
|
||||
pub struct StartupDiagnostic {
|
||||
@@ -5470,6 +5471,37 @@ impl Editor {
|
||||
return;
|
||||
};
|
||||
let query = palette.query.to_lowercase();
|
||||
if palette.buffers {
|
||||
palette.entries.clear();
|
||||
for buffer in self.buffers() {
|
||||
let label = sanitize_label(format!(
|
||||
"{}{}{} {}",
|
||||
buffer.id,
|
||||
if buffer.active { "%" } else { "" },
|
||||
if buffer.dirty { "+" } else { "" },
|
||||
buffer
|
||||
.path
|
||||
.map_or_else(|| Cow::Borrowed("[No Name]"), Path::to_string_lossy),
|
||||
));
|
||||
let searchable = label.to_lowercase();
|
||||
if query
|
||||
.split_whitespace()
|
||||
.all(|token| searchable.contains(token))
|
||||
{
|
||||
palette.entries.push(PaletteEntry {
|
||||
name: format!("buffer {}", buffer.id),
|
||||
label,
|
||||
owner: String::new(),
|
||||
bindings: Vec::new(),
|
||||
enabled: true,
|
||||
modes: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
palette.selected = 0;
|
||||
self.palette = Some(palette);
|
||||
return;
|
||||
}
|
||||
// Derived labels contain the same searchable words as their names.
|
||||
let matches = |name: &str, owner: &str, ex: &str, action: Option<Action>| {
|
||||
query.split_whitespace().all(|token| {
|
||||
@@ -5560,6 +5592,10 @@ impl Editor {
|
||||
.entries
|
||||
.get(palette.selected)
|
||||
.filter(|entry| entry.enabled)?;
|
||||
if palette.buffers {
|
||||
let command = palette.entries.swap_remove(palette.selected).name;
|
||||
return self.execute(&command);
|
||||
}
|
||||
if let Some(action) = Action::named(mode, &entry.name) {
|
||||
if !matches!(action, Action::PaneClose | Action::PaneForceClose) {
|
||||
self.pane_close_picker_origin = None;
|
||||
@@ -6261,6 +6297,7 @@ impl Editor {
|
||||
query: String::new(),
|
||||
entries: Vec::new(),
|
||||
selected: 0,
|
||||
buffers: false,
|
||||
});
|
||||
self.refresh_palette();
|
||||
}
|
||||
@@ -6915,25 +6952,14 @@ impl Editor {
|
||||
self.set_message(format!("The {name} command takes no arguments"));
|
||||
}
|
||||
Action::Buffers => {
|
||||
let mut message = String::new();
|
||||
for buffer in self.buffers() {
|
||||
if !message.is_empty() {
|
||||
message.push_str(" | ");
|
||||
}
|
||||
use std::fmt::Write as _;
|
||||
write!(
|
||||
message,
|
||||
"{}{}{} {}",
|
||||
buffer.id,
|
||||
if buffer.active { "%" } else { "" },
|
||||
if buffer.dirty { "+" } else { "" },
|
||||
buffer
|
||||
.path
|
||||
.map_or_else(|| Cow::Borrowed("[No Name]"), Path::to_string_lossy,),
|
||||
)
|
||||
.expect("writing into a String cannot fail");
|
||||
}
|
||||
self.set_message(message);
|
||||
self.prepare_command();
|
||||
self.palette = Some(Palette {
|
||||
query: String::new(),
|
||||
entries: Vec::new(),
|
||||
selected: 0,
|
||||
buffers: true,
|
||||
});
|
||||
self.refresh_palette();
|
||||
}
|
||||
Action::BufferNext | Action::BufferPrevious => {
|
||||
self.switch_buffer(self.adjacent_buffer(action == Action::BufferNext))
|
||||
@@ -17454,10 +17480,12 @@ mod tests {
|
||||
type_text(&mut editor, " o");
|
||||
editor.execute("buffers");
|
||||
assert!(editor.file_picker().is_none());
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, " eifile");
|
||||
editor.execute("buffers");
|
||||
assert!(!editor.file_picker().unwrap().focused);
|
||||
assert_eq!(editor.file_picker().unwrap().query, "file");
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, " e");
|
||||
editor.handle(Input::Ctrl('w'));
|
||||
assert!(editor.file_picker().is_none());
|
||||
@@ -18383,6 +18411,137 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_picker_searches_sanitized_paths_and_preserves_dirty_history_on_accept_or_cancel() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = editor();
|
||||
let unnamed = editor.buffer_id();
|
||||
let first = directory.path("Älpha\nnote.txt");
|
||||
let second = directory.path("Älpha\tnote.txt");
|
||||
fs::write(&first, "first").unwrap();
|
||||
fs::write(&second, "second").unwrap();
|
||||
editor.execute(&format!("edit {}", first.display()));
|
||||
let first_id = editor.buffer_id();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("first unsaved".into())])
|
||||
.unwrap();
|
||||
editor.execute(&format!("edit {}", second.display()));
|
||||
let second_id = editor.buffer_id();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("second unsaved".into())])
|
||||
.unwrap();
|
||||
editor.execute(&format!("buffer {unnamed}"));
|
||||
editor.bind("selection", "ctrl+j", None).unwrap();
|
||||
editor
|
||||
.bind("selection", "ctrl+x", Some("core.down".into()))
|
||||
.unwrap();
|
||||
|
||||
type_text(&mut editor, "3 bl");
|
||||
assert_eq!(editor.count(), None);
|
||||
assert_eq!(editor.sequence_pending(), None);
|
||||
type_text(&mut editor, "NOTE ÄLPHA");
|
||||
let picker = editor.palette().unwrap();
|
||||
assert_eq!(picker.entries.len(), 2);
|
||||
assert!(
|
||||
picker
|
||||
.entries
|
||||
.iter()
|
||||
.all(|entry| !entry.label.chars().any(char::is_control))
|
||||
);
|
||||
editor.handle(Input::Ctrl('j'));
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_id(), first_id);
|
||||
assert_eq!(editor.context().text, "first unsaved");
|
||||
assert!(editor.dirty());
|
||||
|
||||
editor.execute("buffers");
|
||||
type_text(&mut editor, "ÄLPHA NOTE");
|
||||
editor.handle(Input::Ctrl('x'));
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_id(), second_id);
|
||||
assert_eq!(editor.context().text, "second unsaved");
|
||||
let cursor = editor.cursor();
|
||||
editor.execute("buffers");
|
||||
type_text(&mut editor, ":_|no-match");
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_id(), second_id);
|
||||
assert_eq!(editor.palette().unwrap().query, ":_|no-match");
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
editor.handle(Input::Ctrl('u'));
|
||||
type_text(&mut editor, "e\u{301}👩🚀");
|
||||
editor.handle(Input::Backspace);
|
||||
assert_eq!(editor.palette().unwrap().query, "e\u{301}");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, "second unsaved");
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "second");
|
||||
assert!(!editor.dirty());
|
||||
|
||||
editor.execute("buffers");
|
||||
type_text(&mut editor, "no name");
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_id(), unnamed);
|
||||
editor.execute(&format!("buffer {first_id}"));
|
||||
assert_eq!(editor.context().text, "first unsaved");
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "first");
|
||||
assert_eq!(fs::read_to_string(&second).unwrap(), "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_picker_in_diff_excludes_snapshots_and_switches_only_the_focused_pane() {
|
||||
let directory = Directory::new();
|
||||
let first = directory.path("file.txt");
|
||||
let second = directory.path("other.txt");
|
||||
fs::write(&first, "live first").unwrap();
|
||||
fs::write(&second, "live second").unwrap();
|
||||
let mut editor = Editor::open(Some(first), vec![]).unwrap();
|
||||
editor.set_clipboard_mode("separate").unwrap();
|
||||
let first_id = editor.buffer_id();
|
||||
editor.execute("tab.new");
|
||||
let other_tab = editor.tab_id();
|
||||
editor.execute(&format!("edit {}", second.display()));
|
||||
let second_id = editor.buffer_id();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("hidden unsaved".into())])
|
||||
.unwrap();
|
||||
editor.execute("tab.previous");
|
||||
let source_tab = editor.tab_id();
|
||||
let source_pane = editor.pane_id();
|
||||
editor.execute("pane.split_vertical");
|
||||
let focused = editor.pane_id();
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&directory.0,
|
||||
vec!["before".into()],
|
||||
vec!["after".into()],
|
||||
);
|
||||
let snapshot = editor.buffer_id();
|
||||
let comparison = editor.diff().unwrap().comparison_id;
|
||||
let cursor = editor.cursor();
|
||||
|
||||
editor.execute("buffers");
|
||||
type_text(&mut editor, "file.txt");
|
||||
assert_eq!(editor.palette().unwrap().entries.len(), 1);
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.buffer_id(), snapshot);
|
||||
assert_eq!(editor.diff().unwrap().comparison_id, comparison);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
editor.execute("buffers");
|
||||
type_text(&mut editor, "other.txt");
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.buffer_id(), second_id);
|
||||
assert_eq!(editor.context().text, "hidden unsaved");
|
||||
assert_eq!(editor.tab_id(), source_tab);
|
||||
assert_eq!(editor.pane_id(), focused);
|
||||
assert!(editor.diff().is_none());
|
||||
assert_eq!(editor.panes[&source_pane].buffer_id, first_id);
|
||||
editor.execute("tab.next");
|
||||
assert_eq!(editor.tab_id(), other_tab);
|
||||
assert_eq!(editor.buffer_id(), second_id);
|
||||
assert_eq!(editor.context().text, "hidden unsaved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_leader_routes_save_switch_list_delete_and_open_palette() {
|
||||
let directory = Directory::new();
|
||||
@@ -18402,9 +18561,6 @@ mod tests {
|
||||
assert_eq!(editor.sequence_pending(), None);
|
||||
type_text(&mut editor, " bp");
|
||||
assert_eq!(editor.buffer_id(), 2);
|
||||
type_text(&mut editor, " bl");
|
||||
assert!(editor.message().contains("first"));
|
||||
assert!(editor.message().contains("second"));
|
||||
type_text(&mut editor, " bd");
|
||||
assert!(!editor.has_buffer(2));
|
||||
assert_eq!(editor.buffer_id(), 1);
|
||||
@@ -18876,14 +19032,7 @@ mod tests {
|
||||
editor.apply(vec![Effect::Replace("abcd".into())]).unwrap();
|
||||
editor.execute(&format!("edit {}", directory.path("other").display()));
|
||||
editor.set_leader(",").unwrap();
|
||||
for command in [
|
||||
"buffer 1",
|
||||
"buffer 1",
|
||||
"bnext",
|
||||
"bprev",
|
||||
"buffers",
|
||||
"buffer 999",
|
||||
] {
|
||||
for command in ["buffer 1", "buffer 1", "bnext", "bprev", "buffer 999"] {
|
||||
type_text(&mut editor, "3,b");
|
||||
assert_eq!(editor.sequence_pending(), Some(", b"));
|
||||
editor.execute(command);
|
||||
@@ -20819,14 +20968,6 @@ mod tests {
|
||||
assert!(editor.dirty());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "dirty memory");
|
||||
assert!(!new.exists());
|
||||
editor.execute("buffers");
|
||||
assert!(editor.message().contains("1 "));
|
||||
assert!(editor.message().contains("3%+ "));
|
||||
let control_path = directory.path("line\nbreak");
|
||||
editor.execute(&format!("edit {}", control_path.display()));
|
||||
editor.execute("buffers");
|
||||
assert!(!editor.message().chars().any(char::is_control));
|
||||
assert!(editor.message().contains("line break"));
|
||||
}
|
||||
|
||||
fn palette_entry<'a>(editor: &'a Editor, name: &str) -> &'a PaletteEntry {
|
||||
@@ -23323,7 +23464,7 @@ mod tests {
|
||||
}
|
||||
"startup" => editor.show_startup_diagnostic("notice".into()),
|
||||
"command" => {
|
||||
editor.execute("buffers");
|
||||
editor.execute("plugins");
|
||||
}
|
||||
"paste" => {
|
||||
editor.handle(Input::Paste("!".into()));
|
||||
@@ -23779,7 +23920,7 @@ mod tests {
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
}
|
||||
"command" => {
|
||||
editor.execute("buffers");
|
||||
editor.execute("plugins");
|
||||
}
|
||||
"plugin" => {
|
||||
editor
|
||||
|
||||
Reference in New Issue
Block a user