Implement staging and unstaging hunks (#24606)

- [x] Staging hunks
- [x] Unstaging hunks
- [x] Write a randomized test
- [x] Get test passing
- [x] Fix existing bug in diff_base_byte_range computation
- [x] Remote project support
- [ ] ~~Improve performance of
buffer_range_to_unchanged_diff_base_range~~
- [ ] ~~Bug: project diff editor scrolls to top when staging/unstaging
hunk~~ existing issue
- [ ] ~~UI~~ deferred
- [x] Tricky cases
  - [x] Correctly handle acting on multiple hunks for a single file
- [x] Remove path from index when unstaging the last staged hunk, if
it's absent from HEAD, or staging the only hunk, if it's deleted in the
working copy

Release Notes:

- Add `ToggleStagedSelectedDiffHunks` action for staging and unstaging
individual diff hunks
This commit is contained in:
Cole Miller 2025-02-12 14:46:42 -05:00 committed by GitHub
parent ea8da43c6b
commit eea6b526dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 768 additions and 70 deletions

View file

@ -402,6 +402,7 @@ gpui::actions!(
ToggleInlayHints,
ToggleEditPrediction,
ToggleLineNumbers,
ToggleStagedSelectedDiffHunks,
SwapSelectionEnds,
SetMark,
ToggleRelativeLineNumbers,

View file

@ -52,6 +52,7 @@ pub use actions::{AcceptEditPrediction, OpenExcerpts, OpenExcerptsSplit};
use aho_corasick::AhoCorasick;
use anyhow::{anyhow, Context as _, Result};
use blink_manager::BlinkManager;
use buffer_diff::DiffHunkSecondaryStatus;
use client::{Collaborator, ParticipantIndex};
use clock::ReplicaId;
use collections::{BTreeMap, HashMap, HashSet, VecDeque};
@ -95,7 +96,7 @@ use itertools::Itertools;
use language::{
language_settings::{self, all_language_settings, language_settings, InlayHintSettings},
markdown, point_from_lsp, AutoindentMode, BracketPair, Buffer, Capability, CharKind, CodeLabel,
CompletionDocumentation, CursorShape, Diagnostic, EditPredictionsMode, EditPreview,
CompletionDocumentation, CursorShape, Diagnostic, DiskState, EditPredictionsMode, EditPreview,
HighlightedText, IndentKind, IndentSize, Language, OffsetRangeExt, Point, Selection,
SelectionGoal, TextObject, TransactionId, TreeSitterOptions,
};
@ -12431,6 +12432,121 @@ impl Editor {
self.toggle_diff_hunks_in_ranges(ranges, cx);
}
fn diff_hunks_in_ranges<'a>(
&'a self,
ranges: &'a [Range<Anchor>],
buffer: &'a MultiBufferSnapshot,
) -> impl 'a + Iterator<Item = MultiBufferDiffHunk> {
ranges.iter().flat_map(move |range| {
let end_excerpt_id = range.end.excerpt_id;
let range = range.to_point(buffer);
let mut peek_end = range.end;
if range.end.row < buffer.max_row().0 {
peek_end = Point::new(range.end.row + 1, 0);
}
buffer
.diff_hunks_in_range(range.start..peek_end)
.filter(move |hunk| hunk.excerpt_id.cmp(&end_excerpt_id, buffer).is_le())
})
}
pub fn has_stageable_diff_hunks_in_ranges(
&self,
ranges: &[Range<Anchor>],
snapshot: &MultiBufferSnapshot,
) -> bool {
let mut hunks = self.diff_hunks_in_ranges(ranges, &snapshot);
hunks.any(|hunk| {
log::debug!("considering {hunk:?}");
hunk.secondary_status == DiffHunkSecondaryStatus::HasSecondaryHunk
})
}
pub fn toggle_staged_selected_diff_hunks(
&mut self,
_: &ToggleStagedSelectedDiffHunks,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let ranges: Vec<_> = self.selections.disjoint.iter().map(|s| s.range()).collect();
self.stage_or_unstage_diff_hunks(&ranges, cx);
}
pub fn stage_or_unstage_diff_hunks(
&mut self,
ranges: &[Range<Anchor>],
cx: &mut Context<Self>,
) {
let Some(project) = &self.project else {
return;
};
let snapshot = self.buffer.read(cx).snapshot(cx);
let stage = self.has_stageable_diff_hunks_in_ranges(ranges, &snapshot);
let chunk_by = self
.diff_hunks_in_ranges(&ranges, &snapshot)
.chunk_by(|hunk| hunk.buffer_id);
for (buffer_id, hunks) in &chunk_by {
let Some(buffer) = project.read(cx).buffer_for_id(buffer_id, cx) else {
log::debug!("no buffer for id");
continue;
};
let buffer = buffer.read(cx).snapshot();
let Some((repo, path)) = project
.read(cx)
.repository_and_path_for_buffer_id(buffer_id, cx)
else {
log::debug!("no git repo for buffer id");
continue;
};
let Some(diff) = snapshot.diff_for_buffer_id(buffer_id) else {
log::debug!("no diff for buffer id");
continue;
};
let Some(secondary_diff) = diff.secondary_diff() else {
log::debug!("no secondary diff for buffer id");
continue;
};
let edits = diff.secondary_edits_for_stage_or_unstage(
stage,
hunks.map(|hunk| {
(
hunk.diff_base_byte_range.clone(),
hunk.secondary_diff_base_byte_range.clone(),
hunk.buffer_range.clone(),
)
}),
&buffer,
);
let index_base = secondary_diff.base_text().map_or_else(
|| Rope::from(""),
|snapshot| snapshot.text.as_rope().clone(),
);
let index_buffer = cx.new(|cx| {
Buffer::local_normalized(index_base.clone(), text::LineEnding::default(), cx)
});
let new_index_text = index_buffer.update(cx, |index_buffer, cx| {
index_buffer.edit(edits, None, cx);
index_buffer.snapshot().as_rope().to_string()
});
let new_index_text = if new_index_text.is_empty()
&& (diff.is_single_insertion
|| buffer
.file()
.map_or(false, |file| file.disk_state() == DiskState::New))
{
log::debug!("removing from index");
None
} else {
Some(new_index_text)
};
let _ = repo.read(cx).set_index_text(&path, new_index_text);
}
}
pub fn expand_selected_diff_hunks(&mut self, cx: &mut Context<Self>) {
let ranges: Vec<_> = self.selections.disjoint.iter().map(|s| s.range()).collect();
self.buffer

View file

@ -14047,6 +14047,59 @@ async fn test_edit_after_expanded_modification_hunk(
);
}
#[gpui::test]
async fn test_stage_and_unstage_added_file_hunk(
executor: BackgroundExecutor,
cx: &mut gpui::TestAppContext,
) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
cx.update_editor(|editor, _, cx| {
editor.set_expand_all_diff_hunks(cx);
});
let working_copy = r#"
ˇfn main() {
println!("hello, world!");
}
"#
.unindent();
cx.set_state(&working_copy);
executor.run_until_parked();
cx.assert_state_with_diff(
r#"
+ ˇfn main() {
+ println!("hello, world!");
+ }
"#
.unindent(),
);
cx.assert_index_text(None);
cx.update_editor(|editor, window, cx| {
editor.toggle_staged_selected_diff_hunks(&ToggleStagedSelectedDiffHunks, window, cx);
});
executor.run_until_parked();
cx.assert_index_text(Some(&working_copy.replace("ˇ", "")));
cx.assert_state_with_diff(
r#"
+ ˇfn main() {
+ println!("hello, world!");
+ }
"#
.unindent(),
);
cx.update_editor(|editor, window, cx| {
editor.toggle_staged_selected_diff_hunks(&ToggleStagedSelectedDiffHunks, window, cx);
});
executor.run_until_parked();
cx.assert_index_text(None);
}
async fn setup_indent_guides_editor(
text: &str,
cx: &mut gpui::TestAppContext,

View file

@ -417,7 +417,9 @@ impl EditorElement {
register_action(editor, window, Editor::toggle_git_blame);
register_action(editor, window, Editor::toggle_git_blame_inline);
register_action(editor, window, Editor::toggle_selected_diff_hunks);
register_action(editor, window, Editor::toggle_staged_selected_diff_hunks);
register_action(editor, window, Editor::expand_all_diff_hunks);
register_action(editor, window, |editor, action, window, cx| {
if let Some(task) = editor.format(action, window, cx) {
task.detach_and_notify_err(window, cx);

View file

@ -298,6 +298,18 @@ impl EditorTestContext {
self.cx.run_until_parked();
}
pub fn assert_index_text(&mut self, expected: Option<&str>) {
let fs = self.update_editor(|editor, _, cx| {
editor.project.as_ref().unwrap().read(cx).fs().as_fake()
});
let path = self.update_buffer(|buffer, _| buffer.file().unwrap().path().clone());
let mut found = None;
fs.with_git_state(&Self::root_path().join(".git"), false, |git_state| {
found = git_state.index_contents.get(path.as_ref()).cloned();
});
assert_eq!(expected, found.as_deref());
}
/// Change the editor's text and selections using a string containing
/// embedded range markers that represent the ranges and directions of
/// each selection.