From 699668fc96179ae7303b67e64f10b6c4439836dc Mon Sep 17 00:00:00 2001 From: AidanV Date: Wed, 29 Apr 2026 14:38:14 -0700 Subject: [PATCH 1/8] Modified `test_git_diff_base_change` integration test This was a bug in the integration test that was uncovered by correcting the logic for `OverlapsWithSecondaryHunk` and `HasSecondaryHunk`. Previously, partially staged deletions were incorrectly treated as `HasSecondaryHunk`. The correction for partially staged deletions to display as `OverlapsWithSecondaryHunk` is required for partial staging. In this specific test, "TWO"'s deletion is staged, but "two"'s addition is not staged leading to this edge case triggering. --- crates/collab/tests/integration/integration_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/collab/tests/integration/integration_tests.rs b/crates/collab/tests/integration/integration_tests.rs index 5650b26db98ee7..dde3dbc25ffc98 100644 --- a/crates/collab/tests/integration/integration_tests.rs +++ b/crates/collab/tests/integration/integration_tests.rs @@ -2765,7 +2765,7 @@ async fn test_git_diff_base_change( 1..2, "TWO\n", "two\n", - DiffHunkStatus::modified(DiffHunkSecondaryStatus::HasSecondaryHunk), + DiffHunkStatus::modified(DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk), )], ); }); From 1dc217ed777976e01a40c74e57241421527e654a Mon Sep 17 00:00:00 2001 From: AidanV Date: Wed, 29 Apr 2026 01:41:44 -0700 Subject: [PATCH 2/8] feat(git): Partial committing --- assets/keymaps/default-windows.json | 1 + crates/editor/src/element.rs | 1 + crates/editor/src/git.rs | 15 +++++++++++++++ crates/git/src/git.rs | 3 +++ 4 files changed, 20 insertions(+) diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index fff87c886b3187..3a959e535ce3bf 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -188,6 +188,7 @@ "use_key_equivalents": true, "bindings": { "ctrl-k ctrl-r": "git::Restore", + "ctrl-alt-y": "git::ToggleStaged", "alt-y": "git::StageAndNext", "shift-alt-y": "git::UnstageAndNext", }, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index f58057c8033678..8307dd1794bde1 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -644,6 +644,7 @@ impl EditorElement { } register_action(editor, window, Editor::toggle_selected_diff_hunks); register_action(editor, window, Editor::toggle_staged_selected_diff_hunks); + register_action(editor, window, Editor::toggle_staged_selected_lines); register_action(editor, window, Editor::stage_and_next); register_action(editor, window, Editor::unstage_and_next); register_action(editor, window, Editor::expand_all_diff_hunks); diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index c380bed76fb56f..23b1dc5b774e8a 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -1610,6 +1610,21 @@ impl Editor { .detach_and_log_err(cx); } + // TODO(partial-commit): stage/unstage only the individual lines under the + // cursor within their hunk. The line-level index computation was written + // against the old whole-index-rebuild staging path; it still needs to be + // reimplemented on top of the current optimistic-index model in + // `project::git_store`. Until then, toggling selected lines falls back to + // toggling the whole hunk(s) the selection covers. + pub(super) fn toggle_staged_selected_lines( + &mut self, + _: &::git::ToggleStagedSelectedLines, + window: &mut Window, + cx: &mut Context, + ) { + self.toggle_staged_selected_diff_hunks(&::git::ToggleStaged, window, cx); + } + pub(super) fn stage_and_next( &mut self, _: &::git::StageAndNext, diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 98cbcbd46a0f5a..67f249153f7509 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -43,6 +43,9 @@ actions!( // per-hunk /// Toggles the staged state of the hunk or status entry at cursor. ToggleStaged, + /// Toggles the staged state of individual lines under the cursor within + /// their hunk, leaving the rest of the hunk's staging state unchanged. + ToggleStagedSelectedLines, /// Stage status entries between an anchor entry and the cursor. StageRange, /// Stages the current hunk and moves to the next one. From c18c1192f85da513c242f2188f6968c9e38380d7 Mon Sep 17 00:00:00 2001 From: AidanV Date: Sat, 11 Jul 2026 13:49:25 -0700 Subject: [PATCH 3/8] existence of partial staging wired throughout --- Cargo.lock | 1 + crates/buffer_diff/src/buffer_diff.rs | 245 ++++++++++++++++++++++-- crates/editor/src/git.rs | 17 +- crates/multi_buffer/src/multi_buffer.rs | 16 ++ crates/project/Cargo.toml | 1 + crates/project/src/git_store.rs | 19 +- 6 files changed, 277 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 172b5dc004f891..d19d7bc72ce95c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14125,6 +14125,7 @@ dependencies = [ "regex", "release_channel", "remote", + "rope", "rpc", "schemars 1.0.4", "semver", diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index b4249c97f8a335..4b4b8ceb32c656 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -2,7 +2,7 @@ use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task}; use imara_diff::{Algorithm, Diff, InternedInput, sources::lines}; use language::{ Capability, DiffOptions, Language, LanguageName, LanguageRegistry, - language_settings::LanguageSettings, word_diff_ranges, + language_settings::LanguageSettings, line_diff, word_diff_ranges, }; use rope::Rope; use std::{ @@ -148,7 +148,7 @@ pub enum DiffHunkSecondaryStatus { NoSecondaryHunk, /// We are unstaging SecondaryHunkAdditionPending, - /// We are stagind + /// We are staging SecondaryHunkRemovalPending, } @@ -166,6 +166,8 @@ pub struct DiffHunk { pub buffer_word_diffs: Vec>, // Offsets relative to the start of the deleted diff that represent word diff locations pub base_word_diffs: Vec>, + pub staged_added: Vec>, + pub staged_deleted: Vec>, } /// We store [`InternalDiffHunk`]s internally so we don't need to store the additional row range. @@ -184,6 +186,8 @@ pub struct PendingHunk { diff_base_byte_range: Range, buffer_version: clock::Global, sense: PendingSense, + staged_added: Vec>, + staged_deleted: Vec>, } impl PendingHunk { @@ -192,12 +196,16 @@ impl PendingHunk { diff_base_byte_range: Range, buffer_version: clock::Global, sense: PendingSense, + staged_added: Vec>, + staged_deleted: Vec>, ) -> Self { Self { buffer_range, diff_base_byte_range, buffer_version, sense, + staged_added, + staged_deleted, } } } @@ -394,13 +402,22 @@ impl BufferDiffSnapshot { .filter::<_, DiffHunkSummary>(buffer, filter) .map(move |hunk| { let buffer_range = hunk.buffer_range.clone(); + let range = buffer_range.to_point(buffer); + let base_span = base_row_span( + &self.base_text, + &hunk.diff_base_byte_range, + &hunk.diff_base_point_range, + ); + DiffHunk { - range: buffer_range.to_point(buffer), + range: range.clone(), diff_base_byte_range: hunk.diff_base_byte_range.clone(), - buffer_range, + buffer_range: buffer_range.clone(), secondary_status: DiffHunkSecondaryStatus::NoSecondaryHunk, base_word_diffs: hunk.base_word_diffs.clone(), buffer_word_diffs: hunk.buffer_word_diffs.clone(), + staged_added: vec![buffer_row_span(&range)], + staged_deleted: vec![0..(base_span.end - base_span.start)], } }) } @@ -876,6 +893,20 @@ impl BufferDiffSnapshot { 0..index_len, version, sense, + if stage && file_exists { + vec![Anchor::min_max_range_for_buffer(buffer.remote_id())] + } else { + vec![] + }, + if stage && self.base_text_exists { + vec![base_row_span( + &self.base_text, + &(0..self.base_text.len()), + &(Point::new(0, 0)..self.base_text.max_point()), + )] + } else { + vec![] + }, )]; let edits = new_index_text.map(|rope| vec![(0..index_len, Arc::from(rope.to_string()))]); @@ -910,6 +941,14 @@ impl BufferDiffSnapshot { diff_base_byte_range.clone(), version.clone(), sense, + hunk.staged_added + .iter() + .map(|range| { + buffer.anchor_before(Point::new(range.start, 0)) + ..buffer.anchor_before(Point::new(range.end, 0)) + }) + .collect(), + hunk.staged_deleted.clone(), )); // Advance unstaged_hunk_cursor to skip unstaged hunks before current hunk @@ -958,6 +997,15 @@ impl BufferDiffSnapshot { merged_hunk.diff_base_byte_range.clone(), version.clone(), sense, + merged_hunk + .staged_added + .iter() + .map(|range| { + buffer.anchor_before(Point::new(range.start, 0)) + ..buffer.anchor_before(Point::new(range.end, 0)) + }) + .collect(), + merged_hunk.staged_deleted.clone(), )); continue; } @@ -1075,6 +1123,8 @@ impl BufferDiffSnapshot { secondary_cursor = Some(cursor); } + let mut head_to_index_edits: Option, Range)>> = None; + let max_point = buffer.max_point(); let mut summaries = buffer.summaries_for_anchors_with_payload::(anchor_iter); iter::from_fn(move || { @@ -1089,6 +1139,13 @@ impl BufferDiffSnapshot { continue; } + let buffer_span = buffer_row_span(&(start_point..end_point)); + let base_span = base_row_span( + &self.base_text, + &hunk.diff_base_byte_range, + &hunk.diff_base_point_range, + ); + if end_point.column > 0 && end_point < max_point { end_point.row += 1; end_point.column = 0; @@ -1096,6 +1153,8 @@ impl BufferDiffSnapshot { } let mut secondary_status = DiffHunkSecondaryStatus::NoSecondaryHunk; + let mut staged_added = vec![buffer_span.clone()]; + let mut staged_deleted = vec![0..(base_span.end - base_span.start)]; let mut has_pending = false; if start_anchor @@ -1106,13 +1165,9 @@ impl BufferDiffSnapshot { } if let Some(pending_hunk) = pending_hunks_cursor.item() { - let mut pending_range = pending_hunk.buffer_range.to_point(buffer); - if pending_range.end.column > 0 { - pending_range.end.row += 1; - pending_range.end.column = 0; - } + let pending_span = buffer_row_span(&pending_hunk.buffer_range.to_point(buffer)); - if pending_range == (start_point..end_point) + if pending_span == buffer_span && !buffer.has_edits_since_in_range( &pending_hunk.buffer_version, start_anchor..end_anchor, @@ -1120,6 +1175,14 @@ impl BufferDiffSnapshot { { match pending_hunk.sense { PendingSense::SetSecondaryStatus { stage } => { + staged_added = pending_hunk + .staged_added + .iter() + .map(|anchor_range| { + buffer_row_span(&anchor_range.to_point(buffer)) + }) + .collect(); + staged_deleted = pending_hunk.staged_deleted.clone(); has_pending = true; secondary_status = if stage { DiffHunkSecondaryStatus::SecondaryHunkRemovalPending @@ -1141,19 +1204,88 @@ impl BufferDiffSnapshot { } if let Some(secondary_hunk) = secondary_cursor.item() { - let mut secondary_range = secondary_hunk.buffer_range.to_point(buffer); - if secondary_range.end.column > 0 { - secondary_range.end.row += 1; - secondary_range.end.column = 0; - } + let secondary_range = + buffer_row_span(&secondary_hunk.buffer_range.to_point(buffer)); if secondary_range.is_empty() && secondary_hunk.diff_base_byte_range.is_empty() { // ignore - } else if secondary_range == (start_point..end_point) { + } else if secondary_range == buffer_span + && secondary_hunk.diff_base_byte_range.len() == end_base - start_base + { secondary_status = DiffHunkSecondaryStatus::HasSecondaryHunk; - } else if secondary_range.start <= end_point { + staged_added = vec![]; + staged_deleted = vec![]; + } else if secondary_range.start <= buffer_span.end { secondary_status = DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk; + staged_added = vec![]; + staged_deleted = vec![]; + + let mut new_unstaged_added = vec![]; + while let Some(secondary_hunk) = secondary_cursor.item() { + let secondary_addition_range = + buffer_row_span(&secondary_hunk.buffer_range.to_point(buffer)); + if secondary_addition_range.start >= buffer_span.end { + break; + } + let overlap_start = + secondary_addition_range.start.max(start_point.row); + let overlap_end = secondary_addition_range.end.min(buffer_span.end); + if overlap_start < overlap_end { + new_unstaged_added.push(overlap_start..overlap_end); + } + if secondary_addition_range.end > buffer_span.end { + break; + } + secondary_cursor.next(); + } + let mut row = buffer_span.start; + for unstaged_range in &new_unstaged_added { + if row < unstaged_range.start { + staged_added.push(row..unstaged_range.start); + } + row = unstaged_range.end; + } + if row < buffer_span.end { + staged_added.push(row..buffer_span.end); + } + + if let Some(secondary) = secondary { + let edits = head_to_index_edits.get_or_insert_with(|| { + let mut head_text = if self.base_text_exists { + self.base_text.text() + } else { + String::new() + }; + let mut index_text = if secondary.base_text_exists { + secondary.base_text.text() + } else { + String::new() + }; + // Normalize trailing newlines so a final line without + // one isn't reported as a modification of itself. + if !head_text.is_empty() && !head_text.ends_with('\n') { + head_text.push('\n'); + } + if !index_text.is_empty() && !index_text.ends_with('\n') { + index_text.push('\n'); + } + line_diff(&head_text, &index_text) + }); + for (head_rows, _) in edits.iter() { + let overlap_start = head_rows + .start + .max(base_span.start) + .saturating_sub(base_span.start); + let overlap_end = head_rows + .end + .min(base_span.end) + .saturating_sub(base_span.start); + if overlap_start < overlap_end { + staged_deleted.push(overlap_start..overlap_end); + } + } + } } } } @@ -1165,6 +1297,8 @@ impl BufferDiffSnapshot { base_word_diffs, buffer_word_diffs, secondary_status, + staged_added, + staged_deleted, }); } }) @@ -1182,6 +1316,13 @@ impl BufferDiffSnapshot { let hunk = cursor.item()?; let range = hunk.buffer_range.to_point(buffer); + let staged_added = vec![buffer_row_span(&range)]; + let base_span = base_row_span( + &self.base_text, + &hunk.diff_base_byte_range, + &hunk.diff_base_point_range, + ); + let staged_deleted = vec![0..(base_span.end - base_span.start)]; Some(DiffHunk { range, @@ -1191,6 +1332,8 @@ impl BufferDiffSnapshot { secondary_status: DiffHunkSecondaryStatus::NoSecondaryHunk, base_word_diffs: hunk.base_word_diffs.clone(), buffer_word_diffs: hunk.buffer_word_diffs.clone(), + staged_added, + staged_deleted, }) }) } @@ -1857,6 +2000,14 @@ impl BufferDiff { hunk.diff_base_byte_range, version.clone(), sense, + hunk.staged_added + .iter() + .map(|range| { + buffer.anchor_before(Point::new(range.start, 0)) + ..buffer.anchor_before(Point::new(range.end, 0)) + }) + .collect(), + hunk.staged_deleted, ) }) .collect::>(); @@ -1881,6 +2032,14 @@ impl BufferDiff { hunk.diff_base_byte_range, version.clone(), PendingSense::Suppress, + hunk.staged_added + .iter() + .map(|range| { + buffer.anchor_before(Point::new(range.start, 0)) + ..buffer.anchor_before(Point::new(range.end, 0)) + }) + .collect(), + hunk.staged_deleted.clone(), ) }) .collect::>(); @@ -2323,6 +2482,35 @@ impl BufferDiff { } } +/// Absolute buffer row span of a hunk's point range. A point range that ends +/// mid-line (`column > 0`, e.g. a final line with no trailing newline) still +/// covers that last row, so the end row is bumped by one in that case. +fn buffer_row_span(range: &Range) -> Range { + let end_row = if range.end.column > 0 { + range.end.row + 1 + } else { + range.end.row + }; + range.start.row..end_row +} + +/// Absolute base-text row span of a hunk's deleted range. The base text is +/// offset-addressed, so "does this end on a line boundary" is a trailing-newline +/// test rather than the buffer side's column check. +fn base_row_span( + base_text: &text::BufferSnapshot, + bytes: &Range, + points: &Range, +) -> Range { + let end_row = + if bytes.end > bytes.start && base_text.reversed_chars_at(bytes.end).next() != Some('\n') { + points.end.row + 1 + } else { + points.end.row + }; + points.start.row..end_row +} + impl DiffHunk { pub fn is_created_file(&self) -> bool { self.diff_base_byte_range == (0..0) @@ -3144,12 +3332,19 @@ mod tests { // Install a whole-file pending hunk, as the no-HEAD staging paths do. let version = buffer.version(); diff.update(cx, |diff, cx| { + let base = diff.base_text(cx); diff.set_pending_hunks( &[PendingHunk::new( Anchor::min_max_range_for_buffer(buffer.remote_id()), 0..base_text.len(), version.clone(), PendingSense::SetSecondaryStatus { stage: true }, + vec![Anchor::min_max_range_for_buffer(buffer.remote_id())], + vec![base_row_span( + &base, + &(0..base.len()), + &(Point::new(0, 0)..base.max_point()), + )], )], &buffer, cx, @@ -3163,12 +3358,24 @@ mod tests { // Replace it with a narrower hunk; the emitted change must still cover // the whole extent of the replaced hunk. diff.update(cx, |diff, cx| { + let base = diff.base_text(cx); + let added_range = + buffer.anchor_before(Point::new(3, 0))..buffer.anchor_before(Point::new(4, 0)); + let deleted_range = base_text.find("three").unwrap()..base_text.find("four").unwrap(); + let deleted_span = base_row_span( + &base.text, + &deleted_range, + &(base.offset_to_point(deleted_range.start) + ..base.offset_to_point(deleted_range.end)), + ); diff.set_pending_hunks( &[PendingHunk::new( - buffer.anchor_before(Point::new(3, 0))..buffer.anchor_before(Point::new(4, 0)), - base_text.find("three").unwrap()..base_text.find("four").unwrap(), + added_range.clone(), + deleted_range, version, PendingSense::Suppress, + vec![added_range], + vec![deleted_span], )], &buffer, cx, diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index 23b1dc5b774e8a..672dbe643ce0c8 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -1622,7 +1622,22 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.toggle_staged_selected_diff_hunks(&::git::ToggleStaged, window, cx); + let ranges: Vec<_> = self + .selections + .disjoint_anchors() + .iter() + .map(|s| s.range()) + .collect(); + let task = self.save_buffers_for_ranges_if_needed(&ranges, cx); + cx.spawn_in(window, async move |this, cx| { + task.await?; + this.update_in(cx, |this, window, cx| { + let snapshot = this.buffer.read(cx).snapshot(cx); + let hunks = this.diff_hunks_in_ranges(&ranges, &snapshot).collect(); + this.apply_toggle(hunks, window, cx); + }) + }) + .detach_and_log_err(cx); } pub(super) fn stage_and_next( diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 1d98e24e8b42a4..bdb3897b1bea1f 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -145,6 +145,8 @@ pub struct MultiBufferDiffHunk { pub word_diffs: Vec>, pub excerpt_range: ExcerptRange, pub multi_buffer_range: Range, + pub staged_added: Vec>, + pub staged_deleted: Vec>, } impl MultiBufferDiffHunk { @@ -3561,6 +3563,18 @@ impl MultiBufferSnapshot { }; let multi_buffer_range = Anchor::range_in_buffer(excerpt.path_key_index, buffer_range.clone()); + let staged_added = if is_inverted { + vec![] + } else { + let row_offset = range.start.row - hunk.range.start.row; + hunk.staged_added + .iter() + .map(|r| { + MultiBufferRow(r.start + row_offset)..MultiBufferRow(r.end + row_offset) + }) + .collect() + }; + let staged_deleted = hunk.staged_deleted.clone(); Some(MultiBufferDiffHunk { row_range: MultiBufferRow(range.start.row)..MultiBufferRow(end_row), buffer_id: buffer_snapshot.remote_id(), @@ -3574,6 +3588,8 @@ impl MultiBufferSnapshot { }, excerpt_range: excerpt.range.clone(), multi_buffer_range, + staged_added, + staged_deleted, }) }) } diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 937a123b15bd3a..1943de3e15808e 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -78,6 +78,7 @@ rand.workspace = true regex.workspace = true release_channel.workspace = true remote.workspace = true +rope.workspace = true rpc.workspace = true schemars.workspace = true semver.workspace = true diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index fe62b0ea048549..2b25f8c6bd2c37 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -197,6 +197,7 @@ fn pending_hunks( hunks: &[DiffHunk], version: &clock::Global, sense: PendingSense, + buffer: &text::BufferSnapshot, ) -> Vec { hunks .iter() @@ -206,6 +207,14 @@ fn pending_hunks( hunk.diff_base_byte_range.clone(), version.clone(), sense, + hunk.staged_added + .iter() + .map(|range| { + buffer.anchor_before(rope::Point::new(range.start, 0)) + ..buffer.anchor_before(rope::Point::new(range.end, 0)) + }) + .collect(), + hunk.staged_deleted.clone(), ) }) .collect() @@ -1488,11 +1497,17 @@ impl GitStore { }; let version = buffer_snapshot.version().clone(); - let unstaged_pending = pending_hunks(&unstaged_hunks, &version, PendingSense::Suppress); + let unstaged_pending = pending_hunks( + &unstaged_hunks, + &version, + PendingSense::Suppress, + &buffer_snapshot, + ); let uncommitted_pending = pending_hunks( &uncommitted_hunks, &version, PendingSense::SetSecondaryStatus { stage: true }, + &buffer_snapshot, ); drop(unstaged_snapshot); @@ -1621,7 +1636,7 @@ impl GitStore { .read(cx) .unstage_staged_hunks(&hunks, &index_snapshot); let version = index_snapshot.version().clone(); - let pending = pending_hunks(&hunks, &version, PendingSense::Suppress); + let pending = pending_hunks(&hunks, &version, PendingSense::Suppress, &index_snapshot); drop(staged_snapshot); diff_state.update(cx, |diff_state, _| { From d36c6be085d32ab3997e3fea32b6557936ae2c9c Mon Sep 17 00:00:00 2001 From: AidanV Date: Sun, 12 Jul 2026 00:01:32 -0700 Subject: [PATCH 4/8] partial staging now visible in UI --- crates/buffer_diff/src/buffer_diff.rs | 2 +- crates/editor/src/element.rs | 169 ++++++++++++++++++++---- crates/editor/src/git.rs | 48 +++++++ crates/multi_buffer/src/multi_buffer.rs | 36 ++--- 4 files changed, 213 insertions(+), 42 deletions(-) diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 4b4b8ceb32c656..07f84b191e2dbf 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -2497,7 +2497,7 @@ fn buffer_row_span(range: &Range) -> Range { /// Absolute base-text row span of a hunk's deleted range. The base text is /// offset-addressed, so "does this end on a line boundary" is a trailing-newline /// test rather than the buffer side's column check. -fn base_row_span( +pub fn base_row_span( base_text: &text::BufferSnapshot, bytes: &Range, points: &Range, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 8307dd1794bde1..f34d7af539b6c5 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -5441,11 +5441,16 @@ impl EditorElement { cx.theme().colors().version_control_modified, Corners::all(px(0.)), DiffHunkStatus::modified_none(), + None, )) } DisplayDiffHunk::Unfolded { status, display_row_range, + staged_added, + staged_deleted, + deleted_lines, + is_expanded, .. } => hitbox.as_ref().map(|hunk_hitbox| { let color = match split_side { @@ -5475,13 +5480,28 @@ impl EditorElement { color, Corners::all(1. * line_height), *status, + None, + ), + _ => ( + hunk_hitbox.bounds, + color, + Corners::all(px(0.)), + *status, + Some(( + display_row_range, + staged_added, + staged_deleted, + deleted_lines, + is_expanded, + )), ), - _ => (hunk_hitbox.bounds, color, Corners::all(px(0.)), *status), } }), }; - if let Some((hunk_bounds, background_color, corner_radii, status)) = hunk_to_paint { + if let Some((hunk_bounds, background_color, corner_radii, status, staged_ranges)) = + hunk_to_paint + { // Flatten the background color with the editor color to prevent // elements below transparent hunks from showing through let flattened_background_color = cx @@ -5489,31 +5509,126 @@ impl EditorElement { .colors() .editor_background .blend(background_color); + let flattened_unstaged_background_color = cx + .theme() + .colors() + .editor_background + .blend(background_color.opacity(0.3)); + let paint = |hunk_bounds, hollow, window: &mut Window| { + if hollow { + window.paint_quad(quad( + hunk_bounds, + corner_radii, + flattened_unstaged_background_color, + Edges::all(px(1.0)), + flattened_background_color, + BorderStyle::Solid, + )); + } else { + window.paint_quad(quad( + hunk_bounds, + corner_radii, + flattened_background_color, + Edges::default(), + transparent_black(), + BorderStyle::default(), + )); + } + }; - if !self.diff_hunk_hollow(status, cx) { - window.paint_quad(quad( - hunk_bounds, - corner_radii, - flattened_background_color, - Edges::default(), - transparent_black(), - BorderStyle::default(), - )); - } else { - let flattened_unstaged_background_color = cx - .theme() - .colors() - .editor_background - .blend(background_color.opacity(0.3)); + if status.secondary + == buffer_diff::DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk + && let Some(( + display_row_range, + staged_added, + staged_deleted, + deleted_lines, + is_expanded, + )) = staged_ranges + { + let relative_rows: Vec> = match split_side { + Some(SplitSide::Left) => staged_deleted.clone(), + Some(SplitSide::Right) | None => { + let relative_added = staged_added.iter().map(|range| { + (range.start.0 - display_row_range.start.0) + ..(range.end.0 - display_row_range.start.0) + }); + if *is_expanded && split_side == None && *deleted_lines > 0 { + staged_deleted + .iter() + .cloned() + .chain(relative_added.map(|range| { + (range.start + deleted_lines - 1) + ..(range.end + deleted_lines - 1) + })) + .collect() + } else { + relative_added.collect() + } + } + }; - window.paint_quad(quad( - hunk_bounds, - corner_radii, - flattened_unstaged_background_color, - Edges::all(px(1.0)), - flattened_background_color, - BorderStyle::Solid, - )); + let relative_rows = relative_rows.into_iter().fold( + Vec::>::new(), + |mut merged, range| { + match merged.last_mut() { + Some(last) if range.start <= last.end => { + last.end = last.end.max(range.end); + } + _ => merged.push(range), + } + merged + }, + ); + + let total_lines = display_row_range.len() as u32; + let (mut acc, total, cursor) = relative_rows.iter().fold( + (vec![], 0, 0), + |(mut acc, mut total, cursor), range| { + if cursor < range.start { + let unstaged_lines = range.start - cursor; + acc.push((unstaged_lines, total, false)); + total += unstaged_lines; + let staged_lines = range.end - range.start; + acc.push((staged_lines, total, true)); + total += staged_lines; + (acc, total, range.end) + } else if cursor < range.end { + let staged_lines = range.end - cursor; + acc.push((staged_lines, total, true)); + total += staged_lines; + (acc, total, range.end) + } else { + (acc, total, cursor) + } + }, + ); + if cursor < total_lines { + acc.push((total_lines - cursor, total, false)); + } + let acc = acc.iter().map(|(lines, total, staged)| { + ( + *lines as f32 / total_lines as f32, + *total as f32 / total_lines as f32, + staged, + ) + }); + + acc.into_iter() + .for_each(|(height_scale, origin_offset, staged)| { + let new_y = (1.0 - origin_offset) * hunk_bounds.origin.y + + origin_offset * hunk_bounds.bottom_left().y; + let bounds = Bounds { + origin: point(hunk_bounds.origin.x, new_y), + size: Size { + width: hunk_bounds.size.width, + height: hunk_bounds.size.height * height_scale, + }, + }; + paint(bounds, *staged, window); + }); + } else { + paint(hunk_bounds, self.diff_hunk_hollow(status, cx), window); } } } @@ -6986,6 +7101,10 @@ impl EditorElement { .read(cx) .diff_hunk_renderer() .render_hunk_as_staged(&status, cx); + Self::should_hollow(unstaged, cx) + } + + fn should_hollow(unstaged: bool, cx: &mut App) -> bool { let unstaged_hollow = matches!( ProjectSettings::get_global(cx).git.hunk_style, GitHunkStyleSetting::UnstagedHollow diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index 672dbe643ce0c8..2cef7c8ea6f1fe 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -119,6 +119,10 @@ pub(super) enum DisplayDiffHunk { multi_buffer_range: Range, status: DiffHunkStatus, word_diffs: Vec>, + staged_added: Vec>, + staged_deleted: Vec>, + deleted_lines: u32, + is_expanded: bool, }, } @@ -3001,6 +3005,46 @@ impl EditorSnapshot { } let is_created_file = hunk.is_created_file(); let multi_buffer_range = hunk.multi_buffer_range.clone(); + let is_expanded = self + .buffer_snapshot() + .single_hunk_is_expanded(multi_buffer_range.clone()); + let deleted_lines = if let Some(base) = self + .buffer_snapshot() + .diff_for_buffer_id(hunk.buffer_id) + .map(|diff| diff.base_text()) + { + let start = hunk.diff_base_byte_range.start.0; + let end = hunk.diff_base_byte_range.end.0; + let start_point = base.offset_to_point(start); + let end_point = base.offset_to_point(end); + + let span = buffer_diff::base_row_span( + &base.text, + &(start..end), + &(start_point..end_point), + ); + span.end - span.start + } else { + 0 + }; + + let staged_added = hunk + .staged_added + .iter() + .map(|range| { + self.point_to_display_point( + MultiBufferPoint::new(range.start.0, 0), + Bias::Left, + ) + .row() + ..self + .point_to_display_point( + MultiBufferPoint::new(range.end.0, 0), + Bias::Left, + ) + .row() + }) + .collect(); DisplayDiffHunk::Unfolded { status: hunk.status(), @@ -3010,6 +3054,10 @@ impl EditorSnapshot { display_row_range: hunk_display_start.row()..end_row, multi_buffer_range, is_created_file, + staged_added, + staged_deleted: hunk.staged_deleted.clone(), + deleted_lines, + is_expanded, } }; diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index bdb3897b1bea1f..6e1a6c26d549aa 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -2383,20 +2383,7 @@ impl MultiBuffer { } pub fn single_hunk_is_expanded(&self, range: Range, cx: &App) -> bool { - let snapshot = self.read(cx); - let mut cursor = snapshot.diff_transforms.cursor::(()); - let offset_range = range.to_offset(&snapshot); - cursor.seek(&offset_range.start, Bias::Left); - while let Some(item) = cursor.item() { - if *cursor.start() >= offset_range.end && *cursor.start() > offset_range.start { - break; - } - if item.hunk_info().is_some() { - return true; - } - cursor.next(); - } - false + self.read(cx).single_hunk_is_expanded(range) } pub fn has_expanded_diff_hunks_in_ranges(&self, ranges: &[Range], cx: &App) -> bool { @@ -3566,11 +3553,12 @@ impl MultiBufferSnapshot { let staged_added = if is_inverted { vec![] } else { - let row_offset = range.start.row - hunk.range.start.row; + let hunk_start_row = hunk.range.start.row; hunk.staged_added .iter() .map(|r| { - MultiBufferRow(r.start + row_offset)..MultiBufferRow(r.end + row_offset) + MultiBufferRow(range.start.row + (r.start - hunk_start_row)) + ..MultiBufferRow(range.start.row + (r.end - hunk_start_row)) }) .collect() }; @@ -6699,6 +6687,22 @@ impl MultiBufferSnapshot { self.all_diff_hunks_expanded } + pub fn single_hunk_is_expanded(&self, range: Range) -> bool { + let mut cursor = self.diff_transforms.cursor::(()); + let offset_range = range.to_offset(self); + cursor.seek(&offset_range.start, Bias::Left); + while let Some(item) = cursor.item() { + if *cursor.start() >= offset_range.end && *cursor.start() > offset_range.start { + break; + } + if item.hunk_info().is_some() { + return true; + } + cursor.next(); + } + false + } + /// Visually annotates a position or range with the `Debug` representation of a value. The /// callsite of this function is used as a key - previous annotations will be removed. #[cfg(debug_assertions)] From 3eed990be4cadf4ee11eaf23368b8a043499840e Mon Sep 17 00:00:00 2001 From: AidanV Date: Sun, 12 Jul 2026 16:30:21 -0700 Subject: [PATCH 5/8] toggling on if there are any stageable selected lines --- crates/buffer_diff/src/buffer_diff.rs | 2 +- crates/editor/src/git.rs | 221 ++++++++++++++++++++++-- crates/multi_buffer/src/multi_buffer.rs | 10 +- 3 files changed, 215 insertions(+), 18 deletions(-) diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 07f84b191e2dbf..844d14ae81ab2a 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -2485,7 +2485,7 @@ impl BufferDiff { /// Absolute buffer row span of a hunk's point range. A point range that ends /// mid-line (`column > 0`, e.g. a final line with no trailing newline) still /// covers that last row, so the end row is bumped by one in that case. -fn buffer_row_span(range: &Range) -> Range { +pub fn buffer_row_span(range: &Range) -> Range { let end_row = if range.end.column > 0 { range.end.row + 1 } else { diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index 2cef7c8ea6f1fe..b94560ba30a425 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -5,7 +5,7 @@ use ::git::{ Oid, Restore, blame::BlameEntry, commit::ParsedCommitMessage, repository::RepoPath, status::FileStatus, }; -use buffer_diff::{BufferDiff, DiffHunkStatus, DiffHunkStatusKind}; +use buffer_diff::{BufferDiff, DiffHunkStatus, DiffHunkStatusKind, base_row_span, buffer_row_span}; use project::git_store::Repository; #[derive(Clone)] @@ -13,11 +13,14 @@ struct ResolvedDiffHunk { buffer_range: Range, diff_base_byte_range: Range, status: DiffHunkStatus, + staged_added: Vec>, + staged_deleted: Vec>, } #[derive(Clone)] struct ResolvedDiffHunks { diff: Entity, + buffer_id: BufferId, buffer: Option>, hunks: Vec, } @@ -266,6 +269,7 @@ impl Editor { cx: &App, ) -> Vec { let multibuffer = self.buffer().read(cx); + let multibuffer_snapshot = multibuffer.snapshot(cx); let chunk_by = hunks.into_iter().chunk_by(|hunk| hunk.buffer_id); let mut resolved = Vec::new(); @@ -283,12 +287,25 @@ impl Editor { let mut resolved_hunks = Vec::new(); for hunk in hunks { + let staged_added = hunk + .staged_added + .iter() + .filter_map(|range| { + Some( + multibuffer_snapshot.anchor_to_buffer_anchor(range.start)?.0 + ..multibuffer_snapshot.anchor_to_buffer_anchor(range.end)?.0, + ) + }) + .collect(); + let staged_deleted = hunk.staged_deleted.clone(); if hunk.buffer_id == main_buffer_id { resolved_hunks.push(ResolvedDiffHunk { buffer_range: hunk.buffer_range, diff_base_byte_range: hunk.diff_base_byte_range.start.0 ..hunk.diff_base_byte_range.end.0, status: hunk.status, + staged_added, + staged_deleted, }); } else { let diff_base_byte_range = @@ -316,6 +333,8 @@ impl Editor { kind, secondary: hunk.secondary_status, }, + staged_added, + staged_deleted, }); } } @@ -323,6 +342,7 @@ impl Editor { if !resolved_hunks.is_empty() { resolved.push(ResolvedDiffHunks { diff, + buffer_id: main_buffer_id, buffer, hunks: resolved_hunks, }); @@ -1638,7 +1658,7 @@ impl Editor { this.update_in(cx, |this, window, cx| { let snapshot = this.buffer.read(cx).snapshot(cx); let hunks = this.diff_hunks_in_ranges(&ranges, &snapshot).collect(); - this.apply_toggle(hunks, window, cx); + this.apply_toggle_lines(hunks, ranges, window, cx); }) }) .detach_and_log_err(cx); @@ -1715,6 +1735,158 @@ impl Editor { } } + pub fn apply_toggle_lines( + &mut self, + hunks: Vec, + ranges: Vec>, + _window: &mut Window, + cx: &mut Context, + ) { + let hunks = self.resolve_diff_hunks(hunks, cx); + if hunks.is_empty() { + return; + } + if let Some(target) = self.diff_hunk_action_target.clone() { + target + .update(cx, |editor, cx| { + editor.toggle_resolved_diff_hunk_lines(hunks, ranges, cx); + }) + .log_err(); + return; + } + self.toggle_resolved_diff_hunk_lines(hunks, ranges, cx); + } + + fn toggle_resolved_diff_hunk_lines( + &mut self, + hunks: Vec, + ranges: Vec>, + cx: &mut Context, + ) { + let any_selected_line_unstaged = + self.selected_lines_contain_unstaged(&hunks, &ranges, cx); + for hunks in hunks { + let Some(operations) = hunks.diff.read(cx).operations() else { + continue; + }; + let stage = if !operations.supports_unstaging() { + true + } else if !operations.supports_staging() { + false + } else { + any_selected_line_unstaged + }; + self.stage_or_unstage_resolved_diff_hunk_lines(stage, vec![hunks], &ranges, cx); + } + } + + /// Whether any line covered by `ranges` within `hunks` is not already staged. This decides + /// which direction a line-level toggle goes: if anything selected is unstaged, stage; + /// otherwise unstage. + fn selected_lines_contain_unstaged( + &self, + hunks: &[ResolvedDiffHunks], + ranges: &[Range], + cx: &App, + ) -> bool { + let snapshot = self.buffer().read(cx).snapshot(cx); + + let selections = ranges + .iter() + .map(|range| range.to_point(&snapshot)) + .flat_map(|range| snapshot.range_to_buffer_ranges_with_deleted_hunks(range)) + .map(|(snapshot, range, deleted_anchor)| { + if let Some(buffer_id) = deleted_anchor + .and_then(|anchor| anchor.raw_text_anchor()) + .map(|anchor| anchor.buffer_id) + { + // buffer_id and snapshot differ in source here because + // we want to key to the hunks in the real buffer + // but want the points from the base + (buffer_id, range.to_point(&snapshot), true) + } else { + (snapshot.remote_id(), range.to_point(&snapshot), false) + } + }) + .fold( + HashMap::default(), + |mut acc: HashMap, bool)>>, + (buffer_id, range, is_deleted)| { + let span = if range.start == range.end { + range.start.row..range.start.row + 1 + } else { + buffer_row_span(&range) + }; + acc.entry(buffer_id).or_default().push((span, is_deleted)); + acc + }, + ); + + hunks + .chunk_by(|a, b| a.buffer_id == b.buffer_id) + .any(|hunks| { + let Some(buffer_id) = hunks.first().map(|hunks| hunks.buffer_id) else { + return false; + }; + let (Some(snapshot), Some(selections)) = ( + snapshot.buffer_for_id(buffer_id), + selections.get(&buffer_id), + ) else { + return false; + }; + hunks.iter().any(|hunks| { + let base_text = &hunks.diff.read(cx).base_text(cx); + hunks.hunks.iter().any(|hunk| { + let deleted_buffer_lines = base_row_span( + base_text, + &hunk.diff_base_byte_range, + &hunk.diff_base_byte_range.to_point(base_text), + ); + let added_buffer_lines = + buffer_row_span(&hunk.buffer_range.to_point(snapshot)); + let staged_added = hunk + .staged_added + .iter() + .map(|range| range.to_point(snapshot)) + .map(|range| buffer_row_span(&range)) + .collect_vec(); + let staged_deleted = hunk.staged_deleted.clone(); + selections + .iter() + .filter_map(|(selection, is_deleted)| { + let overlap = if *is_deleted { + let overlap_start = + selection.start.max(deleted_buffer_lines.start); + let overlap_end = selection.end.min(deleted_buffer_lines.end); + if overlap_start >= overlap_end { + return None; + } + // staged_deleted is relative + (overlap_start - deleted_buffer_lines.start) + ..(overlap_end - deleted_buffer_lines.start) + } else { + let overlap_start = + selection.start.max(added_buffer_lines.start); + let overlap_end = selection.end.min(added_buffer_lines.end); + overlap_start..overlap_end + }; + (!overlap.is_empty()).then(|| (overlap, is_deleted)) + }) + .any(|(selection, is_deleted)| { + let staged_lines = if *is_deleted { + &staged_deleted + } else { + &staged_added + }; + !staged_lines.iter().any(|staged| { + selection.start >= staged.start && selection.end <= staged.end + }) + }) + }) + }) + }) + } + pub fn apply_stage_or_unstage( &mut self, stage: bool, @@ -1765,6 +1937,38 @@ impl Editor { } } + pub fn apply_stage_or_unstage_lines( + &mut self, + stage: bool, + ranges: Vec>, + hunks: Vec, + _window: &mut Window, + cx: &mut Context, + ) { + let hunks = self.resolve_diff_hunks(hunks, cx); + if hunks.is_empty() { + return; + } + if let Some(target) = self.diff_hunk_action_target.clone() { + target + .update(cx, |editor, cx| { + editor.stage_or_unstage_resolved_diff_hunk_lines(stage, hunks, &ranges, cx); + }) + .log_err(); + return; + } + self.stage_or_unstage_resolved_diff_hunk_lines(stage, hunks, &ranges, cx); + } + + fn stage_or_unstage_resolved_diff_hunk_lines( + &mut self, + _stage: bool, + _hunks: Vec, + _ranges: &[Range], + _cx: &mut Context, + ) { + } + pub fn apply_restore( &mut self, hunks: Vec, @@ -3032,17 +3236,8 @@ impl EditorSnapshot { .staged_added .iter() .map(|range| { - self.point_to_display_point( - MultiBufferPoint::new(range.start.0, 0), - Bias::Left, - ) - .row() - ..self - .point_to_display_point( - MultiBufferPoint::new(range.end.0, 0), - Bias::Left, - ) - .row() + range.start.to_display_point(&self.display_snapshot).row() + ..range.end.to_display_point(&self.display_snapshot).row() }) .collect(); diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 6e1a6c26d549aa..c5119520d474e0 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -145,7 +145,7 @@ pub struct MultiBufferDiffHunk { pub word_diffs: Vec>, pub excerpt_range: ExcerptRange, pub multi_buffer_range: Range, - pub staged_added: Vec>, + pub staged_added: Vec>, pub staged_deleted: Vec>, } @@ -3553,12 +3553,14 @@ impl MultiBufferSnapshot { let staged_added = if is_inverted { vec![] } else { - let hunk_start_row = hunk.range.start.row; hunk.staged_added .iter() .map(|r| { - MultiBufferRow(range.start.row + (r.start - hunk_start_row)) - ..MultiBufferRow(range.start.row + (r.end - hunk_start_row)) + Anchor::range_in_buffer( + excerpt.path_key_index, + buffer_snapshot + .anchor_range_inside(Point::new(r.start, 0)..Point::new(r.end, 0)), + ) }) .collect() }; From 678fecb50b15e7b394bf0b97a1d43d6e630d5291 Mon Sep 17 00:00:00 2001 From: AidanV Date: Fri, 17 Jul 2026 00:44:30 -0700 Subject: [PATCH 6/8] wip --- crates/buffer_diff/src/buffer_diff.rs | 46 +++- crates/editor/src/git.rs | 319 ++++++++++++++++---------- crates/project/src/git_store.rs | 241 ++++++++++++++++++- crates/project/src/project.rs | 22 +- 4 files changed, 502 insertions(+), 126 deletions(-) diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 844d14ae81ab2a..17579289725d69 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -170,6 +170,17 @@ pub struct DiffHunk { pub staged_deleted: Vec>, } +/// A selected span of lines in a diff, resolved to rows in a single buffer. +/// +/// When `is_deleted` is false, `rows` are absolute rows in the buffer. When +/// `is_deleted` is true, `rows` are absolute rows in the diff's base text, +/// selecting lines of a deletion. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedLineSelection { + pub rows: Range, + pub is_deleted: bool, +} + /// We store [`InternalDiffHunk`]s internally so we don't need to store the additional row range. #[derive(Debug, Clone, PartialEq, Eq)] struct InternalDiffHunk { @@ -214,7 +225,9 @@ impl PendingHunk { pub enum PendingSense { /// Override the secondary status of the matched hunk (used by the /// uncommitted diff to show a hunk as staging/unstaging in place). - SetSecondaryStatus { stage: bool }, + Stage, + Unstage, + PartiallyStage, /// Suppress the matched hunk entirely (used by the unstaged/staged diffs so /// that a hunk disappears the moment it is staged/unstaged). Suppress, @@ -871,7 +884,11 @@ impl BufferDiffSnapshot { let index_text = unstaged_diff .base_text_exists .then(|| unstaged_diff.base_text.as_rope().clone()); - let sense = PendingSense::SetSecondaryStatus { stage }; + let sense = if stage { + PendingSense::Stage + } else { + PendingSense::Unstage + }; let version = buffer.version().clone(); // If the file doesn't exist in either HEAD or the index, then the @@ -1174,7 +1191,7 @@ impl BufferDiffSnapshot { ) { match pending_hunk.sense { - PendingSense::SetSecondaryStatus { stage } => { + PendingSense::Stage | PendingSense::Unstage => { staged_added = pending_hunk .staged_added .iter() @@ -1184,12 +1201,25 @@ impl BufferDiffSnapshot { .collect(); staged_deleted = pending_hunk.staged_deleted.clone(); has_pending = true; - secondary_status = if stage { + secondary_status = if pending_hunk.sense == PendingSense::Stage { DiffHunkSecondaryStatus::SecondaryHunkRemovalPending } else { DiffHunkSecondaryStatus::SecondaryHunkAdditionPending }; } + PendingSense::PartiallyStage => { + staged_added = pending_hunk + .staged_added + .iter() + .map(|anchor_range| { + buffer_row_span(&anchor_range.to_point(buffer)) + }) + .collect(); + staged_deleted = pending_hunk.staged_deleted.clone(); + has_pending = true; + secondary_status = + DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk; + } PendingSense::Suppress => continue, } } @@ -1984,7 +2014,11 @@ impl BufferDiff { buffer: &text::BufferSnapshot, cx: &mut Context, ) { - let sense = PendingSense::SetSecondaryStatus { stage }; + let sense = if stage { + PendingSense::Stage + } else { + PendingSense::Unstage + }; let version = buffer.version().clone(); let hunks = self .snapshot(cx) @@ -3338,7 +3372,7 @@ mod tests { Anchor::min_max_range_for_buffer(buffer.remote_id()), 0..base_text.len(), version.clone(), - PendingSense::SetSecondaryStatus { stage: true }, + PendingSense::Stage, vec![Anchor::min_max_range_for_buffer(buffer.remote_id())], vec![base_row_span( &base, diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index b94560ba30a425..453616c614e6c6 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -5,12 +5,16 @@ use ::git::{ Oid, Restore, blame::BlameEntry, commit::ParsedCommitMessage, repository::RepoPath, status::FileStatus, }; -use buffer_diff::{BufferDiff, DiffHunkStatus, DiffHunkStatusKind, base_row_span, buffer_row_span}; +use buffer_diff::{ + BufferDiff, DiffHunkStatus, DiffHunkStatusKind, ResolvedLineSelection, base_row_span, + buffer_row_span, +}; use project::git_store::Repository; #[derive(Clone)] struct ResolvedDiffHunk { buffer_range: Range, + multi_buffer_range: Range, diff_base_byte_range: Range, status: DiffHunkStatus, staged_added: Vec>, @@ -43,6 +47,145 @@ pub trait DiffHunkRenderer { } } +/// Resolves multibuffer selection ranges into per-buffer line selections for +/// the given hunks, and computes the direction of a toggle: stage if any +/// selected visible line is unstaged. +/// +/// A selection over a hunk whose deletion block is collapsed can't include the +/// deleted lines explicitly, so the whole deletion is implied whenever the +/// selection touches such a hunk. Implied deletions follow the toggle +/// direction, but only drive it for pure deletion hunks, which have no visible +/// lines of their own. +fn resolve_line_selections( + hunks: &[ResolvedDiffHunks], + ranges: &[Range], + snapshot: &MultiBufferSnapshot, + cx: &App, +) -> (HashMap>, bool) { + let mut selections: HashMap> = HashMap::default(); + for range in ranges { + let point_range = range.to_point(snapshot); + let is_cursor = point_range.start == point_range.end; + for (buffer_snapshot, buffer_range, deleted_anchor) in + snapshot.range_to_buffer_ranges_with_deleted_hunks(point_range) + { + let (buffer_id, is_deleted) = if let Some(buffer_id) = deleted_anchor + .and_then(|anchor| anchor.raw_text_anchor()) + .map(|anchor| anchor.buffer_id) + { + // buffer_id and buffer_snapshot differ in source here because + // we want to key to the hunks in the real buffer but want the + // rows from the base text + (buffer_id, true) + } else { + (buffer_snapshot.remote_id(), false) + }; + let buffer_point_range = buffer_range.to_point(buffer_snapshot); + let rows = if is_cursor { + buffer_point_range.start.row..buffer_point_range.start.row + 1 + } else if buffer_point_range.start == buffer_point_range.end { + continue; + } else { + buffer_row_span(&buffer_point_range) + }; + selections + .entry(buffer_id) + .or_default() + .push(ResolvedLineSelection { rows, is_deleted }); + } + } + + let mut stage = false; + let mut implied = Vec::new(); + for group in hunks { + let Some(buffer_snapshot) = snapshot.buffer_for_id(group.buffer_id) else { + continue; + }; + let base_text = group.diff.read(cx).base_text(cx); + let explicit = selections.get(&group.buffer_id); + for hunk in &group.hunks { + let added_rows = buffer_row_span(&hunk.buffer_range.to_point(&buffer_snapshot)); + let deleted_rows = base_row_span( + &base_text, + &hunk.diff_base_byte_range, + &hunk.diff_base_byte_range.to_point(&base_text), + ); + let staged_added = hunk + .staged_added + .iter() + .map(|range| buffer_row_span(&range.to_point(&buffer_snapshot))) + .collect_vec(); + + let mut touched = false; + let mut deletion_selected = false; + for selection in explicit.into_iter().flatten() { + if selection.is_deleted { + let overlap = selection.rows.start.max(deleted_rows.start) + ..selection.rows.end.min(deleted_rows.end); + if overlap.start >= overlap.end { + continue; + } + deletion_selected = true; + // staged_deleted is relative to the hunk's deleted region + let overlap = + overlap.start - deleted_rows.start..overlap.end - deleted_rows.start; + if !rows_fully_staged(overlap, &hunk.staged_deleted) { + stage = true; + } + } else if added_rows.is_empty() { + // A pure deletion has no visible lines; a selection touches + // it when it reaches the hunk's position, boundaries + // included, matching how hunks are resolved for ranges. + touched |= selection.rows.start <= added_rows.start + && added_rows.start <= selection.rows.end; + } else { + let overlap = selection.rows.start.max(added_rows.start) + ..selection.rows.end.min(added_rows.end); + if overlap.start >= overlap.end { + continue; + } + touched = true; + if !rows_fully_staged(overlap, &staged_added) { + stage = true; + } + } + } + + if touched + && !deletion_selected + && !deleted_rows.is_empty() + && !snapshot.single_hunk_is_expanded(hunk.multi_buffer_range.clone()) + { + implied.push(( + group.buffer_id, + ResolvedLineSelection { + rows: deleted_rows.clone(), + is_deleted: true, + }, + )); + if added_rows.is_empty() + && !rows_fully_staged( + 0..deleted_rows.end - deleted_rows.start, + &hunk.staged_deleted, + ) + { + stage = true; + } + } + } + } + for (buffer_id, selection) in implied { + selections.entry(buffer_id).or_default().push(selection); + } + + (selections, stage) +} + +fn rows_fully_staged(rows: Range, staged: &[Range]) -> bool { + rows.into_iter() + .all(|row| staged.iter().any(|staged| staged.contains(&row))) +} + pub struct DefaultDiffHunkRenderer; impl DiffHunkRenderer for DefaultDiffHunkRenderer { @@ -301,6 +444,7 @@ impl Editor { if hunk.buffer_id == main_buffer_id { resolved_hunks.push(ResolvedDiffHunk { buffer_range: hunk.buffer_range, + multi_buffer_range: hunk.multi_buffer_range, diff_base_byte_range: hunk.diff_base_byte_range.start.0 ..hunk.diff_base_byte_range.end.0, status: hunk.status, @@ -308,6 +452,7 @@ impl Editor { staged_deleted, }); } else { + let multi_buffer_range = hunk.multi_buffer_range; let diff_base_byte_range = hunk.diff_base_byte_range.start.0..hunk.diff_base_byte_range.end.0; let Some(hunk) = diff_snapshot @@ -328,6 +473,7 @@ impl Editor { }; resolved_hunks.push(ResolvedDiffHunk { buffer_range: hunk.buffer_range, + multi_buffer_range, diff_base_byte_range: hunk.diff_base_byte_range, status: DiffHunkStatus { kind, @@ -1763,8 +1909,9 @@ impl Editor { ranges: Vec>, cx: &mut Context, ) { - let any_selected_line_unstaged = - self.selected_lines_contain_unstaged(&hunks, &ranges, cx); + let snapshot = self.buffer().read(cx).snapshot(cx); + let (selections, any_selected_line_unstaged) = + resolve_line_selections(&hunks, &ranges, &snapshot, cx); for hunks in hunks { let Some(operations) = hunks.diff.read(cx).operations() else { continue; @@ -1776,117 +1923,10 @@ impl Editor { } else { any_selected_line_unstaged }; - self.stage_or_unstage_resolved_diff_hunk_lines(stage, vec![hunks], &ranges, cx); + self.stage_or_unstage_resolved_diff_hunk_lines(stage, vec![hunks], &selections, cx); } } - /// Whether any line covered by `ranges` within `hunks` is not already staged. This decides - /// which direction a line-level toggle goes: if anything selected is unstaged, stage; - /// otherwise unstage. - fn selected_lines_contain_unstaged( - &self, - hunks: &[ResolvedDiffHunks], - ranges: &[Range], - cx: &App, - ) -> bool { - let snapshot = self.buffer().read(cx).snapshot(cx); - - let selections = ranges - .iter() - .map(|range| range.to_point(&snapshot)) - .flat_map(|range| snapshot.range_to_buffer_ranges_with_deleted_hunks(range)) - .map(|(snapshot, range, deleted_anchor)| { - if let Some(buffer_id) = deleted_anchor - .and_then(|anchor| anchor.raw_text_anchor()) - .map(|anchor| anchor.buffer_id) - { - // buffer_id and snapshot differ in source here because - // we want to key to the hunks in the real buffer - // but want the points from the base - (buffer_id, range.to_point(&snapshot), true) - } else { - (snapshot.remote_id(), range.to_point(&snapshot), false) - } - }) - .fold( - HashMap::default(), - |mut acc: HashMap, bool)>>, - (buffer_id, range, is_deleted)| { - let span = if range.start == range.end { - range.start.row..range.start.row + 1 - } else { - buffer_row_span(&range) - }; - acc.entry(buffer_id).or_default().push((span, is_deleted)); - acc - }, - ); - - hunks - .chunk_by(|a, b| a.buffer_id == b.buffer_id) - .any(|hunks| { - let Some(buffer_id) = hunks.first().map(|hunks| hunks.buffer_id) else { - return false; - }; - let (Some(snapshot), Some(selections)) = ( - snapshot.buffer_for_id(buffer_id), - selections.get(&buffer_id), - ) else { - return false; - }; - hunks.iter().any(|hunks| { - let base_text = &hunks.diff.read(cx).base_text(cx); - hunks.hunks.iter().any(|hunk| { - let deleted_buffer_lines = base_row_span( - base_text, - &hunk.diff_base_byte_range, - &hunk.diff_base_byte_range.to_point(base_text), - ); - let added_buffer_lines = - buffer_row_span(&hunk.buffer_range.to_point(snapshot)); - let staged_added = hunk - .staged_added - .iter() - .map(|range| range.to_point(snapshot)) - .map(|range| buffer_row_span(&range)) - .collect_vec(); - let staged_deleted = hunk.staged_deleted.clone(); - selections - .iter() - .filter_map(|(selection, is_deleted)| { - let overlap = if *is_deleted { - let overlap_start = - selection.start.max(deleted_buffer_lines.start); - let overlap_end = selection.end.min(deleted_buffer_lines.end); - if overlap_start >= overlap_end { - return None; - } - // staged_deleted is relative - (overlap_start - deleted_buffer_lines.start) - ..(overlap_end - deleted_buffer_lines.start) - } else { - let overlap_start = - selection.start.max(added_buffer_lines.start); - let overlap_end = selection.end.min(added_buffer_lines.end); - overlap_start..overlap_end - }; - (!overlap.is_empty()).then(|| (overlap, is_deleted)) - }) - .any(|(selection, is_deleted)| { - let staged_lines = if *is_deleted { - &staged_deleted - } else { - &staged_added - }; - !staged_lines.iter().any(|staged| { - selection.start >= staged.start && selection.end <= staged.end - }) - }) - }) - }) - }) - } - pub fn apply_stage_or_unstage( &mut self, stage: bool, @@ -1952,21 +1992,66 @@ impl Editor { if let Some(target) = self.diff_hunk_action_target.clone() { target .update(cx, |editor, cx| { - editor.stage_or_unstage_resolved_diff_hunk_lines(stage, hunks, &ranges, cx); + editor.stage_or_unstage_resolved_diff_hunk_lines_in_ranges( + stage, hunks, ranges, cx, + ); }) .log_err(); return; } - self.stage_or_unstage_resolved_diff_hunk_lines(stage, hunks, &ranges, cx); + self.stage_or_unstage_resolved_diff_hunk_lines_in_ranges(stage, hunks, ranges, cx); + } + + fn stage_or_unstage_resolved_diff_hunk_lines_in_ranges( + &mut self, + stage: bool, + hunks: Vec, + ranges: Vec>, + cx: &mut Context, + ) { + let snapshot = self.buffer().read(cx).snapshot(cx); + let (selections, _) = resolve_line_selections(&hunks, &ranges, &snapshot, cx); + self.stage_or_unstage_resolved_diff_hunk_lines(stage, hunks, &selections, cx); } fn stage_or_unstage_resolved_diff_hunk_lines( &mut self, - _stage: bool, - _hunks: Vec, - _ranges: &[Range], - _cx: &mut Context, + stage: bool, + hunks: Vec, + selections: &HashMap>, + cx: &mut Context, ) { + let Some(project) = self.project().cloned() else { + return; + }; + for hunks in hunks { + // Line-level staging is expressed in terms of the uncommitted diff, which is the + // only diff that can both stage and unstage. + let Some(operations) = hunks.diff.read(cx).operations() else { + continue; + }; + if !operations.supports_staging() || !operations.supports_unstaging() { + continue; + } + let Some(buffer) = hunks.buffer else { + continue; + }; + let buffer_id = buffer.read(cx).remote_id(); + let Some(selections) = selections.get(&buffer_id) else { + continue; + }; + project + .update(cx, |project, cx| { + project.stage_or_unstage_lines( + stage, + buffer, + hunks.diff, + selections.clone(), + cx, + ) + }) + .log_err(); + } } pub fn apply_restore( diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 2b25f8c6bd2c37..6029e14ac2267d 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -17,6 +17,7 @@ use anyhow::{Context as _, Result, anyhow, bail}; use askpass::{AskPassDelegate, EncryptedPassword, IKnowWhatIAmDoingAndIHaveReadTheDocs}; use buffer_diff::{ BufferDiff, DiffHunk, DiffHunkSecondaryStatus, DiffOperations, PendingHunk, PendingSense, + ResolvedLineSelection, base_row_span, buffer_row_span, }; use client::ProjectId; use collections::HashMap; @@ -85,7 +86,7 @@ use std::{ }; use sum_tree::{Edit, SumTree, TreeMap}; use task::Shell; -use text::{Bias, BufferId, OffsetRangeExt, Rope, ToOffset}; +use text::{Bias, BufferId, OffsetRangeExt, Point, Rope, ToOffset}; use util::{ ResultExt, debug_panic, paths::{PathStyle, SanitizedPath}, @@ -303,6 +304,29 @@ fn decode_commit_diff(diff: git::repository::CommitDiff) -> CommitDiff { } } +fn selected_rows_in_span( + selections: &[ResolvedLineSelection], + span: &Range, +) -> BTreeSet { + selections + .iter() + .flat_map(|selection| { + selection.rows.start.max(span.start)..selection.rows.end.min(span.end) + }) + .collect() +} + +fn row_set_to_ranges(rows: &BTreeSet) -> Vec> { + let mut ranges: Vec> = Vec::new(); + for &row in rows { + match ranges.last_mut() { + Some(last) if last.end == row => last.end = row + 1, + _ => ranges.push(row..row + 1), + } + } + ranges +} + #[derive(Clone, Debug)] enum DiffBasesChange { SetIndex(Option), @@ -1506,7 +1530,7 @@ impl GitStore { let uncommitted_pending = pending_hunks( &uncommitted_hunks, &version, - PendingSense::SetSecondaryStatus { stage: true }, + PendingSense::Stage, &buffer_snapshot, ); drop(unstaged_snapshot); @@ -1534,6 +1558,219 @@ impl GitStore { Ok(()) } + /// Stages (or unstages) the selected lines of the uncommitted hunks they + /// cover, invoked from the uncommitted (gutter) controls. Added selections + /// are in worktree buffer rows; deleted selections are in rows of the + /// uncommitted diff's base (HEAD) text. + pub fn stage_or_unstage_lines( + &mut self, + stage: bool, + buffer: Entity, + uncommitted_diff: Entity, + selections: Vec, + cx: &mut Context, + ) -> Result<()> { + if selections.is_empty() { + return Ok(()); + } + let buffer_snapshot = buffer.read(cx).snapshot(); + let buffer_id = buffer_snapshot.remote_id(); + let file_exists = buffer_snapshot + .file() + .is_some_and(|file| file.disk_state().exists()); + + let uncommitted_diff = self + .get_uncommitted_diff(buffer_id, cx) + .unwrap_or(uncommitted_diff); + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff(); + let uncommitted_snapshot = uncommitted_diff.read(cx).snapshot(cx); + let unstaged_snapshot = uncommitted_snapshot + .secondary_diff() + .context("diff has no unstaged secondary")?; + let head_base_text = uncommitted_snapshot.base_text(); + + let (selected_added_rows, selected_deleted_rows): (Vec<_>, Vec<_>) = selections + .into_iter() + .partition(|selection| !selection.is_deleted); + + // Collect the uncommitted hunks the selections cover: added selections + // are located in worktree coordinates, deleted selections in base text + // coordinates. Hunks are read with pending overrides applied so that + // consecutive line operations compose before the index write settles. + let mut hunks = Vec::new(); + for selection in &selected_added_rows { + let start = buffer_snapshot.clip_point(Point::new(selection.rows.start, 0), Bias::Left); + let end = buffer_snapshot.clip_point(Point::new(selection.rows.end, 0), Bias::Left); + let range = buffer_snapshot.anchor_before(start)..buffer_snapshot.anchor_before(end); + hunks.extend(uncommitted_snapshot.hunks_intersecting_range(range, &buffer_snapshot)); + } + for selection in &selected_deleted_rows { + let start = head_base_text.clip_point(Point::new(selection.rows.start, 0), Bias::Left); + let end = head_base_text.clip_point(Point::new(selection.rows.end, 0), Bias::Left); + let range = head_base_text.point_to_offset(start)..head_base_text.point_to_offset(end); + hunks.extend( + uncommitted_snapshot.hunks_intersecting_base_text_range(range, &buffer_snapshot), + ); + } + hunks.sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&buffer_snapshot)); + hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start); + + let version = buffer_snapshot.version().clone(); + let mut index_edits = Vec::new(); + let mut index_footprints = Vec::new(); + let mut uncommitted_pending = Vec::new(); + let mut suppressed_unstaged_hunks = Vec::new(); + + for hunk in &hunks { + let buffer_span = buffer_row_span(&hunk.range); + let base_point_range = head_base_text.offset_to_point(hunk.diff_base_byte_range.start) + ..head_base_text.offset_to_point(hunk.diff_base_byte_range.end); + let base_span = base_row_span( + head_base_text, + &hunk.diff_base_byte_range, + &base_point_range, + ); + let base_row_count = base_span.end - base_span.start; + + let selected_added = selected_rows_in_span(&selected_added_rows, &buffer_span); + // Deleted rows are tracked relative to the hunk's base region, the + // same convention as `DiffHunk::staged_deleted`. + let selected_deleted = selected_rows_in_span(&selected_deleted_rows, &base_span) + .into_iter() + .map(|row| row - base_span.start) + .collect::>(); + if selected_added.is_empty() && selected_deleted.is_empty() { + continue; + } + + let mut desired_added = hunk + .staged_added + .iter() + .flat_map(|rows| rows.clone()) + .collect::>(); + let mut desired_deleted = hunk + .staged_deleted + .iter() + .flat_map(|rows| rows.clone()) + .collect::>(); + if stage { + desired_added.extend(selected_added); + desired_deleted.extend(selected_deleted); + } else { + desired_added.retain(|row| !selected_added.contains(row)); + desired_deleted.retain(|row| !selected_deleted.contains(row)); + } + + if file_exists { + // Rebuild the hunk's index region from scratch: the deletion + // lines that remain unstaged (from HEAD), followed by the added + // lines that are staged (from the worktree buffer). + let index_range = unstaged_snapshot + .base_text_range_for_buffer_range(hunk.buffer_range.clone(), &buffer_snapshot); + let base_region_text = head_base_text + .text_for_range(hunk.diff_base_byte_range.clone()) + .collect::(); + let mut replacement = String::new(); + for (row, line) in base_region_text.split_inclusive('\n').enumerate() { + if !desired_deleted.contains(&(row as u32)) { + replacement.push_str(line); + } + } + // A retained final base line without a trailing newline would + // otherwise fuse with the first staged addition. + if !replacement.is_empty() + && !replacement.ends_with('\n') + && !desired_added.is_empty() + { + replacement.push('\n'); + } + let hunk_offset_range = hunk.buffer_range.to_offset(&buffer_snapshot); + let hunk_start_row = buffer_snapshot.offset_to_point(hunk_offset_range.start).row; + let buffer_region_text = buffer_snapshot + .text_for_range(hunk_offset_range) + .collect::(); + for (row, line) in buffer_region_text.split_inclusive('\n').enumerate() { + if desired_added.contains(&(hunk_start_row + row as u32)) { + replacement.push_str(line); + } + } + index_edits.push((index_range.clone(), Arc::::from(replacement))); + index_footprints.push(index_range); + } + + let fully_staged = buffer_span.clone().all(|row| desired_added.contains(&row)) + && (0..base_row_count).all(|row| desired_deleted.contains(&row)); + let fully_unstaged = desired_added.is_empty() && desired_deleted.is_empty(); + let sense = if fully_staged { + PendingSense::Stage + } else if fully_unstaged { + PendingSense::Unstage + } else { + PendingSense::PartiallyStage + }; + + if fully_staged { + // Like whole-hunk staging, the corresponding unstaged hunks + // disappear the moment they are staged. + suppressed_unstaged_hunks.extend( + unstaged_snapshot + .raw_hunks_intersecting_range(hunk.buffer_range.clone(), &buffer_snapshot), + ); + } + + uncommitted_pending.push(PendingHunk::new( + hunk.buffer_range.clone(), + hunk.diff_base_byte_range.clone(), + version.clone(), + sense, + row_set_to_ranges(&desired_added) + .into_iter() + .map(|rows| { + let start = + buffer_snapshot.clip_point(Point::new(rows.start, 0), Bias::Left); + let end = buffer_snapshot.clip_point(Point::new(rows.end, 0), Bias::Left); + buffer_snapshot.anchor_before(start)..buffer_snapshot.anchor_before(end) + }) + .collect(), + row_set_to_ranges(&desired_deleted), + )); + } + + suppressed_unstaged_hunks + .sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&buffer_snapshot)); + suppressed_unstaged_hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start); + let unstaged_pending = pending_hunks( + &suppressed_unstaged_hunks, + &version, + PendingSense::Suppress, + &buffer_snapshot, + ); + drop(uncommitted_snapshot); + + let diff_state = self + .diffs + .get(&buffer_id) + .cloned() + .context("failed to find git state for buffer")?; + diff_state.update(cx, |diff_state, _| { + diff_state.remove_overlapping_pending_index_edits(&index_footprints); + // The worktree file is gone: staging removes it from the index. + diff_state.insert_pending_index_edits(file_exists.then_some(index_edits)); + }); + + uncommitted_diff.update(cx, |diff, cx| { + diff.set_pending_hunks(&uncommitted_pending, &buffer_snapshot, cx); + }); + if let Some(unstaged_diff) = unstaged_diff { + unstaged_diff.update(cx, |diff, cx| { + diff.set_pending_hunks(&unstaged_pending, &buffer_snapshot, cx); + }); + } + + self.write_optimistic_index(buffer_id, cx); + Ok(()) + } + /// Unstages the worktree changes covered by `worktree_ranges`, acting on the /// given uncommitted (HEAD-vs-worktree) diff, invoked from the uncommitted /// (gutter) controls. Uses the worktree->index projection (the hard part) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 2bdff41b16fb20..4b191f74f1a656 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -25,7 +25,7 @@ pub mod trusted_worktrees; pub mod worktree_store; mod environment; -use buffer_diff::BufferDiff; +use buffer_diff::{BufferDiff, ResolvedLineSelection}; use context_server_store::ContextServerStore; pub use environment::ProjectEnvironmentEvent; use git::repository::get_git_committer; @@ -3372,6 +3372,26 @@ impl Project { }) } + /// Stages or unstages the selected lines (in the worktree buffer's + /// coordinates for added lines, and the uncommitted diff's base text + /// coordinates for deleted lines), acting on the given uncommitted diff. + /// Used by the uncommitted (gutter) controls. + pub fn stage_or_unstage_lines( + &mut self, + stage: bool, + buffer: Entity, + uncommitted_diff: Entity, + selections: Vec, + cx: &mut Context, + ) -> Result<()> { + if self.is_disconnected(cx) { + return Err(anyhow!(ErrorCode::Disconnected)); + } + self.git_store.update(cx, |git_store, cx| { + git_store.stage_or_unstage_lines(stage, buffer, uncommitted_diff, selections, cx) + }) + } + /// Unstages the worktree changes covered by `worktree_ranges` (in the worktree /// buffer's coordinates), acting on the given uncommitted diff. Used by the /// uncommitted (gutter) controls. From e5db0706ba98c8f9df0a9519463b535063ec3b54 Mon Sep 17 00:00:00 2001 From: AidanV Date: Fri, 17 Jul 2026 09:40:50 -0700 Subject: [PATCH 7/8] line-level staging for collapsed hunks, wired end-to-end with tests --- crates/buffer_diff/src/buffer_diff.rs | 46 +++---- crates/editor/src/editor_tests.rs | 187 ++++++++++++++++++++++++++ crates/editor/src/git.rs | 8 +- 3 files changed, 205 insertions(+), 36 deletions(-) diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 17579289725d69..8ea6749c58ebbf 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -425,7 +425,7 @@ impl BufferDiffSnapshot { DiffHunk { range: range.clone(), diff_base_byte_range: hunk.diff_base_byte_range.clone(), - buffer_range: buffer_range.clone(), + buffer_range, secondary_status: DiffHunkSecondaryStatus::NoSecondaryHunk, base_word_diffs: hunk.base_word_diffs.clone(), buffer_word_diffs: hunk.buffer_word_diffs.clone(), @@ -1190,38 +1190,26 @@ impl BufferDiffSnapshot { start_anchor..end_anchor, ) { - match pending_hunk.sense { - PendingSense::Stage | PendingSense::Unstage => { - staged_added = pending_hunk - .staged_added - .iter() - .map(|anchor_range| { - buffer_row_span(&anchor_range.to_point(buffer)) - }) - .collect(); - staged_deleted = pending_hunk.staged_deleted.clone(); - has_pending = true; - secondary_status = if pending_hunk.sense == PendingSense::Stage { - DiffHunkSecondaryStatus::SecondaryHunkRemovalPending - } else { - DiffHunkSecondaryStatus::SecondaryHunkAdditionPending - }; + let pending_secondary_status = match pending_hunk.sense { + PendingSense::Stage => { + DiffHunkSecondaryStatus::SecondaryHunkRemovalPending + } + PendingSense::Unstage => { + DiffHunkSecondaryStatus::SecondaryHunkAdditionPending } PendingSense::PartiallyStage => { - staged_added = pending_hunk - .staged_added - .iter() - .map(|anchor_range| { - buffer_row_span(&anchor_range.to_point(buffer)) - }) - .collect(); - staged_deleted = pending_hunk.staged_deleted.clone(); - has_pending = true; - secondary_status = - DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk; + DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk } PendingSense::Suppress => continue, - } + }; + staged_added = pending_hunk + .staged_added + .iter() + .map(|anchor_range| buffer_row_span(&anchor_range.to_point(buffer))) + .collect(); + staged_deleted = pending_hunk.staged_deleted.clone(); + has_pending = true; + secondary_status = pending_secondary_status; } } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index e92b1b89496f08..38c7c6ebbef1d3 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -33383,6 +33383,193 @@ async fn test_partially_staged_hunk(cx: &mut TestAppContext) { "})); } +// Behavior: ToggleStagedSelectedLines over a selection that crosses a collapsed +// pure-deletion hunk stages (then unstages) that deletion, even though the +// deleted lines are not visible in the buffer. +#[gpui::test] +async fn test_toggle_staged_selected_lines_collapsed_deletion(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 + " + }); + // The worktree deletes `three`; its deletion hunk stays collapsed. + cx.set_state(indoc! {" + one + «two + fourˇ» + five + "}); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.toggle_staged_selected_lines(&Default::default(), window, cx); + }); + cx.run_until_parked(); + cx.assert_index_text(Some(indoc! {" + one + two + four + five + "})); + + // Toggling again over the same selection unstages the deletion. + cx.update_editor(|editor, window, cx| { + editor.toggle_staged_selected_lines(&Default::default(), window, cx); + }); + cx.run_until_parked(); + cx.assert_index_text(Some(indoc! {" + one + two + three + four + five + "})); +} + +// Behavior: ToggleStagedSelectedLines over the visible added lines of a +// collapsed modified hunk (a 2-line deletion / 3-line addition where only the +// added lines are visible) stages (then unstages) the whole hunk. +#[gpui::test] +async fn test_toggle_staged_selected_lines_collapsed_modification(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_head_text(indoc! { " + a + bee + cee + d + " + }); + cx.set_index_text(indoc! { " + a + bee + cee + d + " + }); + // `bee`, `cee` (2 lines) are replaced by `x1`, `x2`, `x3` (3 lines). The + // deletion side is collapsed, so only the three added lines are visible. + cx.set_state(indoc! {" + a + «x1 + x2 + x3ˇ» + d + "}); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.toggle_staged_selected_lines(&Default::default(), window, cx); + }); + cx.run_until_parked(); + cx.assert_index_text(Some(indoc! {" + a + x1 + x2 + x3 + d + "})); + + cx.update_editor(|editor, window, cx| { + editor.toggle_staged_selected_lines(&Default::default(), window, cx); + }); + cx.run_until_parked(); + cx.assert_index_text(Some(indoc! {" + a + bee + cee + d + "})); +} + +// Behavior: ToggleStagedSelectedLines over a single added line of a multi-line +// pure-addition hunk stages only that line, leaving the hunk partially staged. +#[gpui::test] +async fn test_toggle_staged_selected_lines_partial_addition(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_head_text(indoc! { " + a + d + " + }); + cx.set_index_text(indoc! { " + a + d + " + }); + // Three lines are added between `a` and `d`; only the middle is selected. + cx.set_state(indoc! {" + a + x1 + «x2ˇ» + x3 + d + "}); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.toggle_staged_selected_lines(&Default::default(), window, cx); + }); + cx.run_until_parked(); + cx.assert_index_text(Some(indoc! {" + a + x2 + d + "})); + + // The hunk is now partially staged. + 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::>(); + assert_eq!(hunks.len(), 1); + assert_eq!( + hunks[0].status(), + DiffHunkStatus { + kind: DiffHunkStatusKind::Added, + secondary: DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk + } + ); + }); + + // Toggling the same line again unstages it. + cx.set_state(indoc! {" + a + x1 + «x2ˇ» + x3 + d + "}); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.toggle_staged_selected_lines(&Default::default(), window, cx); + }); + cx.run_until_parked(); + cx.assert_index_text(Some(indoc! {" + a + d + "})); +} + #[gpui::test] fn test_crease_insertion_and_rendering(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index 453616c614e6c6..c30bded2fe9d3e 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -1780,12 +1780,6 @@ impl Editor { .detach_and_log_err(cx); } - // TODO(partial-commit): stage/unstage only the individual lines under the - // cursor within their hunk. The line-level index computation was written - // against the old whole-index-rebuild staging path; it still needs to be - // reimplemented on top of the current optimistic-index model in - // `project::git_store`. Until then, toggling selected lines falls back to - // toggling the whole hunk(s) the selection covers. pub(super) fn toggle_staged_selected_lines( &mut self, _: &::git::ToggleStagedSelectedLines, @@ -3335,7 +3329,7 @@ impl EditorSnapshot { multi_buffer_range, is_created_file, staged_added, - staged_deleted: hunk.staged_deleted.clone(), + staged_deleted: hunk.staged_deleted, deleted_lines, is_expanded, } From 821cc984d0927186421ce3b08c80fdff6e25c086 Mon Sep 17 00:00:00 2001 From: AidanV Date: Sat, 18 Jul 2026 01:09:39 -0700 Subject: [PATCH 8/8] fixed end of file bug and display bug --- crates/editor/src/element.rs | 5 +---- crates/editor/src/git.rs | 9 +++++++-- crates/multi_buffer/src/multi_buffer.rs | 6 +----- crates/project/src/git_store.rs | 5 +---- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index f34d7af539b6c5..c64f00494711a8 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -5557,10 +5557,7 @@ impl EditorElement { staged_deleted .iter() .cloned() - .chain(relative_added.map(|range| { - (range.start + deleted_lines - 1) - ..(range.end + deleted_lines - 1) - })) + .chain(relative_added) .collect() } else { relative_added.collect() diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index c30bded2fe9d3e..1ea75066e652ce 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -3315,8 +3315,13 @@ impl EditorSnapshot { .staged_added .iter() .map(|range| { - range.start.to_display_point(&self.display_snapshot).row() - ..range.end.to_display_point(&self.display_snapshot).row() + let start = range.start.to_display_point(&self.display_snapshot); + let end = range.end.to_display_point(&self.display_snapshot); + let mut end_row = end.row(); + if end.column() > 0 { + end_row.0 += 1; + } + start.row()..end_row }) .collect(); diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index c5119520d474e0..4bb8cf4193c89d 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -2382,10 +2382,6 @@ impl MultiBuffer { .is_some() } - pub fn single_hunk_is_expanded(&self, range: Range, cx: &App) -> bool { - self.read(cx).single_hunk_is_expanded(range) - } - pub fn has_expanded_diff_hunks_in_ranges(&self, ranges: &[Range], cx: &App) -> bool { let snapshot = self.read(cx); let mut cursor = snapshot.diff_transforms.cursor::(()); @@ -3134,7 +3130,7 @@ impl MultiBuffer { .excerpt_containing(range.end..range.end) .and_then(|(_, excerpt_range)| snapshot.anchor_in_excerpt(excerpt_range.context.end)); let point_range = range.to_point(&snapshot); - let expand = !self.single_hunk_is_expanded(range, cx); + let expand = !snapshot.single_hunk_is_expanded(range); let edits = self.expand_or_collapse_diff_hunks_inner([(point_range, excerpt_end)], expand, cx); if !edits.is_empty() { diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 6029e14ac2267d..4476238bbc19c3 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -304,10 +304,7 @@ fn decode_commit_diff(diff: git::repository::CommitDiff) -> CommitDiff { } } -fn selected_rows_in_span( - selections: &[ResolvedLineSelection], - span: &Range, -) -> BTreeSet { +fn selected_rows_in_span(selections: &[ResolvedLineSelection], span: &Range) -> BTreeSet { selections .iter() .flat_map(|selection| {