I am aware of #4335, but wanted to make a dedicated issue that addresses the potential implementation details of such a buffer, and some of the design aspects. Not necessarily things related to find-and-search specifically. I might want to try to implement this, as it looks incredibly useful. And importantly, find-and-replace appears to only be one use-case (but which I would use as the reference implementation!).
The most popular form of this is Zed's implementation: https://zed.dev/docs/multibuffers
The key points are that you can open up multiple views of documents, including the same document multiple times, and use normal editing machinery to manipulate the text content en masse, in one buffer, and then save the buffer and have the changes saved as a whole.
As for implementation details, what I am thinking now is we add a new [multibuffer] type, which gets opened when multi-buffer things are requested. From there, we can use multi-cursors, local search, etc. to manipulate as we please. This would of course enable a reference find-and-replace solution, but I have seen that its used for many other things as well.
For example, one thing I came across today that I wanted to do was be able to look at all references to an enum variant. I had to use the last buffer picker to cycle through, and constantly had to context switch, and go back and forth to make sure I understood its use in some key areas (its what motivated this post!).
What I imagine is that we still use the current pickers as they are, but offer a keybind that would take the current state of the picker and then open a multi-buffer using the state (like for the global search or reference pickers).
This would then open the multi-buffer with the state now displayed in the multi-buffer, such as cursors already selecting the text that was searched for. Just as a normal view, the user can manipulate the text as they see fit and then save. I do think we should have some differences from Zed's choice where the have :w save all the buffers' changes, as if we delegate this action to the existing :wa, we could allow a finer grained approach to saving individual buffers, like where the primary cursor is, the user could use :w to save the buffer one by one, as they evaluates the changes.
For some further features, I think the multi-buffer would offer different actions that would assist in interacting with the multi-buffer, such as a goto buffer action that would open the buffer where the primary cursor is, in new full blown views or splits, or even allow to open where all cursors are. Another important one would be a way to remove views from the multi-buffer.
I also imagine a picker interaction where you could add views to the multi-buffer in the background, so that the user can use the picker to build up the multi-buffer, offering a way of filtering that integrates with normal workflows.
For how we would go about displaying the content itself, I imagine that we model a similar look as the horizontal split, using a line to visually separate the views, except now the cursor is not captured solely in the view. We could put the relative path inline with this separator line, or as a virtual text annotation in a location above the view.
We could put the line number of the view in the render as well, though it could be a lot noise if the user has a number line enable for the buffer. You would see the normal number line, but then also, in close proximity, the line numbers of the views. This would need to be refined through trial and error.
Beyond that, the view areas should behave like normal views. Given that we are just, in essence, showing a sub-slice of a view, there should still be access to LSP, diff markers, etc. The toughest part of all this seems to be coordinating the state across all the views.
Zed groups the same document results into the same view section:
I think it would be both simpler, but also more powerful, to just have each result section be its own separate view, as this would make it easier to remove a section from the multibuffer, but still keep the other sections intact. This should also hopefully make it easier to save changes, as we can just iterate through all the independent views and save, from first element to the last element (top to bottom of the multibuffer). This would also make it easy to handle when users expand a view above or below. If they did this maximally, this would just open up full views of the documents multiple times, rather than needing to worry about how to merge the views together and handle complicated mergers of both state and UI.
I think the scaffolding for all of these concepts exist already as an implementation in the code base right now, or have PRs that have done some aspect of what is desired, such as #14538 adding a diff to the picker preview, among other PRs (I will go through and add any relevant PRs that could serve as prior art as I comb through and see which are relevant).
(NOTE: I will be editing this issue over time as I refine more details and collect information on how the implementation would work)
Some high level sketching as code:
use std::assert as pre;
use std::assert as post;
use std::panic as invariant;
use std::panic as logic;
pub struct MultiBufferId(u64);
pub struct MultiBuffer {
id: MultiBufferId,
// Would be the label, rather than `[multibuffer]`?
// Like taking from the current search parameters.
name: String,
// As the multi-buffer will be append-only, we can
// stably map a `ViewSliceId` to an index into the
// `Vec`.
//
// For operations that operate on specific views, this
// allows `O(1)` time to look up an index without having
// to rely solely on iterating through the `Vec` until we
// find the View we want to manipulate.
// INVARIANT:
// - Must always point to a valid index in `views`.
// - `ViewSliceId` should always match with the indexed `ViewSliceId`.
idx_map: HashMap<ViewSliceId, usize>,
// "Removed" views are just tombstoned.
// This allows a stable mapping between `ViewSliceId`
// and an index into the `Vec`.
// INVARIANT: Append-Only.
views: Vec<Option<ViewSlice>>,
}
impl MultiBuffer {
// Append new view slice to the bottom of the buffer.
pub fn push(&mut self, view: View) {
let condition = self.views.len();
pre!(
self.views.len() == self.idx_map.len(),
"pre-condition failed: `MultiBuffer::views` and `MultiBuffer::idx_map` must always have the same number of elements as the mapping should be 1:1"
);
let id = ViewSliceId(1);
self.views.push(Some(ViewSlice {
id,
look_above: 2,
line: view.cursor().line(),
look_below: 2,
view,
}));
post!(
condition + 1 == self.views.len(),
"post-condition failed: `MultiBuffer` can only grow by one element when using `MultiBuffer::push`"
);
self.idx_map.insert(id, self.views.len() - 1);
post!(
self.idx_map.len() == self.views.len(),
"post-condition failed: `MultiBuffer::idx_map` and `MultiBuffer::push` must always have the same number of elements"
);
match self.idx_map.get(&id) {
Some(idx) if self.views.get(*idx).is_none() => invariant!(
"invariant failed: `ViewSliceId` must always round trip from `idx_map` to `views`, yet returned `{idx}`, which is out of bounds of `{}`",
self.views.len() - 1
),
Some(idx)
if let Some(Some(view)) = self.views.get(*idx)
&& view.id != id =>
{
invariant!(
"invariant failed: `ViewSliceId` must always round trip from `idx_map` to `views`, but the returned `ViewSlice::id({:?})` was not the expected {id:?}",
view.id
)
}
None => invariant!(
"invariant failed: `ViewSliceId` must always round trip from `idx_map` to `views`, yet `{id:?}` didn't map to an index"
),
Some(idx)
if let Some(Some(view)) = self.views.get(*idx)
&& view.id == id =>
{
assert!(true)
}
Some(_) => unreachable!(),
}
}
// "Remove" a buffer by tombstone-ing it.
//
// This should be more efficient than something like `Vec::remove`,
// which can have `O(N)` operations, and more intuitive than something
// like `swap_remove` which would reorder views in odd ways as the user
// interacts with the buffer.
//
// Tombstone-ing should keep the CPU pre-fetcher happy, but as this can
// potentially have thousands of views, performance must be scrutinized!
pub fn remove(&mut self, id: ViewSliceId) {
let condition = self.views.len();
pre!(
self.views.len() == self.idx_map.len(),
"pre-condition failed: `MultiBuffer::views` and `MultiBuffer::idx_map` must always have the same number of elements as the mapping should be 1:1"
);
let Some(idx) = self.idx_map.get(&id) else {
invariant!(
"tried to index an impossible `ViewSliceId` from `MultiBuffer::idx_map`, indicating an issue in `MultiBuffer::push`"
);
};
match self.views.get_mut(*idx) {
Some(Some(view)) if view.id != id => invariant!(
"invariant failed: `{id:?}` and {:?} were not matching, yet the idx gotten from `idx_map` indicated they MUST",
view.id
),
Some(None) => {
logic!(
"logic failed: attempted to tombstone a view that was already tombstoned, and it would be considered a logic but to keep a copy of a tombstoned id"
)
}
Some(view) => *view = None, // Tombstone.
None => invariant!(
"invariant failed: `MultiBuffer` idx_map index must always point to a valid `View` in the `Vec`"
),
}
post!(
condition == self.views.len(),
"post-condition failed: `MultiBuffer` can only grow, never shrink"
);
}
}
#[derive(Hash, PartialEq, Eq, Debug)]
pub struct ViewSliceId(u64);
pub struct ViewSlice {
id: ViewSliceId,
// Default to two lines above and below as a look around.
// These could be mutated, like for example to see more
// above or below the default area, to get more context.
// This similar to what you can do on GitHub for example.
look_above: u16 = 2,
// The line that was relavant to making the multi-buffer
// to begin with. This acts as an anchor to render around.
line: u16,
look_below: u16 = 2,
// By storing a standard View, we should hopefully get
// the bulk of what we need from existing implementations.
view: View,
}
// SubView, ViewWindow?
impl ViewSlice {
// When re-rendered, it would show `lines` more
// above, clamped to document bounds.
pub fn see_more_above(&mut self, lines: u16) {}
// When re-rendered, it would show `lines` more
// below, clamped to document bounds.
pub fn see_more_below(&mut self, lines: u16) {}
}
Potentially relevant PRs:
Video example:
I am aware of #4335, but wanted to make a dedicated issue that addresses the potential implementation details of such a buffer, and some of the design aspects. Not necessarily things related to find-and-search specifically. I might want to try to implement this, as it looks incredibly useful. And importantly, find-and-replace appears to only be one use-case (but which I would use as the reference implementation!).
The most popular form of this is Zed's implementation: https://zed.dev/docs/multibuffers
The key points are that you can open up multiple views of documents, including the same document multiple times, and use normal editing machinery to manipulate the text content en masse, in one buffer, and then save the buffer and have the changes saved as a whole.
As for implementation details, what I am thinking now is we add a new
[multibuffer]type, which gets opened when multi-buffer things are requested. From there, we can use multi-cursors, local search, etc. to manipulate as we please. This would of course enable a reference find-and-replace solution, but I have seen that its used for many other things as well.For example, one thing I came across today that I wanted to do was be able to look at all references to an enum variant. I had to use the last buffer picker to cycle through, and constantly had to context switch, and go back and forth to make sure I understood its use in some key areas (its what motivated this post!).
What I imagine is that we still use the current pickers as they are, but offer a keybind that would take the current state of the picker and then open a multi-buffer using the state (like for the global search or reference pickers).
This would then open the multi-buffer with the state now displayed in the multi-buffer, such as cursors already selecting the text that was searched for. Just as a normal view, the user can manipulate the text as they see fit and then save. I do think we should have some differences from Zed's choice where the have
:wsave all the buffers' changes, as if we delegate this action to the existing:wa, we could allow a finer grained approach to saving individual buffers, like where the primary cursor is, the user could use:wto save the buffer one by one, as they evaluates the changes.For some further features, I think the multi-buffer would offer different actions that would assist in interacting with the multi-buffer, such as a
goto bufferaction that would open the buffer where the primary cursor is, in new full blown views or splits, or even allow to open where all cursors are. Another important one would be a way to remove views from the multi-buffer.I also imagine a picker interaction where you could add views to the multi-buffer in the background, so that the user can use the picker to build up the multi-buffer, offering a way of filtering that integrates with normal workflows.
For how we would go about displaying the content itself, I imagine that we model a similar look as the horizontal split, using a line to visually separate the views, except now the cursor is not captured solely in the view. We could put the relative path inline with this separator line, or as a virtual text annotation in a location above the view.
We could put the line number of the view in the render as well, though it could be a lot noise if the user has a number line enable for the buffer. You would see the normal number line, but then also, in close proximity, the line numbers of the views. This would need to be refined through trial and error.
Beyond that, the view areas should behave like normal views. Given that we are just, in essence, showing a sub-slice of a view, there should still be access to LSP, diff markers, etc. The toughest part of all this seems to be coordinating the state across all the views.
Zed groups the same document results into the same view section:
I think it would be both simpler, but also more powerful, to just have each result section be its own separate view, as this would make it easier to remove a section from the multibuffer, but still keep the other sections intact. This should also hopefully make it easier to save changes, as we can just iterate through all the independent views and save, from first element to the last element (top to bottom of the multibuffer). This would also make it easy to handle when users expand a view above or below. If they did this maximally, this would just open up full views of the documents multiple times, rather than needing to worry about how to merge the views together and handle complicated mergers of both state and UI.
I think the scaffolding for all of these concepts exist already as an implementation in the code base right now, or have PRs that have done some aspect of what is desired, such as #14538 adding a diff to the picker preview, among other PRs (I will go through and add any relevant PRs that could serve as prior art as I comb through and see which are relevant).
(NOTE: I will be editing this issue over time as I refine more details and collect information on how the implementation would work)
Some high level sketching as code:
Potentially relevant PRs:
Video example: