Expand git diffs when clicking the gutter strip, display their controls in a block above (#18313)

Todo:

* [x] Tooltips for hunk buttons
* [x] Buttons to go to next and previous hunk
* [x] Ellipsis button that opens a context menu with `Revert all`

/cc @iamnbutler @danilo-leal for design 👀 

Release Notes:

- Changed the behavior of the git gutter so that diff hunk are expanded
immediately when clicking the gutter, and hunk controls are displayed
above the hunk.

---------

Co-authored-by: Marshall <marshall@zed.dev>
Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
This commit is contained in:
Max Brunsfeld 2024-09-25 12:50:38 -07:00 committed by GitHub
parent ae6a3d15af
commit 21a023980d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 580 additions and 758 deletions

View file

@ -1,28 +1,26 @@
use collections::{hash_map, HashMap, HashSet};
use git::diff::DiffHunkStatus;
use gpui::{Action, AppContext, CursorStyle, Hsla, Model, MouseButton, Subscription, Task, View};
use gpui::{Action, AnchorCorner, AppContext, CursorStyle, Hsla, Model, MouseButton, Task, View};
use language::{Buffer, BufferId, Point};
use multi_buffer::{
Anchor, AnchorRangeExt, ExcerptRange, MultiBuffer, MultiBufferDiffHunk, MultiBufferRow,
MultiBufferSnapshot, ToPoint,
};
use settings::SettingsStore;
use std::{
ops::{Range, RangeInclusive},
sync::Arc,
};
use ui::{
prelude::*, ActiveTheme, ContextMenu, InteractiveElement, IntoElement, ParentElement, Pixels,
Styled, ViewContext, VisualContext,
prelude::*, ActiveTheme, ContextMenu, IconButtonShape, InteractiveElement, IntoElement,
ParentElement, PopoverMenu, Styled, Tooltip, ViewContext, VisualContext,
};
use util::{debug_panic, RangeExt};
use util::RangeExt;
use crate::{
editor_settings::CurrentLineHighlight, hunk_status, hunks_for_selections,
mouse_context_menu::MouseContextMenu, BlockDisposition, BlockProperties, BlockStyle,
CustomBlockId, DiffRowHighlight, DisplayRow, DisplaySnapshot, Editor, EditorElement,
EditorSnapshot, ExpandAllHunkDiffs, RangeToAnchorExt, RevertFile, RevertSelectedHunks,
ToDisplayPoint, ToggleHunkDiff,
editor_settings::CurrentLineHighlight, hunk_status, hunks_for_selections, BlockDisposition,
BlockProperties, BlockStyle, CustomBlockId, DiffRowHighlight, DisplayRow, DisplaySnapshot,
Editor, EditorElement, EditorSnapshot, ExpandAllHunkDiffs, GoToHunk, GoToPrevHunk,
RangeToAnchorExt, RevertFile, RevertSelectedHunks, ToDisplayPoint, ToggleHunkDiff,
};
#[derive(Debug, Clone)]
@ -41,7 +39,7 @@ pub(super) struct ExpandedHunks {
#[derive(Debug, Clone)]
pub(super) struct ExpandedHunk {
pub block: Option<CustomBlockId>,
pub blocks: Vec<CustomBlockId>,
pub hunk_range: Range<Anchor>,
pub diff_base_byte_range: Range<usize>,
pub status: DiffHunkStatus,
@ -77,85 +75,6 @@ impl ExpandedHunks {
}
impl Editor {
pub(super) fn open_hunk_context_menu(
&mut self,
hovered_hunk: HoveredHunk,
clicked_point: gpui::Point<Pixels>,
cx: &mut ViewContext<Editor>,
) {
let focus_handle = self.focus_handle.clone();
let expanded = self
.expanded_hunks
.hunks(false)
.any(|expanded_hunk| expanded_hunk.hunk_range == hovered_hunk.multi_buffer_range);
let editor_handle = cx.view().clone();
let editor_snapshot = self.snapshot(cx);
let start_point = self
.to_pixel_point(hovered_hunk.multi_buffer_range.start, &editor_snapshot, cx)
.unwrap_or(clicked_point);
let end_point = self
.to_pixel_point(hovered_hunk.multi_buffer_range.start, &editor_snapshot, cx)
.unwrap_or(clicked_point);
let norm =
|a: gpui::Point<Pixels>, b: gpui::Point<Pixels>| (a.x - b.x).abs() + (a.y - b.y).abs();
let closest_source = if norm(start_point, clicked_point) < norm(end_point, clicked_point) {
hovered_hunk.multi_buffer_range.start
} else {
hovered_hunk.multi_buffer_range.end
};
self.mouse_context_menu = MouseContextMenu::pinned_to_editor(
self,
closest_source,
clicked_point,
ContextMenu::build(cx, move |menu, _| {
menu.on_blur_subscription(Subscription::new(|| {}))
.context(focus_handle)
.entry(
if expanded {
"Collapse Hunk"
} else {
"Expand Hunk"
},
Some(ToggleHunkDiff.boxed_clone()),
{
let editor = editor_handle.clone();
let hunk = hovered_hunk.clone();
move |cx| {
editor.update(cx, |editor, cx| {
editor.toggle_hovered_hunk(&hunk, cx);
});
}
},
)
.entry("Revert Hunk", Some(RevertSelectedHunks.boxed_clone()), {
let editor = editor_handle.clone();
let hunk = hovered_hunk.clone();
move |cx| {
let multi_buffer = editor.read(cx).buffer().clone();
let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
let mut revert_changes = HashMap::default();
if let Some(hunk) =
crate::hunk_diff::to_diff_hunk(&hunk, &multi_buffer_snapshot)
{
Editor::prepare_revert_change(
&mut revert_changes,
&multi_buffer,
&hunk,
cx,
);
}
if !revert_changes.is_empty() {
editor.update(cx, |editor, cx| editor.revert(revert_changes, cx));
}
}
})
.action("Revert File", RevertFile.boxed_clone())
}),
cx,
)
}
pub(super) fn toggle_hovered_hunk(
&mut self,
hovered_hunk: &HoveredHunk,
@ -264,7 +183,8 @@ impl Editor {
break;
} else if expanded_hunk_row_range == hunk_to_toggle_row_range {
highlights_to_remove.push(expanded_hunk.hunk_range.clone());
blocks_to_remove.extend(expanded_hunk.block);
blocks_to_remove
.extend(expanded_hunk.blocks.iter().copied());
hunks_to_toggle.next();
retain = false;
break;
@ -371,9 +291,17 @@ impl Editor {
Err(ix) => ix,
};
let block = match hunk.status {
let blocks;
match hunk.status {
DiffHunkStatus::Removed => {
self.insert_deleted_text_block(diff_base_buffer, deleted_text_lines, hunk, cx)
blocks = self.insert_blocks(
[
self.hunk_header_block(&hunk, cx),
Self::deleted_text_block(hunk, diff_base_buffer, deleted_text_lines, cx),
],
None,
cx,
);
}
DiffHunkStatus::Added => {
self.highlight_rows::<DiffRowHighlight>(
@ -382,7 +310,7 @@ impl Editor {
false,
cx,
);
None
blocks = self.insert_blocks([self.hunk_header_block(&hunk, cx)], None, cx);
}
DiffHunkStatus::Modified => {
self.highlight_rows::<DiffRowHighlight>(
@ -391,13 +319,20 @@ impl Editor {
false,
cx,
);
self.insert_deleted_text_block(diff_base_buffer, deleted_text_lines, hunk, cx)
blocks = self.insert_blocks(
[
self.hunk_header_block(&hunk, cx),
Self::deleted_text_block(hunk, diff_base_buffer, deleted_text_lines, cx),
],
None,
cx,
);
}
};
self.expanded_hunks.hunks.insert(
block_insert_index,
ExpandedHunk {
block,
blocks,
hunk_range: hunk_start..hunk_end,
status: hunk.status,
folded: false,
@ -408,109 +343,368 @@ impl Editor {
Some(())
}
fn insert_deleted_text_block(
&mut self,
fn hunk_header_block(
&self,
hunk: &HoveredHunk,
cx: &mut ViewContext<'_, Editor>,
) -> BlockProperties<Anchor> {
let border_color = cx.theme().colors().border_disabled;
let gutter_color = match hunk.status {
DiffHunkStatus::Added => cx.theme().status().created,
DiffHunkStatus::Modified => cx.theme().status().modified,
DiffHunkStatus::Removed => cx.theme().status().deleted,
};
BlockProperties {
position: hunk.multi_buffer_range.start,
height: 1,
style: BlockStyle::Sticky,
disposition: BlockDisposition::Above,
priority: 0,
render: Box::new({
let editor = cx.view().clone();
let hunk = hunk.clone();
move |cx| {
let hunk_controls_menu_handle =
editor.read(cx).hunk_controls_menu_handle.clone();
h_flex()
.id(cx.block_id)
.w_full()
.h(cx.line_height())
.child(
div()
.id("gutter-strip")
.w(EditorElement::diff_hunk_strip_width(cx.line_height()))
.h_full()
.bg(gutter_color)
.cursor(CursorStyle::PointingHand)
.on_click({
let editor = editor.clone();
let hunk = hunk.clone();
move |_event, cx| {
editor.update(cx, |editor, cx| {
editor.toggle_hovered_hunk(&hunk, cx);
});
}
}),
)
.child(
h_flex()
.size_full()
.justify_between()
.border_t_1()
.border_color(border_color)
.child(
h_flex()
.gap_2()
.pl_6()
.child(
IconButton::new("next-hunk", IconName::ArrowDown)
.shape(IconButtonShape::Square)
.icon_size(IconSize::Small)
.tooltip({
let focus_handle = editor.focus_handle(cx);
move |cx| {
Tooltip::for_action_in(
"Next Hunk",
&GoToHunk,
&focus_handle,
cx,
)
}
})
.on_click({
let editor = editor.clone();
let hunk = hunk.clone();
move |_event, cx| {
editor.update(cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
let position = hunk
.multi_buffer_range
.end
.to_point(
&snapshot.buffer_snapshot,
);
if let Some(hunk) = editor
.go_to_hunk_after_position(
&snapshot, position, cx,
)
{
let multi_buffer_start = snapshot
.buffer_snapshot
.anchor_before(Point::new(
hunk.row_range.start.0,
0,
));
let multi_buffer_end = snapshot
.buffer_snapshot
.anchor_after(Point::new(
hunk.row_range.end.0,
0,
));
editor.expand_diff_hunk(
None,
&HoveredHunk {
multi_buffer_range:
multi_buffer_start
..multi_buffer_end,
status: hunk_status(&hunk),
diff_base_byte_range: hunk
.diff_base_byte_range,
},
cx,
);
}
});
}
}),
)
.child(
IconButton::new("prev-hunk", IconName::ArrowUp)
.shape(IconButtonShape::Square)
.icon_size(IconSize::Small)
.tooltip({
let focus_handle = editor.focus_handle(cx);
move |cx| {
Tooltip::for_action_in(
"Previous Hunk",
&GoToPrevHunk,
&focus_handle,
cx,
)
}
})
.on_click({
let editor = editor.clone();
let hunk = hunk.clone();
move |_event, cx| {
editor.update(cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
let position = hunk
.multi_buffer_range
.start
.to_point(
&snapshot.buffer_snapshot,
);
let hunk = editor
.go_to_hunk_before_position(
&snapshot, position, cx,
);
if let Some(hunk) = hunk {
let multi_buffer_start = snapshot
.buffer_snapshot
.anchor_before(Point::new(
hunk.row_range.start.0,
0,
));
let multi_buffer_end = snapshot
.buffer_snapshot
.anchor_after(Point::new(
hunk.row_range.end.0,
0,
));
editor.expand_diff_hunk(
None,
&HoveredHunk {
multi_buffer_range:
multi_buffer_start
..multi_buffer_end,
status: hunk_status(&hunk),
diff_base_byte_range: hunk
.diff_base_byte_range,
},
cx,
);
}
});
}
}),
),
)
.child(
h_flex()
.gap_2()
.pr_6()
.child({
let focus = editor.focus_handle(cx);
PopoverMenu::new("hunk-controls-dropdown")
.trigger(
IconButton::new(
"toggle_editor_selections_icon",
IconName::EllipsisVertical,
)
.shape(IconButtonShape::Square)
.icon_size(IconSize::Small)
.style(ButtonStyle::Subtle)
.selected(
hunk_controls_menu_handle.is_deployed(),
)
.when(
!hunk_controls_menu_handle.is_deployed(),
|this| {
this.tooltip(|cx| {
Tooltip::text("Hunk Controls", cx)
})
},
),
)
.anchor(AnchorCorner::TopRight)
.with_handle(hunk_controls_menu_handle)
.menu(move |cx| {
let focus = focus.clone();
let menu =
ContextMenu::build(cx, move |menu, _| {
menu.context(focus.clone()).action(
"Discard All",
RevertFile.boxed_clone(),
)
});
Some(menu)
})
})
.child(
IconButton::new("discard", IconName::RotateCcw)
.shape(IconButtonShape::Square)
.icon_size(IconSize::Small)
.tooltip({
let focus_handle = editor.focus_handle(cx);
move |cx| {
Tooltip::for_action_in(
"Discard Hunk",
&RevertSelectedHunks,
&focus_handle,
cx,
)
}
})
.on_click({
let editor = editor.clone();
let hunk = hunk.clone();
move |_event, cx| {
let multi_buffer =
editor.read(cx).buffer().clone();
let multi_buffer_snapshot =
multi_buffer.read(cx).snapshot(cx);
let mut revert_changes = HashMap::default();
if let Some(hunk) =
crate::hunk_diff::to_diff_hunk(
&hunk,
&multi_buffer_snapshot,
)
{
Editor::prepare_revert_change(
&mut revert_changes,
&multi_buffer,
&hunk,
cx,
);
}
if !revert_changes.is_empty() {
editor.update(cx, |editor, cx| {
editor.revert(revert_changes, cx)
});
}
}
}),
)
.child(
IconButton::new("collapse", IconName::Close)
.shape(IconButtonShape::Square)
.icon_size(IconSize::Small)
.tooltip({
let focus_handle = editor.focus_handle(cx);
move |cx| {
Tooltip::for_action_in(
"Collapse Hunk",
&ToggleHunkDiff,
&focus_handle,
cx,
)
}
})
.on_click({
let editor = editor.clone();
let hunk = hunk.clone();
move |_event, cx| {
editor.update(cx, |editor, cx| {
editor.toggle_hovered_hunk(&hunk, cx);
});
}
}),
),
),
)
.into_any_element()
}
}),
}
}
fn deleted_text_block(
hunk: &HoveredHunk,
diff_base_buffer: Model<Buffer>,
deleted_text_height: u32,
hunk: &HoveredHunk,
cx: &mut ViewContext<'_, Self>,
) -> Option<CustomBlockId> {
cx: &mut ViewContext<'_, Editor>,
) -> BlockProperties<Anchor> {
let gutter_color = match hunk.status {
DiffHunkStatus::Added => unreachable!(),
DiffHunkStatus::Modified => cx.theme().status().modified,
DiffHunkStatus::Removed => cx.theme().status().deleted,
};
let deleted_hunk_color = deleted_hunk_color(cx);
let (editor_height, editor_with_deleted_text) =
editor_with_deleted_text(diff_base_buffer, deleted_hunk_color, hunk, cx);
let editor = cx.view().clone();
let hunk = hunk.clone();
let height = editor_height.max(deleted_text_height);
let mut new_block_ids = self.insert_blocks(
Some(BlockProperties {
position: hunk.multi_buffer_range.start,
height,
style: BlockStyle::Flex,
disposition: BlockDisposition::Above,
render: Box::new(move |cx| {
let width = EditorElement::diff_hunk_strip_width(cx.line_height());
let gutter_dimensions = editor.read(cx.context).gutter_dimensions;
BlockProperties {
position: hunk.multi_buffer_range.start,
height,
style: BlockStyle::Flex,
disposition: BlockDisposition::Above,
priority: 0,
render: Box::new(move |cx| {
let width = EditorElement::diff_hunk_strip_width(cx.line_height());
let gutter_dimensions = editor.read(cx.context).gutter_dimensions;
let close_button = editor.update(cx.context, |editor, cx| {
let editor_snapshot = editor.snapshot(cx);
let hunk_display_range = hunk
.multi_buffer_range
.clone()
.to_display_points(&editor_snapshot);
editor.close_hunk_diff_button(
hunk.clone(),
hunk_display_range.start.row(),
cx,
)
});
h_flex()
.id("gutter with editor")
.bg(deleted_hunk_color)
.h(height as f32 * cx.line_height())
.w_full()
.child(
h_flex()
.id("gutter")
.max_w(gutter_dimensions.full_width())
.min_w(gutter_dimensions.full_width())
.size_full()
.child(
h_flex()
.id("gutter hunk")
.bg(cx.theme().status().deleted)
.pl(gutter_dimensions.margin
+ gutter_dimensions
.git_blame_entries_width
.unwrap_or_default())
.max_w(width)
.min_w(width)
.size_full()
.cursor(CursorStyle::PointingHand)
.on_mouse_down(MouseButton::Left, {
let editor = editor.clone();
let hunk = hunk.clone();
move |event, cx| {
let modifiers = event.modifiers;
if modifiers.control || modifiers.platform {
editor.update(cx, |editor, cx| {
editor.toggle_hovered_hunk(&hunk, cx);
});
} else {
editor.update(cx, |editor, cx| {
editor.open_hunk_context_menu(
hunk.clone(),
event.position,
cx,
);
});
}
}
}),
)
.child(
v_flex()
.size_full()
.pt(rems(0.25))
.justify_start()
.child(close_button),
),
)
.child(editor_with_deleted_text.clone())
.into_any_element()
}),
priority: 0,
h_flex()
.id(cx.block_id)
.bg(deleted_hunk_color)
.h(height as f32 * cx.line_height())
.w_full()
.child(
h_flex()
.id("gutter")
.max_w(gutter_dimensions.full_width())
.min_w(gutter_dimensions.full_width())
.size_full()
.child(
h_flex()
.id("gutter hunk")
.bg(gutter_color)
.pl(gutter_dimensions.margin
+ gutter_dimensions
.git_blame_entries_width
.unwrap_or_default())
.max_w(width)
.min_w(width)
.size_full()
.cursor(CursorStyle::PointingHand)
.on_mouse_down(MouseButton::Left, {
let editor = editor.clone();
let hunk = hunk.clone();
move |_event, cx| {
editor.update(cx, |editor, cx| {
editor.toggle_hovered_hunk(&hunk, cx);
});
}
}),
),
)
.child(editor_with_deleted_text.clone())
.into_any_element()
}),
None,
cx,
);
if new_block_ids.len() == 1 {
new_block_ids.pop()
} else {
debug_panic!(
"Inserted one editor block but did not receive exactly one block id: {new_block_ids:?}"
);
None
}
}
@ -521,7 +715,7 @@ impl Editor {
.expanded_hunks
.hunks
.drain(..)
.filter_map(|expanded_hunk| expanded_hunk.block)
.flat_map(|expanded_hunk| expanded_hunk.blocks.into_iter())
.collect::<HashSet<_>>();
if to_remove.is_empty() {
false
@ -603,7 +797,7 @@ impl Editor {
expanded_hunk.folded = true;
highlights_to_remove
.push(expanded_hunk.hunk_range.clone());
if let Some(block) = expanded_hunk.block.take() {
for block in expanded_hunk.blocks.drain(..) {
blocks_to_remove.insert(block);
}
break;
@ -650,7 +844,7 @@ impl Editor {
}
}
if !retain {
blocks_to_remove.extend(expanded_hunk.block);
blocks_to_remove.extend(expanded_hunk.blocks.drain(..));
highlights_to_remove.push(expanded_hunk.hunk_range.clone());
}
retain
@ -749,7 +943,7 @@ fn added_hunk_color(cx: &AppContext) -> Hsla {
}
fn deleted_hunk_color(cx: &AppContext) -> Hsla {
let mut deleted_color = cx.theme().status().git().deleted;
let mut deleted_color = cx.theme().status().deleted;
deleted_color.fade_out(0.7);
deleted_color
}
@ -788,32 +982,15 @@ fn editor_with_deleted_text(
false,
cx,
);
let subscription_editor = parent_editor.clone();
editor._subscriptions.extend([
cx.on_blur(&editor.focus_handle, |editor, cx| {
editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
editor
._subscriptions
.extend([cx.on_blur(&editor.focus_handle, |editor, cx| {
editor.change_selections(None, cx, |s| {
s.try_cancel();
});
cx.notify();
}),
cx.on_focus(&editor.focus_handle, move |editor, cx| {
let restored_highlight = if let Some(parent_editor) = subscription_editor.upgrade()
{
parent_editor.read(cx).current_line_highlight
} else {
None
};
editor.set_current_line_highlight(restored_highlight);
cx.notify();
}),
cx.observe_global::<SettingsStore>(|editor, cx| {
if !editor.is_focused(cx) {
editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
}
}),
]);
})]);
let parent_editor_for_reverts = parent_editor.clone();
let original_multi_buffer_range = hunk.multi_buffer_range.clone();
let diff_base_range = hunk.diff_base_byte_range.clone();