Add a command to expand the context for a multibuffer (#10300)

This PR adds an action to expand the excerpts lines of context in a
multibuffer.

Release Notes:

- Added an `editor::ExpandExcerpts` action (bound to `shift-enter` by
default), which can expand the excerpt the cursor is currently in by 3
lines. You can customize the number of lines by rebinding this action
like so:

```json5
// In your keybindings array...
  {
    "context": "Editor && mode == full",
    "bindings": {
      "shift-enter": ["editor::ExpandExcerpts", { "lines": 5 }],
    }
  }
```

---------

Co-authored-by: Nathan <nathan@zed.dev>
Co-authored-by: Max <max@zed.dev>
This commit is contained in:
Mikayla Maki 2024-04-19 14:27:56 -07:00 committed by GitHub
parent 9d9bce08a7
commit 8a02159b82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 353 additions and 45 deletions

View file

@ -24,14 +24,18 @@ test-support = [
anyhow.workspace = true
clock.workspace = true
collections.workspace = true
ctor.workspace = true
env_logger.workspace = true
futures.workspace = true
git.workspace = true
gpui.workspace = true
itertools.workspace = true
language.workspace = true
log.workspace = true
parking_lot.workspace = true
rand.workspace = true
settings.workspace = true
smallvec.workspace = true
sum_tree.workspace = true
text.workspace = true
theme.workspace = true

View file

@ -7,6 +7,7 @@ use collections::{BTreeMap, Bound, HashMap, HashSet};
use futures::{channel::mpsc, SinkExt};
use git::diff::DiffHunk;
use gpui::{AppContext, EventEmitter, Model, ModelContext};
use itertools::Itertools;
use language::{
char_kind,
language_settings::{language_settings, LanguageSettings},
@ -15,6 +16,7 @@ use language::{
Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, ToOffset as _,
ToOffsetUtf16 as _, ToPoint as _, ToPointUtf16 as _, TransactionId, Unclipped,
};
use smallvec::SmallVec;
use std::{
borrow::Cow,
cell::{Ref, RefCell},
@ -1008,12 +1010,12 @@ impl MultiBuffer {
anchor_ranges.extend(ranges.by_ref().take(range_count).map(|range| {
let start = Anchor {
buffer_id: Some(buffer_id),
excerpt_id: excerpt_id,
excerpt_id,
text_anchor: buffer_snapshot.anchor_after(range.start),
};
let end = Anchor {
buffer_id: Some(buffer_id),
excerpt_id: excerpt_id,
excerpt_id,
text_anchor: buffer_snapshot.anchor_after(range.end),
};
start..end
@ -1573,6 +1575,86 @@ impl MultiBuffer {
self.as_singleton().unwrap().read(cx).is_parsing()
}
pub fn expand_excerpts(
&mut self,
ids: impl IntoIterator<Item = ExcerptId>,
line_count: u32,
cx: &mut ModelContext<Self>,
) {
if line_count == 0 {
return;
}
self.sync(cx);
let snapshot = self.snapshot(cx);
let locators = snapshot.excerpt_locators_for_ids(ids);
let mut new_excerpts = SumTree::new();
let mut cursor = snapshot.excerpts.cursor::<(Option<&Locator>, usize)>();
let mut edits = Vec::<Edit<usize>>::new();
for locator in &locators {
let prefix = cursor.slice(&Some(locator), Bias::Left, &());
new_excerpts.append(prefix, &());
let mut excerpt = cursor.item().unwrap().clone();
let old_text_len = excerpt.text_summary.len;
let start_row = excerpt
.range
.context
.start
.to_point(&excerpt.buffer)
.row
.saturating_sub(line_count);
let start_point = Point::new(start_row, 0);
excerpt.range.context.start = excerpt.buffer.anchor_before(start_point);
let end_point = excerpt.buffer.clip_point(
excerpt.range.context.end.to_point(&excerpt.buffer) + Point::new(line_count, 0),
Bias::Left,
);
excerpt.range.context.end = excerpt.buffer.anchor_after(end_point);
excerpt.max_buffer_row = end_point.row;
excerpt.text_summary = excerpt
.buffer
.text_summary_for_range(start_point..end_point);
let new_start_offset = new_excerpts.summary().text.len;
let old_start_offset = cursor.start().1;
let edit = Edit {
old: old_start_offset..old_start_offset + old_text_len,
new: new_start_offset..new_start_offset + excerpt.text_summary.len,
};
if let Some(last_edit) = edits.last_mut() {
if last_edit.old.end == edit.old.start {
last_edit.old.end = edit.old.end;
last_edit.new.end = edit.new.end;
} else {
edits.push(edit);
}
} else {
edits.push(edit);
}
new_excerpts.push(excerpt, &());
cursor.next(&());
}
new_excerpts.append(cursor.suffix(&()), &());
drop(cursor);
self.snapshot.borrow_mut().excerpts = new_excerpts;
self.subscriptions.publish_mut(edits);
cx.emit(Event::Edited {
singleton_buffer_edited: false,
});
cx.notify();
}
fn sync(&self, cx: &AppContext) {
let mut snapshot = self.snapshot.borrow_mut();
let mut excerpts_to_edit = Vec::new();
@ -1796,6 +1878,19 @@ impl MultiBuffer {
log::info!("Clearing multi-buffer");
self.clear(cx);
continue;
} else if rng.gen_bool(0.1) && !self.excerpt_ids().is_empty() {
let ids = self.excerpt_ids();
let mut excerpts = HashSet::default();
for _ in 0..rng.gen_range(0..ids.len()) {
excerpts.extend(ids.choose(rng).copied());
}
let line_count = rng.gen_range(0..5);
log::info!("Expanding excerpts {excerpts:?} by {line_count} lines");
self.expand_excerpts(excerpts.iter().cloned(), line_count, cx);
continue;
}
let excerpt_ids = self.excerpt_ids();
@ -3361,6 +3456,39 @@ impl MultiBufferSnapshot {
}
}
// Returns the locators referenced by the given excerpt ids, sorted by locator.
fn excerpt_locators_for_ids(
&self,
ids: impl IntoIterator<Item = ExcerptId>,
) -> SmallVec<[Locator; 1]> {
let mut sorted_ids = ids.into_iter().collect::<SmallVec<[_; 1]>>();
sorted_ids.sort_unstable();
let mut locators = SmallVec::new();
while sorted_ids.last() == Some(&ExcerptId::max()) {
sorted_ids.pop();
locators.push(Locator::max());
}
let mut sorted_ids = sorted_ids.into_iter().dedup().peekable();
if sorted_ids.peek() == Some(&ExcerptId::min()) {
sorted_ids.next();
locators.push(Locator::min());
}
let mut cursor = self.excerpt_ids.cursor::<ExcerptId>();
for id in sorted_ids {
if cursor.seek_forward(&id, Bias::Left, &()) {
locators.push(cursor.item().unwrap().locator.clone());
} else {
panic!("invalid excerpt id {:?}", id);
}
}
locators.sort_unstable();
locators
}
pub fn buffer_id_for_excerpt(&self, excerpt_id: ExcerptId) -> Option<BufferId> {
Some(self.excerpt(excerpt_id)?.buffer_id)
}
@ -4286,7 +4414,8 @@ where
.peekable();
while let Some(range) = range_iter.next() {
let excerpt_start = Point::new(range.start.row.saturating_sub(context_line_count), 0);
let mut excerpt_end = Point::new(range.end.row + context_line_count, 0).min(max_point);
// These + 1s ensure that we select the whole next line
let mut excerpt_end = Point::new(range.end.row + 1 + context_line_count, 0).min(max_point);
let mut ranges_in_excerpt = 1;
@ -4323,6 +4452,13 @@ mod tests {
use std::env;
use util::test::sample_text;
#[ctor::ctor]
fn init_logger() {
if std::env::var("RUST_LOG").is_ok() {
env_logger::init();
}
}
#[gpui::test]
fn test_singleton(cx: &mut AppContext) {
let buffer = cx.new_model(|cx| Buffer::local(sample_text(6, 6, 'a'), cx));
@ -4721,6 +4857,59 @@ mod tests {
assert_eq!(*follower_edit_event_count.read(), 4);
}
#[gpui::test]
fn test_expand_excerpts(cx: &mut AppContext) {
let buffer = cx.new_model(|cx| Buffer::local(sample_text(20, 3, 'a'), cx));
let multibuffer = cx.new_model(|_| MultiBuffer::new(0, Capability::ReadWrite));
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.push_excerpts_with_context_lines(
buffer.clone(),
vec![
// Note that in this test, this first excerpt
// does not contain a new line
Point::new(3, 2)..Point::new(3, 3),
Point::new(7, 1)..Point::new(7, 3),
Point::new(15, 0)..Point::new(15, 0),
],
1,
cx,
)
});
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.expand_excerpts(multibuffer.excerpt_ids(), 1, cx)
});
let snapshot = multibuffer.read(cx).snapshot(cx);
// Expanding context lines causes the line containing 'fff' to appear in two different excerpts.
// We don't attempt to merge them, because removing the excerpt could create inconsistency with other layers
// that are tracking excerpt ids.
assert_eq!(
snapshot.text(),
concat!(
"bbb\n", // Preserve newlines
"ccc\n", //
"ddd\n", //
"eee\n", //
"fff\n", // <- Same as below
"\n", // Excerpt boundary
"fff\n", // <- Same as above
"ggg\n", //
"hhh\n", //
"iii\n", //
"jjj\n", //
"\n", //
"nnn\n", //
"ooo\n", //
"ppp\n", //
"qqq\n", //
"rrr\n", //
)
);
}
#[gpui::test]
fn test_push_excerpts_with_context_lines(cx: &mut AppContext) {
let buffer = cx.new_model(|cx| Buffer::local(sample_text(20, 3, 'a'), cx));
@ -4729,6 +4918,8 @@ mod tests {
multibuffer.push_excerpts_with_context_lines(
buffer.clone(),
vec![
// Note that in this test, this first excerpt
// does contain a new line
Point::new(3, 2)..Point::new(4, 2),
Point::new(7, 1)..Point::new(7, 3),
Point::new(15, 0)..Point::new(15, 0),
@ -4741,7 +4932,23 @@ mod tests {
let snapshot = multibuffer.read(cx).snapshot(cx);
assert_eq!(
snapshot.text(),
"bbb\nccc\nddd\neee\nfff\nggg\nhhh\niii\njjj\n\nnnn\nooo\nppp\nqqq\n"
concat!(
"bbb\n", // Preserve newlines
"ccc\n", //
"ddd\n", //
"eee\n", //
"fff\n", //
"ggg\n", //
"hhh\n", //
"iii\n", //
"jjj\n", //
"\n", //
"nnn\n", //
"ooo\n", //
"ppp\n", //
"qqq\n", //
"rrr\n", //
)
);
assert_eq!(
@ -4777,7 +4984,23 @@ mod tests {
let snapshot = multibuffer.update(cx, |multibuffer, cx| multibuffer.snapshot(cx));
assert_eq!(
snapshot.text(),
"bbb\nccc\nddd\neee\nfff\nggg\nhhh\niii\njjj\n\nnnn\nooo\nppp\nqqq\n"
concat!(
"bbb\n", //
"ccc\n", //
"ddd\n", //
"eee\n", //
"fff\n", //
"ggg\n", //
"hhh\n", //
"iii\n", //
"jjj\n", //
"\n", //
"nnn\n", //
"ooo\n", //
"ppp\n", //
"qqq\n", //
"rrr\n", //
)
);
assert_eq!(
@ -5027,10 +5250,45 @@ mod tests {
for _ in 0..operations {
match rng.gen_range(0..100) {
0..=19 if !buffers.is_empty() => {
0..=14 if !buffers.is_empty() => {
let buffer = buffers.choose(&mut rng).unwrap();
buffer.update(cx, |buf, cx| buf.randomly_edit(&mut rng, 5, cx));
}
15..=19 if !expected_excerpts.is_empty() => {
multibuffer.update(cx, |multibuffer, cx| {
let ids = multibuffer.excerpt_ids();
let mut excerpts = HashSet::default();
for _ in 0..rng.gen_range(0..ids.len()) {
excerpts.extend(ids.choose(&mut rng).copied());
}
let line_count = rng.gen_range(0..5);
let excerpt_ixs = excerpts
.iter()
.map(|id| excerpt_ids.iter().position(|i| i == id).unwrap())
.collect::<Vec<_>>();
log::info!("Expanding excerpts {excerpt_ixs:?} by {line_count} lines");
multibuffer.expand_excerpts(excerpts.iter().cloned(), line_count, cx);
if line_count > 0 {
for id in excerpts {
let excerpt_ix = excerpt_ids.iter().position(|&i| i == id).unwrap();
let (buffer, range) = &mut expected_excerpts[excerpt_ix];
let snapshot = buffer.read(cx).snapshot();
let mut point_range = range.to_point(&snapshot);
point_range.start =
Point::new(point_range.start.row.saturating_sub(line_count), 0);
point_range.end = snapshot.clip_point(
Point::new(point_range.end.row + line_count, 0),
Bias::Left,
);
*range = snapshot.anchor_before(point_range.start)
..snapshot.anchor_after(point_range.end);
}
}
});
}
20..=29 if !expected_excerpts.is_empty() => {
let mut ids_to_remove = vec![];
for _ in 0..rng.gen_range(1..=3) {
@ -5093,8 +5351,9 @@ mod tests {
_ => {
let buffer_handle = if buffers.is_empty() || rng.gen_bool(0.4) {
let base_text = util::RandomCharIter::new(&mut rng)
.take(10)
.take(25)
.collect::<String>();
buffers.push(cx.new_model(|cx| Buffer::local(base_text, cx)));
buffers.last().unwrap()
} else {