Implement staging of partially-staged hunks (#25520)

Cole Miller , Max Brunsfeld , and Max created

Closes: #25475 

This PR makes it possible to stage uncommitted hunks that overlap but do
not coincide with an unstaged hunk.

Release Notes:

- Made it possible to stage hunks that are already partially staged

---------

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
Co-authored-by: Max <max@zed.dev>

Change summary

crates/buffer_diff/src/buffer_diff.rs         | 453 ++++++++++++++------
crates/editor/src/editor.rs                   |  33 -
crates/editor/src/editor_tests.rs             | 106 ++++
crates/editor/src/test/editor_test_context.rs |  15 
crates/multi_buffer/src/multi_buffer.rs       |   3 
crates/text/src/text.rs                       |   1 
6 files changed, 432 insertions(+), 179 deletions(-)

Detailed changes

crates/buffer_diff/src/buffer_diff.rs 🔗

@@ -3,7 +3,8 @@ use git2::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as
 use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter};
 use language::{Language, LanguageRegistry};
 use rope::Rope;
-use std::{cmp, future::Future, iter, ops::Range, sync::Arc};
+use std::cmp::Ordering;
+use std::{future::Future, iter, ops::Range, sync::Arc};
 use sum_tree::SumTree;
 use text::ToOffset as _;
 use text::{Anchor, Bias, BufferId, OffsetRangeExt, Point};
@@ -68,7 +69,6 @@ pub struct DiffHunk {
     /// The range in the buffer's diff base text to which this hunk corresponds.
     pub diff_base_byte_range: Range<usize>,
     pub secondary_status: DiffHunkSecondaryStatus,
-    pub secondary_diff_base_byte_range: Option<Range<usize>>,
 }
 
 /// We store [`InternalDiffHunk`]s internally so we don't need to store the additional row range.
@@ -110,12 +110,17 @@ impl sum_tree::Summary for DiffHunkSummary {
 }
 
 impl<'a> sum_tree::SeekTarget<'a, DiffHunkSummary, DiffHunkSummary> for Anchor {
-    fn cmp(
-        &self,
-        cursor_location: &DiffHunkSummary,
-        buffer: &text::BufferSnapshot,
-    ) -> cmp::Ordering {
-        self.cmp(&cursor_location.buffer_range.end, buffer)
+    fn cmp(&self, cursor_location: &DiffHunkSummary, buffer: &text::BufferSnapshot) -> Ordering {
+        if self
+            .cmp(&cursor_location.buffer_range.start, buffer)
+            .is_lt()
+        {
+            Ordering::Less
+        } else if self.cmp(&cursor_location.buffer_range.end, buffer).is_gt() {
+            Ordering::Greater
+        } else {
+            Ordering::Equal
+        }
     }
 }
 
@@ -171,97 +176,96 @@ impl BufferDiffSnapshot {
         }
     }
 
-    fn buffer_range_to_unchanged_diff_base_range(
-        &self,
-        buffer_range: Range<Anchor>,
-        buffer: &text::BufferSnapshot,
-    ) -> Option<Range<usize>> {
-        let mut hunks = self.inner.hunks.iter();
-        let mut start = 0;
-        let mut pos = buffer.anchor_before(0);
-        while let Some(hunk) = hunks.next() {
-            assert!(buffer_range.start.cmp(&pos, buffer).is_ge());
-            assert!(hunk.buffer_range.start.cmp(&pos, buffer).is_ge());
-            if hunk
-                .buffer_range
-                .start
-                .cmp(&buffer_range.end, buffer)
-                .is_ge()
-            {
-                // target buffer range is contained in the unchanged stretch leading up to this next hunk,
-                // so do a final adjustment based on that
-                break;
-            }
-
-            // if the target buffer range intersects this hunk at all, no dice
-            if buffer_range
-                .start
-                .cmp(&hunk.buffer_range.end, buffer)
-                .is_lt()
-            {
-                return None;
-            }
-
-            start += hunk.buffer_range.start.to_offset(buffer) - pos.to_offset(buffer);
-            start += hunk.diff_base_byte_range.end - hunk.diff_base_byte_range.start;
-            pos = hunk.buffer_range.end;
-        }
-        start += buffer_range.start.to_offset(buffer) - pos.to_offset(buffer);
-        let end = start + buffer_range.end.to_offset(buffer) - buffer_range.start.to_offset(buffer);
-        Some(start..end)
-    }
-
-    pub fn secondary_edits_for_stage_or_unstage(
+    pub fn new_secondary_text_for_stage_or_unstage(
         &self,
         stage: bool,
-        hunks: impl Iterator<Item = (Range<usize>, Option<Range<usize>>, Range<Anchor>)>,
+        hunks: impl Iterator<Item = (Range<Anchor>, Range<usize>)>,
         buffer: &text::BufferSnapshot,
-    ) -> Vec<(Range<usize>, String)> {
-        let Some(secondary_diff) = self.secondary_diff() else {
-            log::debug!("no secondary diff");
-            return Vec::new();
+        cx: &mut App,
+    ) -> Option<Rope> {
+        let secondary_diff = self.secondary_diff()?;
+        let index_base = if let Some(index_base) = secondary_diff.base_text() {
+            index_base.text.as_rope().clone()
+        } else if stage {
+            Rope::from("")
+        } else {
+            return None;
         };
-        let index_base = secondary_diff.base_text().map_or_else(
-            || Rope::from(""),
-            |snapshot| snapshot.text.as_rope().clone(),
-        );
         let head_base = self.base_text().map_or_else(
             || Rope::from(""),
             |snapshot| snapshot.text.as_rope().clone(),
         );
-        log::debug!("original: {:?}", index_base.to_string());
+
+        let mut secondary_cursor = secondary_diff.inner.hunks.cursor::<DiffHunkSummary>(buffer);
+        secondary_cursor.next(buffer);
         let mut edits = Vec::new();
-        for (diff_base_byte_range, secondary_diff_base_byte_range, buffer_range) in hunks {
-            let (index_byte_range, replacement_text) = if stage {
+        let mut prev_secondary_hunk_buffer_offset = 0;
+        let mut prev_secondary_hunk_base_text_offset = 0;
+        for (buffer_range, diff_base_byte_range) in hunks {
+            let skipped_hunks = secondary_cursor.slice(&buffer_range.start, Bias::Left, buffer);
+
+            if let Some(secondary_hunk) = skipped_hunks.last() {
+                prev_secondary_hunk_base_text_offset = secondary_hunk.diff_base_byte_range.end;
+                prev_secondary_hunk_buffer_offset =
+                    secondary_hunk.buffer_range.end.to_offset(buffer);
+            }
+
+            let mut buffer_offset_range = buffer_range.to_offset(buffer);
+            let start_overshoot = buffer_offset_range.start - prev_secondary_hunk_buffer_offset;
+            let mut secondary_base_text_start =
+                prev_secondary_hunk_base_text_offset + start_overshoot;
+
+            while let Some(secondary_hunk) = secondary_cursor.item().filter(|item| {
+                item.buffer_range
+                    .start
+                    .cmp(&buffer_range.end, buffer)
+                    .is_le()
+            }) {
+                let secondary_hunk_offset_range = secondary_hunk.buffer_range.to_offset(buffer);
+                prev_secondary_hunk_base_text_offset = secondary_hunk.diff_base_byte_range.end;
+                prev_secondary_hunk_buffer_offset = secondary_hunk_offset_range.end;
+
+                secondary_base_text_start =
+                    secondary_base_text_start.min(secondary_hunk.diff_base_byte_range.start);
+                buffer_offset_range.start = buffer_offset_range
+                    .start
+                    .min(secondary_hunk_offset_range.start);
+
+                secondary_cursor.next(buffer);
+            }
+
+            let end_overshoot = buffer_offset_range
+                .end
+                .saturating_sub(prev_secondary_hunk_buffer_offset);
+            let secondary_base_text_end = prev_secondary_hunk_base_text_offset + end_overshoot;
+
+            let secondary_base_text_range = secondary_base_text_start..secondary_base_text_end;
+            buffer_offset_range.end = buffer_offset_range
+                .end
+                .max(prev_secondary_hunk_buffer_offset);
+
+            let replacement_text = if stage {
                 log::debug!("staging");
-                let mut replacement_text = String::new();
-                let Some(index_byte_range) = secondary_diff_base_byte_range.clone() else {
-                    log::debug!("not a stageable hunk");
-                    continue;
-                };
-                log::debug!("using {:?}", index_byte_range);
-                for chunk in buffer.text_for_range(buffer_range.clone()) {
-                    replacement_text.push_str(chunk);
-                }
-                (index_byte_range, replacement_text)
+                buffer
+                    .text_for_range(buffer_offset_range)
+                    .collect::<String>()
             } else {
                 log::debug!("unstaging");
-                let mut replacement_text = String::new();
-                let Some(index_byte_range) = secondary_diff
-                    .buffer_range_to_unchanged_diff_base_range(buffer_range.clone(), &buffer)
-                else {
-                    log::debug!("not an unstageable hunk");
-                    continue;
-                };
-                for chunk in head_base.chunks_in_range(diff_base_byte_range.clone()) {
-                    replacement_text.push_str(chunk);
-                }
-                (index_byte_range, replacement_text)
+                head_base
+                    .chunks_in_range(diff_base_byte_range.clone())
+                    .collect::<String>()
             };
-            edits.push((index_byte_range, replacement_text));
+            edits.push((secondary_base_text_range, replacement_text));
         }
-        log::debug!("edits: {edits:?}");
-        edits
+
+        let buffer = cx.new(|cx| {
+            language::Buffer::local_normalized(index_base, text::LineEnding::default(), cx)
+        });
+        let new_text = buffer.update(cx, |buffer, cx| {
+            buffer.edit(edits, None, cx);
+            buffer.as_rope().clone()
+        });
+        Some(new_text)
     }
 }
 
@@ -322,13 +326,12 @@ impl BufferDiffInner {
             }
 
             let mut secondary_status = DiffHunkSecondaryStatus::None;
-            let mut secondary_diff_base_byte_range = None;
             if let Some(secondary_cursor) = secondary_cursor.as_mut() {
                 if start_anchor
                     .cmp(&secondary_cursor.start().buffer_range.start, buffer)
                     .is_gt()
                 {
-                    secondary_cursor.seek_forward(&end_anchor, Bias::Left, buffer);
+                    secondary_cursor.seek_forward(&start_anchor, Bias::Left, buffer);
                 }
 
                 if let Some(secondary_hunk) = secondary_cursor.item() {
@@ -339,12 +342,12 @@ impl BufferDiffInner {
                     }
                     if secondary_range == (start_point..end_point) {
                         secondary_status = DiffHunkSecondaryStatus::HasSecondaryHunk;
-                        secondary_diff_base_byte_range =
-                            Some(secondary_hunk.diff_base_byte_range.clone());
                     } else if secondary_range.start <= end_point {
                         secondary_status = DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk;
                     }
                 }
+            } else {
+                log::debug!("no secondary cursor!!");
             }
 
             return Some(DiffHunk {
@@ -352,7 +355,6 @@ impl BufferDiffInner {
                 diff_base_byte_range: start_base..end_base,
                 buffer_range: start_anchor..end_anchor,
                 secondary_status,
-                secondary_diff_base_byte_range,
             });
         })
     }
@@ -387,7 +389,6 @@ impl BufferDiffInner {
                 buffer_range: hunk.buffer_range.clone(),
                 // The secondary status is not used by callers of this method.
                 secondary_status: DiffHunkSecondaryStatus::None,
-                secondary_diff_base_byte_range: None,
             })
         })
     }
@@ -408,12 +409,12 @@ impl BufferDiffInner {
                         .start
                         .cmp(&old_hunk.buffer_range.start, new_snapshot)
                     {
-                        cmp::Ordering::Less => {
+                        Ordering::Less => {
                             start.get_or_insert(new_hunk.buffer_range.start);
                             end.replace(new_hunk.buffer_range.end);
                             new_cursor.next(new_snapshot);
                         }
-                        cmp::Ordering::Equal => {
+                        Ordering::Equal => {
                             if new_hunk != old_hunk {
                                 start.get_or_insert(new_hunk.buffer_range.start);
                                 if old_hunk
@@ -431,7 +432,7 @@ impl BufferDiffInner {
                             new_cursor.next(new_snapshot);
                             old_cursor.next(new_snapshot);
                         }
-                        cmp::Ordering::Greater => {
+                        Ordering::Greater => {
                             start.get_or_insert(old_hunk.buffer_range.start);
                             end.replace(old_hunk.buffer_range.end);
                             old_cursor.next(new_snapshot);
@@ -1059,6 +1060,7 @@ mod tests {
     use rand::{rngs::StdRng, Rng as _};
     use text::{Buffer, BufferId, Rope};
     use unindent::Unindent as _;
+    use util::test::marked_text_ranges;
 
     #[ctor::ctor]
     fn init_logger() {
@@ -1257,6 +1259,208 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_stage_hunk(cx: &mut TestAppContext) {
+        struct Example {
+            name: &'static str,
+            head_text: String,
+            index_text: String,
+            buffer_marked_text: String,
+            final_index_text: String,
+        }
+
+        let table = [
+            Example {
+                name: "uncommitted hunk straddles end of unstaged hunk",
+                head_text: "
+                    one
+                    two
+                    three
+                    four
+                    five
+                "
+                .unindent(),
+                index_text: "
+                    one
+                    TWO_HUNDRED
+                    three
+                    FOUR_HUNDRED
+                    five
+                "
+                .unindent(),
+                buffer_marked_text: "
+                    ZERO
+                    one
+                    two
+                    «THREE_HUNDRED
+                    FOUR_HUNDRED»
+                    five
+                    SIX
+                "
+                .unindent(),
+                final_index_text: "
+                    one
+                    two
+                    THREE_HUNDRED
+                    FOUR_HUNDRED
+                    five
+                "
+                .unindent(),
+            },
+            Example {
+                name: "uncommitted hunk straddles start of unstaged hunk",
+                head_text: "
+                    one
+                    two
+                    three
+                    four
+                    five
+                "
+                .unindent(),
+                index_text: "
+                    one
+                    TWO_HUNDRED
+                    three
+                    FOUR_HUNDRED
+                    five
+                "
+                .unindent(),
+                buffer_marked_text: "
+                    ZERO
+                    one
+                    «TWO_HUNDRED
+                    THREE_HUNDRED»
+                    four
+                    five
+                    SIX
+                "
+                .unindent(),
+                final_index_text: "
+                    one
+                    TWO_HUNDRED
+                    THREE_HUNDRED
+                    four
+                    five
+                "
+                .unindent(),
+            },
+            Example {
+                name: "uncommitted hunk strictly contains unstaged hunks",
+                head_text: "
+                    one
+                    two
+                    three
+                    four
+                    five
+                    six
+                    seven
+                "
+                .unindent(),
+                index_text: "
+                    one
+                    TWO
+                    THREE
+                    FOUR
+                    FIVE
+                    SIX
+                    seven
+                "
+                .unindent(),
+                buffer_marked_text: "
+                    one
+                    TWO
+                    «THREE_HUNDRED
+                    FOUR
+                    FIVE_HUNDRED»
+                    SIX
+                    seven
+                "
+                .unindent(),
+                final_index_text: "
+                    one
+                    TWO
+                    THREE_HUNDRED
+                    FOUR
+                    FIVE_HUNDRED
+                    SIX
+                    seven
+                "
+                .unindent(),
+            },
+            Example {
+                name: "uncommitted deletion hunk",
+                head_text: "
+                    one
+                    two
+                    three
+                    four
+                    five
+                "
+                .unindent(),
+                index_text: "
+                    one
+                    two
+                    three
+                    four
+                    five
+                "
+                .unindent(),
+                buffer_marked_text: "
+                    one
+                    ˇfive
+                "
+                .unindent(),
+                final_index_text: "
+                    one
+                    five
+                "
+                .unindent(),
+            },
+        ];
+
+        for example in table {
+            let (buffer_text, ranges) = marked_text_ranges(&example.buffer_marked_text, false);
+            let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text);
+            let uncommitted_diff =
+                BufferDiff::build_sync(buffer.clone(), example.head_text.clone(), cx);
+            let unstaged_diff =
+                BufferDiff::build_sync(buffer.clone(), example.index_text.clone(), cx);
+            let uncommitted_diff = BufferDiffSnapshot {
+                inner: uncommitted_diff,
+                secondary_diff: Some(Box::new(BufferDiffSnapshot {
+                    inner: unstaged_diff,
+                    is_single_insertion: false,
+                    secondary_diff: None,
+                })),
+                is_single_insertion: false,
+            };
+
+            let range = buffer.anchor_before(ranges[0].start)..buffer.anchor_before(ranges[0].end);
+
+            let new_index_text = cx
+                .update(|cx| {
+                    uncommitted_diff.new_secondary_text_for_stage_or_unstage(
+                        true,
+                        uncommitted_diff
+                            .hunks_intersecting_range(range, &buffer)
+                            .map(|hunk| {
+                                (hunk.buffer_range.clone(), hunk.diff_base_byte_range.clone())
+                            }),
+                        &buffer,
+                        cx,
+                    )
+                })
+                .unwrap()
+                .to_string();
+            pretty_assertions::assert_eq!(
+                new_index_text,
+                example.final_index_text,
+                "example: {}",
+                example.name
+            );
+        }
+    }
+
     #[gpui::test]
     async fn test_buffer_diff_compare(cx: &mut TestAppContext) {
         let base_text = "
@@ -1382,7 +1586,7 @@ mod tests {
     }
 
     #[gpui::test(iterations = 100)]
-    async fn test_secondary_edits_for_stage_unstage(cx: &mut TestAppContext, mut rng: StdRng) {
+    async fn test_staging_and_unstaging_hunks(cx: &mut TestAppContext, mut rng: StdRng) {
         fn gen_line(rng: &mut StdRng) -> String {
             if rng.gen_bool(0.2) {
                 "\n".to_owned()
@@ -1447,7 +1651,7 @@ mod tests {
 
         fn uncommitted_diff(
             working_copy: &language::BufferSnapshot,
-            index_text: &Entity<language::Buffer>,
+            index_text: &Rope,
             head_text: String,
             cx: &mut TestAppContext,
         ) -> BufferDiff {
@@ -1456,7 +1660,7 @@ mod tests {
                 buffer_id: working_copy.remote_id(),
                 inner: BufferDiff::build_sync(
                     working_copy.text.clone(),
-                    index_text.read_with(cx, |index_text, _| index_text.text()),
+                    index_text.to_string(),
                     cx,
                 ),
                 secondary_diff: None,
@@ -1487,17 +1691,11 @@ mod tests {
             )
         });
         let working_copy = working_copy.read_with(cx, |working_copy, _| working_copy.snapshot());
-        let index_text = cx.new(|cx| {
-            language::Buffer::local_normalized(
-                if rng.gen() {
-                    Rope::from(head_text.as_str())
-                } else {
-                    working_copy.as_rope().clone()
-                },
-                text::LineEnding::default(),
-                cx,
-            )
-        });
+        let mut index_text = if rng.gen() {
+            Rope::from(head_text.as_str())
+        } else {
+            working_copy.as_rope().clone()
+        };
 
         let mut diff = uncommitted_diff(&working_copy, &index_text, head_text.clone(), cx);
         let mut hunks = cx.update(|cx| {
@@ -1511,37 +1709,29 @@ mod tests {
         for _ in 0..operations {
             let i = rng.gen_range(0..hunks.len());
             let hunk = &mut hunks[i];
-            let hunk_fields = (
-                hunk.diff_base_byte_range.clone(),
-                hunk.secondary_diff_base_byte_range.clone(),
-                hunk.buffer_range.clone(),
-            );
-            let stage = match (
-                hunk.secondary_status,
-                hunk.secondary_diff_base_byte_range.clone(),
-            ) {
-                (DiffHunkSecondaryStatus::HasSecondaryHunk, Some(_)) => {
+            let stage = match hunk.secondary_status {
+                DiffHunkSecondaryStatus::HasSecondaryHunk => {
                     hunk.secondary_status = DiffHunkSecondaryStatus::None;
-                    hunk.secondary_diff_base_byte_range = None;
                     true
                 }
-                (DiffHunkSecondaryStatus::None, None) => {
+                DiffHunkSecondaryStatus::None => {
                     hunk.secondary_status = DiffHunkSecondaryStatus::HasSecondaryHunk;
-                    // We don't look at this, just notice whether it's Some or not.
-                    hunk.secondary_diff_base_byte_range = Some(17..17);
                     false
                 }
                 _ => unreachable!(),
             };
 
             let snapshot = cx.update(|cx| diff.snapshot(cx));
-            let edits = snapshot.secondary_edits_for_stage_or_unstage(
-                stage,
-                [hunk_fields].into_iter(),
-                &working_copy,
-            );
-            index_text.update(cx, |index_text, cx| {
-                index_text.edit(edits, None, cx);
+            index_text = cx.update(|cx| {
+                snapshot
+                    .new_secondary_text_for_stage_or_unstage(
+                        stage,
+                        [(hunk.buffer_range.clone(), hunk.diff_base_byte_range.clone())]
+                            .into_iter(),
+                        &working_copy,
+                        cx,
+                    )
+                    .unwrap()
             });
 
             diff = uncommitted_diff(&working_copy, &index_text, head_text.clone(), cx);
@@ -1550,6 +1740,7 @@ mod tests {
                     .collect::<Vec<_>>()
             });
             assert_eq!(hunks.len(), found_hunks.len());
+
             for (expected_hunk, found_hunk) in hunks.iter().zip(&found_hunks) {
                 assert_eq!(
                     expected_hunk.buffer_range.to_point(&working_copy),
@@ -1560,10 +1751,6 @@ mod tests {
                     found_hunk.diff_base_byte_range
                 );
                 assert_eq!(expected_hunk.secondary_status, found_hunk.secondary_status);
-                assert_eq!(
-                    expected_hunk.secondary_diff_base_byte_range.is_some(),
-                    found_hunk.secondary_diff_base_byte_range.is_some()
-                )
             }
             hunks = found_hunks;
         }

crates/editor/src/editor.rs 🔗

@@ -13329,7 +13329,7 @@ impl Editor {
         snapshot: &MultiBufferSnapshot,
     ) -> bool {
         let mut hunks = self.diff_hunks_in_ranges(ranges, &snapshot);
-        hunks.any(|hunk| hunk.secondary_status == DiffHunkSecondaryStatus::HasSecondaryHunk)
+        hunks.any(|hunk| hunk.secondary_status != DiffHunkSecondaryStatus::None)
     }
 
     pub fn toggle_staged_selected_diff_hunks(
@@ -13474,12 +13474,8 @@ impl Editor {
             log::debug!("no diff for buffer id");
             return;
         };
-        let Some(secondary_diff) = diff.secondary_diff() else {
-            log::debug!("no secondary diff for buffer id");
-            return;
-        };
 
-        let edits = diff.secondary_edits_for_stage_or_unstage(
+        let Some(new_index_text) = diff.new_secondary_text_for_stage_or_unstage(
             stage,
             hunks.filter_map(|hunk| {
                 if stage && hunk.secondary_status == DiffHunkSecondaryStatus::None {
@@ -13489,29 +13485,14 @@ impl Editor {
                 {
                     return None;
                 }
-                Some((
-                    hunk.diff_base_byte_range.clone(),
-                    hunk.secondary_diff_base_byte_range.clone(),
-                    hunk.buffer_range.clone(),
-                ))
+                Some((hunk.buffer_range.clone(), hunk.diff_base_byte_range.clone()))
             }),
             &buffer_snapshot,
-        );
-
-        let Some(index_base) = secondary_diff
-            .base_text()
-            .map(|snapshot| snapshot.text.as_rope().clone())
-        else {
-            log::debug!("no index base");
+            cx,
+        ) else {
+            log::debug!("missing secondary diff or index text");
             return;
         };
-        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()
             && !stage
             && (diff.is_single_insertion
@@ -13531,7 +13512,7 @@ impl Editor {
 
         cx.background_spawn(
             repo.read(cx)
-                .set_index_text(&path, new_index_text)
+                .set_index_text(&path, new_index_text.map(|rope| rope.to_string()))
                 .log_err(),
         )
         .detach();

crates/editor/src/editor_tests.rs 🔗

@@ -7,7 +7,7 @@ use crate::{
     },
     JoinLines,
 };
-use buffer_diff::{BufferDiff, DiffHunkStatus};
+use buffer_diff::{BufferDiff, DiffHunkStatus, DiffHunkStatusKind};
 use futures::StreamExt;
 use gpui::{
     div, BackgroundExecutor, SemanticVersion, TestAppContext, UpdateGlobal, VisualTestContext,
@@ -3389,7 +3389,7 @@ async fn test_join_lines_with_git_diff_base(executor: BackgroundExecutor, cx: &m
         .unindent(),
     );
 
-    cx.set_diff_base(&diff_base);
+    cx.set_head_text(&diff_base);
     executor.run_until_parked();
 
     // Join lines
@@ -3429,7 +3429,7 @@ async fn test_custom_newlines_cause_no_false_positive_diffs(
     init_test(cx, |_| {});
     let mut cx = EditorTestContext::new(cx).await;
     cx.set_state("Line 0\r\nLine 1\rˇ\nLine 2\r\nLine 3");
-    cx.set_diff_base("Line 0\r\nLine 1\r\nLine 2\r\nLine 3");
+    cx.set_head_text("Line 0\r\nLine 1\r\nLine 2\r\nLine 3");
     executor.run_until_parked();
 
     cx.update_editor(|editor, window, cx| {
@@ -5811,7 +5811,7 @@ async fn test_fold_function_bodies(cx: &mut TestAppContext) {
 
     let mut cx = EditorLspTestContext::new_rust(Default::default(), cx).await;
     cx.set_state(&text);
-    cx.set_diff_base(&base_text);
+    cx.set_head_text(&base_text);
     cx.update_editor(|editor, window, cx| {
         editor.expand_all_diff_hunks(&Default::default(), window, cx);
     });
@@ -11039,7 +11039,7 @@ async fn test_go_to_hunk(executor: BackgroundExecutor, cx: &mut TestAppContext)
         .unindent(),
     );
 
-    cx.set_diff_base(&diff_base);
+    cx.set_head_text(&diff_base);
     executor.run_until_parked();
 
     cx.update_editor(|editor, window, cx| {
@@ -12531,7 +12531,7 @@ async fn test_deleting_over_diff_hunk(cx: &mut TestAppContext) {
         three
         "#};
 
-    cx.set_diff_base(base_text);
+    cx.set_head_text(base_text);
     cx.set_state("\nˇ\n");
     cx.executor().run_until_parked();
     cx.update_editor(|editor, _window, cx| {
@@ -13168,7 +13168,7 @@ async fn test_toggle_selected_diff_hunks(executor: BackgroundExecutor, cx: &mut
         .unindent(),
     );
 
-    cx.set_diff_base(&diff_base);
+    cx.set_head_text(&diff_base);
     executor.run_until_parked();
 
     cx.update_editor(|editor, window, cx| {
@@ -13302,7 +13302,7 @@ async fn test_diff_base_change_with_expanded_diff_hunks(
         .unindent(),
     );
 
-    cx.set_diff_base(&diff_base);
+    cx.set_head_text(&diff_base);
     executor.run_until_parked();
 
     cx.update_editor(|editor, window, cx| {
@@ -13330,7 +13330,7 @@ async fn test_diff_base_change_with_expanded_diff_hunks(
         .unindent(),
     );
 
-    cx.set_diff_base("new diff base!");
+    cx.set_head_text("new diff base!");
     executor.run_until_parked();
     cx.assert_state_with_diff(
         r#"
@@ -13630,7 +13630,7 @@ async fn test_edits_around_expanded_insertion_hunks(
         .unindent(),
     );
 
-    cx.set_diff_base(&diff_base);
+    cx.set_head_text(&diff_base);
     executor.run_until_parked();
 
     cx.update_editor(|editor, window, cx| {
@@ -13778,7 +13778,7 @@ async fn test_toggling_adjacent_diff_hunks(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
 
     let mut cx = EditorTestContext::new(cx).await;
-    cx.set_diff_base(indoc! { "
+    cx.set_head_text(indoc! { "
         one
         two
         three
@@ -13901,7 +13901,7 @@ async fn test_edits_around_expanded_deletion_hunks(
         .unindent(),
     );
 
-    cx.set_diff_base(&diff_base);
+    cx.set_head_text(&diff_base);
     executor.run_until_parked();
 
     cx.update_editor(|editor, window, cx| {
@@ -14024,7 +14024,7 @@ async fn test_backspace_after_deletion_hunk(executor: BackgroundExecutor, cx: &m
         .unindent(),
     );
 
-    cx.set_diff_base(&base_text);
+    cx.set_head_text(&base_text);
     executor.run_until_parked();
 
     cx.update_editor(|editor, window, cx| {
@@ -14106,7 +14106,7 @@ async fn test_edit_after_expanded_modification_hunk(
         .unindent(),
     );
 
-    cx.set_diff_base(&diff_base);
+    cx.set_head_text(&diff_base);
     executor.run_until_parked();
     cx.update_editor(|editor, window, cx| {
         editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx);
@@ -14841,7 +14841,7 @@ async fn test_adjacent_diff_hunks(executor: BackgroundExecutor, cx: &mut TestApp
         "#
         .unindent(),
     );
-    cx.set_diff_base(&diff_base);
+    cx.set_head_text(&diff_base);
     cx.update_editor(|editor, window, cx| {
         editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx);
     });
@@ -14978,6 +14978,80 @@ async fn test_adjacent_diff_hunks(executor: BackgroundExecutor, cx: &mut TestApp
     );
 }
 
+#[gpui::test]
+async fn test_partially_staged_hunk(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.set_head_text(indoc! { "
+        one
+        two
+        three
+        four
+        five
+        "
+    });
+    cx.set_index_text(indoc! { "
+        one
+        two
+        three
+        four
+        five
+        "
+    });
+    cx.set_state(indoc! {"
+        one
+        TWO
+        ˇTHREE
+        FOUR
+        five
+    "});
+    cx.run_until_parked();
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_staged_selected_diff_hunks(&Default::default(), window, cx);
+    });
+    cx.run_until_parked();
+    cx.assert_index_text(Some(indoc! {"
+        one
+        TWO
+        THREE
+        FOUR
+        five
+    "}));
+    cx.set_state(indoc! { "
+        one
+        TWO
+        ˇTHREE-HUNDRED
+        FOUR
+        five
+    "});
+    cx.run_until_parked();
+    cx.update_editor(|editor, window, cx| {
+        let snapshot = editor.snapshot(window, cx);
+        let hunks = editor
+            .diff_hunks_in_ranges(&[Anchor::min()..Anchor::max()], &snapshot.buffer_snapshot)
+            .collect::<Vec<_>>();
+        assert_eq!(hunks.len(), 1);
+        assert_eq!(
+            hunks[0].status(),
+            DiffHunkStatus {
+                kind: DiffHunkStatusKind::Modified,
+                secondary: DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk
+            }
+        );
+
+        editor.toggle_staged_selected_diff_hunks(&Default::default(), window, cx);
+    });
+    cx.run_until_parked();
+    cx.assert_index_text(Some(indoc! {"
+        one
+        TWO
+        THREE-HUNDRED
+        FOUR
+        five
+    "}));
+}
+
 #[gpui::test]
 fn test_crease_insertion_and_rendering(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -16341,7 +16415,7 @@ fn assert_hunk_revert(
     cx: &mut EditorLspTestContext,
 ) {
     cx.set_state(not_reverted_text_with_selections);
-    cx.set_diff_base(base_text);
+    cx.set_head_text(base_text);
     cx.executor().run_until_parked();
 
     let actual_hunk_statuses_before = cx.update_editor(|editor, window, cx| {

crates/editor/src/test/editor_test_context.rs 🔗

@@ -285,7 +285,7 @@ impl EditorTestContext {
         snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end)
     }
 
-    pub fn set_diff_base(&mut self, diff_base: &str) {
+    pub fn set_head_text(&mut self, diff_base: &str) {
         self.cx.run_until_parked();
         let fs = self.update_editor(|editor, _, cx| {
             editor.project.as_ref().unwrap().read(cx).fs().as_fake()
@@ -298,6 +298,19 @@ impl EditorTestContext {
         self.cx.run_until_parked();
     }
 
+    pub fn set_index_text(&mut self, diff_base: &str) {
+        self.cx.run_until_parked();
+        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());
+        fs.set_index_for_repo(
+            &Self::root_path().join(".git"),
+            &[(path.into(), diff_base.to_string())],
+        );
+        self.cx.run_until_parked();
+    }
+
     #[track_caller]
     pub fn assert_index_text(&mut self, expected: Option<&str>) {
         let fs = self.update_editor(|editor, _, cx| {

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -131,7 +131,6 @@ pub struct MultiBufferDiffHunk {
     pub diff_base_byte_range: Range<usize>,
     /// Whether or not this hunk also appears in the 'secondary diff'.
     pub secondary_status: DiffHunkSecondaryStatus,
-    pub secondary_diff_base_byte_range: Option<Range<usize>>,
 }
 
 impl MultiBufferDiffHunk {
@@ -3506,7 +3505,6 @@ impl MultiBufferSnapshot {
                 buffer_range: hunk.buffer_range.clone(),
                 diff_base_byte_range: hunk.diff_base_byte_range.clone(),
                 secondary_status: hunk.secondary_status,
-                secondary_diff_base_byte_range: hunk.secondary_diff_base_byte_range,
             })
         })
     }
@@ -3876,7 +3874,6 @@ impl MultiBufferSnapshot {
                         buffer_range: hunk.buffer_range.clone(),
                         diff_base_byte_range: hunk.diff_base_byte_range.clone(),
                         secondary_status: hunk.secondary_status,
-                        secondary_diff_base_byte_range: hunk.secondary_diff_base_byte_range,
                     });
                 }
             }

crates/text/src/text.rs 🔗

@@ -2934,6 +2934,7 @@ impl ToOffset for Point {
 }
 
 impl ToOffset for usize {
+    #[track_caller]
     fn to_offset(&self, snapshot: &BufferSnapshot) -> usize {
         assert!(
             *self <= snapshot.len(),