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

@ -94,12 +94,19 @@ pub struct SelectDownByLines {
pub(super) lines: u32,
}
#[derive(PartialEq, Clone, Deserialize, Default)]
pub struct ExpandExcerpts {
#[serde(default)]
pub(super) lines: u32,
}
impl_actions!(
editor,
[
SelectNext,
SelectPrevious,
SelectToBeginningOfLine,
ExpandExcerpts,
MovePageUp,
MovePageDown,
SelectToEndOfLine,
@ -254,6 +261,6 @@ gpui::actions!(
UndoSelection,
UnfoldLines,
UniqueLinesCaseSensitive,
UniqueLinesCaseInsensitive
UniqueLinesCaseInsensitive,
]
);

View file

@ -7462,6 +7462,28 @@ impl Editor {
self.selection_history.mode = SelectionHistoryMode::Normal;
}
pub fn expand_excerpts(&mut self, action: &ExpandExcerpts, cx: &mut ViewContext<Self>) {
let selections = self.selections.disjoint_anchors();
let lines = if action.lines == 0 { 3 } else { action.lines };
self.buffer.update(cx, |buffer, cx| {
buffer.expand_excerpts(
selections
.into_iter()
.map(|selection| selection.head().excerpt_id)
.dedup(),
lines,
cx,
)
})
}
pub fn expand_excerpt(&mut self, excerpt: ExcerptId, cx: &mut ViewContext<Self>) {
self.buffer
.update(cx, |buffer, cx| buffer.expand_excerpts([excerpt], 3, cx))
}
fn go_to_diagnostic(&mut self, _: &GoToDiagnostic, cx: &mut ViewContext<Self>) {
self.go_to_diagnostic_impl(Direction::Next, cx)
}

View file

@ -13,9 +13,9 @@ use crate::{
mouse_context_menu::{self, MouseContextMenu},
scroll::scroll_amount::ScrollAmount,
CursorShape, DisplayPoint, DocumentHighlightRead, DocumentHighlightWrite, Editor, EditorMode,
EditorSettings, EditorSnapshot, EditorStyle, GutterDimensions, HalfPageDown, HalfPageUp,
HoveredCursor, LineDown, LineUp, OpenExcerpts, PageDown, PageUp, Point, SelectPhase, Selection,
SoftWrap, ToPoint, CURSORS_VISIBLE_FOR, MAX_LINE_LEN,
EditorSettings, EditorSnapshot, EditorStyle, ExpandExcerpts, GutterDimensions, HalfPageDown,
HalfPageUp, HoveredCursor, LineDown, LineUp, OpenExcerpts, PageDown, PageUp, Point,
SelectPhase, Selection, SoftWrap, ToPoint, CURSORS_VISIBLE_FOR, MAX_LINE_LEN,
};
use anyhow::Result;
use collections::{BTreeMap, HashMap};
@ -257,6 +257,9 @@ impl EditorElement {
register_action(view, cx, Editor::move_to_enclosing_bracket);
register_action(view, cx, Editor::undo_selection);
register_action(view, cx, Editor::redo_selection);
if !view.read(cx).is_singleton(cx) {
register_action(view, cx, Editor::expand_excerpts);
}
register_action(view, cx, Editor::go_to_diagnostic);
register_action(view, cx, Editor::go_to_prev_diagnostic);
register_action(view, cx, Editor::go_to_hunk);
@ -1543,6 +1546,7 @@ impl EditorElement {
range,
starts_new_buffer,
height,
id,
..
} => {
let include_root = self
@ -1700,45 +1704,38 @@ impl EditorElement {
)
.h_full()
.child(
ButtonLike::new("jump-icon")
ButtonLike::new("expand-icon")
.style(ButtonStyle::Transparent)
.child(
svg()
.path(IconName::ArrowUpRight.path())
.path(IconName::ExpandVertical.path())
.size(IconSize::XSmall.rems())
.text_color(cx.theme().colors().border)
.group_hover("excerpt-jump-action", |style| {
.text_color(
cx.theme().colors().editor_line_number,
)
.group("")
.hover(|style| {
style.text_color(
cx.theme().colors().editor_line_number,
cx.theme()
.colors()
.editor_active_line_number,
)
}),
)
.when_some(jump_data.clone(), |this, jump_data| {
this.on_click(cx.listener_for(&self.editor, {
let path = jump_data.path.clone();
move |editor, _, cx| {
editor.jump(
path.clone(),
jump_data.position,
jump_data.anchor,
jump_data.line_offset_from_top,
cx,
);
}
}))
.tooltip({
move |cx| {
Tooltip::for_action(
format!(
"Jump to {}:L{}",
jump_data.path.path.display(),
jump_data.position.row + 1
),
&OpenExcerpts,
cx,
)
}
})
.on_click(cx.listener_for(&self.editor, {
let id = *id;
move |editor, _, cx| {
editor.expand_excerpt(id, cx);
}
}))
.tooltip({
move |cx| {
Tooltip::for_action(
"Expand Excerpt",
&ExpandExcerpts { lines: 0 },
cx,
)
}
}),
),
)

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 {

View file

@ -405,6 +405,7 @@ where
summary.0
}
/// Returns whether we found the item you where seeking for
fn seek_internal(
&mut self,
target: &dyn SeekTarget<'a, T::Summary, D>,

View file

@ -47,6 +47,7 @@ pub enum IconName {
ChevronLeft,
ChevronRight,
ChevronUp,
ExpandVertical,
Close,
Collab,
Command,
@ -149,6 +150,7 @@ impl IconName {
IconName::ChevronLeft => "icons/chevron_left.svg",
IconName::ChevronRight => "icons/chevron_right.svg",
IconName::ChevronUp => "icons/chevron_up.svg",
IconName::ExpandVertical => "icons/expand_vertical.svg",
IconName::Close => "icons/x.svg",
IconName::Collab => "icons/user_group_16.svg",
IconName::Command => "icons/command.svg",

View file

@ -289,7 +289,6 @@ define_connection! {
sql!(
ALTER TABLE workspaces ADD COLUMN centered_layout INTEGER; //bool
),
];
}