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
+1
View File
@@ -35,6 +35,7 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal.
| Select/copy/cut | `v` characterwise or `V` whole rows, move to extend, then `y`/`d` in Visual | | Select/copy/cut | `v` characterwise or `V` whole rows, move to extend, then `y`/`d` in Visual |
| Paste in Normal | `p`/`P` after/before the caret or row for linewise fragments; system clipboard by default | | Paste in Normal | `p`/`P` after/before the caret or row for linewise fragments; system clipboard by default |
| Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels | | Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels |
| Buffer picker in Normal | Space then `b l` or `:buffers`; type to search, arrows select, Enter switches, Esc cancels |
| Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` | | Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` |
| Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Esc closes the selector; Working checkout After is editable; `:diff.close` exits the comparison | | Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Esc closes the selector; Working checkout After is editable; `:diff.close` exits the comparison |
| Diff layout in Normal | Space then `v` cycles split/unified; Space then `s` flips left/right vs above/below split | | Diff layout in Normal | Space then `v` cycles split/unified; Space then `s` flips left/right vs above/below split |
+13 -11
View File
@@ -29,17 +29,8 @@
- [x] make `<line>g` go to that line. - [x] make `<line>g` go to that line.
- [x] diff view: if the after point is selected to be current working changes (i.e. `@`), diff view should update to include this after point accordingly (and automatically) - i.e. it should move the after point. this should only be the case when the new checkout is ahead of the after point selected. should be enabled by default. - [x] diff view: if the after point is selected to be current working changes (i.e. `@`), diff view should update to include this after point accordingly (and automatically) - i.e. it should move the after point. this should only be the case when the new checkout is ahead of the after point selected. should be enabled by default.
- descendant-only jj/Git checkout tracking, frozen Before, inactive-tab refresh, and `config.diff_follow_working_copy` opt-out; preserve explorer/layout choices and unsaved buffers. - descendant-only jj/Git checkout tracking, frozen Before, inactive-tab refresh, and `config.diff_follow_working_copy` opt-out; preserve explorer/layout choices and unsaved buffers.
- [ ] buffer picker (with search). - [x] buffer picker (with search).
- [ ] pull a pr description and display it in a buffer: - `:buffers` / Space b l opens a searchable picker; shared selection bindings, stable IDs, dirty/active marks, cross-tab buffers, and cancellation without switching.
- open a selector that allows you to pick a pr from remote
- selector should be searchable
- selected pr description is opened in a separate buffer/tab.
- open related pr description directly from diff view (should be able to figure out the relevant pr from the branch involved in the diff - if jj, then from tracked bookmark; if just git, then from branch)
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
- [ ] remove the diff palette polling fallback once a released Herdr fix delivers palette-ready DEC 2031 notifications, including palette-only changes with an unchanged light/dark preference. Related upstream issue: [herdrdev/herdr#4183](https://github.com/herdrdev/herdr/issues/4183).
- verify that notification-triggered queries return fresh colors in Ghostty through Herdr before removal; retain startup, focus-regain, and notification-driven palette refresh.
- [ ] sandboxed, revision-specific LSP analysis for historical diff states and historical tabs. - [ ] sandboxed, revision-specific LSP analysis for historical diff states and historical tabs.
- current limitation: immutable snapshot buffers still use the live repository as their LSP workspace; cross-file analysis can mix historical text with current disk contents. - current limitation: immutable snapshot buffers still use the live repository as their LSP workspace; cross-file analysis can mix historical text with current disk contents.
- lazily materialize the complete selected revision, including unchanged files, manifests, and project configuration; run a separate LSP instance rooted in that snapshot. - lazily materialize the complete selected revision, including unchanged files, manifests, and project configuration; run a separate LSP instance rooted in that snapshot.
@@ -48,3 +39,14 @@
- deny access to the live checkout, home directory, credentials, and SSH agent; deny network access by default. Additional access or code-execution permissions require explicit approval. - deny access to the live checkout, home directory, credentials, and SSH agent; deny network access by default. Additional access or code-execution permissions require explicit approval.
- fail closed: if these restrictions cannot be enforced, keep read-only viewing available but do not launch historical LSP analysis; never silently fall back to an unrestricted process. - fail closed: if these restrictions cannot be enforced, keep read-only viewing available but do not launch historical LSP analysis; never silently fall back to an unrestricted process.
- retain the snapshot and analysis environment while historical views/tabs reference it; stop workers and clean up disposable storage after the final reference closes. - retain the snapshot and analysis environment while historical views/tabs reference it; stop workers and clean up disposable storage after the final reference closes.
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
- [ ] remove the diff palette polling fallback once a released Herdr fix delivers palette-ready DEC 2031 notifications, including palette-only changes with an unchanged light/dark preference. Related upstream issue: [herdrdev/herdr#4183](https://github.com/herdrdev/herdr/issues/4183).
- verify that notification-triggered queries return fresh colors in Ghostty through Herdr before removal; retain startup, focus-regain, and notification-driven palette refresh.
- [ ] improve buffer picker and align with the rest of the editor ui/design language.
- [ ] pull a pr description and display it in a buffer:
- open a selector that allows you to pick a pr from remote
- selector should be searchable
- selected pr description is opened in a separate buffer/tab.
- open related pr description directly from diff view (should be able to figure out the relevant pr from the branch involved in the diff - if jj, then from tracked bookmark; if just git, then from branch)
+2
View File
@@ -210,6 +210,8 @@ Live clean buffers reload external changes automatically, including inactive tab
`core.quit` refuses unsaved changes in any buffer. `core.edit` and `core.buffer` open prefilled Ex prompts for their required path or buffer ID; deletion actions target the current buffer. Configured `core.force_quit`, `core.buffer_force_delete`, `core.pane.force_close`, and `core.tab.force_close` keys act immediately, without confirmation. `core.quit` refuses unsaved changes in any buffer. `core.edit` and `core.buffer` open prefilled Ex prompts for their required path or buffer ID; deletion actions target the current buffer. Configured `core.force_quit`, `core.buffer_force_delete`, `core.pane.force_close`, and `core.tab.force_close` keys act immediately, without confirmation.
`config.core.buffers` opens the searchable buffer picker in Normal mode, also available as `:buffers` and default **Space b l**. Its navigation uses `config.modes.selection`, like the command palette. For example, `config.bind "leader b b", config.core.buffers` adds another opening shortcut. Search, acceptance, and cancellation are described under [Buffers](editing.md#buffers).
`core.restart` and `core.force_restart` have no default keybindings. Their no-argument commands are `:restart` and `:force_restart`, with `:restart!` as an alias for force restart. Normal restart closes clean buffers across every tab, pane, and historical view; any dirty buffer blocks the relaunch with a warning, retains its text and undo/redo history, and leaves the editor interactive without restoring the clean buffers already closed. Force restart discards unsaved text without writing it. The palette confirms force restart, while a configured `config.core.force_restart` binding executes directly. `core.restart` and `core.force_restart` have no default keybindings. Their no-argument commands are `:restart` and `:force_restart`, with `:restart!` as an alias for force restart. Normal restart closes clean buffers across every tab, pane, and historical view; any dirty buffer blocks the relaunch with a warning, retains its text and undo/redo history, and leaves the editor interactive without restoring the clean buffers already closed. Force restart discards unsaved text without writing it. The palette confirms force restart, while a configured `config.core.force_restart` binding executes directly.
Restart relaunches the same executable with the original CLI arguments after full session teardown. Raw mode and input reporting briefly reset, but the alternate screen remains active across the process handoff; the new process resumes input modes and clears and repaints the screen atomically in its first frame. It rereads configuration and the original startup file (or opens an unnamed buffer when no file was supplied), rather than restoring current buffers, tabs, views, or session state. Final quit, errors, and panics restore normal input modes, the normal screen, cursor visibility, and the prior panic hook. Restart relaunches the same executable with the original CLI arguments after full session teardown. Raw mode and input reporting briefly reset, but the alternate screen remains active across the process handoff; the new process resumes input modes and clears and repaints the screen atomically in its first frame. It rereads configuration and the original startup file (or opens an unnamed buffer when no file was supplied), rather than restoring current buffers, tabs, views, or session state. Final quit, errors, and panics restore normal input modes, the normal screen, cursor visibility, and the prior panic hook.
+6 -2
View File
@@ -10,7 +10,7 @@ Default controls and editing behavior. For custom bindings, see [configuration](
| --- | --- | | --- | --- |
| All modes | Ctrl+p opens the command palette; type to filter, Ctrl+j/k or arrows select, Enter accepts, Esc/Ctrl+p cancels | | All modes | Ctrl+p opens the command palette; type to filter, Ctrl+j/k or arrows select, Enter accepts, Esc/Ctrl+p cancels |
| Normal, Visual | Space Space opens the palette through the default leader | | Normal, Visual | Space Space opens the palette through the default leader |
| Normal | Space w saves; Space b n/p/l/d switches next/previous, lists, or closes buffers | | Normal | Space w saves; Space b n/p/l/d switches next/previous, opens the buffer picker, or closes buffers |
| Normal, Visual | Space o opens the floating tree in INSERT/filter mode; Space e opens/focuses the docked compact tree | | Normal, Visual | Space o opens the floating tree in INSERT/filter mode; Space e opens/focuses the docked compact tree |
| Normal | `i` insert before each caret; `a` insert after each caret; `o`/`O` open lines below/above | | Normal | `i` insert before each caret; `a` insert after each caret; `o`/`O` open lines below/above |
| Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first | | Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first |
@@ -278,7 +278,7 @@ Each buffer retains its shared text, path, saved state, and undo/redo history. V
| Command | Behavior | | Command | Behavior |
| --- | --- | | --- | --- |
| `:edit PATH` | Switch to an already-loaded file, or read and validate a new buffer; a nonexistent file opens empty | | `:edit PATH` | Switch to an already-loaded file, or read and validate a new buffer; a nonexistent file opens empty |
| `:buffers` | List stable IDs and paths; `%` marks active, `+` marks dirty | | `:buffers` | Open the searchable buffer picker; stable IDs and paths, `%` active, `+` dirty |
| `:buffer N` | Switch to buffer ID `N` | | `:buffer N` | Switch to buffer ID `N` |
| `:buffer_next` / `:buffer_previous` | Switch in ascending-ID order, wrapping at either end | | `:buffer_next` / `:buffer_previous` | Switch in ascending-ID order, wrapping at either end |
| `:buffer_delete [N]` | Close the active or named buffer; refuse unsaved changes | | `:buffer_delete [N]` | Close the active or named buffer; refuse unsaved changes |
@@ -288,6 +288,10 @@ IDs start at 1 and are never renumbered or reused. Closing the last buffer creat
Switching from Insert commits its pending undo group and parks that view in Normal. Explicit buffer switching from Visual retains the pane's directed ranges and Visual mode for that buffer; switching back restores them. Command/search prompts, palette queries, and pending motion counts are transient, not restored per buffer. Buffer commands appear in the command palette and can be bound by their typed core actions in Normal mode. When several buffers are open, status shows the active ID and total count. Switching from Insert commits its pending undo group and parks that view in Normal. Explicit buffer switching from Visual retains the pane's directed ranges and Visual mode for that buffer; switching back restores them. Command/search prompts, palette queries, and pending motion counts are transient, not restored per buffer. Buffer commands appear in the command palette and can be bound by their typed core actions in Normal mode. When several buffers are open, status shows the active ID and total count.
**Space b l**, `:buffers`, or the **Buffers** command-palette action opens the picker. Type to filter IDs and paths with case-insensitive, whitespace-separated substring tokens; every token must match. Results retain ascending ID order. Ctrl+j/Ctrl+n/Down and Ctrl+k/Up select with wraparound, using the shared Selection bindings and overrides. Backspace edits the query; Ctrl+u clears it. Enter switches only the focused pane to the selected buffer without saving or discarding edits; no matches leave the picker open. Esc or Ctrl+p cancels without switching. Printable keys, including `:` and leader keys, belong to the query.
The picker includes ordinary buffers from every tab and unnamed buffers as `[No Name]`; immutable diff snapshots are excluded. Labels sanitize path control characters, but selection uses stable buffer IDs, so displayed-name collisions still select the correct buffer. Switching out of a diff follows ordinary `:buffer N` behavior.
## File picker and explorer ## File picker and explorer
Space o opens a floating **Tree** explorer directly in **INSERT/filter** mode, ready for typing; Space e opens or focuses the docked **Tree**, starting in **NORMAL/navigation** mode when new. Both use one catalog and instance. Invoking the other action applies that presentation's configured default while retaining query, selection identities, and surviving folder expansion. Floating Tree invocation enters filtering if not already filtering. These actions are available as `config.core.file_picker` and `config.core.file_explorer` in Normal/Visual and in the command palette. Space o opens a floating **Tree** explorer directly in **INSERT/filter** mode, ready for typing; Space e opens or focuses the docked **Tree**, starting in **NORMAL/navigation** mode when new. Both use one catalog and instance. Invoking the other action applies that presentation's configured default while retaining query, selection identities, and surviving folder expansion. Floating Tree invocation enters filtering if not already filtering. These actions are available as `config.core.file_picker` and `config.core.file_explorer` in Normal/Visual and in the command palette.
+182 -41
View File
@@ -110,9 +110,10 @@ impl fmt::Display for Mode {
pub struct Palette { pub struct Palette {
pub query: String, 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 entries: Vec<PaletteEntry>,
pub selected: usize, pub selected: usize,
pub buffers: bool,
} }
pub struct StartupDiagnostic { pub struct StartupDiagnostic {
@@ -5470,6 +5471,37 @@ impl Editor {
return; return;
}; };
let query = palette.query.to_lowercase(); 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. // Derived labels contain the same searchable words as their names.
let matches = |name: &str, owner: &str, ex: &str, action: Option<Action>| { let matches = |name: &str, owner: &str, ex: &str, action: Option<Action>| {
query.split_whitespace().all(|token| { query.split_whitespace().all(|token| {
@@ -5560,6 +5592,10 @@ impl Editor {
.entries .entries
.get(palette.selected) .get(palette.selected)
.filter(|entry| entry.enabled)?; .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 let Some(action) = Action::named(mode, &entry.name) {
if !matches!(action, Action::PaneClose | Action::PaneForceClose) { if !matches!(action, Action::PaneClose | Action::PaneForceClose) {
self.pane_close_picker_origin = None; self.pane_close_picker_origin = None;
@@ -6261,6 +6297,7 @@ impl Editor {
query: String::new(), query: String::new(),
entries: Vec::new(), entries: Vec::new(),
selected: 0, selected: 0,
buffers: false,
}); });
self.refresh_palette(); self.refresh_palette();
} }
@@ -6915,25 +6952,14 @@ impl Editor {
self.set_message(format!("The {name} command takes no arguments")); self.set_message(format!("The {name} command takes no arguments"));
} }
Action::Buffers => { Action::Buffers => {
let mut message = String::new(); self.prepare_command();
for buffer in self.buffers() { self.palette = Some(Palette {
if !message.is_empty() { query: String::new(),
message.push_str(" | "); entries: Vec::new(),
} selected: 0,
use std::fmt::Write as _; buffers: true,
write!( });
message, self.refresh_palette();
"{}{}{} {}",
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);
} }
Action::BufferNext | Action::BufferPrevious => { Action::BufferNext | Action::BufferPrevious => {
self.switch_buffer(self.adjacent_buffer(action == Action::BufferNext)) self.switch_buffer(self.adjacent_buffer(action == Action::BufferNext))
@@ -17454,10 +17480,12 @@ mod tests {
type_text(&mut editor, " o"); type_text(&mut editor, " o");
editor.execute("buffers"); editor.execute("buffers");
assert!(editor.file_picker().is_none()); assert!(editor.file_picker().is_none());
editor.handle(Input::Escape);
type_text(&mut editor, " eifile"); type_text(&mut editor, " eifile");
editor.execute("buffers"); editor.execute("buffers");
assert!(!editor.file_picker().unwrap().focused); assert!(!editor.file_picker().unwrap().focused);
assert_eq!(editor.file_picker().unwrap().query, "file"); assert_eq!(editor.file_picker().unwrap().query, "file");
editor.handle(Input::Escape);
type_text(&mut editor, " e"); type_text(&mut editor, " e");
editor.handle(Input::Ctrl('w')); editor.handle(Input::Ctrl('w'));
assert!(editor.file_picker().is_none()); 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] #[test]
fn default_leader_routes_save_switch_list_delete_and_open_palette() { fn default_leader_routes_save_switch_list_delete_and_open_palette() {
let directory = Directory::new(); let directory = Directory::new();
@@ -18402,9 +18561,6 @@ mod tests {
assert_eq!(editor.sequence_pending(), None); assert_eq!(editor.sequence_pending(), None);
type_text(&mut editor, " bp"); type_text(&mut editor, " bp");
assert_eq!(editor.buffer_id(), 2); 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"); type_text(&mut editor, " bd");
assert!(!editor.has_buffer(2)); assert!(!editor.has_buffer(2));
assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.buffer_id(), 1);
@@ -18876,14 +19032,7 @@ mod tests {
editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); editor.apply(vec![Effect::Replace("abcd".into())]).unwrap();
editor.execute(&format!("edit {}", directory.path("other").display())); editor.execute(&format!("edit {}", directory.path("other").display()));
editor.set_leader(",").unwrap(); editor.set_leader(",").unwrap();
for command in [ for command in ["buffer 1", "buffer 1", "bnext", "bprev", "buffer 999"] {
"buffer 1",
"buffer 1",
"bnext",
"bprev",
"buffers",
"buffer 999",
] {
type_text(&mut editor, "3,b"); type_text(&mut editor, "3,b");
assert_eq!(editor.sequence_pending(), Some(", b")); assert_eq!(editor.sequence_pending(), Some(", b"));
editor.execute(command); editor.execute(command);
@@ -20819,14 +20968,6 @@ mod tests {
assert!(editor.dirty()); assert!(editor.dirty());
assert_eq!(fs::read_to_string(&path).unwrap(), "dirty memory"); assert_eq!(fs::read_to_string(&path).unwrap(), "dirty memory");
assert!(!new.exists()); 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 { fn palette_entry<'a>(editor: &'a Editor, name: &str) -> &'a PaletteEntry {
@@ -23323,7 +23464,7 @@ mod tests {
} }
"startup" => editor.show_startup_diagnostic("notice".into()), "startup" => editor.show_startup_diagnostic("notice".into()),
"command" => { "command" => {
editor.execute("buffers"); editor.execute("plugins");
} }
"paste" => { "paste" => {
editor.handle(Input::Paste("!".into())); editor.handle(Input::Paste("!".into()));
@@ -23779,7 +23920,7 @@ mod tests {
editor.handle(Input::Ctrl('p')); editor.handle(Input::Ctrl('p'));
} }
"command" => { "command" => {
editor.execute("buffers"); editor.execute("plugins");
} }
"plugin" => { "plugin" => {
editor editor
+2 -1
View File
@@ -12,7 +12,7 @@ Usage: vamp [--config PATH | --no-config] [FILE]
Default keys (configurable with config.bind): 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 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/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) 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 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 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 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 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: :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 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 :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 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 "E", config.core.edit
config.bind "B", config.core.buffer config.bind "B", config.core.buffer
config.bind "P", config.core.plugins config.bind "P", config.core.plugins
config.bind "L", config.core.buffers
config.bind "N", config.core.buffer_next config.bind "N", config.core.buffer_next
config.bind "V", config.core.buffer_previous config.bind "V", config.core.buffer_previous
config.bind "D", config.core.buffer_delete config.bind "D", config.core.buffer_delete
@@ -1069,9 +1068,6 @@ end"#,
assert_eq!(session.editor().lines(), &["base"]); assert_eq!(session.editor().lines(), &["base"]);
session.handle(Input::Char('P')); session.handle(Input::Char('P'));
assert!(session.editor().message().contains("text")); 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')); session.handle(Input::Char('N'));
assert_eq!(session.editor().buffer_id(), 2); assert_eq!(session.editor().buffer_id(), 2);
session.handle(Input::Char('V')); session.handle(Input::Char('V'));
+33 -3
View File
@@ -4377,7 +4377,14 @@ fn draw_palette(
} else { } else {
Borders::NONE Borders::NONE
}) })
.title(format!(" Commands · {mode} ")); .title(format!(
" {} · {mode} ",
if palette.buffers {
"Buffers"
} else {
"Commands"
}
));
let content = if bordered { block.inner(panel) } else { panel }; let content = if bordered { block.inner(panel) } else { panel };
if bordered { if bordered {
frame.render_widget(block, panel); 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)); frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
if palette.entries.is_empty() { 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 { } 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 count = usize::from(results_area.height) / row_height;
let first = palette.selected.saturating_sub(count - 1); let first = palette.selected.saturating_sub(count - 1);
let mut lines = Vec::with_capacity(count * row_height); let mut lines = Vec::with_capacity(count * row_height);
@@ -4413,6 +4431,17 @@ fn draw_palette(
if index == palette.selected { if index == palette.selected {
style = style.bg(Color::Blue).add_modifier(Modifier::BOLD); 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() { let bindings = if entry.bindings.is_empty() {
"unbound".into() "unbound".into()
} else { } else {
@@ -14397,6 +14426,7 @@ end
query: "long\t界e\u{301}z".into(), query: "long\t界e\u{301}z".into(),
entries: vec![], entries: vec![],
selected: 0, selected: 0,
buffers: false,
}; };
for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] { 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); let area = ratatui::layout::Rect::new(3, 2, width, height);