feat: add a searchable buffer picker

This commit is contained in:
Valdemar Lorenzen
2026-10-13 13:53:59 +02:00
parent bfdb7c544f
commit f5ba61624f
8 changed files with 239 additions and 62 deletions
+182 -41
View File
@@ -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
+2 -1
View File
@@ -12,7 +12,7 @@ Usage: vamp [--config PATH | --no-config] [FILE]
Default keys (configurable with config.bind):
All modes: Ctrl+p command palette; type to filter, Ctrl+j/k or arrows 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
Normal leader: Space w saves; Space b n/p/l/d next/previous/pick/close buffer
Files: Space o floating tree INSERT; Space e docked tree NORMAL (right by default)
tree NORMAL: hjkl navigate; INSERT: type to filter, Ctrl+hjkl navigate; Enter toggles/opens
i filters; Esc retains filter and returns NORMAL, then editor; Ctrl+u clears in either mode
@@ -56,6 +56,7 @@ Save actions: core.save, core.force_save, core.save_and_quit, core.force_save_an
Reload preserves/clamps views and clears stale undo/redo; snapshots never reload
Dirty buffers keep edits; missing/unreadable/invalid changed files keep text and warn
Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffer_delete [N], :buffer_force_delete [N]
:buffers / Space b l searches IDs/paths; Enter switches, Esc/Ctrl+p cancels, Ctrl+u clears
Aliases: bnext, bprev, bdelete, bdelete!; arguments and safety rules are unchanged
:quit refuses any dirty buffer; both save-and-quit commands require all buffers clean
Restart: :restart closes clean buffers; unsaved buffers remain open and block restart with a warning
-4
View File
@@ -992,7 +992,6 @@ end
config.bind "E", config.core.edit
config.bind "B", config.core.buffer
config.bind "P", config.core.plugins
config.bind "L", config.core.buffers
config.bind "N", config.core.buffer_next
config.bind "V", config.core.buffer_previous
config.bind "D", config.core.buffer_delete
@@ -1069,9 +1068,6 @@ end"#,
assert_eq!(session.editor().lines(), &["base"]);
session.handle(Input::Char('P'));
assert!(session.editor().message().contains("text"));
session.handle(Input::Char('L'));
assert!(session.editor().message().contains("buffer.txt"));
assert!(session.editor().message().contains("second.txt"));
session.handle(Input::Char('N'));
assert_eq!(session.editor().buffer_id(), 2);
session.handle(Input::Char('V'));
+33 -3
View File
@@ -4377,7 +4377,14 @@ fn draw_palette(
} else {
Borders::NONE
})
.title(format!(" Commands · {mode} "));
.title(format!(
" {} · {mode} ",
if palette.buffers {
"Buffers"
} else {
"Commands"
}
));
let content = if bordered { block.inner(panel) } else { panel };
if bordered {
frame.render_widget(block, panel);
@@ -4399,9 +4406,20 @@ fn draw_palette(
);
frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
if palette.entries.is_empty() {
frame.render_widget(Paragraph::new("No matching commands"), results_area);
frame.render_widget(
Paragraph::new(if palette.buffers {
"No matching buffers"
} else {
"No matching commands"
}),
results_area,
);
} else if results_area.height > 0 {
let row_height = usize::from(results_area.height >= 2) + 1;
let row_height = if palette.buffers {
1
} else {
usize::from(results_area.height >= 2) + 1
};
let count = usize::from(results_area.height) / row_height;
let first = palette.selected.saturating_sub(count - 1);
let mut lines = Vec::with_capacity(count * row_height);
@@ -4413,6 +4431,17 @@ fn draw_palette(
if index == palette.selected {
style = style.bg(Color::Blue).add_modifier(Modifier::BOLD);
}
if palette.buffers {
lines.push(
Line::from(format!(
"{} {}",
if index == palette.selected { ">" } else { " " },
entry.label,
))
.style(style),
);
continue;
}
let bindings = if entry.bindings.is_empty() {
"unbound".into()
} else {
@@ -14397,6 +14426,7 @@ end
query: "long\t界e\u{301}z".into(),
entries: vec![],
selected: 0,
buffers: false,
};
for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] {
let area = ratatui::layout::Rect::new(3, 2, width, height);