TODO:
- [x] merge main
- [x] nonshrinking `set_excerpts_for_path`
- [x] Test-drive potential problem areas in the app
- [x] prepare cloud side
- [x] test collaboration
- [ ] docstrings
- [ ] ???
## Context
### Background
Currently, a multibuffer consists of an arbitrary list of
anchor-delimited excerpts from individual buffers. Excerpt ranges for a
fixed buffer are permitted to overlap, and can appear in any order in
the multibuffer, possibly separated by excerpts from other buffers.
However, in practice all code that constructs multibuffers does so using
the APIs defined in the `path_key` submodule of the `multi_buffer` crate
(`set_excerpts_for_path` etc.) If you only use these APIs, the resulting
multibuffer will maintain the following invariants:
- All excerpts for the same buffer appear contiguously in the
multibuffer
- Excerpts for the same buffer cannot overlap
- Excerpts for the same buffer appear in order
- The placement of the excerpts for a specific buffer in the multibuffer
are determined by the `PathKey` passed to `set_excerpts_for_path`. There
is exactly one `PathKey` per buffer in the multibuffer
### Purpose of this PR
This PR changes the multibuffer so that the invariants maintained by the
`path_key` APIs *always* hold. It's no longer possible to construct a
multibuffer with overlapping excerpts, etc. The APIs that permitted
this, like `insert_excerpts_with_ids_after`, have been removed in favor
of the `path_key` suite.
The main upshot of this is that given a `text::Anchor` and a
multibuffer, it's possible to efficiently figure out the unique excerpt
that includes that anchor, if any:
```
impl MultiBufferSnapshot {
fn buffer_anchor_to_anchor(&self, anchor: text::Anchor) -> Option<multi_buffer::Anchor>;
}
```
And in the other direction, given a `multi_buffer::Anchor`, we can look
at its `text::Anchor` to locate the excerpt that contains it. That means
we don't need an `ExcerptId` to create or resolve
`multi_buffer::Anchor`, and in fact we can delete `ExcerptId` entirely,
so that excerpts no longer have any identity outside their
`Range<text::Anchor>`.
There are a large number of changes to `editor` and other downstream
crates as a result of removing `ExcerptId` and multibuffer APIs that
assumed it.
### Other changes
There are some other improvements that are not immediate consequences of
that big change, but helped make it smoother. Notably:
- The `buffer_id` field of `text::Anchor` is no longer optional.
`text::Anchor::{MIN, MAX}` have been removed in favor of
`min_for_buffer`, etc.
- `multi_buffer::Anchor` is now a three-variant enum (inlined slightly):
```
enum Anchor {
Min,
Excerpt {
text_anchor: text::Anchor,
path_key_index: PathKeyIndex,
diff_base_anchor: Option<text::Anchor>,
},
Max,
}
```
That means it's no longer possible to unconditionally access the
`text_anchor` field, which is good because most of the places that were
doing that were buggy for min/max! Instead, we have a new API that
correctly resolves min/max to the start of the first excerpt or the end
of the last excerpt:
```
impl MultiBufferSnapshot {
fn anchor_to_buffer_anchor(&self, anchor: multi_buffer::Anchor) -> Option<text::Anchor>;
}
```
- `MultiBufferExcerpt` has been removed in favor of a new
`map_excerpt_ranges` API directly on `MultiBufferSnapshot`.
## Self-Review Checklist
<!-- Check before requesting review: -->
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable
Release Notes:
- N/A
---------
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
Co-authored-by: Jakub Konka <kubkon@jakubkonka.com>
Co-authored-by: Conrad <conrad@zed.dev>
332 lines
12 KiB
Rust
332 lines
12 KiB
Rust
use collections::HashMap;
|
|
use gpui::{AppContext, Context, Entity, Window};
|
|
use itertools::Itertools;
|
|
use language::Buffer;
|
|
use std::{ops::Range, sync::Arc, time::Duration};
|
|
use text::{Anchor, AnchorRangeExt, Bias, BufferId, ToOffset, ToPoint};
|
|
use util::ResultExt;
|
|
|
|
use crate::Editor;
|
|
|
|
#[derive(Clone, Default)]
|
|
pub(super) struct LinkedEditingRanges(
|
|
/// Ranges are non-overlapping and sorted by .0 (thus, [x + 1].start > [x].end must hold)
|
|
pub HashMap<BufferId, Vec<(Range<Anchor>, Vec<Range<Anchor>>)>>,
|
|
);
|
|
|
|
impl LinkedEditingRanges {
|
|
pub(super) fn get(
|
|
&self,
|
|
id: BufferId,
|
|
anchor: Range<Anchor>,
|
|
snapshot: &text::BufferSnapshot,
|
|
) -> Option<&(Range<Anchor>, Vec<Range<Anchor>>)> {
|
|
let ranges_for_buffer = self.0.get(&id)?;
|
|
let lower_bound = ranges_for_buffer
|
|
.partition_point(|(range, _)| range.start.cmp(&anchor.start, snapshot).is_le());
|
|
if lower_bound == 0 {
|
|
// None of the linked ranges contains `anchor`.
|
|
return None;
|
|
}
|
|
ranges_for_buffer
|
|
.get(lower_bound - 1)
|
|
.filter(|(range, _)| range.end.cmp(&anchor.end, snapshot).is_ge())
|
|
}
|
|
pub(super) fn is_empty(&self) -> bool {
|
|
self.0.is_empty()
|
|
}
|
|
|
|
pub(super) fn clear(&mut self) {
|
|
self.0.clear();
|
|
}
|
|
}
|
|
|
|
const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
|
|
|
|
// TODO do not refresh anything at all, if the settings/capabilities do not have it enabled.
|
|
pub(super) fn refresh_linked_ranges(
|
|
editor: &mut Editor,
|
|
window: &mut Window,
|
|
cx: &mut Context<Editor>,
|
|
) -> Option<()> {
|
|
if !editor.lsp_data_enabled() || editor.pending_rename.is_some() {
|
|
return None;
|
|
}
|
|
let project = editor.project()?.downgrade();
|
|
|
|
editor.linked_editing_range_task = Some(cx.spawn_in(window, async move |editor, cx| {
|
|
cx.background_executor().timer(UPDATE_DEBOUNCE).await;
|
|
|
|
let mut applicable_selections = Vec::new();
|
|
editor
|
|
.update(cx, |editor, cx| {
|
|
let display_snapshot = editor.display_snapshot(cx);
|
|
let selections = editor.selections.all_anchors(&display_snapshot);
|
|
let snapshot = display_snapshot.buffer_snapshot();
|
|
let buffer = editor.buffer.read(cx);
|
|
for selection in selections.iter() {
|
|
if let Some((_, range)) =
|
|
snapshot.anchor_range_to_buffer_anchor_range(selection.range())
|
|
&& let Some(buffer) = buffer.buffer(range.start.buffer_id)
|
|
{
|
|
applicable_selections.push((buffer, range.start, range.end));
|
|
}
|
|
}
|
|
})
|
|
.ok()?;
|
|
|
|
if applicable_selections.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let highlights = project
|
|
.update(cx, |project, cx| {
|
|
let mut linked_edits_tasks = vec![];
|
|
for (buffer, start, end) in &applicable_selections {
|
|
let linked_edits_task = project.linked_edits(buffer, *start, cx);
|
|
let cx = cx.to_async();
|
|
let highlights = async move {
|
|
let edits = linked_edits_task.await.log_err()?;
|
|
let snapshot = cx.read_entity(&buffer, |buffer, _| buffer.snapshot());
|
|
let buffer_id = snapshot.remote_id();
|
|
|
|
// Find the range containing our current selection.
|
|
// We might not find one, because the selection contains both the start and end of the contained range
|
|
// (think of selecting <`html>foo`</html> - even though there's a matching closing tag, the selection goes beyond the range of the opening tag)
|
|
// or the language server may not have returned any ranges.
|
|
|
|
let start_point = start.to_point(&snapshot);
|
|
let end_point = end.to_point(&snapshot);
|
|
let _current_selection_contains_range = edits.iter().find(|range| {
|
|
range.start.to_point(&snapshot) <= start_point
|
|
&& range.end.to_point(&snapshot) >= end_point
|
|
});
|
|
_current_selection_contains_range?;
|
|
// Now link every range as each-others sibling.
|
|
let mut siblings: HashMap<Range<Anchor>, Vec<_>> = Default::default();
|
|
let mut insert_sorted_anchor =
|
|
|key: &Range<Anchor>, value: &Range<Anchor>| {
|
|
siblings.entry(key.clone()).or_default().push(value.clone());
|
|
};
|
|
for items in edits.into_iter().combinations(2) {
|
|
let Ok([first, second]): Result<[_; 2], _> = items.try_into() else {
|
|
unreachable!()
|
|
};
|
|
|
|
insert_sorted_anchor(&first, &second);
|
|
insert_sorted_anchor(&second, &first);
|
|
}
|
|
let mut siblings: Vec<(_, _)> = siblings.into_iter().collect();
|
|
siblings.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0, &snapshot));
|
|
Some((buffer_id, siblings))
|
|
};
|
|
linked_edits_tasks.push(highlights);
|
|
}
|
|
linked_edits_tasks
|
|
})
|
|
.ok()?;
|
|
|
|
let highlights = futures::future::join_all(highlights).await;
|
|
|
|
editor
|
|
.update(cx, |this, cx| {
|
|
this.linked_edit_ranges.0.clear();
|
|
if this.pending_rename.is_some() {
|
|
return;
|
|
}
|
|
for (buffer_id, ranges) in highlights.into_iter().flatten() {
|
|
this.linked_edit_ranges
|
|
.0
|
|
.entry(buffer_id)
|
|
.or_default()
|
|
.extend(ranges);
|
|
}
|
|
for (buffer_id, values) in this.linked_edit_ranges.0.iter_mut() {
|
|
let Some(snapshot) = this
|
|
.buffer
|
|
.read(cx)
|
|
.buffer(*buffer_id)
|
|
.map(|buffer| buffer.read(cx).snapshot())
|
|
else {
|
|
continue;
|
|
};
|
|
values.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0, &snapshot));
|
|
}
|
|
|
|
cx.notify();
|
|
})
|
|
.ok()?;
|
|
|
|
Some(())
|
|
}));
|
|
None
|
|
}
|
|
|
|
/// Accumulates edits destined for linked editing ranges, for example, matching
|
|
/// HTML/JSX tags, across one or more buffers. Edits are stored as anchor ranges
|
|
/// so they track buffer changes and are only resolved to concrete points at
|
|
/// apply time.
|
|
pub struct LinkedEdits(HashMap<Entity<Buffer>, Vec<(Range<Anchor>, Arc<str>)>>);
|
|
|
|
impl LinkedEdits {
|
|
pub fn new() -> Self {
|
|
Self(HashMap::default())
|
|
}
|
|
|
|
/// Queries the editor's linked editing ranges for the given anchor range and, if any
|
|
/// are found, records them paired with `text` for later application.
|
|
pub(crate) fn push(
|
|
&mut self,
|
|
editor: &Editor,
|
|
anchor_range: Range<Anchor>,
|
|
text: Arc<str>,
|
|
cx: &gpui::App,
|
|
) {
|
|
if let Some(editing_ranges) = editor.linked_editing_ranges_for(anchor_range, cx) {
|
|
for (buffer, ranges) in editing_ranges {
|
|
self.0
|
|
.entry(buffer)
|
|
.or_default()
|
|
.extend(ranges.into_iter().map(|range| (range, text.clone())));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resolves all stored anchor ranges to points using the current buffer snapshot,
|
|
/// sorts them, and applies the edits.
|
|
pub fn apply(self, cx: &mut Context<Editor>) {
|
|
self.apply_inner(false, cx);
|
|
}
|
|
|
|
/// Like [`apply`](Self::apply), but empty ranges (where start == end) are
|
|
/// expanded one character to the left before applying. For context, this
|
|
/// was introduced in order to be available to `backspace` so as to delete a
|
|
/// character in each linked range even when the selection was a cursor.
|
|
pub fn apply_with_left_expansion(self, cx: &mut Context<Editor>) {
|
|
self.apply_inner(true, cx);
|
|
}
|
|
|
|
fn apply_inner(self, expand_empty_ranges_left: bool, cx: &mut Context<Editor>) {
|
|
for (buffer, ranges_edits) in self.0 {
|
|
buffer.update(cx, |buffer, cx| {
|
|
let snapshot = buffer.snapshot();
|
|
let edits = ranges_edits
|
|
.into_iter()
|
|
.map(|(range, text)| {
|
|
let mut start = range.start.to_point(&snapshot);
|
|
let end = range.end.to_point(&snapshot);
|
|
|
|
if expand_empty_ranges_left && start == end {
|
|
let offset = range.start.to_offset(&snapshot).saturating_sub(1);
|
|
start = snapshot.clip_point(offset.to_point(&snapshot), Bias::Left);
|
|
}
|
|
|
|
(start..end, text)
|
|
})
|
|
.sorted_by_key(|(range, _)| range.start);
|
|
|
|
buffer.edit(edits, None, cx);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::{editor_tests::init_test, test::editor_test_context::EditorTestContext};
|
|
use gpui::TestAppContext;
|
|
use text::Point;
|
|
|
|
#[gpui::test]
|
|
async fn test_linked_edits_push_and_apply(cx: &mut TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
let mut cx = EditorTestContext::new(cx).await;
|
|
|
|
cx.set_state("<diˇv></div>");
|
|
cx.update_editor(|editor, _window, cx| {
|
|
editor
|
|
.set_linked_edit_ranges_for_testing(
|
|
vec![(
|
|
Point::new(0, 1)..Point::new(0, 4),
|
|
vec![Point::new(0, 7)..Point::new(0, 10)],
|
|
)],
|
|
cx,
|
|
)
|
|
.unwrap();
|
|
});
|
|
|
|
cx.simulate_keystroke("x");
|
|
cx.assert_editor_state("<dixˇv></dixv>");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_linked_edits_backspace(cx: &mut TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
let mut cx = EditorTestContext::new(cx).await;
|
|
|
|
cx.set_state("<divˇ></div>");
|
|
cx.update_editor(|editor, _window, cx| {
|
|
editor
|
|
.set_linked_edit_ranges_for_testing(
|
|
vec![(
|
|
Point::new(0, 1)..Point::new(0, 4),
|
|
vec![Point::new(0, 7)..Point::new(0, 10)],
|
|
)],
|
|
cx,
|
|
)
|
|
.unwrap();
|
|
});
|
|
|
|
cx.update_editor(|editor, window, cx| {
|
|
editor.backspace(&Default::default(), window, cx);
|
|
});
|
|
cx.assert_editor_state("<diˇ></di>");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_linked_edits_delete(cx: &mut TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
let mut cx = EditorTestContext::new(cx).await;
|
|
|
|
cx.set_state("<ˇdiv></div>");
|
|
cx.update_editor(|editor, _window, cx| {
|
|
editor
|
|
.set_linked_edit_ranges_for_testing(
|
|
vec![(
|
|
Point::new(0, 1)..Point::new(0, 4),
|
|
vec![Point::new(0, 7)..Point::new(0, 10)],
|
|
)],
|
|
cx,
|
|
)
|
|
.unwrap();
|
|
});
|
|
|
|
cx.update_editor(|editor, window, cx| {
|
|
editor.delete(&Default::default(), window, cx);
|
|
});
|
|
cx.assert_editor_state("<ˇiv></iv>");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_linked_edits_selection(cx: &mut TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
let mut cx = EditorTestContext::new(cx).await;
|
|
|
|
cx.set_state("<«divˇ»></div>");
|
|
cx.update_editor(|editor, _window, cx| {
|
|
editor
|
|
.set_linked_edit_ranges_for_testing(
|
|
vec![(
|
|
Point::new(0, 1)..Point::new(0, 4),
|
|
vec![Point::new(0, 7)..Point::new(0, 10)],
|
|
)],
|
|
cx,
|
|
)
|
|
.unwrap();
|
|
});
|
|
|
|
cx.simulate_keystrokes("s p a n");
|
|
cx.assert_editor_state("<spanˇ></span>");
|
|
}
|
|
}
|