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

4
Cargo.lock generated
View File

@ -6051,14 +6051,18 @@ dependencies = [
"anyhow",
"clock",
"collections",
"ctor",
"env_logger",
"futures 0.3.28",
"git",
"gpui",
"itertools 0.11.0",
"language",
"log",
"parking_lot",
"rand 0.8.5",
"settings",
"smallvec",
"sum_tree",
"text",
"theme",

9
assets/icons/LICENSES Normal file
View File

@ -0,0 +1,9 @@
Lucide License
ISC License
Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022.
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-unfold-vertical"><path d="M12 22v-6"/><path d="M12 8V2"/><path d="M4 12H2"/><path d="M10 12H8"/><path d="M16 12h-2"/><path d="M22 12h-2"/><path d="m15 19-3 3-3-3"/><path d="m15 5-3-3-3 3"/></svg>

After

Width:  |  Height:  |  Size: 398 B

View File

@ -527,6 +527,7 @@
"context": "Editor && mode == full",
"bindings": {
"alt-enter": "editor::OpenExcerpts",
"shift-enter": "editor::ExpandExcerpts",
"ctrl-k enter": "editor::OpenExcerptsSplit",
"ctrl-f8": "editor::GoToHunk",
"ctrl-shift-f8": "editor::GoToPrevHunk",

View File

@ -541,6 +541,7 @@
"context": "Editor && mode == full",
"bindings": {
"alt-enter": "editor::OpenExcerpts",
"shift-enter": "editor::ExpandExcerpts",
"cmd-k enter": "editor::OpenExcerptsSplit",
"cmd-f8": "editor::GoToHunk",
"cmd-shift-f8": "editor::GoToPrevHunk",

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
),
];
}

View File

@ -7,10 +7,11 @@ OUTPUT_FILE="${1:-$(pwd)/assets/licenses.md}"
> $OUTPUT_FILE
echo -e "# ###### THEME LICENSES ######\n" >> $OUTPUT_FILE
echo "Generating theme licenses"
cat assets/themes/LICENSES >> $OUTPUT_FILE
echo -e "# ###### ICON LICENSES ######\n" >> $OUTPUT_FILE
cat assets/icons/LICENSES >> $OUTPUT_FILE
echo -e "# ###### CODE LICENSES ######\n" >> $OUTPUT_FILE
[[ "$(cargo about --version)" == "cargo-about 0.6.1" ]] || cargo install cargo-about@0.6.1