Support hunk-wise StageAndNext and UnstageAndNext (#25845)

This PR adds the `whole_excerpt` field to the actions:

- `git::StageAndNext`
- `git::UnstageAndNext`

Which is set by false by default, effectively, now staging and unstaging
with these actions is done hunk-by-hunk, this also affects the `Stage`
and
`Unstage` buttons in the Diff View toolbar.

A caveat: with this PR, there is no way to configure the buttons in the
Diff
View toolbar to restore the previous behavior, if we want, I think we
can make
it a setting, but let's see if anyone really wants that.

Release Notes:

- N/A
This commit is contained in:
João Marcos 2025-02-28 23:39:08 -03:00 committed by GitHub
parent 13deaa3f69
commit a2876f5d3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 93 additions and 35 deletions

View File

@ -370,8 +370,8 @@
"ctrl-shift-v": "markdown::OpenPreview", "ctrl-shift-v": "markdown::OpenPreview",
"ctrl-alt-shift-c": "editor::DisplayCursorNames", "ctrl-alt-shift-c": "editor::DisplayCursorNames",
"ctrl-alt-y": "git::ToggleStaged", "ctrl-alt-y": "git::ToggleStaged",
"alt-y": "git::StageAndNext", "alt-y": ["git::StageAndNext", { "whole_excerpt": false }],
"alt-shift-y": "git::UnstageAndNext", "alt-shift-y": ["git::UnstageAndNext", { "whole_excerpt": false }],
"alt-.": "editor::GoToHunk", "alt-.": "editor::GoToHunk",
"alt-,": "editor::GoToPrevHunk" "alt-,": "editor::GoToPrevHunk"
} }

View File

@ -131,8 +131,8 @@
"cmd-;": "editor::ToggleLineNumbers", "cmd-;": "editor::ToggleLineNumbers",
"cmd-alt-z": "git::Restore", "cmd-alt-z": "git::Restore",
"cmd-alt-y": "git::ToggleStaged", "cmd-alt-y": "git::ToggleStaged",
"cmd-y": "git::StageAndNext", "cmd-y": ["git::StageAndNext", { "whole_excerpt": false }],
"cmd-shift-y": "git::UnstageAndNext", "cmd-shift-y": ["git::UnstageAndNext", { "whole_excerpt": false }],
"cmd-'": "editor::ToggleSelectedDiffHunks", "cmd-'": "editor::ToggleSelectedDiffHunks",
"cmd-\"": "editor::ExpandAllDiffHunks", "cmd-\"": "editor::ExpandAllDiffHunks",
"cmd-alt-g b": "editor::ToggleGitBlame", "cmd-alt-g b": "editor::ToggleGitBlame",

View File

@ -7722,7 +7722,7 @@ impl Editor {
let mut revert_changes = HashMap::default(); let mut revert_changes = HashMap::default();
let chunk_by = self let chunk_by = self
.snapshot(window, cx) .snapshot(window, cx)
.hunks_for_ranges(ranges.into_iter()) .hunks_for_ranges(ranges)
.into_iter() .into_iter()
.chunk_by(|hunk| hunk.buffer_id); .chunk_by(|hunk| hunk.buffer_id);
for (buffer_id, hunks) in &chunk_by { for (buffer_id, hunks) in &chunk_by {
@ -11424,27 +11424,37 @@ impl Editor {
window: &mut Window, window: &mut Window,
cx: &mut Context<Editor>, cx: &mut Context<Editor>,
) -> Option<MultiBufferDiffHunk> { ) -> Option<MultiBufferDiffHunk> {
let mut hunk = snapshot let hunk = self.hunk_after_position(snapshot, position);
.buffer_snapshot
.diff_hunks_in_range(position..snapshot.buffer_snapshot.max_point())
.find(|hunk| hunk.row_range.start.0 > position.row);
if hunk.is_none() {
hunk = snapshot
.buffer_snapshot
.diff_hunks_in_range(Point::zero()..position)
.find(|hunk| hunk.row_range.end.0 < position.row)
}
if let Some(hunk) = &hunk { if let Some(hunk) = &hunk {
let destination = Point::new(hunk.row_range.start.0, 0); let point = Point::new(hunk.row_range.start.0, 0);
self.unfold_ranges(&[destination..destination], false, false, cx);
self.unfold_ranges(&[point..point], false, false, cx);
self.change_selections(Some(Autoscroll::fit()), window, cx, |s| { self.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
s.select_ranges(vec![destination..destination]); s.select_ranges([point..point]);
}); });
} }
hunk hunk
} }
fn hunk_after_position(
&mut self,
snapshot: &EditorSnapshot,
position: Point,
) -> Option<MultiBufferDiffHunk> {
snapshot
.buffer_snapshot
.diff_hunks_in_range(position..snapshot.buffer_snapshot.max_point())
.find(|hunk| hunk.row_range.start.0 > position.row)
.or_else(|| {
snapshot
.buffer_snapshot
.diff_hunks_in_range(Point::zero()..position)
.find(|hunk| hunk.row_range.end.0 < position.row)
})
}
fn go_to_prev_hunk(&mut self, _: &GoToPrevHunk, window: &mut Window, cx: &mut Context<Self>) { fn go_to_prev_hunk(&mut self, _: &GoToPrevHunk, window: &mut Window, cx: &mut Context<Self>) {
let snapshot = self.snapshot(window, cx); let snapshot = self.snapshot(window, cx);
let selection = self.selections.newest::<Point>(cx); let selection = self.selections.newest::<Point>(cx);
@ -13528,20 +13538,20 @@ impl Editor {
pub fn stage_and_next( pub fn stage_and_next(
&mut self, &mut self,
_: &::git::StageAndNext, action: &::git::StageAndNext,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.do_stage_or_unstage_and_next(true, window, cx); self.do_stage_or_unstage_and_next(true, action.whole_excerpt, window, cx);
} }
pub fn unstage_and_next( pub fn unstage_and_next(
&mut self, &mut self,
_: &::git::UnstageAndNext, action: &::git::UnstageAndNext,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.do_stage_or_unstage_and_next(false, window, cx); self.do_stage_or_unstage_and_next(false, action.whole_excerpt, window, cx);
} }
pub fn stage_or_unstage_diff_hunks( pub fn stage_or_unstage_diff_hunks(
@ -13563,16 +13573,36 @@ impl Editor {
fn do_stage_or_unstage_and_next( fn do_stage_or_unstage_and_next(
&mut self, &mut self,
stage: bool, stage: bool,
whole_excerpt: bool,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let mut ranges = self.selections.disjoint_anchor_ranges().collect::<Vec<_>>(); let mut ranges = self.selections.disjoint_anchor_ranges().collect::<Vec<_>>();
if ranges.iter().any(|range| range.start != range.end) { if ranges.iter().any(|range| range.start != range.end) {
self.stage_or_unstage_diff_hunks(stage, &ranges[..], window, cx); self.stage_or_unstage_diff_hunks(stage, &ranges[..], window, cx);
return; return;
} }
if !self.buffer().read(cx).is_singleton() { if !whole_excerpt {
let snapshot = self.snapshot(window, cx);
let newest_range = self.selections.newest::<Point>(cx).range();
let run_twice = snapshot
.hunks_for_ranges([newest_range])
.first()
.is_some_and(|hunk| {
let next_line = Point::new(hunk.row_range.end.0 + 1, 0);
self.hunk_after_position(&snapshot, next_line)
.is_some_and(|other| other.row_range == hunk.row_range)
});
if run_twice {
self.go_to_next_hunk(&Default::default(), window, cx);
}
} else if !self.buffer().read(cx).is_singleton() {
self.stage_or_unstage_diff_hunks(stage, &ranges[..], window, cx);
if let Some((excerpt_id, buffer, range)) = self.active_excerpt(cx) { if let Some((excerpt_id, buffer, range)) = self.active_excerpt(cx) {
if buffer.read(cx).is_empty() { if buffer.read(cx).is_empty() {
let buffer = buffer.read(cx); let buffer = buffer.read(cx);
@ -13586,9 +13616,9 @@ impl Editor {
let Some(project) = self.project.as_ref() else { let Some(project) = self.project.as_ref() else {
return; return;
}; };
let project = project.read(cx);
let Some(repo) = project.git_store().read(cx).active_repository() else { let Some(repo) = project.read(cx).git_store().read(cx).active_repository()
else {
return; return;
}; };
@ -13620,7 +13650,7 @@ impl Editor {
point = snapshot.clip_point(point, Bias::Right); point = snapshot.clip_point(point, Bias::Right);
self.change_selections(Some(Autoscroll::top_relative(6)), window, cx, |s| { self.change_selections(Some(Autoscroll::top_relative(6)), window, cx, |s| {
s.select_ranges([point..point]); s.select_ranges([point..point]);
}) });
} }
return; return;
} }
@ -13756,7 +13786,7 @@ impl Editor {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let snapshot = self.snapshot(window, cx); let snapshot = self.snapshot(window, cx);
let hunks = snapshot.hunks_for_ranges(self.selections.ranges(cx).into_iter()); let hunks = snapshot.hunks_for_ranges(self.selections.ranges(cx));
let mut ranges_by_buffer = HashMap::default(); let mut ranges_by_buffer = HashMap::default();
self.transact(window, cx, |editor, _window, cx| { self.transact(window, cx, |editor, _window, cx| {
for hunk in hunks { for hunk in hunks {
@ -17083,7 +17113,7 @@ impl EditorSnapshot {
pub fn hunks_for_ranges( pub fn hunks_for_ranges(
&self, &self,
ranges: impl Iterator<Item = Range<Point>>, ranges: impl IntoIterator<Item = Range<Point>>,
) -> Vec<MultiBufferDiffHunk> { ) -> Vec<MultiBufferDiffHunk> {
let mut hunks = Vec::new(); let mut hunks = Vec::new();
let mut processed_buffer_rows: HashMap<BufferId, HashSet<Range<text::Anchor>>> = let mut processed_buffer_rows: HashMap<BufferId, HashSet<Range<text::Anchor>>> =

View File

@ -35,15 +35,23 @@ pub struct Push {
pub options: Option<PushOptions>, pub options: Option<PushOptions>,
} }
impl_actions!(git, [Push]); #[derive(Debug, Copy, Clone, PartialEq, Deserialize, JsonSchema)]
pub struct StageAndNext {
pub whole_excerpt: bool,
}
#[derive(Debug, Copy, Clone, PartialEq, Deserialize, JsonSchema)]
pub struct UnstageAndNext {
pub whole_excerpt: bool,
}
impl_actions!(git, [Push, StageAndNext, UnstageAndNext]);
actions!( actions!(
git, git,
[ [
// per-hunk // per-hunk
ToggleStaged, ToggleStaged,
StageAndNext,
UnstageAndNext,
// per-file // per-file
StageFile, StageFile,
UnstageFile, UnstageFile,

View File

@ -813,7 +813,9 @@ impl Render for ProjectDiffToolbar {
Button::new("stage", "Stage") Button::new("stage", "Stage")
.tooltip(Tooltip::for_action_title_in( .tooltip(Tooltip::for_action_title_in(
"Stage", "Stage",
&StageAndNext, &StageAndNext {
whole_excerpt: false,
},
&focus_handle, &focus_handle,
)) ))
// don't actually disable the button so it's mashable // don't actually disable the button so it's mashable
@ -823,14 +825,22 @@ impl Render for ProjectDiffToolbar {
Color::Disabled Color::Disabled
}) })
.on_click(cx.listener(|this, _, window, cx| { .on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&StageAndNext, window, cx) this.dispatch_action(
&StageAndNext {
whole_excerpt: false,
},
window,
cx,
)
})), })),
) )
.child( .child(
Button::new("unstage", "Unstage") Button::new("unstage", "Unstage")
.tooltip(Tooltip::for_action_title_in( .tooltip(Tooltip::for_action_title_in(
"Unstage", "Unstage",
&UnstageAndNext, &UnstageAndNext {
whole_excerpt: false,
},
&focus_handle, &focus_handle,
)) ))
.color(if button_states.unstage { .color(if button_states.unstage {
@ -839,7 +849,13 @@ impl Render for ProjectDiffToolbar {
Color::Disabled Color::Disabled
}) })
.on_click(cx.listener(|this, _, window, cx| { .on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&UnstageAndNext, window, cx) this.dispatch_action(
&UnstageAndNext {
whole_excerpt: false,
},
window,
cx,
)
})), })),
) )
}), }),

View File

@ -399,6 +399,8 @@ macro_rules! action_with_deprecated_aliases {
/// Registers the action and implements the Action trait for any struct that implements Clone, /// Registers the action and implements the Action trait for any struct that implements Clone,
/// Default, PartialEq, serde_deserialize::Deserialize, and schemars::JsonSchema. /// Default, PartialEq, serde_deserialize::Deserialize, and schemars::JsonSchema.
/// ///
/// Similar to `actions!`, but accepts structs with fields.
///
/// Fields and variants that don't make sense for user configuration should be annotated with /// Fields and variants that don't make sense for user configuration should be annotated with
/// #[serde(skip)]. /// #[serde(skip)].
#[macro_export] #[macro_export]

View File

@ -4112,6 +4112,8 @@ mod tests {
| "vim::PushLiteral" | "vim::PushLiteral"
| "vim::Number" | "vim::Number"
| "vim::SelectRegister" | "vim::SelectRegister"
| "git::StageAndNext"
| "git::UnstageAndNext"
| "terminal::SendText" | "terminal::SendText"
| "terminal::SendKeystroke" | "terminal::SendKeystroke"
| "app_menu::OpenApplicationMenu" | "app_menu::OpenApplicationMenu"