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-alt-shift-c": "editor::DisplayCursorNames",
"ctrl-alt-y": "git::ToggleStaged",
"alt-y": "git::StageAndNext",
"alt-shift-y": "git::UnstageAndNext",
"alt-y": ["git::StageAndNext", { "whole_excerpt": false }],
"alt-shift-y": ["git::UnstageAndNext", { "whole_excerpt": false }],
"alt-.": "editor::GoToHunk",
"alt-,": "editor::GoToPrevHunk"
}

View File

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

View File

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

View File

@ -35,15 +35,23 @@ pub struct Push {
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!(
git,
[
// per-hunk
ToggleStaged,
StageAndNext,
UnstageAndNext,
// per-file
StageFile,
UnstageFile,

View File

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

View File

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