From c31b2b0dc7180247b2981eb084594efaf11ee396 Mon Sep 17 00:00:00 2001 From: drbh Date: Tue, 7 Jul 2026 02:55:29 -0400 Subject: [PATCH] Git partially staged changes (#46541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR explores the addition of a new feature and UI to improve visibility into partially staged commits. Currently, the Git panel shows tracked and untracked changes, but it does not clearly distinguish between staged and unstaged changes. As a result, it’s difficult to quickly see which changes are not staged in the current UI. Both staged and unstaged changes are combined into the `Uncommitted Changes` multibuffer. This developer experience differs from other editors, most notably VS Code; which presents separate Staged Changes and Changes lists. ### Staged and unstaged diffs in multibuffers This PR introduces an alternative UI for unstaged changes that aligns with the overall Zed experience. Instead of showing changes on a per-file basis, staged and unstaged diffs are each displayed in their own multibuffers, similar to how `Uncommitted Changes` currently works. For example the following screenshot shows the current `Uncommitted Changes` on the left, the `Staged Changes` in the middle and the `Unstaged Changes` buffer on the right for comparison ### Indicators/interactions The new multibuffers can be opened in two ways: 1. Via a new `U` chip, which appears when a file has unstaged changes 2. Via new menu options (See screenshots below for both interaction paths.)

via the chip

Via the chip

via the menu

Via the menu
### Design goals - minimally intrusive UI changes (small new badge and menu items) - adhere by Zed'ism (use multibuffer where possible) - avoid disabling any current interactions (Uncommitted Changes ui is unchanged) - avoid introducing an app level view mode (no new settings needed) ### Experience goals - make it easy to see what changes are not staged - make it easy to see that a file has unstaged changes (avoid developers accidently leaving out changes in a commit; a personal issue that I have when using Zed) - elegantly handle large file's unstaged changes (follows the same collapse and expanding seen in `Uncommitted Changes`) ### How to try - Clone the repo and run `cargo run` - Make a change to a file and stage it - Make another change to the file (the `U` indicator will appear) - Click the `U` to see the unstaged view ### Open questions/rough edges - [ ] determine if this user experience is useful for others - [ ] ensure all interactions work as expected (response to all update cases) In general I'm really interested in hearing the community's feedback about this interface, more than happy to make any changes or explore a different solution! ### Related issue: - https://github.com/zed-industries/zed/pull/36646 - https://github.com/zed-industries/zed/issues/26560 Release Notes: - Support partially staged commit multibuffers via a staged and unstaged changes view. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Cole Miller --- crates/agent_ui/src/agent_diff.rs | 96 +- crates/agent_ui/src/entry_view_state.rs | 9 +- crates/buffer_diff/src/buffer_diff.rs | 632 ++-- crates/editor/src/config.rs | 4 - crates/editor/src/editor.rs | 30 +- crates/editor/src/element.rs | 13 +- crates/editor/src/git.rs | 644 ++-- crates/editor/src/split.rs | 190 +- crates/git_ui/src/branch_diff.rs | 1199 ++++++++ crates/git_ui/src/commit_view.rs | 8 +- crates/git_ui/src/conflict_view.rs | 71 +- crates/git_ui/src/diff_multibuffer.rs | 1016 ++++++ crates/git_ui/src/file_diff_view.rs | 12 +- crates/git_ui/src/git_panel.rs | 58 +- crates/git_ui/src/git_ui.rs | 7 + crates/git_ui/src/multi_diff_view.rs | 12 +- crates/git_ui/src/project_diff.rs | 2726 +++++------------ crates/git_ui/src/staged_diff.rs | 1090 +++++++ crates/git_ui/src/text_diff_view.rs | 11 +- crates/git_ui/src/unstaged_diff.rs | 722 +++++ crates/multi_buffer/src/multi_buffer.rs | 5 +- crates/project/src/git_store.rs | 703 ++++- .../{branch_diff.rs => diff_buffer_list.rs} | 143 +- crates/project/src/project.rs | 57 +- .../tests/integration/project_tests.rs | 949 +++++- crates/proto/proto/git.proto | 5 + crates/search/src/buffer_search.rs | 5 +- crates/zed/src/zed.rs | 9 +- crates/zed_actions/src/lib.rs | 6 + 29 files changed, 7599 insertions(+), 2833 deletions(-) create mode 100644 crates/git_ui/src/branch_diff.rs create mode 100644 crates/git_ui/src/diff_multibuffer.rs create mode 100644 crates/git_ui/src/staged_diff.rs create mode 100644 crates/git_ui/src/unstaged_diff.rs rename crates/project/src/git_store/{branch_diff.rs => diff_buffer_list.rs} (76%) diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 9f51e53b292..079b043e467 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -6,8 +6,8 @@ use anyhow::Result; use buffer_diff::DiffHunkStatus; use collections::{HashMap, HashSet}; use editor::{ - Direction, Editor, EditorEvent, EditorSettings, MultiBuffer, MultiBufferSnapshot, - SelectionEffects, SplittableEditor, ToPoint, + DiffHunkDelegate, Direction, Editor, EditorEvent, EditorSettings, MultiBuffer, + MultiBufferSnapshot, ResolvedDiffHunks, SelectionEffects, SplittableEditor, ToPoint, actions::{GoToHunk, GoToPreviousHunk}, multibuffer_context_lines, scroll::Autoscroll, @@ -101,8 +101,7 @@ impl AgentDiffPane { cx, ); diff_display_editor - .set_render_diff_hunk_controls(diff_hunk_controls(&thread, workspace.clone()), cx); - diff_display_editor.set_render_diff_hunks_as_unstaged(cx); + .set_diff_hunk_delegate(Some(agent_diff_delegate(&thread, workspace.clone())), cx); diff_display_editor.update_editors(cx, |editor, _cx| { editor.register_addon(AgentDiffAddon); }); @@ -722,29 +721,68 @@ impl Render for AgentDiffPane { } } -fn diff_hunk_controls( +struct AgentDiffDelegate { + thread: Entity, + workspace: WeakEntity, +} + +fn agent_diff_delegate( thread: &Entity, workspace: WeakEntity, -) -> editor::RenderDiffHunkControlsFn { - let thread = thread.clone(); +) -> Arc { + Arc::new(AgentDiffDelegate { + thread: thread.clone(), + workspace, + }) +} - Arc::new( - move |row, status, hunk_range, is_created_file, line_height, editor, _, cx| { - { - render_diff_hunk_controls( - row, - status, - hunk_range, - is_created_file, - line_height, - &thread, - editor, - workspace.clone(), - cx, - ) - } - }, - ) +impl DiffHunkDelegate for AgentDiffDelegate { + fn toggle( + &self, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn stage_or_unstage( + &self, + _stage: bool, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn render_hunk_controls( + &self, + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + is_created_file: bool, + line_height: Pixels, + editor: &Entity, + _window: &mut Window, + cx: &mut App, + ) -> AnyElement { + render_diff_hunk_controls( + row, + status, + hunk_range, + is_created_file, + line_height, + &self.thread, + editor, + self.workspace.clone(), + cx, + ) + } + + fn render_hunk_as_staged(&self, _status: &DiffHunkStatus, _cx: &App) -> bool { + false + } } fn render_diff_hunk_controls( @@ -1528,7 +1566,7 @@ impl AgentDiff { for (editor, _) in self.reviewing_editors.drain() { editor .update(cx, |editor, cx| { - editor.end_temporary_diff_override(cx); + editor.set_diff_hunk_delegate(None, cx); editor.unregister_addon::(); }) .ok(); @@ -1577,12 +1615,10 @@ impl AgentDiff { if previous_state.is_none() { editor.update(cx, |editor, cx| { - editor.start_temporary_diff_override(); - editor.set_render_diff_hunk_controls( - diff_hunk_controls(&thread, workspace.clone()), + editor.set_diff_hunk_delegate( + Some(agent_diff_delegate(&thread, workspace.clone())), cx, ); - editor.set_render_diff_hunks_as_unstaged(true, cx); editor.set_expand_all_diff_hunks(cx); editor.register_addon(EditorAgentDiffAddon); }); @@ -1629,7 +1665,7 @@ impl AgentDiff { if in_workspace { editor .update(cx, |editor, cx| { - editor.end_temporary_diff_override(cx); + editor.set_diff_hunk_delegate(None, cx); editor.unregister_addon::(); }) .ok(); diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index e2ad7ca71ec..faa760b73a6 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -1,11 +1,14 @@ -use std::ops::Range; +use std::{ops::Range, sync::Arc}; use acp_thread::{AcpThread, AgentThreadEntry, AssistantMessageChunk}; use agent::ThreadStore; use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use collections::{HashMap, HashSet}; -use editor::{Editor, EditorEvent, EditorMode, MinimapVisibility, SizingBehavior}; +use editor::{ + Editor, EditorEvent, EditorMode, MinimapVisibility, RestoreOnlyUnstagedDiffHunkDelegate, + SizingBehavior, +}; use gpui::{ AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable, ScrollHandle, TextStyleRefinement, WeakEntity, Window, @@ -682,7 +685,7 @@ fn create_editor_diff( editor.set_show_code_actions(false, cx); editor.set_show_git_diff_gutter(false, cx); editor.set_expand_all_diff_hunks(cx); - editor.set_render_diff_hunks_as_unstaged(true, cx); + editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx); editor.set_text_style_refinement(diff_editor_text_style_refinement(cx)); editor }) diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index c300ace11ae..5cba96c5a92 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -122,11 +122,37 @@ struct InternalDiffHunk { } #[derive(Debug, Clone, PartialEq, Eq)] -struct PendingHunk { +pub struct PendingHunk { buffer_range: Range, diff_base_byte_range: Range, buffer_version: clock::Global, - new_status: DiffHunkSecondaryStatus, + sense: PendingSense, +} + +impl PendingHunk { + pub fn new( + buffer_range: Range, + diff_base_byte_range: Range, + buffer_version: clock::Global, + sense: PendingSense, + ) -> Self { + Self { + buffer_range, + diff_base_byte_range, + buffer_version, + sense, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +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 }, + /// Suppress the matched hunk entirely (used by the unstaged/staged diffs so + /// that a hunk disappears the moment it is staged/unstaged). + Suppress, } #[derive(Debug, Clone)] @@ -294,6 +320,56 @@ impl BufferDiffSnapshot { self.hunks_intersecting_range_impl(filter, buffer, unstaged_counterpart) } + /// Like [`hunks_intersecting_range`], but ignores optimistic pending hunks + /// (both secondary-status overrides and suppressions) and does not compute a + /// secondary status. + pub fn raw_hunks_intersecting_range<'a>( + &'a self, + range: Range, + buffer: &'a text::BufferSnapshot, + ) -> impl 'a + Iterator { + let range = range.to_offset(buffer); + let filter = move |summary: &DiffHunkSummary| { + let summary_range = summary.buffer_range.to_offset(buffer); + !(summary_range.end < range.start) && !(summary_range.start > range.end) + }; + self.hunks + .filter::<_, DiffHunkSummary>(buffer, filter) + .map(move |hunk| { + let buffer_range = hunk.buffer_range.clone(); + DiffHunk { + range: buffer_range.to_point(buffer), + diff_base_byte_range: hunk.diff_base_byte_range.clone(), + buffer_range, + secondary_status: DiffHunkSecondaryStatus::NoSecondaryHunk, + base_word_diffs: hunk.base_word_diffs.clone(), + buffer_word_diffs: hunk.buffer_word_diffs.clone(), + } + }) + } + + /// Maps a range in this diff's main buffer to the range it covers in the + /// base text, expanding to whole hunks wherever the range endpoints fall + /// inside or touch a hunk (`edit_for_old_position` is inclusive on both + /// boundaries, matching `raw_hunks_intersecting_range`). Used by the + /// index-write path to compute the index-coordinate footprint of a staging + /// operation; like the raw hunks, the mapping ignores optimistic pending + /// hunks. + pub fn base_text_range_for_buffer_range( + &self, + range: Range, + buffer: &text::BufferSnapshot, + ) -> Range { + let point_range = range.to_point(buffer); + let patch = self.patch_for_buffer_range(point_range.start..=point_range.end, buffer); + let start_point = patch.edit_for_old_position(point_range.start).new.start; + let end_point = patch.edit_for_old_position(point_range.end).new.end; + let base_text = self.base_text(); + let start = base_text.point_to_offset(start_point.min(base_text.max_point())); + let end = base_text.point_to_offset(end_point.min(base_text.max_point())); + start.min(end)..end + } + pub fn hunks_intersecting_range_rev<'a>( &'a self, range: Range, @@ -705,114 +781,80 @@ impl BufferDiffSnapshot { } impl BufferDiffSnapshot { - fn stage_or_unstage_hunks_impl( - &mut self, + // Compute the edits to apply to the index, and the resulting pending hunks, + // for a stage or unstage operation on the uncommitted diff. + pub fn compute_uncommitted_index_edits( + &self, unstaged_diff: &Self, stage: bool, hunks: &[DiffHunk], buffer: &text::BufferSnapshot, file_exists: bool, - ) -> Option { + ) -> (Option, Arc)>>, Vec) { let head_text = self .base_text_exists .then(|| self.base_text.as_rope().clone()); let index_text = unstaged_diff .base_text_exists .then(|| unstaged_diff.base_text.as_rope().clone()); + let sense = PendingSense::SetSecondaryStatus { stage }; + let version = buffer.version().clone(); // If the file doesn't exist in either HEAD or the index, then the // entire file must be either created or deleted in the index. let (index_text, head_text) = match (index_text, head_text) { (Some(index_text), Some(head_text)) if file_exists || !stage => (index_text, head_text), (index_text, head_text) => { - let (new_index_text, new_status) = if stage { + let index_len = index_text.as_ref().map_or(0, |rope| rope.len()); + let new_index_text: Option = if stage { log::debug!("stage all"); - ( - file_exists.then(|| buffer.as_rope().clone()), - DiffHunkSecondaryStatus::SecondaryHunkRemovalPending, - ) + file_exists.then(|| buffer.as_rope().clone()) } else { log::debug!("unstage all"); - ( - head_text, - DiffHunkSecondaryStatus::SecondaryHunkAdditionPending, - ) + head_text }; - let hunk = PendingHunk { - buffer_range: Anchor::min_max_range_for_buffer(buffer.remote_id()), - diff_base_byte_range: 0..index_text.map_or(0, |rope| rope.len()), - buffer_version: buffer.version().clone(), - new_status, - }; - self.pending_hunks = SumTree::from_item(hunk, buffer); - return new_index_text; + let pending = vec![PendingHunk::new( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + 0..index_len, + version, + sense, + )]; + let edits = + new_index_text.map(|rope| vec![(0..index_len, Arc::from(rope.to_string()))]); + return (edits, pending); } }; - let mut pending_hunks = SumTree::new(buffer); - let mut old_pending_hunks = self.pending_hunks.cursor::(buffer); - - // first, merge new hunks into pending_hunks - for DiffHunk { - buffer_range, - diff_base_byte_range, - secondary_status, - .. - } in hunks.iter().cloned() - { - let preceding_pending_hunks = old_pending_hunks.slice(&buffer_range.start, Bias::Left); - pending_hunks.append(preceding_pending_hunks, buffer); - - // Skip all overlapping or adjacent old pending hunks - while old_pending_hunks.item().is_some_and(|old_hunk| { - old_hunk - .buffer_range - .start - .cmp(&buffer_range.end, buffer) - .is_le() - }) { - old_pending_hunks.next(); - } - - if (stage && secondary_status == DiffHunkSecondaryStatus::NoSecondaryHunk) - || (!stage && secondary_status == DiffHunkSecondaryStatus::HasSecondaryHunk) - { - continue; - } - - pending_hunks.push( - PendingHunk { - buffer_range, - diff_base_byte_range, - buffer_version: buffer.version().clone(), - new_status: if stage { - DiffHunkSecondaryStatus::SecondaryHunkRemovalPending - } else { - DiffHunkSecondaryStatus::SecondaryHunkAdditionPending - }, - }, - buffer, - ); - } - // append the remainder - pending_hunks.append(old_pending_hunks.suffix(), buffer); - let mut unstaged_hunk_cursor = unstaged_diff.hunks.cursor::(buffer); unstaged_hunk_cursor.next(); - // then, iterate over all pending hunks (both new ones and the existing ones) and compute the edits let mut prev_unstaged_hunk_buffer_end = 0; let mut prev_unstaged_hunk_base_text_end = 0; - let mut edits = Vec::<(Range, String)>::new(); - let mut pending_hunks_iter = pending_hunks.iter().cloned().peekable(); - while let Some(PendingHunk { - buffer_range, - diff_base_byte_range, - new_status, - .. - }) = pending_hunks_iter.next() - { + let mut edits = Vec::<(Range, Arc)>::new(); + let mut pending = Vec::::new(); + + // Process only the hunks the user acted on, skipping any already in the + // desired state. + let mut hunks_iter = hunks + .iter() + .filter(|hunk| { + !((stage && hunk.secondary_status == DiffHunkSecondaryStatus::NoSecondaryHunk) + || (!stage + && hunk.secondary_status == DiffHunkSecondaryStatus::HasSecondaryHunk)) + }) + .peekable(); + + while let Some(hunk) = hunks_iter.next() { + let buffer_range = hunk.buffer_range.clone(); + let diff_base_byte_range = hunk.diff_base_byte_range.clone(); + pending.push(PendingHunk::new( + buffer_range.clone(), + diff_base_byte_range.clone(), + version.clone(), + sense, + )); + // Advance unstaged_hunk_cursor to skip unstaged hunks before current hunk let skipped_unstaged = unstaged_hunk_cursor.slice(&buffer_range.start, Bias::Left); @@ -846,16 +888,20 @@ impl BufferDiffSnapshot { } } - // If any unstaged hunks were merged, then subsequent pending hunks may - // now overlap this hunk. Merge them. - if let Some(next_pending_hunk) = pending_hunks_iter.peek() { - let next_pending_hunk_offset_range = - next_pending_hunk.buffer_range.to_offset(buffer); - if next_pending_hunk_offset_range.start <= buffer_offset_range.end { - buffer_offset_range.end = buffer_offset_range - .end - .max(next_pending_hunk_offset_range.end); - pending_hunks_iter.next(); + // If any unstaged hunks were merged, then subsequent acted-on hunks + // may now overlap this hunk. Merge them. + if let Some(next_hunk) = hunks_iter.peek() { + let next_hunk_offset_range = next_hunk.buffer_range.to_offset(buffer); + if next_hunk_offset_range.start <= buffer_offset_range.end { + buffer_offset_range.end = + buffer_offset_range.end.max(next_hunk_offset_range.end); + let merged_hunk = hunks_iter.next().expect("peeked hunk exists"); + pending.push(PendingHunk::new( + merged_hunk.buffer_range.clone(), + merged_hunk.diff_base_byte_range.clone(), + version.clone(), + sense, + )); continue; } } @@ -879,49 +925,59 @@ impl BufferDiffSnapshot { let index_start = index_start.min(index_end); let index_byte_range = index_start..index_end; - let replacement_text = match new_status { - DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => { - log::debug!("staging hunk {:?}", buffer_offset_range); + let replacement_text: Arc = if stage { + log::debug!("staging hunk {:?}", buffer_offset_range); + Arc::from( buffer .text_for_range(buffer_offset_range) - .collect::() - } - DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => { - log::debug!("unstaging hunk {:?}", buffer_offset_range); + .collect::(), + ) + } else { + log::debug!("unstaging hunk {:?}", buffer_offset_range); + Arc::from( head_text .chunks_in_range(diff_base_byte_range.clone()) - .collect::() - } - _ => { - debug_assert!(false); - continue; - } + .collect::(), + ) }; - edits.push((index_byte_range, replacement_text)); + // Distinct worktree hunks can project to touching index ranges + // (e.g. a staged deletion ending exactly where the next hunk's + // index position starts). Merge them so the edit list stays + // strictly disjoint, which the pending-edit eviction logic relies + // on to not evict one of these edits when the other is inserted. + if let Some((last_range, last_text)) = edits.last_mut() + && index_byte_range.start <= last_range.end + { + debug_assert!(index_byte_range.start == last_range.end); + debug_assert!(index_byte_range.end >= last_range.end); + last_range.end = index_byte_range.end; + let mut merged_text = + String::with_capacity(last_text.len() + replacement_text.len()); + merged_text.push_str(last_text); + merged_text.push_str(&replacement_text); + *last_text = Arc::from(merged_text); + } else { + edits.push((index_byte_range, replacement_text)); + } } - drop(pending_hunks_iter); - drop(old_pending_hunks); - self.pending_hunks = pending_hunks; #[cfg(debug_assertions)] // invariants: non-overlapping and sorted { for window in edits.windows(2) { let (range_a, range_b) = (&window[0].0, &window[1].0); - debug_assert!(range_a.end < range_b.start); + debug_assert!( + range_a.end < range_b.start, + "index edits out of order or overlapping: {:?}", + edits + .iter() + .map(|(range, text)| (range.clone(), text.len())) + .collect::>() + ); } } - let mut new_index_text = Rope::new(); - let mut index_cursor = index_text.cursor(0); - - for (old_range, replacement_text) in edits { - new_index_text.append(index_cursor.slice(old_range.start)); - index_cursor.seek_forward(old_range.end); - new_index_text.push(&replacement_text); - } - new_index_text.append(index_cursor.suffix()); - Some(new_index_text) + (Some(edits), pending) } } @@ -1005,8 +1061,17 @@ impl BufferDiffSnapshot { start_anchor..end_anchor, ) { - has_pending = true; - secondary_status = pending_hunk.new_status; + match pending_hunk.sense { + PendingSense::SetSecondaryStatus { stage } => { + has_pending = true; + secondary_status = if stage { + DiffHunkSecondaryStatus::SecondaryHunkRemovalPending + } else { + DiffHunkSecondaryStatus::SecondaryHunkAdditionPending + }; + } + PendingSense::Suppress => continue, + } } } @@ -1474,7 +1539,6 @@ pub struct DiffChanged { pub enum BufferDiffEvent { BaseTextChanged, DiffChanged(DiffChanged), - HunksStagedOrUnstaged(Option), } impl EventEmitter for BufferDiff {} @@ -1594,24 +1658,195 @@ impl BufferDiff { let Some(diff_snapshot) = &mut self.diff_snapshot else { return; }; - if self.secondary_diff.is_some() { - diff_snapshot.pending_hunks = SumTree::from_summary(DiffHunkSummary { - buffer_range: Anchor::min_min_range_for_buffer(self.buffer_id), - diff_base_byte_range: 0..0, - added_rows: 0, - removed_rows: 0, - }); - let changed_range = Some(Anchor::min_max_range_for_buffer(self.buffer_id)); - let base_text_range = Some(0..self.base_text(cx).len()); + let Some((first, last)) = diff_snapshot + .pending_hunks + .first() + .zip(diff_snapshot.pending_hunks.last()) + else { + return; + }; + let changed_range = first.buffer_range.start..last.buffer_range.end; + let base_text_changed_range = + first.diff_base_byte_range.start..last.diff_base_byte_range.end; + let buffer = diff_snapshot.buffer_snapshot.clone(); + diff_snapshot.pending_hunks = SumTree::new(&buffer); + cx.emit(BufferDiffEvent::DiffChanged(DiffChanged { + changed_range: Some(changed_range.clone()), + base_text_changed_range: Some(base_text_changed_range), + extended_range: Some(changed_range), + base_text_changed: false, + })); + } + + /// Installs optimistic pending hunks in this diff, merging them with any + /// existing pending hunks (newest wins on overlap) and emitting a + /// `DiffChanged` covering both the new hunks and any existing pending hunks + /// they replace. `hunks` must be sorted by `buffer_range.start` and + /// non-overlapping. + /// + /// `buffer` must be a current snapshot of this diff's main buffer: the + /// incoming hunks carry anchors minted from the current buffer, which this + /// diff's internal snapshot (from the last settled recalculation) may not + /// have observed yet. + pub fn set_pending_hunks( + &mut self, + hunks: &[PendingHunk], + buffer: &text::BufferSnapshot, + cx: &mut Context, + ) { + if hunks.is_empty() { + return; + } + let Some(diff_snapshot) = self.diff_snapshot.as_mut() else { + return; + }; + + let mut new_pending = SumTree::new(buffer); + let mut old = diff_snapshot + .pending_hunks + .cursor::(buffer); + let mut changed_start: Option = None; + let mut changed_end: Option = None; + let mut base_start = usize::MAX; + let mut base_end = 0usize; + let mut extend_changed_range = |buffer_range: &Range, base_range: &Range| { + changed_start = Some(changed_start.map_or(buffer_range.start, |start| { + *start.min(&buffer_range.start, buffer) + })); + changed_end = Some( + changed_end.map_or(buffer_range.end, |end| *end.max(&buffer_range.end, buffer)), + ); + base_start = base_start.min(base_range.start); + base_end = base_end.max(base_range.end); + }; + for hunk in hunks { + let preceding = old.slice(&hunk.buffer_range.start, Bias::Left); + new_pending.append(preceding, buffer); + + // Drop any overlapping or adjacent existing pending hunks, folding + // them into the changed range so that views repaint their full + // extent (a replaced hunk can be wider than its replacement). + while let Some(old_hunk) = old.item() { + if old_hunk + .buffer_range + .start + .cmp(&hunk.buffer_range.end, buffer) + .is_gt() + { + break; + } + extend_changed_range(&old_hunk.buffer_range, &old_hunk.diff_base_byte_range); + old.next(); + } + + extend_changed_range(&hunk.buffer_range, &hunk.diff_base_byte_range); + new_pending.push(hunk.clone(), buffer); + } + new_pending.append(old.suffix(), buffer); + drop(old); + diff_snapshot.pending_hunks = new_pending; + + if let (Some(start), Some(end)) = (changed_start, changed_end) { + let changed_range = Some(start..end); cx.emit(BufferDiffEvent::DiffChanged(DiffChanged { changed_range: changed_range.clone(), - base_text_changed_range: base_text_range, + base_text_changed_range: Some(base_start..base_end), extended_range: changed_range, base_text_changed: false, })); } } + /// Optimistically marks every stageable (resp. unstageable) hunk in this diff + /// as staging (resp. unstaging). Used by whole-file staging from the git + /// panel, where the actual index change is performed by `git add`/`reset` + /// rather than the optimistic index patch. + pub fn mark_all_hunks_pending( + &mut self, + stage: bool, + buffer: &text::BufferSnapshot, + cx: &mut Context, + ) { + let sense = PendingSense::SetSecondaryStatus { stage }; + let version = buffer.version().clone(); + let hunks = self + .snapshot(cx) + .hunks_intersecting_range(Anchor::min_max_range_for_buffer(buffer.remote_id()), buffer) + .filter(|hunk| { + !((stage && hunk.secondary_status == DiffHunkSecondaryStatus::NoSecondaryHunk) + || (!stage + && hunk.secondary_status == DiffHunkSecondaryStatus::HasSecondaryHunk)) + }) + .map(|hunk| { + PendingHunk::new( + hunk.buffer_range, + hunk.diff_base_byte_range, + version.clone(), + sense, + ) + }) + .collect::>(); + self.set_pending_hunks(&hunks, buffer, cx); + } + + pub fn suppress_all_hunks_pending( + &mut self, + buffer: &text::BufferSnapshot, + cx: &mut Context, + ) { + let version = buffer.version().clone(); + let hunks = self + .snapshot(cx) + .raw_hunks_intersecting_range( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + buffer, + ) + .map(|hunk| { + PendingHunk::new( + hunk.buffer_range, + hunk.diff_base_byte_range, + version.clone(), + PendingSense::Suppress, + ) + }) + .collect::>(); + self.set_pending_hunks(&hunks, buffer, cx); + } + + /// Computes the index-text edits for unstaging the given staged (HEAD-vs-index) + /// hunks. `index_buffer` is this diff's main buffer (the index text). The + /// returned edits are in index coordinates. + pub fn unstage_staged_hunks( + &self, + hunks: &[DiffHunk], + index_buffer: &text::BufferSnapshot, + ) -> Option, Arc)>> { + let Some(diff_snapshot) = self.diff_snapshot.as_ref() else { + return Some(Vec::new()); + }; + // With no HEAD, the whole file is one staged addition; unstaging it + // removes the file from the index entirely. + if !diff_snapshot.base_text_exists { + return None; + } + let head_text = diff_snapshot.base_text.as_rope(); + let mut edits = hunks + .iter() + .map(|hunk| { + let index_range = hunk.buffer_range.to_offset(index_buffer); + let replacement_text: Arc = Arc::from( + head_text + .chunks_in_range(hunk.diff_base_byte_range.clone()) + .collect::(), + ); + (index_range, replacement_text) + }) + .collect::>(); + edits.sort_by_key(|(range, _)| range.start); + Some(edits) + } + + #[cfg(any(test, feature = "test-support"))] pub fn stage_or_unstage_hunks( &mut self, stage: bool, @@ -1621,74 +1856,40 @@ impl BufferDiff { cx: &mut Context, ) -> Option { let secondary_diff = self.secondary_diff.clone()?; - let diff_snapshot = self.diff_snapshot.as_mut()?; let unstaged_diff_snapshot = secondary_diff.read_with(cx, |secondary_diff, _cx| { secondary_diff.diff_snapshot.clone() })?; - let new_index_text = diff_snapshot.stage_or_unstage_hunks_impl( + let diff_snapshot = self.diff_snapshot.clone()?; + let (edits, pending) = diff_snapshot.compute_uncommitted_index_edits( &unstaged_diff_snapshot, stage, hunks, buffer, file_exists, ); - - cx.emit(BufferDiffEvent::HunksStagedOrUnstaged( - new_index_text.clone(), - )); - if let Some((first, last)) = hunks.first().zip(hunks.last()) { - let changed_range = Some(first.buffer_range.start..last.buffer_range.end); - let base_text_changed_range = - Some(first.diff_base_byte_range.start..last.diff_base_byte_range.end); - cx.emit(BufferDiffEvent::DiffChanged(DiffChanged { - changed_range: changed_range.clone(), - base_text_changed_range, - extended_range: changed_range, - base_text_changed: false, - })); - } - new_index_text + self.set_pending_hunks(&pending, buffer, cx); + edits.map(|edits| { + let mut index_text = unstaged_diff_snapshot.base_text.as_rope().clone(); + for (old_range, replacement_text) in edits.iter().rev() { + index_text.replace(old_range.clone(), replacement_text); + } + index_text + }) } + #[cfg(any(test, feature = "test-support"))] pub fn stage_or_unstage_all_hunks( &mut self, stage: bool, buffer: &text::BufferSnapshot, file_exists: bool, cx: &mut Context, - ) { + ) -> Option { let hunks = self .snapshot(cx) .hunks_intersecting_range(Anchor::min_max_range_for_buffer(buffer.remote_id()), buffer) .collect::>(); - let Some(diff_snapshot) = &mut self.diff_snapshot else { - return; - }; - let Some(secondary) = self.secondary_diff.clone() else { - return; - }; - let secondary = secondary.read(cx); - let Some(secondary_snapshot) = &secondary.diff_snapshot else { - return; - }; - diff_snapshot.stage_or_unstage_hunks_impl( - &secondary_snapshot, - stage, - &hunks, - buffer, - file_exists, - ); - if let Some((first, last)) = hunks.first().zip(hunks.last()) { - let changed_range = Some(first.buffer_range.start..last.buffer_range.end); - let base_text_changed_range = - Some(first.diff_base_byte_range.start..last.diff_base_byte_range.end); - cx.emit(BufferDiffEvent::DiffChanged(DiffChanged { - changed_range: changed_range.clone(), - base_text_changed_range, - extended_range: changed_range, - base_text_changed: false, - })); - } + self.stage_or_unstage_hunks(stage, &hunks, buffer, file_exists, cx) } pub fn update_diff( @@ -2837,6 +3038,81 @@ mod tests { }); } + #[gpui::test] + async fn test_set_pending_hunks_change_covers_replaced_hunks(cx: &mut TestAppContext) { + let base_text = " + zero + one + two + three + four + five + " + .unindent(); + let buffer_text = " + ZERO + one + two + THREE + four + FIVE + " + .unindent(); + let buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), buffer_text); + let diff = cx.new(|cx| BufferDiff::new_with_base_text(&base_text, &buffer, cx)); + + // Install a whole-file pending hunk, as the no-HEAD staging paths do. + let version = buffer.version(); + diff.update(cx, |diff, 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 }, + )], + &buffer, + cx, + ) + }); + + let (tx, rx) = mpsc::channel(); + let subscription = + cx.update(|cx| cx.subscribe(&diff, move |_, event, _| tx.send(event.clone()).unwrap())); + + // Replace it with a narrower hunk; the emitted change must still cover + // the whole extent of the replaced hunk. + diff.update(cx, |diff, cx| { + 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(), + version, + PendingSense::Suppress, + )], + &buffer, + cx, + ) + }); + + drop(subscription); + let events = rx.into_iter().collect::>(); + match events.as_slice() { + [ + BufferDiffEvent::DiffChanged(DiffChanged { + changed_range: Some(changed_range), + .. + }), + ] => { + assert_eq!( + changed_range.to_point(&buffer), + Point::zero()..buffer.max_point(), + ); + } + _ => panic!("unexpected events: {:?}", events), + } + } + #[gpui::test] async fn test_buffer_diff_compare(cx: &mut TestAppContext) { let base_text = " diff --git a/crates/editor/src/config.rs b/crates/editor/src/config.rs index 9b5df0b8671..bfda1aae44e 100644 --- a/crates/editor/src/config.rs +++ b/crates/editor/src/config.rs @@ -360,10 +360,6 @@ impl Editor { self.delegate_expand_excerpts = delegate; } - pub(super) fn set_delegate_stage_and_restore(&mut self, delegate: bool) { - self.delegate_stage_and_restore = delegate; - } - pub(super) fn set_on_local_selections_changed( &mut self, callback: Option) + 'static>>, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1d224ad40b4..73a0c3ebfdd 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -106,13 +106,16 @@ pub use element::{ file_status_label_color, render_breadcrumb_text, }; pub use git::blame::BlameRenderer; +pub use git::{ + DiffHunkDelegate, ResolvedDiffHunk, ResolvedDiffHunks, RestoreOnlyDiffHunkDelegate, + RestoreOnlyUnstagedDiffHunkDelegate, UncommittedDiffHunkDelegate, render_diff_hunk_controls, + set_blame_renderer, +}; pub(crate) use git::{DiffHunkKey, StoredReviewComment}; use git::{ - DiffReviewDragState, DiffReviewOverlay, InlineBlamePopover, render_diff_hunk_controls, - update_uncommitted_diff_for_buffer, + DiffReviewDragState, DiffReviewOverlay, InlineBlamePopover, update_uncommitted_diff_for_buffer, }; pub(crate) use git::{DisplayDiffHunk, PhantomDiffReviewIndicator}; -pub use git::{RenderDiffHunkControlsFn, set_blame_renderer}; pub use hover_popover::hover_markdown_style; pub use inlays::Inlay; pub use items::MAX_TAB_TITLE_LEN; @@ -973,7 +976,6 @@ pub struct Editor { offset_content: bool, disable_expand_excerpt_buttons: bool, delegate_expand_excerpts: bool, - delegate_stage_and_restore: bool, delegate_open_excerpts: bool, enable_lsp_data: bool, needs_initial_data_update: bool, @@ -1074,7 +1076,6 @@ pub struct Editor { show_git_blame_inline: bool, show_git_blame_inline_delay_task: Option>, git_blame_inline_enabled: bool, - render_diff_hunk_controls: RenderDiffHunkControlsFn, buffer_serialization: Option, show_selection_menu: Option, blame: Option>, @@ -1129,12 +1130,7 @@ pub struct Editor { addons: TypeIdHashMap>, registered_buffers: HashMap, load_diff_task: Option>>, - /// Whether we are temporarily displaying a diff other than git's - temporary_diff_override: bool, - /// Whether to render all diff hunks with the "unstaged" appearance, - /// regardless of whether they have a secondary hunk. Used by views whose - /// diffs aren't related to the git index (e.g. agent diffs). - render_diff_hunks_as_unstaged: bool, + diff_hunk_delegate: Option>, selection_mark_mode: bool, toggle_fold_multiple_buffers: Task<()>, _scroll_cursor_center_top_bottom_task: Task<()>, @@ -2285,7 +2281,6 @@ impl Editor { use_relative_line_numbers: None, disable_expand_excerpt_buttons: !full_mode, delegate_expand_excerpts: false, - delegate_stage_and_restore: false, delegate_open_excerpts: false, enable_lsp_data: full_mode, needs_initial_data_update: full_mode, @@ -2390,7 +2385,6 @@ impl Editor { show_git_blame_inline_delay_task: None, git_blame_inline_enabled: full_mode && ProjectSettings::get_global(cx).git.inline_blame.enabled, - render_diff_hunk_controls: Arc::new(render_diff_hunk_controls), buffer_serialization: is_minimap.not().then(|| { BufferSerialization::new( ProjectSettings::get_global(cx) @@ -2460,8 +2454,7 @@ impl Editor { serialize_folds: Task::ready(()), text_style_refinement: None, load_diff_task: load_uncommitted_diff, - temporary_diff_override: false, - render_diff_hunks_as_unstaged: false, + diff_hunk_delegate: None, minimap: None, change_list: ChangeList::new(), mode, @@ -11782,17 +11775,10 @@ pub enum EditorEvent { lines: u32, direction: ExpandExcerptDirection, }, - StageOrUnstageRequested { - stage: bool, - hunks: Vec, - }, OpenExcerptsRequested { selections_by_buffer: HashMap>, Option)>, split: bool, }, - RestoreRequested { - hunks: Vec, - }, /// Emitted when an underlying buffer changes, including edits made through another editor. BufferEdited, /// Emitted when this editor creates, undoes, or redoes an edit transaction. diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index e5836446cee..a82b3ea4488 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -4624,7 +4624,7 @@ impl EditorElement { window: &mut Window, cx: &mut App, ) -> (Vec, Vec<(DisplayRow, Bounds)>) { - let render_diff_hunk_controls = editor.read(cx).render_diff_hunk_controls.clone(); + let diff_hunk_delegate = editor.read(cx).diff_hunk_delegate(); let hovered_diff_hunk_row = editor.read(cx).hovered_diff_hunk_row; let sticky_top = text_hitbox.bounds.top() + sticky_header_height; @@ -4696,7 +4696,7 @@ impl EditorElement { sticky_top.min(max_y) }; - let mut element = render_diff_hunk_controls( + let mut element = diff_hunk_delegate.render_hunk_controls( display_row_range.start.0, status, multi_buffer_range.clone(), @@ -6548,8 +6548,11 @@ impl EditorElement { } fn diff_hunk_hollow(&self, status: DiffHunkStatus, cx: &mut App) -> bool { - let unstaged = - self.editor.read(cx).render_diff_hunks_as_unstaged || status.has_secondary_hunk(); + let unstaged = !self + .editor + .read(cx) + .diff_hunk_delegate() + .render_hunk_as_staged(&status, cx); let unstaged_hollow = matches!( ProjectSettings::get_global(cx).git.hunk_style, GitHunkStyleSetting::UnstagedHollow @@ -9296,7 +9299,7 @@ impl Element for EditorElement { }; let (diff_hunk_controls, diff_hunk_control_bounds) = - if is_read_only && !self.editor.read(cx).delegate_stage_and_restore { + if is_read_only && self.editor.read(cx).diff_hunk_delegate.is_none() { (vec![], vec![]) } else { self.layout_diff_hunk_controls( diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index 6bb94a892dc..d9871fa2cb5 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -2,20 +2,242 @@ pub(super) mod blame; use super::*; use ::git::{Restore, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus}; -use buffer_diff::DiffHunkStatus; +use buffer_diff::{BufferDiff, DiffHunkStatus, DiffHunkStatusKind}; -pub type RenderDiffHunkControlsFn = Arc< - dyn Fn( - u32, - &DiffHunkStatus, - Range, - bool, - Pixels, - &Entity, - &mut Window, - &mut App, - ) -> AnyElement, ->; +#[derive(Clone)] +pub struct ResolvedDiffHunk { + pub buffer_range: Range, + pub diff_base_byte_range: Range, + pub status: DiffHunkStatus, +} + +#[derive(Clone)] +pub struct ResolvedDiffHunks { + pub diff: Entity, + pub buffer_id: BufferId, + pub buffer: Option>, + pub hunks: Vec, +} + +pub trait DiffHunkDelegate { + fn toggle( + &self, + hunks: Vec, + editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ); + + fn stage_or_unstage( + &self, + stage: bool, + hunks: Vec, + editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ); + + fn restore( + &self, + hunks: Vec, + editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + if hunks.is_empty() || editor.read_only(cx) { + return; + } + self.stage_or_unstage(false, hunks.clone(), editor, window, cx); + editor.transact(window, cx, |editor, window, cx| { + editor.restore_diff_hunks(hunks, cx); + let selections = editor + .selections + .all::(&editor.display_snapshot(cx)); + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selections_state| { + selections_state.select(selections); + }, + ); + }); + } + + fn render_hunk_controls( + &self, + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + is_created_file: bool, + line_height: Pixels, + editor: &Entity, + window: &mut Window, + cx: &mut App, + ) -> AnyElement; + + fn render_hunk_as_staged(&self, status: &DiffHunkStatus, _cx: &App) -> bool { + !status.has_secondary_hunk() + } +} + +pub struct UncommittedDiffHunkDelegate; + +impl DiffHunkDelegate for UncommittedDiffHunkDelegate { + fn toggle( + &self, + hunks: Vec, + editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + let stage = hunks + .iter() + .flat_map(|hunks| hunks.hunks.iter()) + .any(|hunk| hunk.status.has_secondary_hunk()); + self.stage_or_unstage(stage, hunks, editor, window, cx); + } + + fn stage_or_unstage( + &self, + stage: bool, + hunks: Vec, + editor: &mut Editor, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(project) = editor.project() else { + return; + }; + for hunks in hunks { + let Some(buffer) = hunks.buffer else { + continue; + }; + let ranges = hunks + .hunks + .into_iter() + .map(|hunk| hunk.buffer_range) + .collect::>(); + if ranges.is_empty() { + continue; + } + let secondary_diff = hunks.diff.read(cx).secondary_diff(); + project + .update(cx, |project, cx| { + if stage { + let Some(secondary_diff) = secondary_diff else { + return Err(anyhow::anyhow!("diff has no unstaged secondary")); + }; + project.stage_hunks(buffer, secondary_diff, ranges, cx) + } else { + project.unstage_uncommitted_hunks(buffer, hunks.diff, ranges, cx) + } + }) + .log_err(); + } + } + + fn render_hunk_controls( + &self, + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + is_created_file: bool, + line_height: Pixels, + editor: &Entity, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + render_diff_hunk_controls( + row, + status, + hunk_range, + is_created_file, + line_height, + editor, + window, + cx, + ) + } +} + +pub struct RestoreOnlyDiffHunkDelegate; + +impl DiffHunkDelegate for RestoreOnlyDiffHunkDelegate { + fn toggle( + &self, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn stage_or_unstage( + &self, + _stage: bool, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn render_hunk_controls( + &self, + _row: u32, + _status: &DiffHunkStatus, + _hunk_range: Range, + _is_created_file: bool, + _line_height: Pixels, + _editor: &Entity, + _window: &mut Window, + _cx: &mut App, + ) -> AnyElement { + gpui::Empty.into_any_element() + } +} + +pub struct RestoreOnlyUnstagedDiffHunkDelegate; + +impl DiffHunkDelegate for RestoreOnlyUnstagedDiffHunkDelegate { + fn toggle( + &self, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn stage_or_unstage( + &self, + _stage: bool, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn render_hunk_controls( + &self, + _row: u32, + _status: &DiffHunkStatus, + _hunk_range: Range, + _is_created_file: bool, + _line_height: Pixels, + _editor: &Entity, + _window: &mut Window, + _cx: &mut App, + ) -> AnyElement { + gpui::Empty.into_any_element() + } + + fn render_hunk_as_staged(&self, _status: &DiffHunkStatus, _cx: &App) -> bool { + false + } +} #[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum DisplayDiffHunk { @@ -166,24 +388,115 @@ impl Editor { }) } - pub fn set_render_diff_hunk_controls( - &mut self, - render_diff_hunk_controls: RenderDiffHunkControlsFn, - cx: &mut Context, - ) { - self.render_diff_hunk_controls = render_diff_hunk_controls; - cx.notify(); + fn resolve_diff_hunks( + &self, + hunks: Vec, + cx: &App, + ) -> Vec { + let multibuffer = self.buffer().read(cx); + let chunk_by = hunks.into_iter().chunk_by(|hunk| hunk.buffer_id); + let mut resolved = Vec::new(); + + for (source_buffer_id, hunks) in &chunk_by { + let Some(diff) = multibuffer.diff_for(source_buffer_id) else { + continue; + }; + let diff_snapshot = diff.read(cx).snapshot(cx); + let main_buffer_id = diff_snapshot.buffer_id(); + let buffer = multibuffer.buffer(main_buffer_id).or_else(|| { + self.project + .as_ref() + .and_then(|project| project.read(cx).buffer_for_id(main_buffer_id, cx)) + }); + let mut resolved_hunks = Vec::new(); + + for hunk in hunks { + 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, + }); + } else { + let diff_base_byte_range = + hunk.diff_base_byte_range.start.0..hunk.diff_base_byte_range.end.0; + let Some(hunk) = diff_snapshot + .hunks_intersecting_base_text_range( + diff_base_byte_range.clone(), + diff_snapshot.buffer_snapshot(), + ) + .find(|hunk| hunk.diff_base_byte_range == diff_base_byte_range) + else { + continue; + }; + let kind = if hunk.buffer_range.start == hunk.buffer_range.end { + DiffHunkStatusKind::Deleted + } else if hunk.diff_base_byte_range.is_empty() { + DiffHunkStatusKind::Added + } else { + DiffHunkStatusKind::Modified + }; + resolved_hunks.push(ResolvedDiffHunk { + buffer_range: hunk.buffer_range, + diff_base_byte_range: hunk.diff_base_byte_range, + status: DiffHunkStatus { + kind, + secondary: hunk.secondary_status, + }, + }); + } + } + + if !resolved_hunks.is_empty() { + resolved.push(ResolvedDiffHunks { + diff, + buffer_id: main_buffer_id, + buffer, + hunks: resolved_hunks, + }); + } + } + + resolved } - /// Make all diff hunks render with the "unstaged" appearance, regardless - /// of whether they have a secondary hunk. Intended for views whose diffs - /// aren't related to the git index (e.g. agent diffs). - pub fn set_render_diff_hunks_as_unstaged( + pub fn diff_hunk_delegate(&self) -> Arc { + self.diff_hunk_delegate + .clone() + .unwrap_or_else(|| Arc::new(UncommittedDiffHunkDelegate)) + } + + pub fn set_diff_hunk_delegate( &mut self, - render_as_unstaged: bool, + delegate: Option>, cx: &mut Context, ) { - self.render_diff_hunks_as_unstaged = render_as_unstaged; + let had_delegate = self.diff_hunk_delegate.is_some(); + let has_delegate = delegate.is_some(); + self.diff_hunk_delegate = delegate; + + if !had_delegate && has_delegate { + self.load_diff_task.take(); + } else if had_delegate && !has_delegate { + self.buffer.update(cx, |buffer, cx| { + buffer.set_all_diff_hunks_collapsed(cx); + }); + + if let Some(project) = self.project.clone() { + self.load_diff_task = Some( + update_uncommitted_diff_for_buffer( + cx.entity(), + &project, + self.buffer.read(cx).all_buffers(), + self.buffer.clone(), + cx, + ) + .shared(), + ); + } + } + cx.notify(); } @@ -265,33 +578,6 @@ impl Editor { cx.notify(); } - pub fn start_temporary_diff_override(&mut self) { - self.load_diff_task.take(); - self.temporary_diff_override = true; - } - - pub fn end_temporary_diff_override(&mut self, cx: &mut Context) { - self.temporary_diff_override = false; - self.render_diff_hunks_as_unstaged = false; - self.set_render_diff_hunk_controls(Arc::new(render_diff_hunk_controls), cx); - self.buffer.update(cx, |buffer, cx| { - buffer.set_all_diff_hunks_collapsed(cx); - }); - - if let Some(project) = self.project.clone() { - self.load_diff_task = Some( - update_uncommitted_diff_for_buffer( - cx.entity(), - &project, - self.buffer.read(cx).all_buffers(), - self.buffer.clone(), - cx, - ) - .shared(), - ); - } - } - /// Hides the inline blame popover element, in case it's already visible, or /// interrupts the task meant to show it, in case the task is running. /// @@ -764,31 +1050,48 @@ impl Editor { ); } - pub(super) fn restore_diff_hunks(&self, hunks: Vec, cx: &mut App) { - let mut revert_changes = HashMap::default(); - let chunk_by = hunks.into_iter().chunk_by(|hunk| hunk.buffer_id); - for (buffer_id, hunks) in &chunk_by { - let hunks = hunks.collect::>(); - for hunk in &hunks { - self.prepare_restore_change(&mut revert_changes, hunk, cx); - } - self.do_stage_or_unstage(false, buffer_id, hunks.into_iter(), cx); - } - if !revert_changes.is_empty() { - self.buffer().update(cx, |multi_buffer, cx| { - for (buffer_id, changes) in revert_changes { - if let Some(buffer) = multi_buffer.buffer(buffer_id) { - buffer.update(cx, |buffer, cx| { - buffer.edit( - changes - .into_iter() - .map(|(range, text)| (range, text.to_string())), - None, - cx, - ); - }); + pub(super) fn restore_diff_hunks( + &mut self, + hunks: Vec, + cx: &mut Context, + ) { + let mut revert_changes = Vec::new(); + for hunks in hunks { + let Some(buffer) = hunks.buffer else { + continue; + }; + let diff_snapshot = hunks.diff.read(cx).snapshot(cx); + let changes = hunks + .hunks + .into_iter() + .filter_map(|hunk| { + if hunk.diff_base_byte_range == (0..0) + && hunk.buffer_range.start.is_min() + && hunk.buffer_range.end.is_max() + { + return None; } - } + let original_text = diff_snapshot + .base_text() + .as_rope() + .slice(hunk.diff_base_byte_range.start..hunk.diff_base_byte_range.end); + Some((hunk.buffer_range, original_text)) + }) + .collect::>(); + if !changes.is_empty() { + revert_changes.push((buffer, changes)); + } + } + + for (buffer, changes) in revert_changes { + buffer.update(cx, |buffer, cx| { + buffer.edit( + changes + .into_iter() + .map(|(range, text)| (range, text.to_string())), + None, + cx, + ); }); } } @@ -1421,18 +1724,25 @@ impl Editor { pub(super) fn toggle_staged_selected_diff_hunks( &mut self, _: &::git::ToggleStaged, - _: &mut Window, + window: &mut Window, cx: &mut Context, ) { - let snapshot = self.buffer.read(cx).snapshot(cx); let ranges: Vec<_> = self .selections .disjoint_anchors() .iter() .map(|s| s.range()) .collect(); - let stage = self.has_stageable_diff_hunks_in_ranges(&ranges, &snapshot); - self.stage_or_unstage_diff_hunks(stage, ranges, cx); + 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( @@ -1453,42 +1763,47 @@ impl Editor { self.do_stage_or_unstage_and_next(false, window, cx); } - pub(super) fn do_stage_or_unstage( - &self, + pub fn apply_toggle( + &mut self, + hunks: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let hunks = self.resolve_diff_hunks(hunks, cx); + if hunks.is_empty() { + return; + } + let delegate = self.diff_hunk_delegate(); + delegate.toggle(hunks, self, window, cx); + } + + pub fn apply_stage_or_unstage( + &mut self, stage: bool, - buffer_id: BufferId, - hunks: impl Iterator, - cx: &mut App, - ) -> Option<()> { - let project = self.project()?; - let buffer = project.read(cx).buffer_for_id(buffer_id, cx)?; - let diff = self.buffer.read(cx).diff_for(buffer_id)?; - let buffer_snapshot = buffer.read(cx).snapshot(); - let file_exists = buffer_snapshot - .file() - .is_some_and(|file| file.disk_state().exists()); - diff.update(cx, |diff, cx| { - diff.stage_or_unstage_hunks( - stage, - &hunks - .map(|hunk| buffer_diff::DiffHunk { - buffer_range: hunk.buffer_range, - // We don't need to pass in word diffs here because they're only used for rendering and - // this function changes internal state - base_word_diffs: Vec::default(), - buffer_word_diffs: Vec::default(), - diff_base_byte_range: hunk.diff_base_byte_range.start.0 - ..hunk.diff_base_byte_range.end.0, - secondary_status: hunk.status.secondary, - range: Point::zero()..Point::zero(), // unused - }) - .collect::>(), - &buffer_snapshot, - file_exists, - cx, - ) - }); - None + hunks: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let hunks = self.resolve_diff_hunks(hunks, cx); + if hunks.is_empty() { + return; + } + let delegate = self.diff_hunk_delegate(); + delegate.stage_or_unstage(stage, hunks, self, window, cx); + } + + pub fn apply_restore( + &mut self, + hunks: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let hunks = self.resolve_diff_hunks(hunks, cx); + if hunks.is_empty() { + return; + } + let delegate = self.diff_hunk_delegate(); + delegate.restore(hunks, self, window, cx); } pub(super) fn clear_expanded_diff_hunks(&mut self, cx: &mut Context) -> bool { @@ -1776,31 +2091,20 @@ impl Editor { } } - fn stage_or_unstage_diff_hunks( + pub fn stage_or_unstage_diff_hunks( &mut self, stage: bool, ranges: Vec>, + window: &mut Window, cx: &mut Context, ) { - if self.delegate_stage_and_restore { - let snapshot = self.buffer.read(cx).snapshot(cx); - let hunks: Vec<_> = self.diff_hunks_in_ranges(&ranges, &snapshot).collect(); - if !hunks.is_empty() { - cx.emit(EditorEvent::StageOrUnstageRequested { stage, hunks }); - } - return; - } let task = self.save_buffers_for_ranges_if_needed(&ranges, cx); - cx.spawn(async move |this, cx| { + cx.spawn_in(window, async move |this, cx| { task.await?; - this.update(cx, |this, cx| { + this.update_in(cx, |this, window, cx| { let snapshot = this.buffer.read(cx).snapshot(cx); - let chunk_by = this - .diff_hunks_in_ranges(&ranges, &snapshot) - .chunk_by(|hunk| hunk.buffer_id); - for (buffer_id, hunks) in &chunk_by { - this.do_stage_or_unstage(stage, buffer_id, hunks, cx); - } + let hunks = this.diff_hunks_in_ranges(&ranges, &snapshot).collect(); + this.apply_stage_or_unstage(stage, hunks, window, cx); }) }) .detach_and_log_err(cx); @@ -1847,66 +2151,8 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if self.delegate_stage_and_restore { - let hunks = self.snapshot(window, cx).hunks_for_ranges(ranges); - if !hunks.is_empty() { - cx.emit(EditorEvent::RestoreRequested { hunks }); - } - return; - } let hunks = self.snapshot(window, cx).hunks_for_ranges(ranges); - self.transact(window, cx, |editor, window, cx| { - editor.restore_diff_hunks(hunks, cx); - let selections = editor - .selections - .all::(&editor.display_snapshot(cx)); - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select(selections); - }); - }); - } - - fn has_stageable_diff_hunks_in_ranges( - &self, - ranges: &[Range], - snapshot: &MultiBufferSnapshot, - ) -> bool { - let mut hunks = self.diff_hunks_in_ranges(ranges, snapshot); - hunks.any(|hunk| hunk.status().has_secondary_hunk()) - } - - fn prepare_restore_change( - &self, - revert_changes: &mut HashMap, Rope)>>, - hunk: &MultiBufferDiffHunk, - cx: &mut App, - ) -> Option<()> { - if hunk.is_created_file() { - return None; - } - let multi_buffer = self.buffer.read(cx); - let multi_buffer_snapshot = multi_buffer.snapshot(cx); - let diff_snapshot = multi_buffer_snapshot.diff_for_buffer_id(hunk.buffer_id)?; - let original_text = diff_snapshot - .base_text() - .as_rope() - .slice(hunk.diff_base_byte_range.start.0..hunk.diff_base_byte_range.end.0); - let buffer = multi_buffer.buffer(hunk.buffer_id)?; - let buffer = buffer.read(cx); - let buffer_snapshot = buffer.snapshot(); - let buffer_revert_changes = revert_changes.entry(buffer.remote_id()).or_default(); - if let Err(i) = buffer_revert_changes.binary_search_by(|probe| { - probe - .0 - .start - .cmp(&hunk.buffer_range.start, &buffer_snapshot) - .then(probe.0.end.cmp(&hunk.buffer_range.end, &buffer_snapshot)) - }) { - buffer_revert_changes.insert(i, (hunk.buffer_range.clone(), original_text)); - Some(()) - } else { - None - } + self.apply_restore(hunks, window, cx); } fn save_buffers_for_ranges_if_needed( @@ -1949,11 +2195,11 @@ impl Editor { let ranges = self.selections.disjoint_anchor_ranges().collect::>(); if ranges.iter().any(|range| range.start != range.end) { - self.stage_or_unstage_diff_hunks(stage, ranges, cx); + self.stage_or_unstage_diff_hunks(stage, ranges, window, cx); return; } - self.stage_or_unstage_diff_hunks(stage, ranges, cx); + self.stage_or_unstage_diff_hunks(stage, ranges, window, cx); let all_diff_hunks_expanded = self.buffer().read(cx).all_diff_hunks_expanded(); let wrap_around = !all_diff_hunks_expanded; @@ -2678,7 +2924,7 @@ pub fn set_blame_renderer(renderer: impl BlameRenderer + 'static, cx: &mut App) cx.set_global(GlobalBlameRenderer(Arc::new(renderer))); } -pub(super) fn render_diff_hunk_controls( +pub fn render_diff_hunk_controls( row: u32, status: &DiffHunkStatus, hunk_range: Range, @@ -2723,11 +2969,12 @@ pub(super) fn render_diff_hunk_controls( }) .on_click({ let editor = editor.clone(); - move |_event, _window, cx| { + move |_event, window, cx| { editor.update(cx, |editor, cx| { editor.stage_or_unstage_diff_hunks( true, vec![hunk_range.start..hunk_range.start], + window, cx, ); }); @@ -2749,11 +2996,12 @@ pub(super) fn render_diff_hunk_controls( }) .on_click({ let editor = editor.clone(); - move |_event, _window, cx| { + move |_event, window, cx| { editor.update(cx, |editor, cx| { editor.stage_or_unstage_diff_hunks( false, vec![hunk_range.start..hunk_range.start], + window, cx, ); }); @@ -2880,7 +3128,7 @@ pub(super) fn update_uncommitted_diff_for_buffer( }); cx.spawn(async move |cx| { let diffs = future::join_all(tasks).await; - if editor.read_with(cx, |editor, _cx| editor.temporary_diff_override) { + if editor.read_with(cx, |editor, _cx| editor.diff_hunk_delegate.is_some()) { return; } diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index dd34391e195..93a6f96a443 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -3,19 +3,19 @@ use std::{ sync::Arc, }; -use buffer_diff::{BufferDiff, BufferDiffSnapshot}; +use buffer_diff::{BufferDiff, BufferDiffSnapshot, DiffHunkStatus}; use collections::HashMap; use fs::Fs; use gpui::{ - Action, Entity, EventEmitter, Focusable, Font, Pixels, Subscription, WeakEntity, canvas, - prelude::*, + Action, AnyElement, Entity, EventEmitter, Focusable, Font, Pixels, Subscription, WeakEntity, + canvas, prelude::*, }; -use itertools::Itertools; + use language::{Buffer, Capability, HighlightedText}; use multi_buffer::{ Anchor, AnchorRangeExt as _, BufferOffset, ExcerptRange, ExpandExcerptDirection, MultiBuffer, - MultiBufferDiffHunk, MultiBufferPoint, MultiBufferSnapshot, PathKey, + MultiBufferPoint, MultiBufferSnapshot, PathKey, }; use project::Project; use rope::Point; @@ -23,6 +23,7 @@ use settings::{DiffViewStyle, SeedQuerySetting, Settings, SettingsStore, update_ use text::{Bias, BufferId, OffsetRangeExt as _, Patch, ToPoint as _}; use ui::{Toggleable as _, Tooltip, prelude::*, render_modifiers}; +use util::ResultExt as _; use crate::{ display_map::CompanionExcerptPatch, @@ -36,7 +37,8 @@ use workspace::{ }; use crate::{ - Autoscroll, Editor, EditorEvent, EditorSettings, RenderDiffHunkControlsFn, ToggleSoftWrap, + Autoscroll, DiffHunkDelegate, Editor, EditorEvent, EditorSettings, ResolvedDiffHunks, + ToggleSoftWrap, UncommittedDiffHunkDelegate, actions::{DisableBreakpoint, EditLogBreakpoint, EnableBreakpoint, ToggleBreakpoint}, display_map::Companion, }; @@ -149,32 +151,97 @@ fn translate_lhs_selections_to_rhs( translated } -fn translate_lhs_hunks_to_rhs( - lhs_hunks: &[MultiBufferDiffHunk], - splittable: &SplittableEditor, - cx: &App, -) -> Vec { - let Some(lhs) = &splittable.lhs else { - return vec![]; - }; - let lhs_snapshot = lhs.multibuffer.read(cx).snapshot(cx); - let rhs_snapshot = splittable.rhs_multibuffer.read(cx).snapshot(cx); - let rhs_hunks: Vec = rhs_snapshot.diff_hunks().collect(); +struct SplitLhsDiffHunkDelegate { + splittable: WeakEntity, +} - let mut translated = Vec::new(); - for lhs_hunk in lhs_hunks { - let Some(diff) = lhs_snapshot.diff_for_buffer_id(lhs_hunk.buffer_id) else { - continue; - }; - let rhs_buffer_id = diff.buffer_id(); - if let Some(rhs_hunk) = rhs_hunks.iter().find(|rhs_hunk| { - rhs_hunk.buffer_id == rhs_buffer_id - && rhs_hunk.diff_base_byte_range == lhs_hunk.diff_base_byte_range - }) { - translated.push(rhs_hunk.clone()); - } +impl DiffHunkDelegate for SplitLhsDiffHunkDelegate { + fn toggle( + &self, + hunks: Vec, + _editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + self.splittable + .update(cx, |splittable, cx| { + splittable.rhs_editor.update(cx, |editor, cx| { + let delegate = editor.diff_hunk_delegate(); + delegate.toggle(hunks, editor, window, cx); + }); + }) + .log_err(); + } + + fn stage_or_unstage( + &self, + stage: bool, + hunks: Vec, + _editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + self.splittable + .update(cx, |splittable, cx| { + splittable.rhs_editor.update(cx, |editor, cx| { + let delegate = editor.diff_hunk_delegate(); + delegate.stage_or_unstage(stage, hunks, editor, window, cx); + }); + }) + .log_err(); + } + + fn restore( + &self, + hunks: Vec, + _editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + self.splittable + .update(cx, |splittable, cx| { + splittable.rhs_editor.update(cx, |editor, cx| { + let delegate = editor.diff_hunk_delegate(); + delegate.restore(hunks, editor, window, cx); + }); + }) + .log_err(); + } + + fn render_hunk_controls( + &self, + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + is_created_file: bool, + line_height: Pixels, + editor: &Entity, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + let Some(splittable) = self.splittable.upgrade() else { + return gpui::Empty.into_any_element(); + }; + let delegate = splittable.read(cx).rhs_editor.read(cx).diff_hunk_delegate(); + delegate.render_hunk_controls( + row, + status, + hunk_range, + is_created_file, + line_height, + editor, + window, + cx, + ) + } + + fn render_hunk_as_staged(&self, status: &DiffHunkStatus, cx: &App) -> bool { + let Some(splittable) = self.splittable.upgrade() else { + return false; + }; + let delegate = splittable.read(cx).rhs_editor.read(cx).diff_hunk_delegate(); + delegate.render_hunk_as_staged(status, cx) } - translated } fn patches_for_range( @@ -569,28 +636,13 @@ impl SplittableEditor { self.lhs.is_some() } - pub fn set_render_diff_hunk_controls( + pub fn set_diff_hunk_delegate( &self, - render_diff_hunk_controls: RenderDiffHunkControlsFn, + delegate: Option>, cx: &mut Context, ) { - self.update_editors(cx, |editor, cx| { - editor.set_render_diff_hunk_controls(render_diff_hunk_controls.clone(), cx); - }); - } - - pub fn disable_diff_hunk_controls(&self, cx: &mut Context) { - let empty_controls = Arc::new(|_, _: &_, _, _, _, _: &_, _: &mut _, _: &mut _| { - gpui::Empty.into_any_element() - }); - self.update_editors(cx, |editor, cx| { - editor.set_render_diff_hunk_controls(empty_controls.clone(), cx); - }); - } - - pub fn set_render_diff_hunks_as_unstaged(&self, cx: &mut Context) { - self.update_editors(cx, |editor, cx| { - editor.set_render_diff_hunks_as_unstaged(true, cx); + self.rhs_editor.update(cx, |editor, cx| { + editor.set_diff_hunk_delegate(delegate, cx); }); } @@ -631,7 +683,7 @@ impl SplittableEditor { editor.disable_inline_diagnostics(); editor.disable_mouse_wheel_zoom(); editor.set_minimap_visibility(crate::MinimapVisibility::Disabled, window, cx); - editor.start_temporary_diff_override(); + editor.set_diff_hunk_delegate(Some(Arc::new(UncommittedDiffHunkDelegate)), cx); editor }); // TODO(split-diff) we might want to tag editor events with whether they came from rhs/lhs @@ -729,15 +781,16 @@ impl SplittableEditor { multibuffer }); - let render_diff_hunk_controls = self.rhs_editor.read(cx).render_diff_hunk_controls.clone(); - let render_diff_hunks_as_unstaged = self.rhs_editor.read(cx).render_diff_hunks_as_unstaged; + let splittable = cx.weak_entity(); let lhs_editor = cx.new(|cx| { let mut editor = Editor::for_multibuffer(lhs_multibuffer.clone(), Some(project.clone()), window, cx); - editor.set_render_diff_hunks_as_unstaged(render_diff_hunks_as_unstaged, cx); editor.set_number_deleted_lines(true, cx); editor.set_delegate_expand_excerpts(true); - editor.set_delegate_stage_and_restore(true); + editor.set_diff_hunk_delegate( + Some(Arc::new(SplitLhsDiffHunkDelegate { splittable })), + cx, + ); editor.set_delegate_open_excerpts(true); editor.set_show_vertical_scrollbar(false, cx); editor.disable_lsp_data(); @@ -748,10 +801,6 @@ impl SplittableEditor { editor }); - lhs_editor.update(cx, |editor, cx| { - editor.set_render_diff_hunk_controls(render_diff_hunk_controls, cx); - }); - let mut subscriptions = vec![cx.subscribe_in( &lhs_editor, window, @@ -782,30 +831,7 @@ impl SplittableEditor { this.expand_excerpts(rhs_anchors.into_iter(), *lines, *direction, cx); } } - EditorEvent::StageOrUnstageRequested { stage, hunks } => { - if this.lhs.is_some() { - let translated = translate_lhs_hunks_to_rhs(hunks, this, cx); - if !translated.is_empty() { - let stage = *stage; - this.rhs_editor.update(cx, |editor, cx| { - let chunk_by = translated.into_iter().chunk_by(|h| h.buffer_id); - for (buffer_id, hunks) in &chunk_by { - editor.do_stage_or_unstage(stage, buffer_id, hunks, cx); - } - }); - } - } - } - EditorEvent::RestoreRequested { hunks } => { - if this.lhs.is_some() { - let translated = translate_lhs_hunks_to_rhs(hunks, this, cx); - if !translated.is_empty() { - this.rhs_editor.update(cx, |editor, cx| { - editor.restore_diff_hunks(translated, cx); - }); - } - } - } + EditorEvent::OpenExcerptsRequested { selections_by_buffer, split, diff --git a/crates/git_ui/src/branch_diff.rs b/crates/git_ui/src/branch_diff.rs new file mode 100644 index 00000000000..068d8e9af19 --- /dev/null +++ b/crates/git_ui/src/branch_diff.rs @@ -0,0 +1,1199 @@ +use crate::{ + branch_picker, + diff_multibuffer::DiffMultibuffer, + project_diff::{ + self, CompareWithBranch, DeployBranchDiff, ReviewDiff, render_send_review_to_agent_button, + }, +}; +use agent_settings::AgentSettings; +use anyhow::{Context as _, Result, anyhow}; +use editor::{ + Addon, Editor, EditorEvent, RestoreOnlyDiffHunkDelegate, SplittableEditor, + actions::SendReviewToAgent, +}; +use git::{repository::DiffType, status::FileStatus}; +use gpui::{ + Action, AnyElement, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, Render, + SharedString, Subscription, Task, WeakEntity, +}; +use language::{BufferId, Capability}; +use project::{ + Project, ProjectPath, + git_store::{ + Repository, + diff_buffer_list::{self, DiffBase}, + }, +}; +use settings::Settings; +use std::{ + any::{Any, TypeId}, + sync::Arc, +}; +use ui::{DiffStat, Divider, PopoverMenu, Tooltip, prelude::*}; +use workspace::{ + ItemHandle, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, + ToolbarItemView, Workspace, + item::{Item, ItemEvent, SaveOptions, TabContentParams}, + notifications::NotifyTaskExt, + searchable::SearchableItemHandle, +}; +use zed_actions::agent::ReviewBranchDiff; + +/// The workspace item for a branch (merge-base) diff: "Changes since {branch}". +/// It wraps a single [`DiffMultibuffer`] over [`DiffBase::Merge`] and delegates +/// the [`Item`] surface to it. The merge base can be changed in place via the +/// [`BranchDiffToolbar`]'s branch picker, which reloads without reconfiguring +/// the editor (the merge styling is identical for every base ref). +pub struct BranchDiff { + diff: Entity, + project: Entity, + workspace: WeakEntity, + _diff_event_subscription: Subscription, +} + +struct BranchDiffAddon { + branch_diff: Entity, +} + +impl Addon for BranchDiffAddon { + fn to_any(&self) -> &dyn std::any::Any { + self + } + + fn override_status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option { + self.branch_diff + .read(cx) + .status_for_buffer_id(buffer_id, cx) + } +} + +impl BranchDiff { + pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { + workspace.register_action(Self::deploy_branch_diff); + workspace.register_action(Self::compare_with_branch); + workspace::register_serializable_item::(cx); + } + + fn deploy_branch_diff( + workspace: &mut Workspace, + _: &DeployBranchDiff, + window: &mut Window, + cx: &mut Context, + ) { + telemetry::event!("Git Branch Diff Opened"); + let project = workspace.project().clone(); + let Some(intended_repo) = project.read(cx).active_repository(cx) else { + let workspace = cx.entity().downgrade(); + window + .spawn(cx, async |_cx| { + let result: Result<()> = Err(anyhow!("No active repository")); + result + }) + .detach_and_notify_err(workspace, window, cx); + return; + }; + + let default_branch = intended_repo.update(cx, |repo, _| repo.default_branch(true)); + let workspace = cx.entity(); + let workspace_weak = workspace.downgrade(); + window + .spawn(cx, async move |cx| { + let base_ref = default_branch + .await?? + .context("Could not determine default branch")?; + + workspace.update_in(cx, |workspace, window, cx| { + Self::deploy_branch_diff_with_base_ref( + workspace, + project, + intended_repo, + base_ref, + window, + cx, + ); + })?; + + anyhow::Ok(()) + }) + .detach_and_notify_err(workspace_weak, window, cx); + } + + fn compare_with_branch( + workspace: &mut Workspace, + _: &CompareWithBranch, + window: &mut Window, + cx: &mut Context, + ) { + let project = workspace.project().clone(); + let Some(repository) = project.read(cx).active_repository(cx) else { + let workspace = cx.entity().downgrade(); + window + .spawn(cx, async |_cx| { + let result: Result<()> = Err(anyhow!("No active repository")); + result + }) + .detach_and_notify_err(workspace, window, cx); + return; + }; + let selected_branch = workspace.active_item_as::(cx).and_then(|item| { + match item.read(cx).diff_base(cx) { + DiffBase::Merge { base_ref } => Some(base_ref.clone()), + DiffBase::Head | DiffBase::Index | DiffBase::Staged => None, + } + }); + let workspace_handle = workspace.weak_handle(); + let on_select = Arc::new({ + let repository = repository.clone(); + let workspace = workspace_handle.clone(); + move |branch: git::repository::Branch, window: &mut Window, cx: &mut App| { + let base_ref: SharedString = branch.name().to_owned().into(); + workspace + .update(cx, |workspace, cx| { + Self::deploy_branch_diff_with_base_ref( + workspace, + project.clone(), + repository.clone(), + base_ref, + window, + cx, + ); + }) + .ok(); + } + }); + + workspace.toggle_modal(window, cx, |window, cx| { + branch_picker::select_modal( + workspace_handle, + Some(repository), + selected_branch, + on_select, + window, + cx, + ) + }); + } + + fn deploy_branch_diff_with_base_ref( + workspace: &mut Workspace, + project: Entity, + intended_repo: Entity, + base_ref: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + let existing = workspace.items_of_type::(cx).find(|item| { + let item = item.read(cx); + matches!( + item.diff_base(cx), + DiffBase::Merge { base_ref: existing_base_ref } if existing_base_ref == &base_ref + ) + }); + if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + + let needs_switch = existing.read(cx).repo(cx).map_or(true, |current| { + current.read(cx).id != intended_repo.read(cx).id + }); + + if needs_switch { + existing.update(cx, |branch_diff, cx| { + branch_diff.set_repo(Some(intended_repo), cx); + }); + } + + return; + } + + let workspace = cx.entity(); + let workspace_weak = workspace.downgrade(); + window + .spawn(cx, async move |cx| { + let this = cx + .update(|window, cx| { + Self::new_with_branch_base( + project, + workspace.clone(), + base_ref, + intended_repo, + window, + cx, + ) + })? + .await?; + workspace + .update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx); + }) + .ok(); + anyhow::Ok(()) + }) + .detach_and_notify_err(workspace_weak, window, cx); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn new_with_default_branch( + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else { + return Task::ready(Err(anyhow!("No active repository"))); + }; + let main_branch = repo.update(cx, |repo, _| repo.default_branch(true)); + window.spawn(cx, async move |cx| { + let base_ref = main_branch + .await?? + .context("Could not determine default branch")?; + cx.update(|window, cx| { + cx.new(|cx| { + Self::new_with_base_ref(project, workspace, base_ref, Some(repo), window, cx) + }) + }) + }) + } + + pub(crate) fn new_with_branch_base( + project: Entity, + workspace: Entity, + base_ref: SharedString, + repo: Entity, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + window.spawn(cx, async move |cx| { + cx.update(|window, cx| { + cx.new(|cx| { + Self::new_with_base_ref(project, workspace, base_ref, Some(repo), window, cx) + }) + }) + }) + } + + pub(crate) fn new_with_base_ref( + project: Entity, + workspace: Entity, + base_ref: SharedString, + repo: Option>, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let branch_diff = cx.new(|cx| { + let mut branch_diff = diff_buffer_list::DiffBufferList::new( + DiffBase::Merge { base_ref }, + project.clone(), + window, + cx, + ); + if repo.is_some() { + branch_diff.set_repo(repo, cx); + } + branch_diff + }); + let branch_diff_for_addon = branch_diff.clone(); + let diff = cx.new(|cx| { + DiffMultibuffer::new( + branch_diff, + Capability::ReadWrite, + "No changes", + move |editor, cx| { + editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyDiffHunkDelegate)), cx); + editor.rhs_editor().update(cx, move |rhs_editor, _cx| { + rhs_editor.set_read_only(false); + rhs_editor.register_addon(BranchDiffAddon { + branch_diff: branch_diff_for_addon, + }); + }); + }, + project.clone(), + workspace.clone(), + window, + cx, + ) + }); + Self::from_diff(diff, project, workspace, cx) + } + + fn from_diff( + diff: Entity, + project: Entity, + workspace: Entity, + cx: &mut Context, + ) -> Self { + let diff_event_subscription = cx.subscribe(&diff, |_, _, event: &EditorEvent, cx| { + cx.emit(event.clone()) + }); + Self { + diff, + project, + workspace: workspace.downgrade(), + _diff_event_subscription: diff_event_subscription, + } + } + + pub(crate) fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase { + self.diff.read(cx).diff_base(cx) + } + + pub(crate) fn repo(&self, cx: &App) -> Option> { + self.diff.read(cx).repo(cx) + } + + pub(crate) fn set_repo(&mut self, repo: Option>, cx: &mut Context) { + self.diff.update(cx, |diff, cx| diff.set_repo(repo, cx)); + } + + fn set_merge_base(&mut self, base_ref: SharedString, cx: &mut Context) { + self.diff.update(cx, |diff, cx| { + diff.branch_diff().update(cx, |branch_diff, cx| { + branch_diff.set_diff_base(DiffBase::Merge { base_ref }, cx); + }); + }); + } + + fn review_diff(&mut self, _: &ReviewDiff, window: &mut Window, cx: &mut Context) { + let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else { + return; + }; + let Some(repo) = self.repo(cx) else { + return; + }; + + let diff_receiver = repo.update(cx, |repo, cx| { + repo.diff( + DiffType::MergeBase { + base_ref: base_ref.clone(), + }, + cx, + ) + }); + + let workspace = self.workspace.clone(); + window + .spawn(cx, { + let workspace = workspace.clone(); + async move |cx| { + let diff_text = diff_receiver.await??; + + if let Some(workspace) = workspace.upgrade() { + workspace.update_in(cx, |_workspace, window, cx| { + window.dispatch_action( + ReviewBranchDiff { + diff_text: diff_text.into(), + base_ref, + } + .boxed_clone(), + cx, + ); + })?; + } + + anyhow::Ok(()) + } + }) + .detach_and_notify_err(workspace, window, cx); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn editor(&self, cx: &App) -> Entity { + self.diff.read(cx).editor().clone() + } +} + +impl EventEmitter for BranchDiff {} + +impl Focusable for BranchDiff { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.diff.read(cx).focus_handle(cx) + } +} + +impl Item for BranchDiff { + type Event = EditorEvent; + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::GitBranch).color(Color::Muted)) + } + + fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.diff + .update(cx, |diff, cx| diff.deactivated(window, cx)); + } + + fn navigate( + &mut self, + data: Arc, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.diff + .update(cx, |diff, cx| diff.navigate(data, window, cx)) + } + + fn tab_tooltip_text(&self, cx: &App) -> Option { + Some(self.tab_content_text(0, cx)) + } + + fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { + Label::new(self.tab_content_text(0, cx)) + .color(if params.selected { + Color::Default + } else { + Color::Muted + }) + .into_any_element() + } + + fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { + match self.diff_base(cx) { + DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(), + DiffBase::Head | DiffBase::Index | DiffBase::Staged => "Changes".into(), + } + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("Branch Diff Opened") + } + + fn as_searchable(&self, _: &Entity, cx: &App) -> Option> { + Some(Box::new(self.diff.read(cx).editor().clone())) + } + + fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + self.diff.read(cx).for_each_project_item(cx, f); + } + + fn active_project_path(&self, cx: &App) -> Option { + self.diff.read(cx).active_project_path(cx) + } + + fn set_nav_history( + &mut self, + nav_history: ItemNavHistory, + _: &mut Window, + cx: &mut Context, + ) { + self.diff + .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx)); + } + + fn can_split(&self) -> bool { + true + } + + fn clone_on_split( + &self, + _workspace_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> Task>> + where + Self: Sized, + { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(None); + }; + let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else { + return Task::ready(None); + }; + let repo = self.repo(cx); + let project = self.project.clone(); + Task::ready(Some(cx.new(|cx| { + Self::new_with_base_ref(project, workspace, base_ref, repo, window, cx) + }))) + } + + fn is_dirty(&self, cx: &App) -> bool { + self.diff.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &App) -> bool { + self.diff.read(cx).has_conflict(cx) + } + + fn can_save(&self, _cx: &App) -> bool { + true + } + + fn save( + &mut self, + options: SaveOptions, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.diff + .update(cx, |diff, cx| diff.save(options, project, window, cx)) + } + + fn save_as( + &mut self, + _: Entity, + _: ProjectPath, + _: &mut Window, + _: &mut Context, + ) -> Task> { + unreachable!() + } + + fn reload( + &mut self, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.diff + .update(cx, |diff, cx| diff.reload(project, window, cx)) + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + cx: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.clone().into()) + } else if type_id == TypeId::of::() { + Some( + self.diff + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + .into(), + ) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).editor().clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).branch_diff().clone().into()) + } else { + None + } + } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + self.diff.update(cx, |diff, cx| { + diff.added_to_workspace(workspace, window, cx) + }); + } +} + +impl Render for BranchDiff { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .on_action(cx.listener(Self::review_diff)) + .child(self.diff.clone()) + } +} + +impl SerializableItem for BranchDiff { + fn serialized_item_kind() -> &'static str { + "BranchDiff" + } + + fn cleanup( + _: workspace::WorkspaceId, + _: Vec, + _: &mut Window, + _: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn deserialize( + project: Entity, + workspace: WeakEntity, + workspace_id: workspace::WorkspaceId, + item_id: workspace::ItemId, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + let db = project_diff::persistence::ProjectDiffDb::global(cx); + window.spawn(cx, async move |cx| { + let diff_base = db.get_project_diff_base(item_id, workspace_id)?; + let DiffBase::Merge { base_ref } = diff_base else { + anyhow::bail!("expected a merge base for a branch diff"); + }; + let workspace = workspace.upgrade().context("workspace gone")?; + cx.update(|window, cx| { + cx.new(|cx| Self::new_with_base_ref(project, workspace, base_ref, None, window, cx)) + }) + }) + } + + fn serialize( + &mut self, + workspace: &mut Workspace, + item_id: workspace::ItemId, + _closing: bool, + _window: &mut Window, + cx: &mut Context, + ) -> Option>> { + let workspace_id = workspace.database_id()?; + let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else { + return None; + }; + let diff_base = DiffBase::Merge { base_ref }; + let db = project_diff::persistence::ProjectDiffDb::global(cx); + Some(cx.background_spawn(async move { + db.save_project_diff_base(item_id, workspace_id, diff_base) + .await + })) + } + + fn should_serialize(&self, _: &Self::Event) -> bool { + false + } +} + +pub struct BranchDiffToolbar { + branch_diff: Option>, +} + +impl BranchDiffToolbar { + pub fn new(_cx: &mut Context) -> Self { + Self { branch_diff: None } + } + + fn branch_diff(&self, _: &App) -> Option> { + self.branch_diff.as_ref()?.upgrade() + } + + fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) { + if let Some(branch_diff) = self.branch_diff(cx) { + branch_diff.focus_handle(cx).focus(window, cx); + } + let action = action.boxed_clone(); + cx.defer(move |cx| { + cx.dispatch_action(action.as_ref()); + }) + } +} + +impl EventEmitter for BranchDiffToolbar {} + +impl ToolbarItemView for BranchDiffToolbar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + self.branch_diff = active_pane_item + .and_then(|item| item.act_as::(cx)) + .map(|entity| entity.downgrade()); + if self.branch_diff.is_some() { + ToolbarItemLocation::PrimaryRight + } else { + ToolbarItemLocation::Hidden + } + } + + fn pane_focus_update( + &mut self, + _pane_focused: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + } +} + +impl Render for BranchDiffToolbar { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(branch_diff) = self.branch_diff(cx) else { + return div(); + }; + let focus_handle = branch_diff.focus_handle(cx); + let review_count = branch_diff + .read(cx) + .diff + .read(cx) + .total_review_comment_count(); + let (additions, deletions) = branch_diff + .read(cx) + .diff + .read(cx) + .calculate_changed_lines(cx); + let diff_base = branch_diff.read(cx).diff_base(cx).clone(); + let DiffBase::Merge { base_ref } = diff_base else { + return div(); + }; + let selected_base_ref = base_ref.clone(); + let base_ref_label = format!("Base: {base_ref}"); + let repository = branch_diff.read(cx).repo(cx); + let workspace = branch_diff.read(cx).workspace.clone(); + let view_for_picker = branch_diff.downgrade(); + + let is_multibuffer_empty = branch_diff + .read(cx) + .diff + .read(cx) + .multibuffer() + .read(cx) + .is_empty(); + let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx); + + let show_review_button = !is_multibuffer_empty && is_ai_enabled; + + h_flex() + .my_neg_1() + .py_1() + .gap_1p5() + .flex_wrap() + .justify_between() + .when(!is_multibuffer_empty, |this| { + this.child(DiffStat::new( + "branch-diff-stat", + additions as usize, + deletions as usize, + )) + }) + .child(Divider::vertical().ml_1()) + .child( + PopoverMenu::new("branch-diff-base-branch-picker") + .menu(move |window, cx| { + let view_for_picker = view_for_picker.clone(); + let on_select = Arc::new( + move |branch: git::repository::Branch, + _window: &mut Window, + cx: &mut App| { + let base_ref: SharedString = branch.name().to_owned().into(); + view_for_picker + .update(cx, |branch_diff, cx| { + branch_diff.set_merge_base(base_ref, cx); + cx.notify(); + }) + .ok(); + }, + ); + + Some(branch_picker::select_popover( + workspace.clone(), + repository.clone(), + Some(selected_base_ref.clone()), + on_select, + window, + cx, + )) + }) + .trigger_with_tooltip( + Button::new("branch-diff-base-branch", base_ref_label).end_icon( + Icon::new(IconName::ChevronDown) + .size(IconSize::XSmall) + .color(Color::Muted), + ), + Tooltip::text("Select Base Branch"), + ), + ) + .when(show_review_button, |this| { + let focus_handle = focus_handle.clone(); + this.child(Divider::vertical()).child( + Button::new("review-diff", "Review Diff") + .start_icon( + Icon::new(IconName::ZedAssistant) + .size(IconSize::Small) + .color(Color::Muted), + ) + .tooltip(move |_, cx| { + Tooltip::with_meta_in( + "Review Diff", + Some(&ReviewDiff), + "Send this diff for your last agent to review.", + &focus_handle, + cx, + ) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&ReviewDiff, window, cx); + })), + ) + }) + .when(review_count > 0, |this| { + this.child(Divider::vertical()).child( + render_send_review_to_agent_button(review_count, &focus_handle).on_click( + cx.listener(|this, _, window, cx| { + this.dispatch_action(&SendReviewToAgent, window, cx) + }), + ), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use anyhow::anyhow; + use collections::HashMap; + use editor::test::editor_test_context::assert_state_with_diff; + use git::status::{FileStatus, TrackedStatus, UnmergedStatus, UnmergedStatusCode}; + use gpui::TestAppContext; + use project::FakeFs; + use serde_json::json; + use settings::{DiffViewStyle, SettingsStore}; + use std::path::Path; + use std::sync::Arc; + use unindent::Unindent as _; + use util::{ + path, + rel_path::{RelPath, rel_path}, + }; + use workspace::MultiWorkspace; + + use super::*; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.diff_view_style = Some(DiffViewStyle::Unified); + }); + }); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + crate::init(cx); + }); + } + + #[gpui::test(iterations = 50)] + async fn test_split_diff_conflict_path_transition_with_dirty_buffer_invalid_anchor_panics( + cx: &mut TestAppContext, + ) { + init_test(cx); + + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.diff_view_style = Some(DiffViewStyle::Split); + }); + }); + }); + + let build_conflict_text: fn(usize) -> String = |tag: usize| { + let mut lines = (0..80) + .map(|line_index| format!("line {line_index}")) + .collect::>(); + for offset in [5usize, 20, 37, 61] { + lines[offset] = format!("base-{tag}-line-{offset}"); + } + format!("{}\n", lines.join("\n")) + }; + let initial_conflict_text = build_conflict_text(0); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "helper.txt": "same\n", + "conflict.txt": initial_conflict_text, + }), + ) + .await; + fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { + state + .refs + .insert("MERGE_HEAD".into(), "conflict-head".into()); + }) + .unwrap(); + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[( + "conflict.txt", + FileStatus::Unmerged(UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + }), + )], + ); + fs.set_merge_base_content_for_repo( + path!("/project/.git").as_ref(), + &[ + ("conflict.txt", build_conflict_text(1)), + ("helper.txt", "same\n".to_string()), + ], + ); + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let _branch_diff = cx + .update(|window, cx| { + BranchDiff::new_with_default_branch(project.clone(), workspace, window, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/conflict.txt"), cx) + }) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "dirty\n")], None, cx)); + assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty())); + cx.run_until_parked(); + + cx.update(|window, cx| { + let fs = fs.clone(); + window + .spawn(cx, async move |cx| { + cx.background_executor().simulate_random_delay().await; + fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { + state.refs.insert("HEAD".into(), "head-1".into()); + state.refs.remove("MERGE_HEAD"); + }) + .unwrap(); + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ( + "conflict.txt", + FileStatus::Tracked(TrackedStatus { + index_status: git::status::StatusCode::Modified, + worktree_status: git::status::StatusCode::Modified, + }), + ), + ( + "helper.txt", + FileStatus::Tracked(TrackedStatus { + index_status: git::status::StatusCode::Modified, + worktree_status: git::status::StatusCode::Modified, + }), + ), + ], + ); + // FakeFs assigns deterministic OIDs by entry position; flipping order churns + // conflict diff identity without reaching into view internals. + fs.set_merge_base_content_for_repo( + path!("/project/.git").as_ref(), + &[ + ("helper.txt", "helper-base\n".to_string()), + ("conflict.txt", build_conflict_text(2)), + ], + ); + }) + .detach(); + }); + + cx.update(|window, cx| { + let buffer = buffer.clone(); + window + .spawn(cx, async move |cx| { + cx.background_executor().simulate_random_delay().await; + for edit_index in 0..10 { + if edit_index > 0 { + cx.background_executor().simulate_random_delay().await; + } + buffer.update(cx, |buffer, cx| { + let len = buffer.len(); + if edit_index % 2 == 0 { + buffer.edit( + [(0..0, format!("status-burst-head-{edit_index}\n"))], + None, + cx, + ); + } else { + buffer.edit( + [(len..len, format!("status-burst-tail-{edit_index}\n"))], + None, + cx, + ); + } + }); + } + }) + .detach(); + }); + + cx.run_until_parked(); + } + + #[gpui::test] + async fn test_branch_diff(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "a.txt": "C", + "b.txt": "new", + "c.txt": "in-merge-base-and-work-tree", + "d.txt": "created-in-head", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let diff = cx + .update(|window, cx| { + BranchDiff::new_with_default_branch(project.clone(), workspace, window, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())], + "sha", + ); + fs.set_merge_base_content_for_repo( + Path::new(path!("/project/.git")), + &[ + ("a.txt", "A".into()), + ("c.txt", "in-merge-base-and-work-tree".into()), + ], + ); + cx.run_until_parked(); + + let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); + + assert_state_with_diff( + &editor, + cx, + &" + - A + + ˇC + + new + + created-in-head" + .unindent(), + ); + + let statuses: HashMap, Option> = + editor.update(cx, |editor, cx| { + editor + .buffer() + .read(cx) + .all_buffers() + .iter() + .map(|buffer| { + ( + buffer.read(cx).file().unwrap().path().clone(), + editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx), + ) + }) + .collect() + }); + + assert_eq!( + statuses, + HashMap::from_iter([ + ( + rel_path("a.txt").into_arc(), + Some(FileStatus::Tracked(TrackedStatus { + index_status: git::status::StatusCode::Modified, + worktree_status: git::status::StatusCode::Modified + })) + ), + (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)), + ( + rel_path("d.txt").into_arc(), + Some(FileStatus::Tracked(TrackedStatus { + index_status: git::status::StatusCode::Added, + worktree_status: git::status::StatusCode::Added + })) + ) + ]) + ); + } + + #[gpui::test] + async fn test_branch_diff_action_matches_existing_item_by_base_ref(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "a.txt": "changed", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let target_branch_diff = cx + .update(|window, cx| { + let Some(repository) = project.read(cx).active_repository(cx) else { + return Task::ready(Err(anyhow!("No active repository"))); + }; + BranchDiff::new_with_branch_base( + project.clone(), + workspace.clone(), + "topic".into(), + repository, + window, + cx, + ) + }) + .await + .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new(target_branch_diff.clone()), + None, + true, + window, + cx, + ); + }); + cx.run_until_parked(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(DeployBranchDiff.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let (active_base_ref, mut base_refs) = workspace.update(cx, |workspace, cx| { + let active_item = workspace.active_item_as::(cx).unwrap(); + let active_base_ref = match active_item.read(cx).diff_base(cx) { + DiffBase::Merge { base_ref } => base_ref.to_string(), + DiffBase::Head | DiffBase::Index | DiffBase::Staged => { + panic!("expected active item to be a branch diff") + } + }; + let base_refs = workspace + .items_of_type::(cx) + .filter_map(|item| match item.read(cx).diff_base(cx) { + DiffBase::Merge { base_ref } => Some(base_ref.to_string()), + DiffBase::Head | DiffBase::Index | DiffBase::Staged => None, + }) + .collect::>(); + (active_base_ref, base_refs) + }); + base_refs.sort(); + + assert_eq!(active_base_ref, "origin/main"); + assert_eq!(base_refs, vec!["origin/main", "topic"]); + } +} diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 9fff74d3983..a45590260da 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -2,8 +2,8 @@ use anyhow::{Context as _, Result}; use buffer_diff::BufferDiff; use collections::HashMap; use editor::{ - Addon, Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor, - hover_markdown_style, multibuffer_context_lines, + Addon, Editor, EditorEvent, EditorSettings, MultiBuffer, RestoreOnlyDiffHunkDelegate, + SplittableEditor, hover_markdown_style, multibuffer_context_lines, }; use futures_lite::future::yield_now; use git::repository::{CommitDetails, CommitDiff, RepoPath, is_binary_content}; @@ -275,7 +275,7 @@ impl CommitView { window, cx, ); - editor.disable_diff_hunk_controls(cx); + editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyDiffHunkDelegate)), cx); editor.rhs_editor().update(cx, |editor, cx| { editor.set_show_bookmarks(false, cx); @@ -1193,7 +1193,7 @@ impl Item for CommitView { window, cx, ); - editor.disable_diff_hunk_controls(cx); + editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyDiffHunkDelegate)), cx); editor.rhs_editor().update(cx, |editor, cx| { editor.set_show_bookmarks(false, cx); editor.set_show_breakpoints(false, cx); diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 309a1180781..1199afb9d44 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -17,7 +17,7 @@ use project::{ use settings::Settings; use std::{ops::Range, sync::Arc}; use ui::{ButtonLike, Divider, Tooltip, prelude::*}; -use util::{debug_panic, maybe}; +use util::debug_panic; use workspace::{HideStatusItem, StatusItemView, Workspace, item::ItemHandle}; use zed_actions::agent::{ ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, @@ -115,19 +115,17 @@ pub(crate) fn buffer_ranges_updated( return; } - let buffer_conflicts = editor - .addon_mut::() - .unwrap() - .buffers - .entry(buffer_id) - .or_insert_with(|| { - let subscription = cx.subscribe(&conflict_set, conflicts_updated); - BufferConflicts { - block_ids: Vec::new(), - conflict_set: conflict_set.clone(), - _subscription: subscription, - } - }); + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; + let buffer_conflicts = conflict_addon.buffers.entry(buffer_id).or_insert_with(|| { + let subscription = cx.subscribe(&conflict_set, conflicts_updated); + BufferConflicts { + block_ids: Vec::new(), + conflict_set: conflict_set.clone(), + _subscription: subscription, + } + }); let conflict_set = buffer_conflicts.conflict_set.clone(); let conflicts_len = conflict_set.read(cx).snapshot().conflicts.len(); @@ -150,18 +148,17 @@ pub(crate) fn buffers_removed( cx: &mut Context, ) { let mut removed_block_ids = HashSet::default(); - editor - .addon_mut::() - .unwrap() - .buffers - .retain(|buffer_id, buffer| { - if removed_buffer_ids.contains(buffer_id) { - removed_block_ids.extend(buffer.block_ids.iter().map(|(_, block_id)| *block_id)); - false - } else { - true - } - }); + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; + conflict_addon.buffers.retain(|buffer_id, buffer| { + if removed_buffer_ids.contains(buffer_id) { + removed_block_ids.extend(buffer.block_ids.iter().map(|(_, block_id)| *block_id)); + false + } else { + true + } + }); editor.remove_blocks(removed_block_ids, None, cx); } @@ -176,9 +173,13 @@ fn conflicts_updated( let conflict_set = conflict_set.read(cx).snapshot(); let multibuffer = editor.buffer().read(cx); let snapshot = multibuffer.snapshot(cx); - let old_range = maybe!({ - let conflict_addon = editor.addon_mut::().unwrap(); - let buffer_conflicts = conflict_addon.buffers.get(&buffer_id)?; + let old_range = { + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; + let Some(buffer_conflicts) = conflict_addon.buffers.get(&buffer_id) else { + return; + }; match buffer_conflicts.block_ids.get(event.old_range.clone()) { Some(_) => Some(event.old_range.clone()), None => { @@ -197,10 +198,12 @@ fn conflicts_updated( } } } - }); + }; // Remove obsolete highlights and blocks - let conflict_addon = editor.addon_mut::().unwrap(); + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; if let Some((buffer_conflicts, old_range)) = conflict_addon .buffers .get_mut(&buffer_id) @@ -256,7 +259,9 @@ fn conflicts_updated( } let new_block_ids = editor.insert_blocks(blocks, None, cx); - let conflict_addon = editor.addon_mut::().unwrap(); + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; if let Some((buffer_conflicts, old_range)) = conflict_addon.buffers.get_mut(&buffer_id).zip(old_range) { @@ -481,7 +486,7 @@ pub(crate) fn resolve_conflict( let buffer_id = resolved_conflict.ours.end.buffer_id; let buffer = multibuffer.read(cx).buffer(buffer_id)?; resolved_conflict.resolve(buffer.clone(), &ranges, cx); - let conflict_addon = editor.addon_mut::().unwrap(); + let conflict_addon = editor.addon_mut::()?; let snapshot = multibuffer.read(cx).snapshot(cx); let buffer_snapshot = buffer.read(cx).snapshot(); let state = conflict_addon diff --git a/crates/git_ui/src/diff_multibuffer.rs b/crates/git_ui/src/diff_multibuffer.rs new file mode 100644 index 00000000000..96c05303719 --- /dev/null +++ b/crates/git_ui/src/diff_multibuffer.rs @@ -0,0 +1,1016 @@ +use crate::{ + conflict_view, + git_panel::{GitPanel, GitStatusEntry}, + git_panel_settings::GitPanelSettings, +}; +use anyhow::Result; +use buffer_diff::BufferDiff; +use collections::{HashMap, HashSet}; +use editor::{ + EditorEvent, EditorSettings, SelectionEffects, SplittableEditor, actions::GoToHunk, + multibuffer_context_lines, scroll::Autoscroll, +}; +use futures_lite::future::yield_now; +use git::{repository::RepoPath, status::FileStatus}; +use gpui::{ + App, AppContext as _, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable, Render, + SharedString, Subscription, Task, WeakEntity, +}; +use language::{Anchor, Buffer, BufferId, Capability, OffsetRangeExt}; +use multi_buffer::{MultiBuffer, PathKey}; +use project::{ + ConflictSet, Project, ProjectPath, + git_store::{ + Repository, + diff_buffer_list::{self, BranchDiffEvent, DiffBase}, + }, +}; +use settings::{GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore}; +use std::{collections::BTreeMap, sync::Arc}; +use theme::ActiveTheme; +use ui::{CommonAnimationExt as _, KeyBinding, prelude::*}; +use util::{ResultExt as _, rel_path::RelPath}; +use workspace::{ + CloseActiveItem, ItemNavHistory, Workspace, + item::{Item, SaveOptions}, +}; +use ztracing::instrument; + +struct BufferSubscriptions { + _diff: Entity, + display_buffer: Entity, + _diff_subscription: Subscription, + _conflict_set: Option>, + _conflict_set_subscription: Option, +} + +pub struct DiffMultibuffer { + multibuffer: Entity, + branch_diff: Entity, + editor: Entity, + buffer_subscriptions: HashMap, + workspace: WeakEntity, + focus_handle: FocusHandle, + pending_scroll: Option, + review_comment_count: usize, + empty_label: SharedString, + _task: Task>, + _subscription: Subscription, +} + +impl DiffMultibuffer { + pub(crate) fn new( + branch_diff: Entity, + multibuffer_capability: Capability, + empty_label: impl Into, + configure_editor: impl FnOnce(&mut SplittableEditor, &mut Context) + 'static, + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(multibuffer_capability); + multibuffer.set_all_diff_hunks_expanded(cx); + multibuffer + }); + let editor = cx.new(|cx| { + let mut diff_display_editor = SplittableEditor::new( + EditorSettings::get_global(cx).diff_view_style, + multibuffer.clone(), + project.clone(), + workspace.clone(), + window, + cx, + ); + configure_editor(&mut diff_display_editor, cx); + diff_display_editor.rhs_editor().update(cx, |editor, cx| { + editor.set_show_diff_review_button(true, cx); + }); + diff_display_editor + }); + let editor_subscription = cx.subscribe_in(&editor, window, Self::handle_editor_event); + + let primary_editor = editor.read(cx).rhs_editor().clone(); + let review_comment_subscription = + cx.subscribe(&primary_editor, |this, _editor, event: &EditorEvent, cx| { + if let EditorEvent::ReviewCommentsChanged { total_count } = event { + this.review_comment_count = *total_count; + cx.notify(); + } + }); + + let branch_diff_subscription = cx.subscribe_in( + &branch_diff, + window, + move |this, _git_store, event, window, cx| match event { + BranchDiffEvent::FileListChanged => { + this._task = window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, cx).await + }) + } + BranchDiffEvent::DiffBaseChanged => { + this.pending_scroll.take(); + this._task = window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, cx).await + }) + } + }, + ); + + let mut was_sort_by = GitPanelSettings::get_global(cx).sort_by; + let mut was_group_by = GitPanelSettings::get_global(cx).group_by; + let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; + let mut was_collapse_untracked_diff = + GitPanelSettings::get_global(cx).collapse_untracked_diff; + cx.observe_global_in::(window, move |this, window, cx| { + let settings = GitPanelSettings::get_global(cx); + let sort_by = settings.sort_by; + let group_by = settings.group_by; + let tree_view = settings.tree_view; + let is_collapse_untracked_diff = settings.collapse_untracked_diff; + if sort_by != was_sort_by + || group_by != was_group_by + || tree_view != was_tree_view + || is_collapse_untracked_diff != was_collapse_untracked_diff + { + this._task = { + window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, cx).await + }) + } + } + was_sort_by = sort_by; + was_group_by = group_by; + was_tree_view = tree_view; + was_collapse_untracked_diff = is_collapse_untracked_diff; + }) + .detach(); + + let task = window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, cx).await + }); + + Self { + workspace: workspace.downgrade(), + branch_diff, + focus_handle, + editor, + multibuffer, + buffer_subscriptions: Default::default(), + pending_scroll: None, + review_comment_count: 0, + empty_label: empty_label.into(), + _task: task, + _subscription: Subscription::join( + branch_diff_subscription, + Subscription::join(editor_subscription, review_comment_subscription), + ), + } + } + + pub(crate) fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase { + self.branch_diff.read(cx).diff_base() + } + + pub(crate) fn branch_diff(&self) -> &Entity { + &self.branch_diff + } + + pub(crate) fn repo(&self, cx: &App) -> Option> { + self.branch_diff.read(cx).repo().cloned() + } + + pub(crate) fn set_repo(&mut self, repo: Option>, cx: &mut Context) { + self.branch_diff.update(cx, |branch_diff, cx| { + branch_diff.set_repo(repo, cx); + }); + } + + pub(crate) fn is_dirty(&self, cx: &App) -> bool { + self.multibuffer.read(cx).is_dirty(cx) + } + + pub(crate) fn has_conflict(&self, cx: &App) -> bool { + self.multibuffer.read(cx).has_conflict(cx) + } + + pub(crate) fn multibuffer(&self) -> &Entity { + &self.multibuffer + } + + pub(crate) fn move_to_entry( + &mut self, + entry: GitStatusEntry, + window: &mut Window, + cx: &mut Context, + ) { + let Some(git_repo) = self.branch_diff.read(cx).repo() else { + return; + }; + let repo = git_repo.read(cx); + let path_key = project_diff_path_key(repo, &entry.repo_path, entry.status, cx); + + self.move_to_path(path_key, window, cx) + } + + pub(crate) fn move_to_project_path( + &mut self, + project_path: &ProjectPath, + window: &mut Window, + cx: &mut Context, + ) { + let Some(git_repo) = self.branch_diff.read(cx).repo() else { + return; + }; + let Some(repo_path) = git_repo + .read(cx) + .project_path_to_repo_path(project_path, cx) + else { + return; + }; + let status = git_repo + .read(cx) + .status_for_path(&repo_path) + .map(|entry| entry.status) + .unwrap_or(FileStatus::Untracked); + let path_key = project_diff_path_key(&git_repo.read(cx), &repo_path, status, cx); + self.move_to_path(path_key, window, cx) + } + + pub(crate) fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges(vec![multi_buffer::Anchor::Min..multi_buffer::Anchor::Min]); + }); + }); + }); + } + + pub(crate) fn move_to_path( + &mut self, + path_key: PathKey, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::focused()), + window, + cx, + |s| { + s.select_ranges([position..position]); + }, + ) + }) + }); + } else { + self.pending_scroll = Some(path_key); + } + } + + pub(crate) fn autoscroll(&self, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, cx| { + editor.request_autoscroll(Autoscroll::fit(), cx); + }) + }) + } + + pub(crate) fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) { + self.multibuffer.read(cx).snapshot(cx).total_changed_lines() + } + + /// Returns the total count of review comments across all hunks/files. + pub(crate) fn total_review_comment_count(&self) -> usize { + self.review_comment_count + } + + /// Returns a reference to the splittable editor. + pub(crate) fn editor(&self) -> &Entity { + &self.editor + } + + pub(crate) fn selected_ranges( + &self, + cx: &App, + ) -> (bool, Vec>) { + let editor = self.editor.read(cx).rhs_editor().read(cx); + let snapshot = self.multibuffer.read(cx).snapshot(cx); + let mut selection = true; + let mut ranges = editor + .selections + .disjoint_anchor_ranges() + .collect::>(); + if !ranges.iter().any(|range| range.start != range.end) { + selection = false; + let anchor = editor.selections.newest_anchor().head(); + if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor) + && let Some(range) = snapshot + .anchor_in_buffer(excerpt_range.context.start) + .zip(snapshot.anchor_in_buffer(excerpt_range.context.end)) + .map(|(start, end)| start..end) + { + ranges = vec![range]; + } else { + ranges = Vec::default(); + }; + } + + (selection, ranges) + } + + /// Ranges for a toolbar stage/unstage action: the selection, or the cursor + /// (a zero-width range that resolves to the single hunk under it) when + /// there is no selection. Unlike [`Self::selected_ranges`], this never + /// widens to the whole excerpt, so actions affect one hunk at a time. + fn hunk_action_ranges(&self, cx: &App) -> Vec> { + self.editor + .read(cx) + .rhs_editor() + .read(cx) + .selections + .disjoint_anchor_ranges() + .collect() + } + + pub(crate) fn stage_or_unstage_selected_hunks( + &mut self, + stage: bool, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + let editor = self.editor.read(cx).rhs_editor().clone(); + let ranges = self.hunk_action_ranges(cx); + // Route through the editor's delegated stage path, the same path taken + // by the hunk buttons (on either side of a split) and the keyboard. + // For staging, dirty buffers are saved first, exactly as they are when + // staging from the uncommitted diff or a normal editor. + editor.update(cx, |editor, cx| { + editor.stage_or_unstage_diff_hunks(stage, ranges, window, cx); + }); + if move_to_next { + editor + .focus_handle(cx) + .dispatch_action(&GoToHunk, window, cx); + } + } + + fn handle_editor_event( + &mut self, + editor: &Entity, + event: &EditorEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + EditorEvent::SelectionsChanged { local: true } => { + // Only follow the git panel selection from the view the user is + // actually interacting with. Background (non-active) diff views + // refresh on their own and must not hijack the panel selection. + if !editor.focus_handle(cx).contains_focused(window, cx) { + return; + } + let Some(project_path) = self.active_project_path(cx) else { + return; + }; + self.workspace + .update(cx, |workspace, cx| { + if let Some(git_panel) = workspace.panel::(cx) { + git_panel.update(cx, |git_panel, cx| { + git_panel.select_entry_by_path(project_path, window, cx) + }) + } + }) + .ok(); + } + EditorEvent::Saved => { + self._task = + cx.spawn_in(window, async move |this, cx| Self::refresh(this, cx).await); + } + + _ => {} + } + if editor.focus_handle(cx).contains_focused(window, cx) + && self.multibuffer.read(cx).is_empty() + { + self.focus_handle.focus(window, cx) + } + } + + #[instrument(skip_all)] + fn register_buffer( + &mut self, + repo_path: RepoPath, + path_key: PathKey, + file_status: FileStatus, + display_buffer: Entity, + main_buffer: Entity, + diff: Entity, + conflict_set: Option>, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let diff_subscription = cx.subscribe_in(&diff, window, { + let repo_path = repo_path.clone(); + let path_key = path_key.clone(); + let display_buffer = display_buffer.clone(); + let main_buffer = main_buffer.clone(); + let diff = diff.clone(); + let conflict_set = conflict_set.clone(); + move |this, _, event, window, cx| match event { + buffer_diff::BufferDiffEvent::DiffChanged(_) => { + this.buffer_ranges_changed( + repo_path.clone(), + path_key.clone(), + file_status, + display_buffer.clone(), + main_buffer.clone(), + diff.clone(), + conflict_set.clone(), + window, + cx, + ); + } + buffer_diff::BufferDiffEvent::BaseTextChanged => {} + } + }); + let conflict_set_subscription = conflict_set.as_ref().map(|conflict_set| { + cx.subscribe_in(conflict_set, window, { + let repo_path = repo_path.clone(); + let path_key = path_key.clone(); + let display_buffer = display_buffer.clone(); + let main_buffer = main_buffer.clone(); + let diff = diff.clone(); + let conflict_set = Some(conflict_set.clone()); + move |this, _, _, window, cx| { + this.buffer_ranges_changed( + repo_path.clone(), + path_key.clone(), + file_status, + display_buffer.clone(), + main_buffer.clone(), + diff.clone(), + conflict_set.clone(), + window, + cx, + ) + } + }) + }); + self.buffer_subscriptions.insert( + repo_path, + BufferSubscriptions { + _diff: diff.clone(), + display_buffer: display_buffer.clone(), + _diff_subscription: diff_subscription, + _conflict_set: conflict_set.clone(), + _conflict_set_subscription: conflict_set_subscription, + }, + ); + + let snapshot = display_buffer.read(cx).snapshot(); + let diff_snapshot = diff.read(cx).snapshot(cx); + + let excerpt_ranges = { + let diff_hunk_ranges = diff_snapshot + .hunks_intersecting_range( + Anchor::min_max_range_for_buffer(snapshot.remote_id()), + &snapshot, + ) + .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)); + let conflict_ranges = conflict_set.as_ref().and_then(|conflict_set| { + let conflicts = conflict_set.read(cx).snapshot(); + let conflicts = conflicts + .conflicts + .iter() + .map(|conflict| conflict.range.to_point(&snapshot)) + .collect::>(); + (!conflicts.is_empty()).then_some(conflicts) + }); + + conflict_ranges.unwrap_or_else(|| diff_hunk_ranges.collect()) + }; + + let buffer_id = snapshot.text.remote_id(); + let mut needs_fold = false; + + let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| { + let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty(); + let is_newly_added = editor.update_excerpts_for_path( + path_key.clone(), + display_buffer, + excerpt_ranges, + multibuffer_context_lines(cx), + diff, + cx, + ); + if let Some(conflict_set) = conflict_set { + editor.rhs_editor().update(cx, |editor, cx| { + conflict_view::buffer_ranges_updated(editor, conflict_set, cx); + }); + } + (was_empty, is_newly_added) + }); + + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, cx| { + if was_empty { + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections.select_ranges([ + multi_buffer::Anchor::Min..multi_buffer::Anchor::Min + ]) + }, + ); + } + if is_excerpt_newly_added + && (file_status.is_deleted() + || (file_status.is_untracked() + && GitPanelSettings::get_global(cx).collapse_untracked_diff)) + { + needs_fold = true; + } + }) + }); + + if self.multibuffer.read(cx).is_empty() + && self + .editor + .read(cx) + .focus_handle(cx) + .contains_focused(window, cx) + { + self.focus_handle.focus(window, cx); + } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { + self.editor.update(cx, |editor, cx| { + editor.focus_handle(cx).focus(window, cx); + }); + } + if self.pending_scroll.as_ref() == Some(&path_key) { + self.move_to_path(path_key, window, cx); + } + + needs_fold.then_some(buffer_id) + } + + fn buffer_ranges_changed( + &mut self, + repo_path: RepoPath, + path_key: PathKey, + file_status: FileStatus, + display_buffer: Entity, + main_buffer: Entity, + diff: Entity, + conflict_set: Option>, + window: &mut Window, + cx: &mut Context, + ) { + if display_buffer.read(cx).is_dirty() { + return; + } + self.register_buffer( + repo_path, + path_key, + file_status, + display_buffer, + main_buffer, + diff, + conflict_set, + window, + cx, + ); + } + + #[instrument(skip(this, cx))] + pub(crate) async fn refresh(this: WeakEntity, cx: &mut AsyncWindowContext) -> Result<()> { + let entries = this.update(cx, |this, cx| { + let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| { + let load_buffers = branch_diff.load_buffers(cx); + (branch_diff.repo().cloned(), load_buffers) + }); + let mut previous_paths = this + .multibuffer + .read(cx) + .snapshot(cx) + .buffers_with_paths() + .map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id())) + .collect::>(); + + let mut entries = BTreeMap::new(); + let mut live_repo_paths = HashSet::default(); + if let Some(repo) = repo { + let repo = repo.read(cx); + for diff_buffer in buffers_to_load { + live_repo_paths.insert(diff_buffer.repo_path.clone()); + let path_key = project_diff_path_key( + &repo, + &diff_buffer.repo_path, + diff_buffer.file_status, + cx, + ); + previous_paths.remove(&path_key); + entries.insert(path_key, diff_buffer); + } + } + + let repo_path_by_display_id = this + .buffer_subscriptions + .iter() + .map(|(repo_path, sub)| { + (sub.display_buffer.read(cx).remote_id(), repo_path.clone()) + }) + .collect::>(); + + this.editor.update(cx, |editor, cx| { + for (path, buffer_id) in previous_paths { + if let Some(repo_path) = repo_path_by_display_id.get(&buffer_id) { + this.buffer_subscriptions.remove(repo_path); + } + editor.rhs_editor().update(cx, |editor, cx| { + conflict_view::buffers_removed(editor, &[buffer_id], cx); + }); + let _span = ztracing::info_span!("remove_excerpts_for_path"); + _span.enter(); + editor.remove_excerpts_for_path(path, cx); + } + }); + + this.buffer_subscriptions + .retain(|repo_path, _| live_repo_paths.contains(repo_path)); + + entries + })?; + + let mut buffers_to_fold = Vec::new(); + + for (path_key, entry) in entries { + if let Some(loaded_buffer) = entry.load.await.log_err() { + // We might be lagging behind enough that all future entry.load futures are no longer pending. + // If that is the case, this task will never yield, starving the foreground thread of execution time. + yield_now().await; + cx.update(|window, cx| { + this.update(cx, |this, cx| { + if let Some(buffer_id) = this.register_buffer( + entry.repo_path, + path_key, + entry.file_status, + loaded_buffer.display_buffer, + loaded_buffer.main_buffer, + loaded_buffer.diff, + loaded_buffer.conflict_set, + window, + cx, + ) { + buffers_to_fold.push(buffer_id); + } + }) + .ok(); + })?; + } + } + this.update(cx, |this, cx| { + if !buffers_to_fold.is_empty() { + this.editor.update(cx, |editor, cx| { + editor + .rhs_editor() + .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx)); + }); + } + this.pending_scroll.take(); + cx.notify(); + })?; + + Ok(()) + } + + pub(crate) fn active_project_path(&self, cx: &App) -> Option { + let editor = self.editor.read(cx).focused_editor().read(cx); + let multibuffer = editor.buffer().read(cx); + let position = editor.selections.newest_anchor().head(); + let snapshot = multibuffer.snapshot(cx); + let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?; + let buffer = multibuffer.buffer(text_anchor.buffer_id)?; + + let file = buffer.read(cx).file()?; + Some(ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }) + } + + pub(crate) fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + self.editor.update(cx, |editor, cx| { + editor.added_to_workspace(workspace, window, cx) + }); + } + + pub(crate) fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |primary_editor, cx| { + primary_editor.deactivated(window, cx); + }) + }); + } + + pub(crate) fn navigate( + &mut self, + data: Arc, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |primary_editor, cx| { + primary_editor.navigate(data, window, cx) + }) + }) + } + + pub(crate) fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |primary_editor, _| { + primary_editor.set_nav_history(Some(nav_history)); + }) + }); + } + + pub(crate) fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + self.editor + .read(cx) + .rhs_editor() + .read(cx) + .for_each_project_item(cx, f) + } + + pub(crate) fn save( + &mut self, + options: SaveOptions, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |primary_editor, cx| { + primary_editor.save(options, project, window, cx) + }) + }) + } + + pub(crate) fn reload( + &mut self, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |primary_editor, cx| { + primary_editor.reload(project, window, cx) + }) + }) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn excerpt_paths(&self, cx: &App) -> Vec> { + let snapshot = self + .editor() + .read(cx) + .rhs_editor() + .read(cx) + .buffer() + .read(cx) + .snapshot(cx); + snapshot + .excerpts() + .map(|excerpt| { + snapshot + .path_for_buffer(excerpt.context.start.buffer_id) + .unwrap() + .path + .clone() + }) + .collect() + } + + /// Returns the real (worktree-relative) path of each excerpted buffer, in + /// the order the excerpts appear in the multibuffer. Unlike + /// [`Self::excerpt_paths`], this resolves the buffer's actual `File` rather + /// than the (possibly synthetic) `PathKey` path used for sorting. + #[cfg(any(test, feature = "test-support"))] + pub fn excerpt_file_paths(&self, cx: &App) -> Vec { + let multibuffer = self + .editor() + .read(cx) + .rhs_editor() + .read(cx) + .buffer() + .clone(); + let snapshot = multibuffer.read(cx).snapshot(cx); + let mut result = Vec::new(); + let mut last_buffer_id = None; + for excerpt in snapshot.excerpts() { + let buffer_id = excerpt.context.start.buffer_id; + if last_buffer_id == Some(buffer_id) { + continue; + } + last_buffer_id = Some(buffer_id); + if let Some(buffer) = multibuffer.read(cx).buffer(buffer_id) + && let Some(file) = buffer.read(cx).file() + { + result.push(file.path().as_unix_str().to_string()); + } + } + result + } +} + +impl EventEmitter for DiffMultibuffer {} + +impl Focusable for DiffMultibuffer { + fn focus_handle(&self, cx: &App) -> FocusHandle { + if self.multibuffer.read(cx).is_empty() { + self.focus_handle.clone() + } else { + self.editor.focus_handle(cx) + } + } +} + +impl Render for DiffMultibuffer { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_empty = self.multibuffer.read(cx).is_empty(); + let is_loading = self.branch_diff.read(cx).is_tree_base_loading() || !self._task.is_ready(); + let empty_label = self.empty_label.clone(); + + div() + .track_focus(&self.focus_handle) + .key_context(if is_empty { "EmptyPane" } else { "GitDiff" }) + .bg(cx.theme().colors().editor_background) + .flex() + .items_center() + .justify_center() + .size_full() + .when(is_empty && is_loading, |el| { + let rems = TextSize::Large.rems(cx); + el.child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Custom(rems)) + .color(Color::Accent) + .with_rotate_animation(3) + .into_any_element(), + ) + }) + .when(is_empty && !is_loading, |el| { + let remote_button = if let Some(panel) = self + .workspace + .upgrade() + .and_then(|workspace| workspace.read(cx).panel::(cx)) + { + panel.update(cx, |panel, cx| panel.render_remote_button(cx)) + } else { + None + }; + let keybinding_focus_handle = self.focus_handle(cx); + el.child( + v_flex() + .gap_1() + .child(h_flex().justify_around().child(Label::new(empty_label))) + .map(|el| match remote_button { + Some(button) => el.child(h_flex().justify_around().child(button)), + None => el.child( + h_flex() + .justify_around() + .child(Label::new("Remote up to date")), + ), + }) + .child( + h_flex().justify_around().mt_1().child( + Button::new("project-diff-close-button", "Close") + .key_binding(KeyBinding::for_action_in( + &CloseActiveItem::default(), + &keybinding_focus_handle, + cx, + )) + .on_click(move |_, window, cx| { + window.focus(&keybinding_focus_handle, cx); + window.dispatch_action( + Box::new(CloseActiveItem::default()), + cx, + ); + }), + ), + ), + ) + }) + .when(!is_empty, |el| el.child(self.editor.clone())) + } +} + +const CONFLICT_SORT_PREFIX: u64 = 1; +const TRACKED_SORT_PREFIX: u64 = 2; +const NEW_SORT_PREFIX: u64 = 3; + +/// Computes a stable [`PathKey`] for a buffer in the project diff. +/// +/// The key is an intrinsic function of the file's own repo path and status; it +/// never depends on which other buffers happen to be present in the +/// multibuffer. This is required because the multibuffer uses the path key both +/// to order excerpts and to identify which excerpts belong to a given buffer, so +/// a key that shifted as files were added or removed would break that identity. +/// +/// Status grouping is encoded in the `sort_prefix`, and the within-group order +/// is encoded in the (possibly synthetic) path so that `PathKey`'s natural +/// ordering reproduces the git panel's order. The path here is only ever used +/// for sorting and multibuffer identity; the path shown in the UI comes from the +/// buffer's own `File`. +pub(crate) fn project_diff_path_key( + repo: &Repository, + repo_path: &RepoPath, + status: FileStatus, + cx: &App, +) -> PathKey { + let settings = GitPanelSettings::get_global(cx); + let sort_prefix = if settings.group_by != GitPanelGroupBy::Status { + TRACKED_SORT_PREFIX + } else if repo.had_conflict_on_last_merge_head_change(repo_path) { + CONFLICT_SORT_PREFIX + } else if status.is_created() { + NEW_SORT_PREFIX + } else { + TRACKED_SORT_PREFIX + }; + let path = project_diff_sort_path(repo_path, settings.tree_view, settings.sort_by); + PathKey::with_sort_prefix(sort_prefix, path) +} + +fn project_diff_sort_path( + repo_path: &RelPath, + tree_view: bool, + sort_by: GitPanelSortBy, +) -> Arc { + if tree_view { + tree_sort_path(repo_path) + } else { + match sort_by { + GitPanelSortBy::Path => repo_path.into_arc(), + GitPanelSortBy::Name => name_sort_path(repo_path), + } + } +} + +/// Builds a synthetic path that sorts by file name first, falling back to the +/// full path to keep the key unique per file. +fn name_sort_path(repo_path: &RelPath) -> Arc { + let Some(file_name) = repo_path.file_name() else { + return repo_path.into_arc(); + }; + let synthetic = format!("{}/{}", file_name, repo_path.as_unix_str()); + RelPath::unix(&synthetic) + .map(|path| path.into_arc()) + .unwrap_or_else(|_| repo_path.into_arc()) +} + +/// Builds a synthetic path whose natural component-wise ordering reproduces a +/// folder-first tree order. Each directory component is prefixed with a NUL +/// byte, which can never appear in a real path component and sorts before every +/// printable character, so at each level directories sort before files. +fn tree_sort_path(repo_path: &RelPath) -> Arc { + let components: Vec<&str> = repo_path.components().collect(); + if components.len() <= 1 { + return repo_path.into_arc(); + } + let last = components.len() - 1; + let mut synthetic = String::new(); + for (index, component) in components.into_iter().enumerate() { + if index > 0 { + synthetic.push('/'); + } + if index < last { + synthetic.push('\0'); + } + synthetic.push_str(component); + } + RelPath::unix(&synthetic) + .map(|path| path.into_arc()) + .unwrap_or_else(|_| repo_path.into_arc()) +} diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index 477f18be545..b046380b28a 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -2,7 +2,10 @@ use anyhow::Result; use buffer_diff::BufferDiff; -use editor::{Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor}; +use editor::{ + Editor, EditorEvent, EditorSettings, MultiBuffer, RestoreOnlyUnstagedDiffHunkDelegate, + SplittableEditor, +}; use futures::{FutureExt, select_biased}; use gpui::{ AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, @@ -103,11 +106,8 @@ impl FileDiffView { window, cx, ); - splittable.rhs_editor().update(cx, |editor, _| { - editor.start_temporary_diff_override(); - }); - splittable.disable_diff_hunk_controls(cx); - splittable.set_render_diff_hunks_as_unstaged(cx); + splittable + .set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx); splittable }); diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index e2b04699b22..90463f1b9e1 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3,9 +3,11 @@ use crate::commit_modal::CommitModal; use crate::commit_tooltip::{CommitAvatar, CommitTooltip}; use crate::commit_view::CommitView; use crate::git_panel_settings::GitPanelScrollbarAccessor; -use crate::project_diff::{BranchDiff, Diff, ProjectDiff}; +use crate::project_diff::{DeployBranchDiff, Diff, ProjectDiff}; use crate::remote_output::{self, RemoteAction, SuccessMessage}; use crate::solo_diff_view::SoloDiffView; +use crate::staged_diff::StagedDiff; +use crate::unstaged_diff::UnstagedDiff; use crate::{branch_picker, picker_prompt, render_remote_button}; use crate::{ git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector, @@ -37,10 +39,11 @@ use git::{ ViewFile, parse_git_remote_url, }; use gpui::{ - AbsoluteLength, Action, Anchor, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, DismissEvent, - Empty, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, - Point, PromptLevel, ScrollStrategy, Subscription, Task, TaskExt, TextStyle, - UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point, size, uniform_list, + AbsoluteLength, Action, Anchor, AnyElement, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, + DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, + MouseDownEvent, Pixels, Point, PromptLevel, ScrollStrategy, Subscription, Task, TaskExt, + TextStyle, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point, size, + uniform_list, }; use itertools::Itertools; use language::{Buffer, BufferEvent, File}; @@ -137,6 +140,10 @@ actions!( ExpandSelectedEntry, /// Collapses the selected entry to hide its children. CollapseSelectedEntry, + /// View unstaged changes + ViewUnstagedChanges, + /// View staged changes + ViewStagedChanges, /// Activates the Changes tab. ActivateChangesTab, /// Activates the History tab. @@ -3883,6 +3890,40 @@ impl GitPanel { } } + fn view_staged_changes( + &mut self, + _: &ViewStagedChanges, + window: &mut Window, + cx: &mut Context, + ) { + let entry = self + .get_selected_entry() + .and_then(|entry| entry.status_entry()) + .cloned(); + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + StagedDiff::deploy_at(workspace, entry, window, cx); + }); + } + } + + fn view_unstaged_changes( + &mut self, + _: &ViewUnstagedChanges, + window: &mut Window, + cx: &mut Context, + ) { + let entry = self + .get_selected_entry() + .and_then(|entry| entry.status_entry()) + .cloned(); + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + UnstagedDiff::deploy_at(workspace, entry, window, cx); + }); + } + } + fn toggle_tree_view(&mut self, _: &ToggleTreeView, _: &mut Window, cx: &mut Context) { let current_setting = GitPanelSettings::get_global(cx).tree_view; if let Some(workspace) = self.workspace.upgrade() { @@ -6082,7 +6123,7 @@ impl GitPanel { .style(ButtonStyle::Outlined) .on_click(move |_, _, cx| { cx.defer(move |cx| { - cx.dispatch_action(&BranchDiff); + cx.dispatch_action(&DeployBranchDiff); }) }), ) @@ -6510,6 +6551,9 @@ impl GitPanel { .action(stage_title, ToggleStaged.boxed_clone()) .action(restore_title, git::RestoreFile::default().boxed_clone()) .separator() + .action("Unstaged Changes", ViewUnstagedChanges.boxed_clone()) + .action("Staged Changes", ViewStagedChanges.boxed_clone()) + .separator() .action_disabled_when( !is_created, "Add to .gitignore", @@ -7294,6 +7338,8 @@ impl Render for GitPanel { .on_action(cx.listener(Self::open_diff)) .on_action(cx.listener(Self::open_solo_diff)) .on_action(cx.listener(Self::view_file)) + .on_action(cx.listener(Self::view_unstaged_changes)) + .on_action(cx.listener(Self::view_staged_changes)) .on_action(cx.listener(Self::focus_changes_list)) .on_action(cx.listener(Self::focus_editor)) .on_action(cx.listener(Self::expand_commit_editor)) diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 08192d70c0f..aefd0eff50a 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -34,12 +34,14 @@ use crate::{ }; mod askpass_modal; +pub mod branch_diff; pub mod branch_picker; mod commit_modal; pub mod commit_tooltip; pub mod commit_view; mod conflict_view; pub mod created_worktrees; +mod diff_multibuffer; pub mod file_diff_view; pub mod git_graph; pub mod git_panel; @@ -52,8 +54,10 @@ pub mod project_diff; pub(crate) mod remote_output; pub mod repository_selector; pub mod solo_diff_view; +pub mod staged_diff; pub mod stash_picker; pub mod text_diff_view; +pub mod unstaged_diff; pub mod worktree_names; pub mod worktree_picker; pub mod worktree_service; @@ -87,6 +91,9 @@ pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _, cx| { ProjectDiff::register(workspace, cx); + staged_diff::StagedDiff::register(workspace, cx); + unstaged_diff::UnstagedDiff::register(workspace, cx); + branch_diff::BranchDiff::register(workspace, cx); CommitModal::register(workspace); git_panel::register(workspace); repository_selector::register(workspace); diff --git a/crates/git_ui/src/multi_diff_view.rs b/crates/git_ui/src/multi_diff_view.rs index f8097e68f5c..7bf7682938c 100644 --- a/crates/git_ui/src/multi_diff_view.rs +++ b/crates/git_ui/src/multi_diff_view.rs @@ -1,7 +1,10 @@ use crate::file_diff_view::build_buffer_diff; use anyhow::Result; use buffer_diff::BufferDiff; -use editor::{Editor, EditorEvent, MultiBuffer, multibuffer_context_lines}; +use editor::{ + Editor, EditorEvent, MultiBuffer, RestoreOnlyUnstagedDiffHunkDelegate, + multibuffer_context_lines, +}; use gpui::{ AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, Font, IntoElement, Render, SharedString, Task, Window, @@ -196,14 +199,9 @@ impl MultiDiffView { let editor = cx.new(|cx| { let mut editor = Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx); - editor.start_temporary_diff_override(); + editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx); editor.disable_diagnostics(cx); editor.set_expand_all_diff_hunks(cx); - editor.set_render_diff_hunks_as_unstaged(true, cx); - editor.set_render_diff_hunk_controls( - Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()), - cx, - ); editor }); diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 91f351bec29..d674be55766 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -1,56 +1,41 @@ use crate::{ - branch_picker, conflict_view, + diff_multibuffer::DiffMultibuffer, git_panel::{GitPanel, GitPanelAddon, GitStatusEntry}, - git_panel_settings::GitPanelSettings, + staged_diff::StagedDiff, + unstaged_diff::UnstagedDiff, }; -use agent_settings::AgentSettings; -use anyhow::{Context as _, Result, anyhow}; -use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus}; -use collections::HashMap; +use anyhow::{Context as _, Result}; +use buffer_diff::DiffHunkSecondaryStatus; use editor::{ - Addon, Editor, EditorEvent, EditorSettings, SelectionEffects, SplittableEditor, + Editor, EditorEvent, SplittableEditor, UncommittedDiffHunkDelegate, actions::{GoToHunk, GoToPreviousHunk, SendReviewToAgent}, - multibuffer_context_lines, - scroll::Autoscroll, -}; -use futures_lite::future::yield_now; -use git::repository::DiffType; - -use git::{ - Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext, repository::RepoPath, - status::FileStatus, }; +use git::{Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext}; use gpui::{ - Action, AnyElement, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter, - FocusHandle, Focusable, Render, Subscription, Task, WeakEntity, actions, + Action, AnyElement, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, Render, + Subscription, Task, WeakEntity, actions, }; -use language::{Anchor, Buffer, BufferId, Capability, OffsetRangeExt}; -use multi_buffer::{MultiBuffer, PathKey}; +use language::Capability; +use multi_buffer::MultiBuffer; use project::{ - ConflictSet, Project, ProjectPath, + Project, ProjectPath, git_store::{ Repository, - branch_diff::{self, BranchDiffEvent, DiffBase}, + diff_buffer_list::{self, DiffBase}, }, }; -use settings::{GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore}; +use schemars::JsonSchema; +use serde::Deserialize; use std::any::{Any, TypeId}; -use std::collections::BTreeMap; use std::sync::Arc; -use theme::ActiveTheme; -use ui::{ - CommonAnimationExt as _, DiffStat, Divider, KeyBinding, PopoverMenu, Tooltip, prelude::*, -}; -use util::{ResultExt as _, rel_path::RelPath}; +use ui::{DiffStat, Divider, Tooltip, prelude::*}; use workspace::{ - CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, - ToolbarItemView, Workspace, + ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, + Workspace, item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams}, - notifications::NotifyTaskExt, searchable::SearchableItemHandle, }; -use zed_actions::agent::ReviewBranchDiff; -use ztracing::instrument; +use zed_actions::git as git_actions; actions!( git, @@ -59,9 +44,6 @@ actions!( Diff, /// Adds files to the git staging area. Add, - /// Shows the diff between the working directory and your default - /// branch (typically main or master). - BranchDiff, /// Opens a new agent thread with the branch diff for review. ReviewDiff, LeaderAndFollower, @@ -70,32 +52,37 @@ actions!( ] ); -struct BufferSubscriptions { - _diff: Entity, - _diff_subscription: Subscription, - _conflict_set: Entity, - _conflict_set_subscription: Subscription, -} +/// Shows the diff between the working directory and your default +/// branch (typically main or master). +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] +#[action(namespace = git, name = "BranchDiff")] +pub(crate) struct DeployBranchDiff; pub struct ProjectDiff { project: Entity, - multibuffer: Entity, - branch_diff: Entity, - editor: Entity, - buffer_subscriptions: HashMap, BufferSubscriptions>, workspace: WeakEntity, - focus_handle: FocusHandle, - pending_scroll: Option, - review_comment_count: usize, - _task: Task>, - _subscription: Subscription, + diff: Entity, + _diff_observation: Subscription, } impl ProjectDiff { pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { workspace.register_action(Self::deploy); - workspace.register_action(Self::deploy_branch_diff); - workspace.register_action(Self::compare_with_branch); + workspace.register_action( + |workspace, _: &git_actions::ViewUncommittedChanges, window, cx| { + Self::deploy_at(workspace, None, window, cx); + }, + ); + workspace.register_action( + |workspace, _: &git_actions::ViewUnstagedChanges, window, cx| { + UnstagedDiff::deploy_at(workspace, None, window, cx); + }, + ); + workspace.register_action( + |workspace, _: &git_actions::ViewStagedChanges, window, cx| { + StagedDiff::deploy_at(workspace, None, window, cx); + }, + ); workspace.register_action(|workspace, _: &Add, window, cx| { Self::deploy(workspace, &Diff, window, cx); }); @@ -111,216 +98,6 @@ impl ProjectDiff { Self::deploy_at(workspace, None, window, cx) } - fn deploy_branch_diff( - workspace: &mut Workspace, - _: &BranchDiff, - window: &mut Window, - cx: &mut Context, - ) { - telemetry::event!("Git Branch Diff Opened"); - let project = workspace.project().clone(); - let Some(intended_repo) = project.read(cx).active_repository(cx) else { - let workspace = cx.entity().downgrade(); - window - .spawn(cx, async |_cx| { - let result: Result<()> = Err(anyhow!("No active repository")); - result - }) - .detach_and_notify_err(workspace, window, cx); - return; - }; - - let default_branch = intended_repo.update(cx, |repo, _| repo.default_branch(true)); - let workspace = cx.entity(); - let workspace_weak = workspace.downgrade(); - window - .spawn(cx, async move |cx| { - let base_ref = default_branch - .await?? - .context("Could not determine default branch")?; - - workspace.update_in(cx, |workspace, window, cx| { - Self::deploy_branch_diff_with_base_ref( - workspace, - project, - intended_repo, - base_ref, - window, - cx, - ); - })?; - - anyhow::Ok(()) - }) - .detach_and_notify_err(workspace_weak, window, cx); - } - - fn compare_with_branch( - workspace: &mut Workspace, - _: &CompareWithBranch, - window: &mut Window, - cx: &mut Context, - ) { - let project = workspace.project().clone(); - let Some(repository) = project.read(cx).active_repository(cx) else { - let workspace = cx.entity().downgrade(); - window - .spawn(cx, async |_cx| { - let result: Result<()> = Err(anyhow!("No active repository")); - result - }) - .detach_and_notify_err(workspace, window, cx); - return; - }; - let selected_branch = workspace.active_item_as::(cx).and_then(|item| { - match item.read(cx).diff_base(cx) { - DiffBase::Merge { base_ref } => Some(base_ref.clone()), - DiffBase::Head => None, - } - }); - let workspace_handle = workspace.weak_handle(); - let on_select = Arc::new({ - let repository = repository.clone(); - let workspace = workspace_handle.clone(); - move |branch: git::repository::Branch, window: &mut Window, cx: &mut App| { - let base_ref: SharedString = branch.name().to_owned().into(); - workspace - .update(cx, |workspace, cx| { - Self::deploy_branch_diff_with_base_ref( - workspace, - project.clone(), - repository.clone(), - base_ref, - window, - cx, - ); - }) - .ok(); - } - }); - - workspace.toggle_modal(window, cx, |window, cx| { - branch_picker::select_modal( - workspace_handle, - Some(repository), - selected_branch, - on_select, - window, - cx, - ) - }); - } - - fn deploy_branch_diff_with_base_ref( - workspace: &mut Workspace, - project: Entity, - intended_repo: Entity, - base_ref: SharedString, - window: &mut Window, - cx: &mut Context, - ) { - let existing = workspace.items_of_type::(cx).find(|item| { - let item = item.read(cx); - matches!( - item.diff_base(cx), - DiffBase::Merge { base_ref: existing_base_ref } if existing_base_ref == &base_ref - ) - }); - if let Some(existing) = existing { - workspace.activate_item(&existing, true, true, window, cx); - - let needs_switch = existing - .read(cx) - .branch_diff - .read(cx) - .repo() - .map_or(true, |current| { - current.read(cx).id != intended_repo.read(cx).id - }); - - if needs_switch { - existing.update(cx, |project_diff, cx| { - project_diff.branch_diff.update(cx, |branch_diff, cx| { - branch_diff.set_repo(Some(intended_repo), cx); - }); - }); - } - - return; - } - - let workspace = cx.entity(); - let workspace_weak = workspace.downgrade(); - window - .spawn(cx, async move |cx| { - let this = cx - .update(|window, cx| { - Self::new_with_branch_base( - project, - workspace.clone(), - base_ref, - intended_repo, - window, - cx, - ) - })? - .await?; - workspace - .update_in(cx, |workspace, window, cx| { - workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx); - }) - .ok(); - anyhow::Ok(()) - }) - .detach_and_notify_err(workspace_weak, window, cx); - } - - fn review_diff(&mut self, _: &ReviewDiff, window: &mut Window, cx: &mut Context) { - let diff_base = self.diff_base(cx).clone(); - let DiffBase::Merge { base_ref } = diff_base else { - return; - }; - - let Some(repo) = self.branch_diff.read(cx).repo().cloned() else { - return; - }; - - let diff_receiver = repo.update(cx, |repo, cx| { - repo.diff( - DiffType::MergeBase { - base_ref: base_ref.clone(), - }, - cx, - ) - }); - - let workspace = self.workspace.clone(); - - window - .spawn(cx, { - let workspace = workspace.clone(); - async move |cx| { - let diff_text = diff_receiver.await??; - - if let Some(workspace) = workspace.upgrade() { - workspace.update_in(cx, |_workspace, window, cx| { - window.dispatch_action( - ReviewBranchDiff { - diff_text: diff_text.into(), - base_ref, - } - .boxed_clone(), - cx, - ); - })?; - } - - anyhow::Ok(()) - } - }) - .detach_and_notify_err(workspace, window, cx); - } - pub fn deploy_at( workspace: &mut Workspace, entry: Option, @@ -337,9 +114,7 @@ impl ProjectDiff { ); let intended_repo = workspace.project().read(cx).active_repository(cx); - let existing = workspace - .items_of_type::(cx) - .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head)); + let existing = workspace.items_of_type::(cx).next(); let project_diff = if let Some(existing) = existing { existing.update(cx, |project_diff, cx| { project_diff.move_to_beginning(window, cx); @@ -364,15 +139,11 @@ impl ProjectDiff { if let Some(intended) = &intended_repo { let needs_switch = project_diff .read(cx) - .branch_diff - .read(cx) - .repo() + .repo(cx) .map_or(true, |current| current.read(cx).id != intended.read(cx).id); if needs_switch { project_diff.update(cx, |project_diff, cx| { - project_diff.branch_diff.update(cx, |branch_diff, cx| { - branch_diff.set_repo(Some(intended.clone()), cx); - }); + project_diff.set_repo(Some(intended.clone()), cx); }); } } @@ -391,9 +162,7 @@ impl ProjectDiff { cx: &mut Context, ) { telemetry::event!("Git Diff Opened", source = "Agent Panel"); - let existing = workspace - .items_of_type::(cx) - .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head)); + let existing = workspace.items_of_type::(cx).next(); let project_diff = if let Some(existing) = existing { workspace.activate_item(&existing, true, true, window, cx); existing @@ -416,71 +185,7 @@ impl ProjectDiff { } pub fn autoscroll(&self, cx: &mut Context) { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |editor, cx| { - editor.request_autoscroll(Autoscroll::fit(), cx); - }) - }) - } - - #[cfg(test)] - #[allow(dead_code)] - fn new_with_default_branch( - project: Entity, - workspace: Entity, - window: &mut Window, - cx: &mut App, - ) -> Task>> { - let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else { - return Task::ready(Err(anyhow!("No active repository"))); - }; - let main_branch = repo.update(cx, |repo, _| repo.default_branch(true)); - window.spawn(cx, async move |cx| { - let main_branch = main_branch - .await?? - .context("Could not determine default branch")?; - - let branch_diff = cx.new_window_entity(|window, cx| { - let mut branch_diff = branch_diff::BranchDiff::new( - DiffBase::Merge { - base_ref: main_branch, - }, - project.clone(), - window, - cx, - ); - branch_diff.set_repo(Some(repo.clone()), cx); - branch_diff - })?; - cx.new_window_entity(|window, cx| { - Self::new_impl(branch_diff, project, workspace, window, cx) - }) - }) - } - - fn new_with_branch_base( - project: Entity, - workspace: Entity, - base_ref: SharedString, - repo: Entity, - window: &mut Window, - cx: &mut App, - ) -> Task>> { - window.spawn(cx, async move |cx| { - let branch_diff = cx.new_window_entity(|window, cx| { - let mut branch_diff = branch_diff::BranchDiff::new( - DiffBase::Merge { base_ref }, - project.clone(), - window, - cx, - ); - branch_diff.set_repo(Some(repo.clone()), cx); - branch_diff - })?; - cx.new_window_entity(|window, cx| { - Self::new_impl(branch_diff, project, workspace, window, cx) - }) - }) + self.diff.update(cx, |diff, cx| diff.autoscroll(cx)); } fn new( @@ -489,142 +194,69 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) -> Self { - let branch_diff = - cx.new(|cx| branch_diff::BranchDiff::new(DiffBase::Head, project.clone(), window, cx)); + let branch_diff = cx.new(|cx| { + diff_buffer_list::DiffBufferList::new(DiffBase::Head, project.clone(), window, cx) + }); Self::new_impl(branch_diff, project, workspace, window, cx) } fn new_impl( - branch_diff: Entity, + branch_diff: Entity, project: Entity, workspace: Entity, window: &mut Window, cx: &mut Context, ) -> Self { - let focus_handle = cx.focus_handle(); - let multibuffer = cx.new(|cx| { - let mut multibuffer = MultiBuffer::new(Capability::ReadWrite); - multibuffer.set_all_diff_hunks_expanded(cx); - multibuffer - }); - - let editor = cx.new(|cx| { - let diff_display_editor = SplittableEditor::new( - EditorSettings::get_global(cx).diff_view_style, - multibuffer.clone(), + let workspace_handle = workspace.downgrade(); + let diff = cx.new(|cx| { + DiffMultibuffer::new( + branch_diff, + Capability::ReadWrite, + "No uncommitted changes", + move |editor, cx| { + editor.set_diff_hunk_delegate(Some(Arc::new(UncommittedDiffHunkDelegate)), cx); + editor.rhs_editor().update(cx, |rhs_editor, _cx| { + rhs_editor.set_read_only(false); + rhs_editor.register_addon(GitPanelAddon { + workspace: workspace_handle, + }); + }); + }, project.clone(), workspace.clone(), window, cx, - ); - match branch_diff.read(cx).diff_base() { - DiffBase::Head => {} - DiffBase::Merge { .. } => diff_display_editor.disable_diff_hunk_controls(cx), - } - diff_display_editor.rhs_editor().update(cx, |editor, cx| { - editor.set_show_diff_review_button(true, cx); - - match branch_diff.read(cx).diff_base() { - DiffBase::Head => { - editor.register_addon(GitPanelAddon { - workspace: workspace.downgrade(), - }); - } - DiffBase::Merge { .. } => { - editor.register_addon(BranchDiffAddon { - branch_diff: branch_diff.clone(), - }); - } - } - }); - diff_display_editor - }); - let editor_subscription = cx.subscribe_in(&editor, window, Self::handle_editor_event); - - let primary_editor = editor.read(cx).rhs_editor().clone(); - let review_comment_subscription = - cx.subscribe(&primary_editor, |this, _editor, event: &EditorEvent, cx| { - if let EditorEvent::ReviewCommentsChanged { total_count } = event { - this.review_comment_count = *total_count; - cx.notify(); - } - }); - - let branch_diff_subscription = cx.subscribe_in( - &branch_diff, - window, - move |this, _git_store, event, window, cx| match event { - BranchDiffEvent::FileListChanged => { - this._task = window.spawn(cx, { - let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await - }) - } - BranchDiffEvent::DiffBaseChanged => { - this.pending_scroll.take(); - this._task = window.spawn(cx, { - let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await - }) - } - }, - ); - - let mut was_sort_by = GitPanelSettings::get_global(cx).sort_by; - let mut was_group_by = GitPanelSettings::get_global(cx).group_by; - let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; - let mut was_collapse_untracked_diff = - GitPanelSettings::get_global(cx).collapse_untracked_diff; - cx.observe_global_in::(window, move |this, window, cx| { - let settings = GitPanelSettings::get_global(cx); - let sort_by = settings.sort_by; - let group_by = settings.group_by; - let tree_view = settings.tree_view; - let is_collapse_untracked_diff = settings.collapse_untracked_diff; - if sort_by != was_sort_by - || group_by != was_group_by - || tree_view != was_tree_view - || is_collapse_untracked_diff != was_collapse_untracked_diff - { - this._task = { - window.spawn(cx, { - let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await - }) - } - } - was_sort_by = sort_by; - was_group_by = group_by; - was_tree_view = tree_view; - was_collapse_untracked_diff = is_collapse_untracked_diff; - }) - .detach(); - - let task = window.spawn(cx, { - let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await + ) }); + Self::from_diff(diff, project, workspace, cx) + } + fn from_diff( + diff: Entity, + project: Entity, + workspace: Entity, + cx: &mut Context, + ) -> Self { + let observation = cx.observe(&diff, |_, _, cx| cx.notify()); Self { project, workspace: workspace.downgrade(), - branch_diff, - focus_handle, - editor, - multibuffer, - buffer_subscriptions: Default::default(), - pending_scroll: None, - review_comment_count: 0, - _task: task, - _subscription: Subscription::join( - branch_diff_subscription, - Subscription::join(editor_subscription, review_comment_subscription), - ), + diff, + _diff_observation: observation, } } pub fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase { - self.branch_diff.read(cx).diff_base() + self.diff.read(cx).diff_base(cx) + } + + pub(crate) fn repo(&self, cx: &App) -> Option> { + self.diff.read(cx).repo(cx) + } + + pub(crate) fn set_repo(&mut self, repo: Option>, cx: &mut Context) { + self.diff + .update(cx, |diff, cx| diff.set_repo(repo.clone(), cx)); } pub fn move_to_entry( @@ -633,13 +265,8 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) { - let Some(git_repo) = self.branch_diff.read(cx).repo() else { - return; - }; - let repo = git_repo.read(cx); - let path_key = project_diff_path_key(repo, &entry.repo_path, entry.status, cx); - - self.move_to_path(path_key, window, cx) + self.diff + .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); } pub fn move_to_project_path( @@ -648,91 +275,42 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) { - let Some(git_repo) = self.branch_diff.read(cx).repo() else { - return; - }; - let Some(repo_path) = git_repo - .read(cx) - .project_path_to_repo_path(project_path, cx) - else { - return; - }; - let status = git_repo - .read(cx) - .status_for_path(&repo_path) - .map(|entry| entry.status) - .unwrap_or(FileStatus::Untracked); - let path_key = project_diff_path_key(&git_repo.read(cx), &repo_path, status, cx); - self.move_to_path(path_key, window, cx) - } - - fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |editor, cx| { - editor.change_selections(Default::default(), window, cx, |s| { - s.select_ranges(vec![multi_buffer::Anchor::Min..multi_buffer::Anchor::Min]); - }); - }); + self.diff.update(cx, |diff, cx| { + diff.move_to_project_path(project_path, window, cx) }); } - fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context) { - if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |editor, cx| { - editor.change_selections( - SelectionEffects::scroll(Autoscroll::focused()), - window, - cx, - |s| { - s.select_ranges([position..position]); - }, - ) - }) - }); - } else { - self.pending_scroll = Some(path_key); - } + fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) { + self.diff + .update(cx, |diff, cx| diff.move_to_beginning(window, cx)); } pub fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) { - self.multibuffer.read(cx).snapshot(cx).total_changed_lines() + self.diff.read(cx).calculate_changed_lines(cx) } /// Returns the total count of review comments across all hunks/files. - pub fn total_review_comment_count(&self) -> usize { - self.review_comment_count + pub fn total_review_comment_count(&self, cx: &App) -> usize { + self.diff.read(cx).total_review_comment_count() } - /// Returns a reference to the splittable editor. - pub fn editor(&self) -> &Entity { - &self.editor + /// Returns the splittable editor of the currently-shown diff view. + pub fn editor(&self, cx: &App) -> Entity { + self.diff.read(cx).editor().clone() + } + + /// Returns the multibuffer of the currently-shown diff view. + pub fn multibuffer(&self, cx: &App) -> Entity { + self.diff.read(cx).multibuffer().clone() } fn button_states(&self, cx: &App) -> ButtonStates { - let editor = self.editor.read(cx).rhs_editor().read(cx); - let snapshot = self.multibuffer.read(cx).snapshot(cx); + let diff = self.diff.read(cx); + let editor = diff.editor().read(cx).rhs_editor().clone(); + let editor = editor.read(cx); + let snapshot = diff.multibuffer().read(cx).snapshot(cx); let prev_next = snapshot.diff_hunks().nth(1).is_some(); - let mut selection = true; - - let mut ranges = editor - .selections - .disjoint_anchor_ranges() - .collect::>(); - if !ranges.iter().any(|range| range.start != range.end) { - selection = false; - let anchor = editor.selections.newest_anchor().head(); - if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor) - && let Some(range) = snapshot - .anchor_in_buffer(excerpt_range.context.start) - .zip(snapshot.anchor_in_buffer(excerpt_range.context.end)) - .map(|(start, end)| start..end) - { - ranges = vec![range]; - } else { - ranges = Vec::default(); - }; - } + let (selection, ranges) = diff.selected_ranges(cx); let mut has_staged_hunks = false; let mut has_unstaged_hunks = false; for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) { @@ -773,448 +351,31 @@ impl ProjectDiff { } } - fn handle_editor_event( - &mut self, - editor: &Entity, - event: &EditorEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - EditorEvent::SelectionsChanged { local: true } => { - let Some(project_path) = self.active_project_path(cx) else { - return; - }; - self.workspace - .update(cx, |workspace, cx| { - if let Some(git_panel) = workspace.panel::(cx) { - git_panel.update(cx, |git_panel, cx| { - git_panel.select_entry_by_path(project_path, window, cx) - }) - } - }) - .ok(); - } - EditorEvent::Saved => { - self._task = - cx.spawn_in(window, async move |this, cx| Self::refresh(this, cx).await); - } - _ => {} - } - if editor.focus_handle(cx).contains_focused(window, cx) - && self.multibuffer.read(cx).is_empty() - { - self.focus_handle.focus(window, cx) - } - } - - #[instrument(skip_all)] - fn register_buffer( - &mut self, - path_key: PathKey, - file_status: FileStatus, - buffer: Entity, - diff: Entity, - conflict_set: Entity, - window: &mut Window, - cx: &mut Context, - ) -> Option { - let diff_subscription = cx.subscribe_in(&diff, window, { - let path_key = path_key.clone(); - let buffer = buffer.clone(); - let diff = diff.clone(); - let conflict_set = conflict_set.clone(); - move |this, _, event, window, cx| match event { - buffer_diff::BufferDiffEvent::DiffChanged(_) => { - this.buffer_ranges_changed( - path_key.clone(), - file_status, - buffer.clone(), - diff.clone(), - conflict_set.clone(), - window, - cx, - ); - } - buffer_diff::BufferDiffEvent::BaseTextChanged - | buffer_diff::BufferDiffEvent::HunksStagedOrUnstaged(_) => {} - } - }); - let conflict_set_subscription = cx.subscribe_in(&conflict_set, window, { - let path_key = path_key.clone(); - let buffer = buffer.clone(); - let diff = diff.clone(); - let conflict_set = conflict_set.clone(); - move |this, _, _, window, cx| { - this.buffer_ranges_changed( - path_key.clone(), - file_status, - buffer.clone(), - diff.clone(), - conflict_set.clone(), - window, - cx, - ) - } - }); - self.buffer_subscriptions.insert( - path_key.path.clone(), - BufferSubscriptions { - _diff: diff.clone(), - _diff_subscription: diff_subscription, - _conflict_set: conflict_set.clone(), - _conflict_set_subscription: conflict_set_subscription, - }, - ); - - let snapshot = buffer.read(cx).snapshot(); - let diff_snapshot = diff.read(cx).snapshot(cx); - - let excerpt_ranges = { - let diff_hunk_ranges = diff_snapshot - .hunks_intersecting_range( - Anchor::min_max_range_for_buffer(snapshot.remote_id()), - &snapshot, - ) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)); - let conflicts = conflict_set.read(cx).snapshot(); - let mut conflicts = conflicts - .conflicts - .iter() - .map(|conflict| conflict.range.to_point(&snapshot)) - .peekable(); - - if conflicts.peek().is_some() { - conflicts.collect::>() - } else { - diff_hunk_ranges.collect() - } - }; - - let buffer_id = snapshot.text.remote_id(); - let mut needs_fold = false; - - let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| { - let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty(); - let is_newly_added = editor.update_excerpts_for_path( - path_key.clone(), - buffer, - excerpt_ranges, - multibuffer_context_lines(cx), - diff, - cx, - ); - editor.rhs_editor().update(cx, |editor, cx| { - conflict_view::buffer_ranges_updated(editor, conflict_set, cx); - }); - (was_empty, is_newly_added) - }); - - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |editor, cx| { - if was_empty { - editor.change_selections( - SelectionEffects::no_scroll(), - window, - cx, - |selections| { - selections.select_ranges([ - multi_buffer::Anchor::Min..multi_buffer::Anchor::Min - ]) - }, - ); - } - if is_excerpt_newly_added - && (file_status.is_deleted() - || (file_status.is_untracked() - && GitPanelSettings::get_global(cx).collapse_untracked_diff)) - { - needs_fold = true; - } - }) - }); - - if self.multibuffer.read(cx).is_empty() - && self - .editor - .read(cx) - .focus_handle(cx) - .contains_focused(window, cx) - { - self.focus_handle.focus(window, cx); - } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { - self.editor.update(cx, |editor, cx| { - editor.focus_handle(cx).focus(window, cx); - }); - } - if self.pending_scroll.as_ref() == Some(&path_key) { - self.move_to_path(path_key, window, cx); - } - - needs_fold.then_some(buffer_id) - } - - fn buffer_ranges_changed( - &mut self, - path_key: PathKey, - file_status: FileStatus, - buffer: Entity, - diff: Entity, - conflict_set: Entity, - window: &mut Window, - cx: &mut Context, - ) { - if buffer.read(cx).is_dirty() { - return; - } - self.register_buffer( - path_key, - file_status, - buffer, - diff, - conflict_set, - window, - cx, - ); - } - - #[instrument(skip(this, cx))] - pub async fn refresh(this: WeakEntity, cx: &mut AsyncWindowContext) -> Result<()> { - let entries = this.update(cx, |this, cx| { - let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| { - let load_buffers = branch_diff.load_buffers(cx); - (branch_diff.repo().cloned(), load_buffers) - }); - let mut previous_paths = this - .multibuffer - .read(cx) - .snapshot(cx) - .buffers_with_paths() - .map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id())) - .collect::>(); - - let mut entries = BTreeMap::new(); - if let Some(repo) = repo { - let repo = repo.read(cx); - for diff_buffer in buffers_to_load { - let path_key = project_diff_path_key( - &repo, - &diff_buffer.repo_path, - diff_buffer.file_status, - cx, - ); - previous_paths.remove(&path_key); - entries.insert(path_key, diff_buffer); - } - } - - this.editor.update(cx, |editor, cx| { - for (path, buffer_id) in previous_paths { - this.buffer_subscriptions.remove(&path.path); - editor.rhs_editor().update(cx, |editor, cx| { - conflict_view::buffers_removed(editor, &[buffer_id], cx); - }); - let _span = ztracing::info_span!("remove_excerpts_for_path"); - _span.enter(); - editor.remove_excerpts_for_path(path, cx); - } - }); - - entries - })?; - - let mut buffers_to_fold = Vec::new(); - - for (path_key, entry) in entries { - if let Some((buffer, diff, conflict_set)) = entry.load.await.log_err() { - // We might be lagging behind enough that all future entry.load futures are no longer pending. - // If that is the case, this task will never yield, starving the foreground thread of execution time. - yield_now().await; - cx.update(|window, cx| { - this.update(cx, |this, cx| { - if let Some(buffer_id) = this.register_buffer( - path_key, - entry.file_status, - buffer, - diff, - conflict_set, - window, - cx, - ) { - buffers_to_fold.push(buffer_id); - } - }) - .ok(); - })?; - } - } - this.update(cx, |this, cx| { - if !buffers_to_fold.is_empty() { - this.editor.update(cx, |editor, cx| { - editor - .rhs_editor() - .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx)); - }); - } - this.pending_scroll.take(); - cx.notify(); - })?; - - Ok(()) - } - #[cfg(any(test, feature = "test-support"))] pub fn excerpt_paths(&self, cx: &App) -> Vec> { - let snapshot = self - .editor() - .read(cx) - .rhs_editor() - .read(cx) - .buffer() - .read(cx) - .snapshot(cx); - snapshot - .excerpts() - .map(|excerpt| { - snapshot - .path_for_buffer(excerpt.context.start.buffer_id) - .unwrap() - .path - .clone() - }) - .collect() + self.diff.read(cx).excerpt_paths(cx) } - /// Returns the real (worktree-relative) path of each excerpted buffer, in - /// the order the excerpts appear in the multibuffer. Unlike - /// [`Self::excerpt_paths`], this resolves the buffer's actual `File` rather - /// than the (possibly synthetic) `PathKey` path used for sorting. #[cfg(any(test, feature = "test-support"))] pub fn excerpt_file_paths(&self, cx: &App) -> Vec { - let multibuffer = self - .editor() - .read(cx) - .rhs_editor() - .read(cx) - .buffer() - .clone(); - let snapshot = multibuffer.read(cx).snapshot(cx); - let mut result = Vec::new(); - let mut last_buffer_id = None; - for excerpt in snapshot.excerpts() { - let buffer_id = excerpt.context.start.buffer_id; - if last_buffer_id == Some(buffer_id) { - continue; - } - last_buffer_id = Some(buffer_id); - if let Some(buffer) = multibuffer.read(cx).buffer(buffer_id) - && let Some(file) = buffer.read(cx).file() - { - result.push(file.path().as_unix_str().to_string()); - } - } - result + self.diff.read(cx).excerpt_file_paths(cx) } } -const CONFLICT_SORT_PREFIX: u64 = 1; -const TRACKED_SORT_PREFIX: u64 = 2; -const NEW_SORT_PREFIX: u64 = 3; - -/// Computes a stable [`PathKey`] for a buffer in the project diff. -/// -/// The key is an intrinsic function of the file's own repo path and status; it -/// never depends on which other buffers happen to be present in the -/// multibuffer. This is required because the multibuffer uses the path key both -/// to order excerpts and to identify which excerpts belong to a given buffer, so -/// a key that shifted as files were added or removed would break that identity. -/// -/// Status grouping is encoded in the `sort_prefix`, and the within-group order -/// is encoded in the (possibly synthetic) path so that `PathKey`'s natural -/// ordering reproduces the git panel's order. The path here is only ever used -/// for sorting and multibuffer identity; the path shown in the UI comes from the -/// buffer's own `File`. -fn project_diff_path_key( - repo: &Repository, - repo_path: &RepoPath, - status: FileStatus, - cx: &App, -) -> PathKey { - let settings = GitPanelSettings::get_global(cx); - let sort_prefix = if settings.group_by != GitPanelGroupBy::Status { - TRACKED_SORT_PREFIX - } else if repo.had_conflict_on_last_merge_head_change(repo_path) { - CONFLICT_SORT_PREFIX - } else if status.is_created() { - NEW_SORT_PREFIX - } else { - TRACKED_SORT_PREFIX - }; - let path = project_diff_sort_path(repo_path, settings.tree_view, settings.sort_by); - PathKey::with_sort_prefix(sort_prefix, path) -} - -fn project_diff_sort_path( - repo_path: &RelPath, - tree_view: bool, - sort_by: GitPanelSortBy, -) -> Arc { - if tree_view { - tree_sort_path(repo_path) - } else { - match sort_by { - GitPanelSortBy::Path => repo_path.into_arc(), - GitPanelSortBy::Name => name_sort_path(repo_path), - } - } -} - -/// Builds a synthetic path that sorts by file name first, falling back to the -/// full path to keep the key unique per file. -fn name_sort_path(repo_path: &RelPath) -> Arc { - let Some(file_name) = repo_path.file_name() else { - return repo_path.into_arc(); - }; - let synthetic = format!("{}/{}", file_name, repo_path.as_unix_str()); - RelPath::unix(&synthetic) - .map(|path| path.into_arc()) - .unwrap_or_else(|_| repo_path.into_arc()) -} - -/// Builds a synthetic path whose natural component-wise ordering reproduces a -/// folder-first tree order. Each directory component is prefixed with a NUL -/// byte, which can never appear in a real path component and sorts before every -/// printable character, so at each level directories sort before files. -fn tree_sort_path(repo_path: &RelPath) -> Arc { - let components: Vec<&str> = repo_path.components().collect(); - if components.len() <= 1 { - return repo_path.into_arc(); - } - let last = components.len() - 1; - let mut synthetic = String::new(); - for (index, component) in components.into_iter().enumerate() { - if index > 0 { - synthetic.push('/'); - } - if index < last { - synthetic.push('\0'); - } - synthetic.push_str(component); - } - RelPath::unix(&synthetic) - .map(|path| path.into_arc()) - .unwrap_or_else(|_| repo_path.into_arc()) +struct ButtonStates { + stage: bool, + unstage: bool, + prev_next: bool, + selection: bool, + stage_all: bool, + unstage_all: bool, } impl EventEmitter for ProjectDiff {} impl Focusable for ProjectDiff { fn focus_handle(&self, cx: &App) -> FocusHandle { - if self.multibuffer.read(cx).is_empty() { - self.focus_handle.clone() - } else { - self.editor.focus_handle(cx) - } + self.diff.read(cx).focus_handle(cx) } } @@ -1230,11 +391,8 @@ impl Item for ProjectDiff { } fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |primary_editor, cx| { - primary_editor.deactivated(window, cx); - }) - }); + self.diff + .update(cx, |diff, cx| diff.deactivated(window, cx)); } fn navigate( @@ -1243,18 +401,12 @@ impl Item for ProjectDiff { window: &mut Window, cx: &mut Context, ) -> bool { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |primary_editor, cx| { - primary_editor.navigate(data, window, cx) - }) - }) + self.diff + .update(cx, |diff, cx| diff.navigate(data, window, cx)) } fn tab_tooltip_text(&self, cx: &App) -> Option { - match self.diff_base(cx) { - DiffBase::Head => Some("Project Diff".into()), - DiffBase::Merge { .. } => Some("Branch Diff".into()), - } + Some(self.tab_content_text(0, cx)) } fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { @@ -1267,19 +419,16 @@ impl Item for ProjectDiff { .into_any_element() } - fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { - match self.branch_diff.read(cx).diff_base() { - DiffBase::Head => "Uncommitted Changes".into(), - DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(), - } + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Uncommitted Changes".into() } fn telemetry_event_text(&self) -> Option<&'static str> { Some("Project Diff Opened") } - fn as_searchable(&self, _: &Entity, _cx: &App) -> Option> { - Some(Box::new(self.editor.clone())) + fn as_searchable(&self, _: &Entity, cx: &App) -> Option> { + Some(Box::new(self.diff.read(cx).editor().clone())) } fn for_each_project_item( @@ -1287,26 +436,11 @@ impl Item for ProjectDiff { cx: &App, f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), ) { - self.editor - .read(cx) - .rhs_editor() - .read(cx) - .for_each_project_item(cx, f) + self.diff.read(cx).for_each_project_item(cx, f) } fn active_project_path(&self, cx: &App) -> Option { - let editor = self.editor.read(cx).focused_editor().read(cx); - let multibuffer = editor.buffer().read(cx); - let position = editor.selections.newest_anchor().head(); - let snapshot = multibuffer.snapshot(cx); - let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?; - let buffer = multibuffer.buffer(text_anchor.buffer_id)?; - - let file = buffer.read(cx).file()?; - Some(ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path().clone(), - }) + self.diff.read(cx).active_project_path(cx) } fn set_nav_history( @@ -1315,11 +449,8 @@ impl Item for ProjectDiff { _: &mut Window, cx: &mut Context, ) { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |primary_editor, _| { - primary_editor.set_nav_history(Some(nav_history)); - }) - }); + self.diff + .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx)); } fn can_split(&self) -> bool { @@ -1344,11 +475,11 @@ impl Item for ProjectDiff { } fn is_dirty(&self, cx: &App) -> bool { - self.multibuffer.read(cx).is_dirty(cx) + self.diff.read(cx).is_dirty(cx) } fn has_conflict(&self, cx: &App) -> bool { - self.multibuffer.read(cx).has_conflict(cx) + self.diff.read(cx).has_conflict(cx) } fn can_save(&self, _: &App) -> bool { @@ -1362,11 +493,8 @@ impl Item for ProjectDiff { window: &mut Window, cx: &mut Context, ) -> Task> { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |primary_editor, cx| { - primary_editor.save(options, project, window, cx) - }) - }) + self.diff + .update(cx, |diff, cx| diff.save(options, project, window, cx)) } fn save_as( @@ -1385,11 +513,8 @@ impl Item for ProjectDiff { window: &mut Window, cx: &mut Context, ) -> Task> { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |primary_editor, cx| { - primary_editor.reload(project, window, cx) - }) - }) + self.diff + .update(cx, |diff, cx| diff.reload(project, window, cx)) } fn act_as_type<'a>( @@ -1401,9 +526,19 @@ impl Item for ProjectDiff { if type_id == TypeId::of::() { Some(self_handle.clone().into()) } else if type_id == TypeId::of::() { - Some(self.editor.read(cx).rhs_editor().clone().into()) + Some( + self.diff + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + .into(), + ) } else if type_id == TypeId::of::() { - Some(self.editor.clone().into()) + Some(self.diff.read(cx).editor().clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).branch_diff().clone().into()) } else { None } @@ -1415,88 +550,15 @@ impl Item for ProjectDiff { window: &mut Window, cx: &mut Context, ) { - self.editor.update(cx, |editor, cx| { - editor.added_to_workspace(workspace, window, cx) + self.diff.update(cx, |diff, cx| { + diff.added_to_workspace(workspace, window, cx) }); } } impl Render for ProjectDiff { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let is_empty = self.multibuffer.read(cx).is_empty(); - let is_loading = self.branch_diff.read(cx).is_tree_base_loading() || !self._task.is_ready(); - - let is_branch_diff_view = matches!(self.diff_base(cx), DiffBase::Merge { .. }); - - div() - .track_focus(&self.focus_handle) - .key_context(if is_empty { "EmptyPane" } else { "GitDiff" }) - .when(is_branch_diff_view, |this| { - this.on_action(cx.listener(Self::review_diff)) - }) - .bg(cx.theme().colors().editor_background) - .flex() - .items_center() - .justify_center() - .size_full() - .when(is_empty && is_loading, |el| { - let rems = TextSize::Large.rems(cx); - el.child( - Icon::new(IconName::LoadCircle) - .size(IconSize::Custom(rems)) - .color(Color::Accent) - .with_rotate_animation(3) - .into_any_element(), - ) - }) - .when(is_empty && !is_loading, |el| { - let remote_button = if let Some(panel) = self - .workspace - .upgrade() - .and_then(|workspace| workspace.read(cx).panel::(cx)) - { - panel.update(cx, |panel, cx| panel.render_remote_button(cx)) - } else { - None - }; - let keybinding_focus_handle = self.focus_handle(cx); - el.child( - v_flex() - .gap_1() - .child( - h_flex() - .justify_around() - .child(Label::new("No uncommitted changes")), - ) - .map(|el| match remote_button { - Some(button) => el.child(h_flex().justify_around().child(button)), - None => el.child( - h_flex() - .justify_around() - .child(Label::new("Remote up to date")), - ), - }) - .child( - h_flex().justify_around().mt_1().child( - Button::new("project-diff-close-button", "Close") - // .style(ButtonStyle::Transparent) - .key_binding(KeyBinding::for_action_in( - &CloseActiveItem::default(), - &keybinding_focus_handle, - cx, - )) - .on_click(move |_, window, cx| { - window.focus(&keybinding_focus_handle, cx); - window.dispatch_action( - Box::new(CloseActiveItem::default()), - cx, - ); - }), - ), - ), - ) - }) - .when(!is_empty, |el| el.child(self.editor.clone())) + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.diff.clone()) } } @@ -1517,46 +579,38 @@ impl SerializableItem for ProjectDiff { fn deserialize( project: Entity, workspace: WeakEntity, - workspace_id: workspace::WorkspaceId, - item_id: workspace::ItemId, + _workspace_id: workspace::WorkspaceId, + _item_id: workspace::ItemId, window: &mut Window, cx: &mut App, ) -> Task>> { - let db = persistence::ProjectDiffDb::global(cx); window.spawn(cx, async move |cx| { - let diff_base = db.get_diff_base(item_id, workspace_id)?; - - let diff = cx.update(|window, cx| { - let branch_diff = cx - .new(|cx| branch_diff::BranchDiff::new(diff_base, project.clone(), window, cx)); + cx.update(|window, cx| { + let branch_diff = cx.new(|cx| { + diff_buffer_list::DiffBufferList::new( + DiffBase::Head, + project.clone(), + window, + cx, + ) + }); let workspace = workspace.upgrade().context("workspace gone")?; anyhow::Ok( cx.new(|cx| ProjectDiff::new_impl(branch_diff, project, workspace, window, cx)), ) - })??; - - Ok(diff) + })? }) } fn serialize( &mut self, - workspace: &mut Workspace, - item_id: workspace::ItemId, - _closing: bool, - _window: &mut Window, - cx: &mut Context, + _: &mut Workspace, + _: workspace::ItemId, + _: bool, + _: &mut Window, + _: &mut Context, ) -> Option>> { - let workspace_id = workspace.database_id()?; - let diff_base = self.diff_base(cx).clone(); - - let db = persistence::ProjectDiffDb::global(cx); - Some(cx.background_spawn({ - async move { - db.save_diff_base(item_id, workspace_id, diff_base.clone()) - .await - } - })) + Some(Task::ready(Ok(()))) } fn should_serialize(&self, _: &Self::Event) -> bool { @@ -1564,14 +618,14 @@ impl SerializableItem for ProjectDiff { } } -mod persistence { +pub(crate) mod persistence { use anyhow::Context as _; use db::{ sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, sqlez_macros::sql, }; - use project::git_store::branch_diff::DiffBase; + use project::git_store::diff_buffer_list::DiffBase; use workspace::{ItemId, WorkspaceDb, WorkspaceId}; pub struct ProjectDiffDb(ThreadSafeConnection); @@ -1579,7 +633,11 @@ mod persistence { impl Domain for ProjectDiffDb { const NAME: &str = stringify!(ProjectDiffDb); - const MIGRATIONS: &[&str] = &[sql!( + // Legacy databases stored branch diffs under the "ProjectDiff" item + // kind, disambiguated by the `diff_base` column. Step 1 rewrites those + // item kinds so that each diff view owns its serialized kind. + const MIGRATIONS: &[&str] = &[ + sql!( CREATE TABLE project_diffs( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -1590,13 +648,23 @@ mod persistence { FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; - )]; + ), + r#" + UPDATE items SET kind = 'BranchDiff' + WHERE kind = 'ProjectDiff' AND EXISTS ( + SELECT 1 FROM project_diffs + WHERE project_diffs.item_id = items.item_id + AND project_diffs.workspace_id = items.workspace_id + AND project_diffs.diff_base LIKE '{"Merge"%' + ); + "#, + ]; } db::static_connection!(ProjectDiffDb, [WorkspaceDb]); impl ProjectDiffDb { - pub async fn save_diff_base( + pub async fn save_project_diff_base( &self, item_id: ItemId, workspace_id: WorkspaceId, @@ -1616,7 +684,7 @@ mod persistence { .await } - pub fn get_diff_base( + pub fn get_project_diff_base( &self, item_id: ItemId, workspace_id: WorkspaceId, @@ -1702,7 +770,6 @@ impl ToolbarItemView for ProjectDiffToolbar { ) -> ToolbarItemLocation { self.project_diff = active_pane_item .and_then(|item| item.act_as::(cx)) - .filter(|item| item.read(cx).diff_base(cx) == &DiffBase::Head) .map(|entity| entity.downgrade()); if self.project_diff.is_some() { ToolbarItemLocation::PrimaryRight @@ -1720,15 +787,6 @@ impl ToolbarItemView for ProjectDiffToolbar { } } -struct ButtonStates { - stage: bool, - unstage: bool, - prev_next: bool, - selection: bool, - stage_all: bool, - unstage_all: bool, -} - impl Render for ProjectDiffToolbar { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(project_diff) = self.project_diff(cx) else { @@ -1736,10 +794,10 @@ impl Render for ProjectDiffToolbar { }; let focus_handle = project_diff.focus_handle(cx); let button_states = project_diff.read(cx).button_states(cx); - let review_count = project_diff.read(cx).total_review_comment_count(); + let review_count = project_diff.read(cx).total_review_comment_count(cx); let (additions, deletions) = project_diff.read(cx).calculate_changed_lines(cx); - let is_multibuffer_empty = project_diff.read(cx).multibuffer.read(cx).is_empty(); + let is_multibuffer_empty = project_diff.read(cx).multibuffer(cx).read(cx).is_empty(); h_flex() .my_neg_1() @@ -1890,7 +948,10 @@ impl Render for ProjectDiffToolbar { } } -fn render_send_review_to_agent_button(review_count: usize, focus_handle: &FocusHandle) -> Button { +pub(crate) fn render_send_review_to_agent_button( + review_count: usize, + focus_handle: &FocusHandle, +) -> Button { Button::new( "send-review", format!("Send Review to Agent ({})", review_count), @@ -1907,207 +968,19 @@ fn render_send_review_to_agent_button(review_count: usize, focus_handle: &FocusH )) } -pub struct BranchDiffToolbar { - project_diff: Option>, -} - -impl BranchDiffToolbar { - pub fn new(_cx: &mut Context) -> Self { - Self { project_diff: None } - } - - fn project_diff(&self, _: &App) -> Option> { - self.project_diff.as_ref()?.upgrade() - } - - fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) { - if let Some(project_diff) = self.project_diff(cx) { - project_diff.focus_handle(cx).focus(window, cx); - } - let action = action.boxed_clone(); - cx.defer(move |cx| { - cx.dispatch_action(action.as_ref()); - }) - } -} - -impl EventEmitter for BranchDiffToolbar {} - -impl ToolbarItemView for BranchDiffToolbar { - fn set_active_pane_item( - &mut self, - active_pane_item: Option<&dyn ItemHandle>, - _: &mut Window, - cx: &mut Context, - ) -> ToolbarItemLocation { - self.project_diff = active_pane_item - .and_then(|item| item.act_as::(cx)) - .filter(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. })) - .map(|entity| entity.downgrade()); - if self.project_diff.is_some() { - ToolbarItemLocation::PrimaryRight - } else { - ToolbarItemLocation::Hidden - } - } - - fn pane_focus_update( - &mut self, - _pane_focused: bool, - _window: &mut Window, - _cx: &mut Context, - ) { - } -} - -impl Render for BranchDiffToolbar { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let Some(project_diff) = self.project_diff(cx) else { - return div(); - }; - let focus_handle = project_diff.focus_handle(cx); - let review_count = project_diff.read(cx).total_review_comment_count(); - let (additions, deletions) = project_diff.read(cx).calculate_changed_lines(cx); - let diff_base = project_diff.read(cx).diff_base(cx).clone(); - let DiffBase::Merge { base_ref } = diff_base else { - return div(); - }; - let selected_base_ref = base_ref.clone(); - let base_ref_label = format!("Base: {base_ref}"); - let repository = project_diff.read(cx).branch_diff.read(cx).repo().cloned(); - let workspace = project_diff.read(cx).workspace.clone(); - let project_diff_for_picker = project_diff.downgrade(); - - let is_multibuffer_empty = project_diff.read(cx).multibuffer.read(cx).is_empty(); - let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx); - - let show_review_button = !is_multibuffer_empty && is_ai_enabled; - - h_flex() - .my_neg_1() - .py_1() - .gap_1p5() - .flex_wrap() - .justify_between() - .when(!is_multibuffer_empty, |this| { - this.child(DiffStat::new( - "branch-diff-stat", - additions as usize, - deletions as usize, - )) - }) - .child(Divider::vertical().ml_1()) - .child( - PopoverMenu::new("branch-diff-base-branch-picker") - .menu(move |window, cx| { - let project_diff = project_diff_for_picker.clone(); - let on_select = Arc::new( - move |branch: git::repository::Branch, - _window: &mut Window, - cx: &mut App| { - let base_ref: SharedString = branch.name().to_owned().into(); - project_diff - .update(cx, |project_diff, cx| { - let branch_diff = &mut project_diff.branch_diff; - branch_diff.update(cx, |branch_diff, cx| { - branch_diff - .set_diff_base(DiffBase::Merge { base_ref }, cx); - }); - cx.notify(); - }) - .ok(); - }, - ); - - Some(branch_picker::select_popover( - workspace.clone(), - repository.clone(), - Some(selected_base_ref.clone()), - on_select, - window, - cx, - )) - }) - .trigger_with_tooltip( - Button::new("branch-diff-base-branch", base_ref_label).end_icon( - Icon::new(IconName::ChevronDown) - .size(IconSize::XSmall) - .color(Color::Muted), - ), - Tooltip::text("Select Base Branch"), - ), - ) - .when(show_review_button, |this| { - let focus_handle = focus_handle.clone(); - this.child(Divider::vertical()).child( - Button::new("review-diff", "Review Diff") - .start_icon( - Icon::new(IconName::ZedAssistant) - .size(IconSize::Small) - .color(Color::Muted), - ) - .tooltip(move |_, cx| { - Tooltip::with_meta_in( - "Review Diff", - Some(&ReviewDiff), - "Send this diff for your last agent to review.", - &focus_handle, - cx, - ) - }) - .on_click(cx.listener(|this, _, window, cx| { - this.dispatch_action(&ReviewDiff, window, cx); - })), - ) - }) - .when(review_count > 0, |this| { - this.child(Divider::vertical()).child( - render_send_review_to_agent_button(review_count, &focus_handle).on_click( - cx.listener(|this, _, window, cx| { - this.dispatch_action(&SendReviewToAgent, window, cx) - }), - ), - ) - }) - } -} - -struct BranchDiffAddon { - branch_diff: Entity, -} - -impl Addon for BranchDiffAddon { - fn to_any(&self) -> &dyn std::any::Any { - self - } - - fn override_status_for_buffer_id( - &self, - buffer_id: language::BufferId, - cx: &App, - ) -> Option { - self.branch_diff - .read(cx) - .status_for_buffer_id(buffer_id, cx) - } -} - #[cfg(test)] mod tests { - use collections::HashMap; + use buffer_diff::DiffHunkSecondaryStatus; use db::indoc; use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff}; - use git::status::{TrackedStatus, UnmergedStatus, UnmergedStatusCode}; use gpui::TestAppContext; + use multi_buffer::PathKey; use project::FakeFs; use serde_json::json; use settings::{DiffViewStyle, GitPanelGroupBy, GitPanelSortBy, SettingsStore}; use std::path::Path; use unindent::Unindent as _; - use util::{ - path, - rel_path::{RelPath, rel_path}, - }; + use util::{path, rel_path::rel_path}; use workspace::MultiWorkspace; @@ -2133,6 +1006,482 @@ mod tests { }); } + use zed_actions::git as git_actions; + + use crate::project_diff::{self, ProjectDiff}; + + #[test] + fn test_legacy_branch_diff_rows_migrate_to_their_own_kind() { + use db::sqlez::{ + connection::Connection, + domain::{Domain as _, Migrator as _}, + }; + + let connection = Connection::open_memory(Some( + "test_legacy_branch_diff_rows_migrate_to_their_own_kind", + )); + connection.exec("PRAGMA foreign_keys = OFF").unwrap()().unwrap(); + workspace::WorkspaceDb::migrate(&connection).unwrap(); + connection + .migrate( + persistence::ProjectDiffDb::NAME, + &persistence::ProjectDiffDb::MIGRATIONS[..1], + &mut |_, _, _| false, + ) + .unwrap(); + + connection + .exec( + "INSERT INTO workspaces(workspace_id) VALUES (1); + INSERT INTO panes(pane_id, workspace_id, active) VALUES (1, 1, 1); + INSERT INTO items(item_id, workspace_id, pane_id, kind, position, active) VALUES + (1, 1, 1, 'ProjectDiff', 0, 1), + (2, 1, 1, 'ProjectDiff', 1, 0)", + ) + .unwrap()() + .unwrap(); + let head = serde_json::to_string(&DiffBase::Head).unwrap(); + let merge = serde_json::to_string(&DiffBase::Merge { + base_ref: "main".into(), + }) + .unwrap(); + connection + .exec_bound::<(String, String)>( + "INSERT INTO project_diffs(workspace_id, item_id, diff_base) VALUES (1, 1, ?), (1, 2, ?)", + ) + .unwrap()((head, merge)) + .unwrap(); + + persistence::ProjectDiffDb::migrate(&connection).unwrap(); + + let kinds = connection + .select::<(i64, String)>("SELECT item_id, kind FROM items ORDER BY item_id") + .unwrap()() + .unwrap(); + assert_eq!( + kinds, + [ + (1, "ProjectDiff".to_string()), + (2, "BranchDiff".to_string()) + ] + ); + } + + #[gpui::test] + async fn test_update_on_uncommit(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "README.md": "# My cool project\n".to_owned() + }), + ) + .await; + fs.set_head_and_index_for_repo( + Path::new(path!("/project/.git")), + &[("README.md", "# My cool project\n".to_owned())], + ); + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + cx.run_until_parked(); + + let _editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + let item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + cx.focus(&item); + let editor = item.read_with(cx, |item, cx| item.editor(cx).read(cx).rhs_editor().clone()); + + fs.set_head_and_index_for_repo( + Path::new(path!("/project/.git")), + &[( + "README.md", + "# My cool project\nDetails to come.\n".to_owned(), + )], + ); + cx.run_until_parked(); + + let mut cx = EditorTestContext::for_editor_in(editor, cx).await; + + cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n"); + } + + #[gpui::test] + async fn test_deploy_at_respects_active_repository_selection(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project_a"), + json!({ + ".git": {}, + "a.txt": "CHANGED_A\n", + }), + ) + .await; + fs.insert_tree( + path!("/project_b"), + json!({ + ".git": {}, + "b.txt": "CHANGED_B\n", + }), + ) + .await; + + fs.set_head_and_index_for_repo( + Path::new(path!("/project_a/.git")), + &[("a.txt", "original_a\n".to_string())], + ); + fs.set_head_and_index_for_repo( + Path::new(path!("/project_b/.git")), + &[("b.txt", "original_b\n".to_string())], + ); + + let project = Project::test( + fs.clone(), + [ + Path::new(path!("/project_a")), + Path::new(path!("/project_b")), + ], + cx, + ) + .await; + + let (worktree_a_id, worktree_b_id) = project.read_with(cx, |project, cx| { + let mut worktrees: Vec<_> = project.worktrees(cx).collect(); + worktrees.sort_by_key(|w| w.read(cx).abs_path()); + (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) + }); + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + cx.run_until_parked(); + + // Select project A explicitly and open the diff. + workspace.update(cx, |workspace, cx| { + let git_store = workspace.project().read(cx).git_store().clone(); + git_store.update(cx, |git_store, cx| { + git_store.set_active_repo_for_worktree(worktree_a_id, cx); + }); + }); + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let diff_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + let paths_a = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx)); + assert_eq!(paths_a.len(), 1); + assert_eq!(*paths_a[0], *"a.txt"); + + // Switch the explicit active repository to project B and re-run the diff action. + workspace.update(cx, |workspace, cx| { + let git_store = workspace.project().read(cx).git_store().clone(); + git_store.update(cx, |git_store, cx| { + git_store.set_active_repo_for_worktree(worktree_b_id, cx); + }); + }); + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let same_diff_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + assert_eq!(diff_item.entity_id(), same_diff_item.entity_id()); + + let paths_b = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx)); + assert_eq!(paths_b.len(), 1); + assert_eq!(*paths_b[0], *"b.txt"); + } + + #[gpui::test] + async fn test_project_diff_actions_filter_mixed_staged_and_unstaged_hunks( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let committed_contents = r#" + fn main() { + println!("hello world"); + } + "# + .unindent(); + let staged_contents = r#" + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + let file_contents = r#" + // print goodbye + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "src": { + "main.rs": file_contents, + } + }), + ) + .await; + + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", committed_contents)], + "deadbeef", + ); + fs.set_index_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", staged_contents)], + ); + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + cx.run_until_parked(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let diff_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + let diff_editor = + diff_item.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); + assert_eq!( + diff_editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .map(|hunk| hunk.status.secondary) + .collect::>() + }), + vec![ + DiffHunkSecondaryStatus::HasSecondaryHunk, + DiffHunkSecondaryStatus::NoSecondaryHunk, + ] + ); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(git_actions::ViewUnstagedChanges.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let unstaged_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + assert_ne!(diff_item.entity_id(), unstaged_item.entity_id()); + let unstaged_editor = workspace.update(cx, |workspace, cx| { + let active_item = workspace.active_item(cx).unwrap(); + assert_eq!(active_item.tab_content_text(0, cx), "Unstaged Changes"); + active_item + .act_as::(cx) + .unwrap() + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + }); + assert_eq!( + unstaged_editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .map(|hunk| hunk.status.secondary) + .collect::>() + }), + vec![DiffHunkSecondaryStatus::NoSecondaryHunk] + ); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(git_actions::ViewUncommittedChanges.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let uncommitted_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + assert_eq!(diff_item.entity_id(), uncommitted_item.entity_id()); + assert_eq!( + uncommitted_item.read_with(cx, |diff, cx| diff.tab_content_text(0, cx)), + "Uncommitted Changes" + ); + let uncommitted_editor = uncommitted_item + .read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); + assert_eq!( + uncommitted_editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .map(|hunk| hunk.status.secondary) + .collect::>() + }), + vec![ + DiffHunkSecondaryStatus::HasSecondaryHunk, + DiffHunkSecondaryStatus::NoSecondaryHunk, + ] + ); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(git_actions::ViewStagedChanges.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let staged_editor = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap(); + let active_item = workspace.active_item(cx).unwrap(); + assert_eq!(active_item.tab_content_text(0, cx), "Staged Changes"); + active_item + .act_as::(cx) + .unwrap() + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + }); + assert_eq!( + staged_editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .map(|hunk| hunk.status.secondary) + .collect::>() + }), + vec![DiffHunkSecondaryStatus::NoSecondaryHunk] + ); + } + + #[gpui::test] + async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + ".git": {}, + "a.txt": "created\n", + "b.txt": "really changed\n", + "c.txt": "unchanged\n" + }), + ) + .await; + + fs.set_head_and_index_for_repo( + Path::new(path!("/a/.git")), + &[ + ("b.txt", "before\n".to_string()), + ("c.txt", "unchanged\n".to_string()), + ("d.txt", "deleted\n".to_string()), + ], + ); + + let project = Project::test(fs, [Path::new(path!("/a"))], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + cx.run_until_parked(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + + cx.run_until_parked(); + + let item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + cx.focus(&item); + let editor = item.read_with(cx, |item, cx| item.editor(cx).read(cx).rhs_editor().clone()); + + let mut cx = EditorTestContext::for_editor_in(editor, cx).await; + + cx.set_selections_state(indoc!( + " + before + really changed + + deleted + + ˇcreated + " + )); + + cx.dispatch_action(editor::actions::GoToPreviousHunk); + + cx.assert_excerpts_with_selections(indoc!( + " + [EXCERPT] + before + really changed + [EXCERPT] + ˇ[FOLDED] + [EXCERPT] + created + " + )); + + cx.dispatch_action(editor::actions::GoToPreviousHunk); + + cx.assert_excerpts_with_selections(indoc!( + " + [EXCERPT] + ˇbefore + really changed + [EXCERPT] + [FOLDED] + [EXCERPT] + created + " + )); + } + #[gpui::test] async fn test_save_after_restore(cx: &mut TestAppContext) { init_test(cx); @@ -2166,7 +1515,7 @@ mod tests { }); cx.run_until_parked(); - let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone()); + let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); assert_state_with_diff( &editor, cx, @@ -2222,12 +1571,14 @@ mod tests { cx.run_until_parked(); let editor = cx.update_window_entity(&diff, |diff, window, cx| { - diff.move_to_path( - PathKey::with_sort_prefix(2, rel_path("foo").into_arc()), - window, - cx, - ); - diff.editor.read(cx).rhs_editor().clone() + diff.diff.update(cx, |diff, cx| { + diff.move_to_path( + PathKey::with_sort_prefix(2, rel_path("foo").into_arc()), + window, + cx, + ) + }); + diff.editor(cx).read(cx).rhs_editor().clone() }); assert_state_with_diff( &editor, @@ -2243,12 +1594,14 @@ mod tests { ); let editor = cx.update_window_entity(&diff, |diff, window, cx| { - diff.move_to_path( - PathKey::with_sort_prefix(2, rel_path("bar").into_arc()), - window, - cx, - ); - diff.editor.read(cx).rhs_editor().clone() + diff.diff.update(cx, |diff, cx| { + diff.move_to_path( + PathKey::with_sort_prefix(2, rel_path("bar").into_arc()), + window, + cx, + ) + }); + diff.editor(cx).read(cx).rhs_editor().clone() }); assert_state_with_diff( &editor, @@ -2301,7 +1654,8 @@ mod tests { }); cx.run_until_parked(); - let diff_editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone()); + let diff_editor = + diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); assert_state_with_diff( &diff_editor, @@ -2379,334 +1733,6 @@ mod tests { ); } - use crate::project_diff::{self, ProjectDiff}; - - #[gpui::test] - async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/a"), - json!({ - ".git": {}, - "a.txt": "created\n", - "b.txt": "really changed\n", - "c.txt": "unchanged\n" - }), - ) - .await; - - fs.set_head_and_index_for_repo( - Path::new(path!("/a/.git")), - &[ - ("b.txt", "before\n".to_string()), - ("c.txt", "unchanged\n".to_string()), - ("d.txt", "deleted\n".to_string()), - ], - ); - - let project = Project::test(fs, [Path::new(path!("/a"))], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - cx.run_until_parked(); - - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(project_diff::Diff.boxed_clone(), cx); - }); - - cx.run_until_parked(); - - let item = workspace.update(cx, |workspace, cx| { - workspace.active_item_as::(cx).unwrap() - }); - cx.focus(&item); - let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone()); - - let mut cx = EditorTestContext::for_editor_in(editor, cx).await; - - cx.set_selections_state(indoc!( - " - before - really changed - - deleted - - ˇcreated - " - )); - - cx.dispatch_action(editor::actions::GoToPreviousHunk); - - cx.assert_excerpts_with_selections(indoc!( - " - [EXCERPT] - before - really changed - [EXCERPT] - ˇ[FOLDED] - [EXCERPT] - created - " - )); - - cx.dispatch_action(editor::actions::GoToPreviousHunk); - - cx.assert_excerpts_with_selections(indoc!( - " - [EXCERPT] - ˇbefore - really changed - [EXCERPT] - [FOLDED] - [EXCERPT] - created - " - )); - } - - #[gpui::test] - async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) { - init_test(cx); - - let git_contents = indoc! {r#" - #[rustfmt::skip] - fn main() { - let x = 0.0; // this line will be removed - // 1 - // 2 - // 3 - let y = 0.0; // this line will be removed - // 1 - // 2 - // 3 - let arr = [ - 0.0, // this line will be removed - 0.0, // this line will be removed - 0.0, // this line will be removed - 0.0, // this line will be removed - ]; - } - "#}; - let buffer_contents = indoc! {" - #[rustfmt::skip] - fn main() { - // 1 - // 2 - // 3 - // 1 - // 2 - // 3 - let arr = [ - ]; - } - "}; - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/a"), - json!({ - ".git": {}, - "main.rs": buffer_contents, - }), - ) - .await; - - fs.set_head_and_index_for_repo( - Path::new(path!("/a/.git")), - &[("main.rs", git_contents.to_owned())], - ); - - let project = Project::test(fs, [Path::new(path!("/a"))], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - cx.run_until_parked(); - - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(project_diff::Diff.boxed_clone(), cx); - }); - - cx.run_until_parked(); - - let item = workspace.update(cx, |workspace, cx| { - workspace.active_item_as::(cx).unwrap() - }); - cx.focus(&item); - let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone()); - - let mut cx = EditorTestContext::for_editor_in(editor, cx).await; - - cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}")); - - cx.dispatch_action(editor::actions::GoToHunk); - cx.dispatch_action(editor::actions::GoToHunk); - cx.dispatch_action(git::Restore); - cx.dispatch_action(editor::actions::MoveToBeginning); - - cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}")); - } - - #[gpui::test(iterations = 50)] - async fn test_split_diff_conflict_path_transition_with_dirty_buffer_invalid_anchor_panics( - cx: &mut TestAppContext, - ) { - init_test(cx); - - cx.update(|cx| { - cx.update_global::(|store, cx| { - store.update_user_settings(cx, |settings| { - settings.editor.diff_view_style = Some(DiffViewStyle::Split); - }); - }); - }); - - let build_conflict_text: fn(usize) -> String = |tag: usize| { - let mut lines = (0..80) - .map(|line_index| format!("line {line_index}")) - .collect::>(); - for offset in [5usize, 20, 37, 61] { - lines[offset] = format!("base-{tag}-line-{offset}"); - } - format!("{}\n", lines.join("\n")) - }; - let initial_conflict_text = build_conflict_text(0); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - ".git": {}, - "helper.txt": "same\n", - "conflict.txt": initial_conflict_text, - }), - ) - .await; - fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { - state - .refs - .insert("MERGE_HEAD".into(), "conflict-head".into()); - }) - .unwrap(); - fs.set_status_for_repo( - path!("/project/.git").as_ref(), - &[( - "conflict.txt", - FileStatus::Unmerged(UnmergedStatus { - first_head: UnmergedStatusCode::Updated, - second_head: UnmergedStatusCode::Updated, - }), - )], - ); - fs.set_merge_base_content_for_repo( - path!("/project/.git").as_ref(), - &[ - ("conflict.txt", build_conflict_text(1)), - ("helper.txt", "same\n".to_string()), - ], - ); - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let _project_diff = cx - .update(|window, cx| { - ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx) - }) - .await - .unwrap(); - cx.run_until_parked(); - - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/project/conflict.txt"), cx) - }) - .await - .unwrap(); - buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "dirty\n")], None, cx)); - assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty())); - cx.run_until_parked(); - - cx.update(|window, cx| { - let fs = fs.clone(); - window - .spawn(cx, async move |cx| { - cx.background_executor().simulate_random_delay().await; - fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { - state.refs.insert("HEAD".into(), "head-1".into()); - state.refs.remove("MERGE_HEAD"); - }) - .unwrap(); - fs.set_status_for_repo( - path!("/project/.git").as_ref(), - &[ - ( - "conflict.txt", - FileStatus::Tracked(TrackedStatus { - index_status: git::status::StatusCode::Modified, - worktree_status: git::status::StatusCode::Modified, - }), - ), - ( - "helper.txt", - FileStatus::Tracked(TrackedStatus { - index_status: git::status::StatusCode::Modified, - worktree_status: git::status::StatusCode::Modified, - }), - ), - ], - ); - // FakeFs assigns deterministic OIDs by entry position; flipping order churns - // conflict diff identity without reaching into ProjectDiff internals. - fs.set_merge_base_content_for_repo( - path!("/project/.git").as_ref(), - &[ - ("helper.txt", "helper-base\n".to_string()), - ("conflict.txt", build_conflict_text(2)), - ], - ); - }) - .detach(); - }); - - cx.update(|window, cx| { - let buffer = buffer.clone(); - window - .spawn(cx, async move |cx| { - cx.background_executor().simulate_random_delay().await; - for edit_index in 0..10 { - if edit_index > 0 { - cx.background_executor().simulate_random_delay().await; - } - buffer.update(cx, |buffer, cx| { - let len = buffer.len(); - if edit_index % 2 == 0 { - buffer.edit( - [(0..0, format!("status-burst-head-{edit_index}\n"))], - None, - cx, - ); - } else { - buffer.edit( - [(len..len, format!("status-burst-tail-{edit_index}\n"))], - None, - cx, - ); - } - }); - } - }) - .detach(); - }); - - cx.run_until_parked(); - } - #[gpui::test] async fn test_new_hunk_in_modified_file(cx: &mut TestAppContext) { init_test(cx); @@ -2765,7 +1791,7 @@ mod tests { ); cx.run_until_parked(); - let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone()); + let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); assert_state_with_diff( &editor, @@ -2940,323 +1966,79 @@ mod tests { } #[gpui::test] - async fn test_branch_diff(cx: &mut TestAppContext) { + async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) { init_test(cx); + let git_contents = indoc! {r#" + #[rustfmt::skip] + fn main() { + let x = 0.0; // this line will be removed + // 1 + // 2 + // 3 + let y = 0.0; // this line will be removed + // 1 + // 2 + // 3 + let arr = [ + 0.0, // this line will be removed + 0.0, // this line will be removed + 0.0, // this line will be removed + 0.0, // this line will be removed + ]; + } + "#}; + let buffer_contents = indoc! {" + #[rustfmt::skip] + fn main() { + // 1 + // 2 + // 3 + // 1 + // 2 + // 3 + let arr = [ + ]; + } + "}; + let fs = FakeFs::new(cx.executor()); fs.insert_tree( - path!("/project"), + path!("/a"), json!({ ".git": {}, - "a.txt": "C", - "b.txt": "new", - "c.txt": "in-merge-base-and-work-tree", - "d.txt": "created-in-head", + "main.rs": buffer_contents, }), ) .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let diff = cx - .update(|window, cx| { - ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx) - }) - .await - .unwrap(); - cx.run_until_parked(); - - fs.set_head_for_repo( - Path::new(path!("/project/.git")), - &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())], - "sha", - ); - // fs.set_index_for_repo(dot_git, index_state); - fs.set_merge_base_content_for_repo( - Path::new(path!("/project/.git")), - &[ - ("a.txt", "A".into()), - ("c.txt", "in-merge-base-and-work-tree".into()), - ], - ); - cx.run_until_parked(); - - let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone()); - - assert_state_with_diff( - &editor, - cx, - &" - - A - + ˇC - + new - + created-in-head" - .unindent(), - ); - - let statuses: HashMap, Option> = - editor.update(cx, |editor, cx| { - editor - .buffer() - .read(cx) - .all_buffers() - .iter() - .map(|buffer| { - ( - buffer.read(cx).file().unwrap().path().clone(), - editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx), - ) - }) - .collect() - }); - - assert_eq!( - statuses, - HashMap::from_iter([ - ( - rel_path("a.txt").into_arc(), - Some(FileStatus::Tracked(TrackedStatus { - index_status: git::status::StatusCode::Modified, - worktree_status: git::status::StatusCode::Modified - })) - ), - (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)), - ( - rel_path("d.txt").into_arc(), - Some(FileStatus::Tracked(TrackedStatus { - index_status: git::status::StatusCode::Added, - worktree_status: git::status::StatusCode::Added - })) - ) - ]) - ); - } - - #[gpui::test] - async fn test_branch_diff_action_matches_existing_item_by_base_ref(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - ".git": {}, - "a.txt": "changed", - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let target_branch_diff = cx - .update(|window, cx| { - let Some(repository) = project.read(cx).active_repository(cx) else { - return Task::ready(Err(anyhow!("No active repository"))); - }; - ProjectDiff::new_with_branch_base( - project.clone(), - workspace.clone(), - "topic".into(), - repository, - window, - cx, - ) - }) - .await - .unwrap(); - workspace.update_in(cx, |workspace, window, cx| { - workspace.add_item_to_active_pane( - Box::new(target_branch_diff.clone()), - None, - true, - window, - cx, - ); - }); - cx.run_until_parked(); - - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(BranchDiff.boxed_clone(), cx); - }); - cx.run_until_parked(); - - let (active_base_ref, mut base_refs) = workspace.update(cx, |workspace, cx| { - let active_item = workspace.active_item_as::(cx).unwrap(); - let active_base_ref = match active_item.read(cx).diff_base(cx) { - DiffBase::Merge { base_ref } => base_ref.to_string(), - DiffBase::Head => panic!("expected active item to be a branch diff"), - }; - let base_refs = workspace - .items_of_type::(cx) - .filter_map(|item| match item.read(cx).diff_base(cx) { - DiffBase::Merge { base_ref } => Some(base_ref.to_string()), - DiffBase::Head => None, - }) - .collect::>(); - (active_base_ref, base_refs) - }); - base_refs.sort(); - - assert_eq!(active_base_ref, "origin/main"); - assert_eq!(base_refs, vec!["origin/main", "topic"]); - } - - #[gpui::test] - async fn test_update_on_uncommit(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - ".git": {}, - "README.md": "# My cool project\n".to_owned() - }), - ) - .await; - fs.set_head_and_index_for_repo( - Path::new(path!("/project/.git")), - &[("README.md", "# My cool project\n".to_owned())], - ); - let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; - let worktree_id = project.read_with(cx, |project, cx| { - project.worktrees(cx).next().unwrap().read(cx).id() - }); - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - cx.run_until_parked(); - - let _editor = workspace - .update_in(cx, |workspace, window, cx| { - workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(project_diff::Diff.boxed_clone(), cx); - }); - cx.run_until_parked(); - let item = workspace.update(cx, |workspace, cx| { - workspace.active_item_as::(cx).unwrap() - }); - cx.focus(&item); - let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone()); fs.set_head_and_index_for_repo( - Path::new(path!("/project/.git")), - &[( - "README.md", - "# My cool project\nDetails to come.\n".to_owned(), - )], + Path::new(path!("/a/.git")), + &[("main.rs", git_contents.to_owned())], ); + + let project = Project::test(fs, [Path::new(path!("/a"))], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + cx.run_until_parked(); + let diff = cx.new_window_entity(|window, cx| { + ProjectDiff::new(project.clone(), workspace, window, cx) + }); + cx.run_until_parked(); + let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); + let mut cx = EditorTestContext::for_editor_in(editor, cx).await; - cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n"); - } + cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}")); - #[gpui::test] - async fn test_deploy_at_respects_active_repository_selection(cx: &mut TestAppContext) { - init_test(cx); + cx.dispatch_action(editor::actions::GoToHunk); + cx.dispatch_action(editor::actions::GoToHunk); + cx.dispatch_action(git::Restore); + cx.dispatch_action(editor::actions::MoveToBeginning); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project_a"), - json!({ - ".git": {}, - "a.txt": "CHANGED_A\n", - }), - ) - .await; - fs.insert_tree( - path!("/project_b"), - json!({ - ".git": {}, - "b.txt": "CHANGED_B\n", - }), - ) - .await; - - fs.set_head_and_index_for_repo( - Path::new(path!("/project_a/.git")), - &[("a.txt", "original_a\n".to_string())], - ); - fs.set_head_and_index_for_repo( - Path::new(path!("/project_b/.git")), - &[("b.txt", "original_b\n".to_string())], - ); - - let project = Project::test( - fs.clone(), - [ - Path::new(path!("/project_a")), - Path::new(path!("/project_b")), - ], - cx, - ) - .await; - - let (worktree_a_id, worktree_b_id) = project.read_with(cx, |project, cx| { - let mut worktrees: Vec<_> = project.worktrees(cx).collect(); - worktrees.sort_by_key(|w| w.read(cx).abs_path()); - (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) - }); - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - cx.run_until_parked(); - - // Select project A explicitly and open the diff. - workspace.update(cx, |workspace, cx| { - let git_store = workspace.project().read(cx).git_store().clone(); - git_store.update(cx, |git_store, cx| { - git_store.set_active_repo_for_worktree(worktree_a_id, cx); - }); - }); - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(project_diff::Diff.boxed_clone(), cx); - }); - cx.run_until_parked(); - - let diff_item = workspace.update(cx, |workspace, cx| { - workspace.active_item_as::(cx).unwrap() - }); - let paths_a = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx)); - assert_eq!(paths_a.len(), 1); - assert_eq!(*paths_a[0], *"a.txt"); - - // Switch the explicit active repository to project B and re-run the diff action. - workspace.update(cx, |workspace, cx| { - let git_store = workspace.project().read(cx).git_store().clone(); - git_store.update(cx, |git_store, cx| { - git_store.set_active_repo_for_worktree(worktree_b_id, cx); - }); - }); - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(project_diff::Diff.boxed_clone(), cx); - }); - cx.run_until_parked(); - - let same_diff_item = workspace.update(cx, |workspace, cx| { - workspace.active_item_as::(cx).unwrap() - }); - assert_eq!(diff_item.entity_id(), same_diff_item.entity_id()); - - let paths_b = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx)); - assert_eq!(paths_b.len(), 1); - assert_eq!(*paths_b[0], *"b.txt"); + cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}")); } } diff --git a/crates/git_ui/src/staged_diff.rs b/crates/git_ui/src/staged_diff.rs new file mode 100644 index 00000000000..9b607c72cbc --- /dev/null +++ b/crates/git_ui/src/staged_diff.rs @@ -0,0 +1,1090 @@ +use crate::{ + diff_multibuffer::DiffMultibuffer, + git_panel::{GitPanel, GitPanelAddon, GitStatusEntry}, +}; +use anyhow::{Context as _, Result}; +use buffer_diff::DiffHunkStatus; +use editor::{ + DiffHunkDelegate, Editor, EditorEvent, ResolvedDiffHunks, SplittableEditor, + actions::{GoToHunk, GoToPreviousHunk}, +}; +use git::{Commit, UnstageAll, UnstageAndNext}; +use gpui::{ + Action, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, + SharedString, Subscription, Task, WeakEntity, +}; +use language::Capability; +use project::{ + Project, ProjectPath, + git_store::diff_buffer_list::{DiffBase, DiffBufferList}, + project_settings::ProjectSettings, +}; +use settings::Settings; +use std::{ + any::{Any, TypeId}, + ops::Range, + sync::Arc, +}; +use ui::{DiffStat, Divider, Icon, Tooltip, Window, prelude::*}; +use util::ResultExt as _; +use workspace::{ + ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, + Workspace, + item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams}, + searchable::SearchableItemHandle, +}; + +pub(crate) struct StagedDiffDelegate; + +impl DiffHunkDelegate for StagedDiffDelegate { + fn toggle( + &self, + hunks: Vec, + editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + self.stage_or_unstage(false, hunks, editor, window, cx); + } + + fn stage_or_unstage( + &self, + stage: bool, + hunks: Vec, + editor: &mut Editor, + _window: &mut Window, + cx: &mut Context, + ) { + if stage { + return; + } + let Some(project) = editor.project().cloned() else { + return; + }; + for hunks in hunks { + let index_ranges = hunks + .hunks + .into_iter() + .map(|hunk| hunk.buffer_range) + .collect::>(); + if index_ranges.is_empty() { + continue; + } + project + .update(cx, |project, cx| { + project.unstage_staged_hunks(hunks.diff, index_ranges, cx) + }) + .log_err(); + } + } + + fn render_hunk_controls( + &self, + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + _is_created_file: bool, + line_height: Pixels, + editor: &Entity, + _window: &mut Window, + cx: &mut App, + ) -> AnyElement { + if !ProjectSettings::get_global(cx) + .git + .show_stage_restore_buttons + { + return gpui::Empty.into_any_element(); + } + let hunk_range = hunk_range.start..hunk_range.start; + h_flex() + .h(line_height) + .mr_1() + .gap_1() + .px_0p5() + .pb_1() + .border_x_1() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .rounded_b_lg() + .bg(cx.theme().colors().editor_background) + .block_mouse_except_scroll() + .shadow_md() + .child( + Button::new(("unstage", row as u64), "Unstage") + .alpha(if status.is_pending() { 0.66 } else { 1.0 }) + .tooltip(Tooltip::text("Unstage Hunk")) + .on_click({ + let editor = editor.clone(); + move |_event, window, cx| { + editor.update(cx, |editor, cx| { + editor.stage_or_unstage_diff_hunks( + false, + vec![hunk_range.clone()], + window, + cx, + ); + }); + } + }), + ) + .into_any_element() + } +} + +/// The workspace item for the staged diff. It wraps a single read-only +/// [`DiffMultibuffer`] over [`DiffBase::Staged`] and delegates the [`Item`] +/// surface to it. +pub struct StagedDiff { + diff: Entity, + project: Entity, + workspace: WeakEntity, + _diff_event_subscription: Subscription, +} + +impl StagedDiff { + pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { + let _ = workspace; + workspace::register_serializable_item::(cx); + } + + pub fn deploy_at( + workspace: &mut Workspace, + entry: Option, + window: &mut Window, + cx: &mut Context, + ) { + telemetry::event!( + "Git Staged Diff Opened", + source = if entry.is_some() { + "Git Panel" + } else { + "Action" + } + ); + let intended_repo = workspace.project().read(cx).active_repository(cx); + let existing = workspace.items_of_type::(cx).next(); + let staged_diff = if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + existing + } else { + let workspace_handle = cx.entity(); + let staged_diff = + cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx)); + workspace.add_item_to_active_pane( + Box::new(staged_diff.clone()), + None, + true, + window, + cx, + ); + staged_diff + }; + + if let Some(intended) = &intended_repo { + let needs_switch = staged_diff + .read(cx) + .diff + .read(cx) + .repo(cx) + .map_or(true, |current| current.entity_id() != intended.entity_id()); + if needs_switch { + staged_diff.update(cx, |staged_diff, cx| { + staged_diff.diff.update(cx, |diff, cx| { + diff.set_repo(Some(intended.clone()), cx); + }); + }); + } + } + + if let Some(entry) = entry { + staged_diff.update(cx, |staged_diff, cx| { + staged_diff + .diff + .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); + }); + } + } + + pub(crate) fn new( + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let branch_diff = + cx.new(|cx| DiffBufferList::new(DiffBase::Staged, project.clone(), window, cx)); + let workspace_handle = workspace.downgrade(); + let diff = cx.new(|cx| { + DiffMultibuffer::new( + branch_diff, + Capability::ReadOnly, + "No staged changes", + move |editor, cx| { + editor.set_diff_hunk_delegate(Some(Arc::new(StagedDiffDelegate)), cx); + editor.rhs_editor().update(cx, |rhs_editor, _cx| { + rhs_editor.set_read_only(true); + rhs_editor.register_addon(GitPanelAddon { + workspace: workspace_handle, + }); + }); + }, + project.clone(), + workspace.clone(), + window, + cx, + ) + }); + Self::from_diff(diff, project, workspace, cx) + } + + pub(crate) fn from_diff( + diff: Entity, + project: Entity, + workspace: Entity, + cx: &mut Context, + ) -> Self { + let diff_event_subscription = cx.subscribe(&diff, |_, _, event: &EditorEvent, cx| { + cx.emit(event.clone()) + }); + + Self { + diff, + project, + workspace: workspace.downgrade(), + _diff_event_subscription: diff_event_subscription, + } + } + + fn button_states(&self, cx: &App) -> ButtonStates { + let diff = self.diff.read(cx); + let editor = diff.editor().read(cx).rhs_editor().clone(); + let editor = editor.read(cx); + let snapshot = diff.multibuffer().read(cx).snapshot(cx); + let prev_next = snapshot.diff_hunks().nth(1).is_some(); + let (selection, ranges) = diff.selected_ranges(cx); + let unstage = editor + .diff_hunks_in_ranges(&ranges, &snapshot) + .next() + .is_some(); + let mut unstage_all = false; + self.workspace + .read_with(cx, |workspace, cx| { + if let Some(git_panel) = workspace.panel::(cx) { + unstage_all = git_panel.read(cx).can_unstage_all(); + } + }) + .ok(); + + ButtonStates { + unstage, + prev_next, + selection, + unstage_all, + } + } + + fn unstage_selected_staged_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.diff.update(cx, |diff, cx| { + diff.stage_or_unstage_selected_hunks(false, move_to_next, window, cx) + }); + } +} + +struct ButtonStates { + unstage: bool, + prev_next: bool, + selection: bool, + unstage_all: bool, +} + +impl EventEmitter for StagedDiff {} + +impl Focusable for StagedDiff { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.diff.read(cx).focus_handle(cx) + } +} + +impl Item for StagedDiff { + type Event = EditorEvent; + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::GitBranch).color(Color::Muted)) + } + + fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.diff + .update(cx, |diff, cx| diff.deactivated(window, cx)); + } + + fn navigate( + &mut self, + data: Arc, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.diff + .update(cx, |diff, cx| diff.navigate(data, window, cx)) + } + + fn tab_tooltip_text(&self, _: &App) -> Option { + Some("Staged Changes".into()) + } + + fn tab_content(&self, params: TabContentParams, _window: &Window, _cx: &App) -> AnyElement { + Label::new(self.tab_content_text(0, _cx)) + .color(if params.selected { + Color::Default + } else { + Color::Muted + }) + .into_any_element() + } + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Staged Changes".into() + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("Git Staged Diff Opened") + } + + fn as_searchable(&self, _: &Entity, cx: &App) -> Option> { + Some(Box::new(self.diff.read(cx).editor().clone())) + } + + fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + self.diff.read(cx).for_each_project_item(cx, f); + } + + fn set_nav_history( + &mut self, + nav_history: ItemNavHistory, + _: &mut Window, + cx: &mut Context, + ) { + self.diff + .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx)); + } + + fn can_split(&self) -> bool { + true + } + + fn clone_on_split( + &self, + _workspace_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> Task>> + where + Self: Sized, + { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(None); + }; + let project = self.project.clone(); + Task::ready(Some(cx.new(|cx| Self::new(project, workspace, window, cx)))) + } + + fn is_dirty(&self, cx: &App) -> bool { + self.diff.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &App) -> bool { + self.diff.read(cx).has_conflict(cx) + } + + fn can_save(&self, _: &App) -> bool { + false + } + + fn save( + &mut self, + _: SaveOptions, + _: Entity, + _: &mut Window, + _: &mut Context, + ) -> Task> { + Task::ready(Ok(())) + } + + fn save_as( + &mut self, + _: Entity, + _: ProjectPath, + _: &mut Window, + _: &mut Context, + ) -> Task> { + unreachable!() + } + + fn reload( + &mut self, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.diff + .update(cx, |diff, cx| diff.reload(project, window, cx)) + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + cx: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.clone().into()) + } else if type_id == TypeId::of::() { + Some( + self.diff + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + .into(), + ) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).editor().clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).branch_diff().clone().into()) + } else { + None + } + } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + self.diff.update(cx, |diff, cx| { + diff.added_to_workspace(workspace, window, cx) + }); + } +} + +impl SerializableItem for StagedDiff { + fn serialized_item_kind() -> &'static str { + "StagedDiff" + } + + fn cleanup( + _: workspace::WorkspaceId, + _: Vec, + _: &mut Window, + _: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn deserialize( + project: Entity, + workspace: WeakEntity, + _: workspace::WorkspaceId, + _: workspace::ItemId, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + window.spawn(cx, async move |cx| { + let workspace = workspace.upgrade().context("workspace gone")?; + cx.update(|window, cx| Ok(cx.new(|cx| Self::new(project, workspace, window, cx))))? + }) + } + + fn serialize( + &mut self, + _: &mut Workspace, + _: workspace::ItemId, + _: bool, + _: &mut Window, + _: &mut Context, + ) -> Option>> { + Some(Task::ready(Ok(()))) + } + + fn should_serialize(&self, _: &Self::Event) -> bool { + false + } +} + +impl Render for StagedDiff { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.diff.clone() + } +} + +pub struct StagedDiffToolbar { + staged_diff: Option>, + workspace: WeakEntity, +} + +impl StagedDiffToolbar { + pub fn new(workspace: &Workspace, _: &mut Context) -> Self { + Self { + staged_diff: None, + workspace: workspace.weak_handle(), + } + } + + fn staged_diff(&self, _: &App) -> Option> { + self.staged_diff.as_ref()?.upgrade() + } + + fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) { + if let Some(staged_diff) = self.staged_diff(cx) { + staged_diff.focus_handle(cx).focus(window, cx); + } + let action = action.boxed_clone(); + cx.defer(move |cx| { + cx.dispatch_action(action.as_ref()); + }) + } + + fn unstage_selected_staged_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(staged_diff) = self.staged_diff(cx) else { + return; + }; + staged_diff.update(cx, |staged_diff, cx| { + staged_diff.unstage_selected_staged_hunks(move_to_next, window, cx); + }); + } + + fn unstage_all(&mut self, window: &mut Window, cx: &mut Context) { + self.workspace + .update(cx, |workspace, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + panel.update(cx, |panel, cx| { + panel.unstage_all(&Default::default(), window, cx); + }); + }) + .ok(); + } +} + +impl EventEmitter for StagedDiffToolbar {} + +impl ToolbarItemView for StagedDiffToolbar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + self.staged_diff = active_pane_item + .and_then(|item| item.act_as::(cx)) + .map(|entity| entity.downgrade()); + if self.staged_diff.is_some() { + ToolbarItemLocation::PrimaryRight + } else { + ToolbarItemLocation::Hidden + } + } + + fn pane_focus_update( + &mut self, + _pane_focused: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + } +} + +impl Render for StagedDiffToolbar { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(staged_diff) = self.staged_diff(cx) else { + return div(); + }; + let focus_handle = staged_diff.focus_handle(cx); + let button_states = staged_diff.read(cx).button_states(cx); + + let diff = staged_diff.read(cx).diff.read(cx); + let (additions, deletions) = diff.calculate_changed_lines(cx); + let is_multibuffer_empty = diff.multibuffer().read(cx).is_empty(); + + h_flex() + .my_neg_1() + .py_1() + .gap_1p5() + .flex_wrap() + .justify_between() + .when(!is_multibuffer_empty, |this| { + this.child(DiffStat::new( + "staged-diff-stat", + additions as usize, + deletions as usize, + )) + .child(Divider::vertical().ml_1()) + }) + // n.b. the only reason these arrows are here is because we don't + // support "undo" for staging so we need a way to go back. + .child( + h_group_sm() + .child( + IconButton::new("up", IconName::ArrowUp) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Previous Hunk", + &GoToPreviousHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToPreviousHunk, window, cx) + })), + ) + .child( + IconButton::new("down", IconName::ArrowDown) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Next Hunk", + &GoToHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToHunk, window, cx) + })), + ), + ) + .child(Divider::vertical()) + .child( + h_group_sm() + .when(button_states.selection, |this| { + this.child( + Button::new("unstage", "Unstage") + .disabled(!button_states.unstage) + .tooltip(Tooltip::text("Unstage Selected Hunks")) + .on_click(cx.listener(|this, _, window, cx| { + this.unstage_selected_staged_hunks(false, window, cx) + })), + ) + }) + .when(!button_states.selection, |this| { + this.child( + Button::new("unstage", "Unstage") + .disabled(!button_states.unstage) + .tooltip(Tooltip::for_action_title_in( + "Unstage and Go to Next Hunk", + &UnstageAndNext, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.unstage_selected_staged_hunks(true, window, cx) + })), + ) + }), + ) + .child(Divider::vertical()) + .child( + Button::new("unstage-all", "Unstage All") + .width(rems_from_px(80.)) + .disabled(!button_states.unstage_all) + .tooltip(Tooltip::for_action_title_in( + "Unstage All Changes", + &UnstageAll, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| this.unstage_all(window, cx))), + ) + .child(Divider::vertical()) + .child( + Button::new("commit", "Commit") + .tooltip(Tooltip::for_action_title_in( + "Commit", + &Commit, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&Commit, window, cx); + })), + ) + } +} + +#[cfg(test)] +mod tests { + use crate::project_diff::{self, ProjectDiff}; + use git::repository::RepoPath; + use gpui::{Action as _, TestAppContext}; + use language::Point; + use project::{FakeFs, Fs as _}; + use serde_json::json; + use settings::{DiffViewStyle, SettingsStore}; + use std::path::Path; + use unindent::Unindent as _; + use util::{path, rel_path::rel_path}; + use workspace::MultiWorkspace; + + use super::*; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.diff_view_style = Some(DiffViewStyle::Unified); + }); + }); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + crate::init(cx); + }); + } + + #[gpui::test] + async fn test_staged_changes_deploy_as_a_separate_staged_diff_item(cx: &mut TestAppContext) { + init_test(cx); + + let committed_contents = r#" + fn main() { + println!("hello world"); + } + "# + .unindent(); + let staged_contents = r#" + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + let file_contents = r#" + // print goodbye + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "src": { + "main.rs": file_contents.clone(), + } + }), + ) + .await; + + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", committed_contents)], + "deadbeef", + ); + fs.set_index_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", staged_contents.clone())], + ); + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + cx.run_until_parked(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let uncommitted_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + + workspace.update_in(cx, |workspace, window, cx| { + StagedDiff::deploy_at(workspace, None, window, cx); + }); + cx.run_until_parked(); + + workspace.update(cx, |workspace, cx| { + let staged_diff = workspace.active_item_as::(cx).unwrap(); + assert_ne!(staged_diff.entity_id(), uncommitted_item.entity_id()); + let staged_item = workspace + .active_item(cx) + .unwrap() + .act_as::(cx) + .unwrap(); + assert_ne!(staged_item.entity_id(), uncommitted_item.entity_id()); + assert_eq!( + staged_item.read_with(cx, |diff, cx| diff.diff_base(cx).clone()), + DiffBase::Staged + ); + assert!(staged_item.read_with(cx, |diff, cx| diff.multibuffer().read(cx).read_only())); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + + let active_item = workspace.active_item(cx).unwrap(); + assert!(active_item.act_as::(cx).is_some()); + assert!(active_item.act_as::(cx).is_some()); + assert_eq!( + active_item + .to_serializable_item_handle(cx) + .unwrap() + .serialized_item_kind(), + "StagedDiff" + ); + assert_eq!(active_item.tab_content_text(0, cx), "Staged Changes"); + assert!(!active_item.can_save(cx)); + }); + } + + #[gpui::test] + async fn test_toggle_staged_unstages_from_staged_view(cx: &mut TestAppContext) { + init_test(cx); + + let committed_contents = r#" + fn main() { + println!("hello world"); + } + "# + .unindent(); + let staged_contents = r#" + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + let file_contents = r#" + // print goodbye + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "src": { + "main.rs": file_contents, + } + }), + ) + .await; + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", committed_contents.clone())], + "deadbeef", + ); + fs.set_index_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", staged_contents)], + ); + let repo = fs + .open_repo(path!("/project/.git").as_ref(), Some("git".as_ref())) + .unwrap(); + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + cx.run_until_parked(); + + workspace.update_in(cx, |workspace, window, cx| { + StagedDiff::deploy_at(workspace, None, window, cx); + }); + cx.run_until_parked(); + + let editor = workspace.update(cx, |workspace, cx| { + let staged_diff = workspace.active_item_as::(cx).unwrap(); + let staged_diff = staged_diff.read(cx); + staged_diff + .diff + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + }); + editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + assert_eq!( + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .count(), + 1 + ); + }); + + // Hold back FS events so the first assertions observe the optimistic + // state rather than a reloaded diff. + fs.pause_events(); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([Point::new(1, 0)..Point::new(1, 0)]); + }); + }); + cx.focus(&editor); + cx.update(|window, cx| { + window.dispatch_action(git::ToggleStaged.boxed_clone(), cx); + }); + cx.run_until_parked(); + + // The hunk is optimistically suppressed from the staged view, and the + // index write has landed. + editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + assert_eq!( + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .count(), + 0 + ); + }); + assert_eq!( + repo.load_index_text(RepoPath::from_rel_path(rel_path("src/main.rs"))) + .await + .unwrap(), + committed_contents + ); + + fs.unpause_events_and_flush(); + cx.run_until_parked(); + + // Once the write is reconciled, the staged view remains empty. + editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + assert_eq!( + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .count(), + 0 + ); + }); + } + + #[gpui::test] + async fn test_staged_diff_restores_as_staged_diff(cx: &mut TestAppContext) { + init_test(cx); + + let committed_contents = r#" + fn main() { + println!("hello world"); + } + "# + .unindent(); + let staged_contents = r#" + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + let file_contents = r#" + // print goodbye + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "src": { + "main.rs": file_contents, + } + }), + ) + .await; + + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", committed_contents)], + "deadbeef", + ); + fs.set_index_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", staged_contents)], + ); + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + cx.run_until_parked(); + + let project = workspace.update(cx, |workspace, _| workspace.project().clone()); + let workspace_id = workspace::WorkspaceId::from_i64(1); + let item_id = 42; + + let restore_task = workspace.update_in(cx, |_workspace, window, cx| { + ::deserialize( + project.clone(), + cx.entity().downgrade(), + workspace_id, + item_id, + window, + cx, + ) + }); + let restored_staged_diff = restore_task.await.unwrap(); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new(restored_staged_diff.clone()), + None, + true, + window, + cx, + ); + }); + cx.run_until_parked(); + + workspace.update(cx, |workspace, cx| { + let active_item = workspace.active_item(cx).unwrap(); + assert!(active_item.act_as::(cx).is_some()); + assert!(active_item.act_as::(cx).is_some()); + assert_eq!( + active_item + .to_serializable_item_handle(cx) + .unwrap() + .serialized_item_kind(), + "StagedDiff" + ); + assert_eq!(active_item.tab_content_text(0, cx), "Staged Changes"); + assert!(!active_item.can_save(cx)); + assert_eq!(workspace.items_of_type::(cx).count(), 0); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + let diff = active_item.act_as::(cx).unwrap(); + assert_eq!( + diff.read_with(cx, |diff, cx| diff.diff_base(cx).clone()), + DiffBase::Staged + ); + }); + } +} diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index 5312ae6d088..991ebf21252 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -3,8 +3,8 @@ use anyhow::Result; use buffer_diff::BufferDiff; use editor::{ - Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor, ToPoint, - actions::DiffClipboardWithSelectionData, + Editor, EditorEvent, EditorSettings, MultiBuffer, RestoreOnlyUnstagedDiffHunkDelegate, + SplittableEditor, ToPoint, actions::DiffClipboardWithSelectionData, }; use futures::{FutureExt, select_biased}; use gpui::{ @@ -184,11 +184,8 @@ impl TextDiffView { window, cx, ); - splittable.disable_diff_hunk_controls(cx); - splittable.set_render_diff_hunks_as_unstaged(cx); - splittable.rhs_editor().update(cx, |editor, _cx| { - editor.start_temporary_diff_override(); - }); + splittable + .set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx); splittable }); diff --git a/crates/git_ui/src/unstaged_diff.rs b/crates/git_ui/src/unstaged_diff.rs new file mode 100644 index 00000000000..44f700c722a --- /dev/null +++ b/crates/git_ui/src/unstaged_diff.rs @@ -0,0 +1,722 @@ +use crate::{ + diff_multibuffer::DiffMultibuffer, + git_panel::{GitPanel, GitPanelAddon, GitStatusEntry}, +}; +use anyhow::{Context as _, Result}; +use buffer_diff::DiffHunkStatus; +use editor::{ + DiffHunkDelegate, Editor, EditorEvent, ResolvedDiffHunks, SplittableEditor, + actions::{GoToHunk, GoToPreviousHunk}, +}; +use git::{StageAll, StageAndNext}; +use gpui::{ + Action, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, + SharedString, Subscription, Task, WeakEntity, +}; +use language::Capability; +use project::{ + Project, ProjectPath, + git_store::diff_buffer_list::{DiffBase, DiffBufferList}, + project_settings::ProjectSettings, +}; +use settings::Settings; +use std::{ + any::{Any, TypeId}, + ops::Range, + sync::Arc, +}; +use ui::{DiffStat, Divider, Icon, Tooltip, Window, prelude::*}; +use util::ResultExt as _; +use workspace::{ + ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, + Workspace, + item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams}, + searchable::SearchableItemHandle, +}; + +pub(crate) struct UnstagedDiffDelegate; + +impl DiffHunkDelegate for UnstagedDiffDelegate { + fn toggle( + &self, + hunks: Vec, + editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + self.stage_or_unstage(true, hunks, editor, window, cx); + } + + fn stage_or_unstage( + &self, + stage: bool, + hunks: Vec, + editor: &mut Editor, + _window: &mut Window, + cx: &mut Context, + ) { + if !stage { + return; + } + let Some(project) = editor.project().cloned() else { + return; + }; + for hunks in hunks { + let Some(buffer) = hunks.buffer else { + continue; + }; + let worktree_ranges = hunks + .hunks + .into_iter() + .map(|hunk| hunk.buffer_range) + .collect::>(); + if worktree_ranges.is_empty() { + continue; + } + project + .update(cx, |project, cx| { + project.stage_hunks(buffer, hunks.diff, worktree_ranges, cx) + }) + .log_err(); + } + } + + fn render_hunk_controls( + &self, + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + _is_created_file: bool, + line_height: Pixels, + editor: &Entity, + _window: &mut Window, + cx: &mut App, + ) -> AnyElement { + if !ProjectSettings::get_global(cx) + .git + .show_stage_restore_buttons + { + return gpui::Empty.into_any_element(); + } + let hunk_range = hunk_range.start..hunk_range.start; + h_flex() + .h(line_height) + .mr_1() + .gap_1() + .px_0p5() + .pb_1() + .border_x_1() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .rounded_b_lg() + .bg(cx.theme().colors().editor_background) + .block_mouse_except_scroll() + .shadow_md() + .child( + Button::new(("stage", row as u64), "Stage") + .alpha(if status.is_pending() { 0.66 } else { 1.0 }) + .tooltip(Tooltip::text("Stage Hunk")) + .on_click({ + let editor = editor.clone(); + move |_event, window, cx| { + editor.update(cx, |editor, cx| { + editor.stage_or_unstage_diff_hunks( + true, + vec![hunk_range.clone()], + window, + cx, + ); + }); + } + }), + ) + .into_any_element() + } + + fn render_hunk_as_staged(&self, _status: &DiffHunkStatus, _cx: &App) -> bool { + false + } +} + +pub struct UnstagedDiff { + diff: Entity, + project: Entity, + workspace: WeakEntity, + _diff_event_subscription: Subscription, +} + +impl UnstagedDiff { + pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { + let _ = workspace; + workspace::register_serializable_item::(cx); + } + + pub fn deploy_at( + workspace: &mut Workspace, + entry: Option, + window: &mut Window, + cx: &mut Context, + ) { + telemetry::event!( + "Git Unstaged Diff Opened", + source = if entry.is_some() { + "Git Panel" + } else { + "Action" + } + ); + let intended_repo = workspace.project().read(cx).active_repository(cx); + let existing = workspace.items_of_type::(cx).next(); + let unstaged_diff = if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + existing + } else { + let workspace_handle = cx.entity(); + let unstaged_diff = + cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx)); + workspace.add_item_to_active_pane( + Box::new(unstaged_diff.clone()), + None, + true, + window, + cx, + ); + unstaged_diff + }; + + if let Some(intended) = &intended_repo { + let needs_switch = unstaged_diff + .read(cx) + .diff + .read(cx) + .repo(cx) + .map_or(true, |current| current.entity_id() != intended.entity_id()); + if needs_switch { + unstaged_diff.update(cx, |unstaged_diff, cx| { + unstaged_diff.diff.update(cx, |diff, cx| { + diff.set_repo(Some(intended.clone()), cx); + }); + }); + } + } + + if let Some(entry) = entry { + unstaged_diff.update(cx, |unstaged_diff, cx| { + unstaged_diff + .diff + .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); + }); + } + } + + pub(crate) fn new( + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let branch_diff = + cx.new(|cx| DiffBufferList::new(DiffBase::Index, project.clone(), window, cx)); + let workspace_handle = workspace.downgrade(); + let diff = cx.new(|cx| { + DiffMultibuffer::new( + branch_diff, + Capability::ReadWrite, + "No unstaged changes", + move |editor, cx| { + editor.set_diff_hunk_delegate(Some(Arc::new(UnstagedDiffDelegate)), cx); + editor.rhs_editor().update(cx, |rhs_editor, _cx| { + rhs_editor.set_read_only(false); + rhs_editor.register_addon(GitPanelAddon { + workspace: workspace_handle, + }); + }); + }, + project.clone(), + workspace.clone(), + window, + cx, + ) + }); + Self::from_diff(diff, project, workspace, cx) + } + + pub(crate) fn from_diff( + diff: Entity, + project: Entity, + workspace: Entity, + cx: &mut Context, + ) -> Self { + let diff_event_subscription = cx.subscribe(&diff, |_, _, event: &EditorEvent, cx| { + cx.emit(event.clone()) + }); + + Self { + diff, + project, + workspace: workspace.downgrade(), + _diff_event_subscription: diff_event_subscription, + } + } + + fn button_states(&self, cx: &App) -> ButtonStates { + let diff = self.diff.read(cx); + let editor = diff.editor().read(cx).rhs_editor().clone(); + let editor = editor.read(cx); + let snapshot = diff.multibuffer().read(cx).snapshot(cx); + let prev_next = snapshot.diff_hunks().nth(1).is_some(); + let (selection, ranges) = diff.selected_ranges(cx); + let stage = editor + .diff_hunks_in_ranges(&ranges, &snapshot) + .next() + .is_some(); + let mut stage_all = false; + self.workspace + .read_with(cx, |workspace, cx| { + if let Some(git_panel) = workspace.panel::(cx) { + stage_all = git_panel.read(cx).can_stage_all(); + } + }) + .ok(); + + ButtonStates { + stage, + prev_next, + selection, + stage_all, + } + } + + fn stage_selected_unstaged_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.diff.update(cx, |diff, cx| { + diff.stage_or_unstage_selected_hunks(true, move_to_next, window, cx) + }); + } +} + +struct ButtonStates { + stage: bool, + prev_next: bool, + selection: bool, + stage_all: bool, +} + +impl EventEmitter for UnstagedDiff {} + +impl Focusable for UnstagedDiff { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.diff.read(cx).focus_handle(cx) + } +} + +impl Item for UnstagedDiff { + type Event = EditorEvent; + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::GitBranch).color(Color::Muted)) + } + + fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.diff + .update(cx, |diff, cx| diff.deactivated(window, cx)); + } + + fn navigate( + &mut self, + data: Arc, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.diff + .update(cx, |diff, cx| diff.navigate(data, window, cx)) + } + + fn tab_tooltip_text(&self, _: &App) -> Option { + Some("Unstaged Changes".into()) + } + + fn tab_content(&self, params: TabContentParams, _window: &Window, _cx: &App) -> AnyElement { + Label::new(self.tab_content_text(0, _cx)) + .color(if params.selected { + Color::Default + } else { + Color::Muted + }) + .into_any_element() + } + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Unstaged Changes".into() + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("Git Unstaged Diff Opened") + } + + fn as_searchable(&self, _: &Entity, cx: &App) -> Option> { + Some(Box::new(self.diff.read(cx).editor().clone())) + } + + fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + self.diff.read(cx).for_each_project_item(cx, f); + } + + fn set_nav_history( + &mut self, + nav_history: ItemNavHistory, + _: &mut Window, + cx: &mut Context, + ) { + self.diff + .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx)); + } + + fn can_split(&self) -> bool { + true + } + + fn clone_on_split( + &self, + _workspace_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> Task>> + where + Self: Sized, + { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(None); + }; + let project = self.project.clone(); + Task::ready(Some(cx.new(|cx| Self::new(project, workspace, window, cx)))) + } + + fn is_dirty(&self, cx: &App) -> bool { + self.diff.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &App) -> bool { + self.diff.read(cx).has_conflict(cx) + } + + fn can_save(&self, _cx: &App) -> bool { + true + } + + fn save( + &mut self, + options: SaveOptions, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.diff + .update(cx, |diff, cx| diff.save(options, project, window, cx)) + } + + fn save_as( + &mut self, + _: Entity, + _: ProjectPath, + _: &mut Window, + _: &mut Context, + ) -> Task> { + unreachable!() + } + + fn reload( + &mut self, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.diff + .update(cx, |diff, cx| diff.reload(project, window, cx)) + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + cx: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.clone().into()) + } else if type_id == TypeId::of::() { + Some( + self.diff + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + .into(), + ) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).editor().clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).branch_diff().clone().into()) + } else { + None + } + } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + self.diff.update(cx, |diff, cx| { + diff.added_to_workspace(workspace, window, cx) + }); + } +} + +impl SerializableItem for UnstagedDiff { + fn serialized_item_kind() -> &'static str { + "UnstagedDiff" + } + + fn cleanup( + _: workspace::WorkspaceId, + _: Vec, + _: &mut Window, + _: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn deserialize( + project: Entity, + workspace: WeakEntity, + _: workspace::WorkspaceId, + _: workspace::ItemId, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + window.spawn(cx, async move |cx| { + let workspace = workspace.upgrade().context("workspace gone")?; + cx.update(|window, cx| Ok(cx.new(|cx| Self::new(project, workspace, window, cx))))? + }) + } + + fn serialize( + &mut self, + _: &mut Workspace, + _: workspace::ItemId, + _: bool, + _: &mut Window, + _: &mut Context, + ) -> Option>> { + Some(Task::ready(Ok(()))) + } + + fn should_serialize(&self, _: &Self::Event) -> bool { + false + } +} + +impl Render for UnstagedDiff { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.diff.clone() + } +} + +pub struct UnstagedDiffToolbar { + unstaged_diff: Option>, + workspace: WeakEntity, +} + +impl UnstagedDiffToolbar { + pub fn new(workspace: &Workspace, _: &mut Context) -> Self { + Self { + unstaged_diff: None, + workspace: workspace.weak_handle(), + } + } + + fn unstaged_diff(&self, _: &App) -> Option> { + self.unstaged_diff.as_ref()?.upgrade() + } + + fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) { + if let Some(unstaged_diff) = self.unstaged_diff(cx) { + unstaged_diff.focus_handle(cx).focus(window, cx); + } + let action = action.boxed_clone(); + cx.defer(move |cx| { + cx.dispatch_action(action.as_ref()); + }) + } + + fn stage_selected_unstaged_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(unstaged_diff) = self.unstaged_diff(cx) else { + return; + }; + unstaged_diff.update(cx, |unstaged_diff, cx| { + unstaged_diff.stage_selected_unstaged_hunks(move_to_next, window, cx); + }); + } + + fn stage_all(&mut self, window: &mut Window, cx: &mut Context) { + self.workspace + .update(cx, |workspace, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + panel.update(cx, |panel, cx| { + panel.stage_all(&Default::default(), window, cx); + }); + }) + .ok(); + } +} + +impl EventEmitter for UnstagedDiffToolbar {} + +impl ToolbarItemView for UnstagedDiffToolbar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + self.unstaged_diff = active_pane_item + .and_then(|item| item.act_as::(cx)) + .map(|entity| entity.downgrade()); + if self.unstaged_diff.is_some() { + ToolbarItemLocation::PrimaryRight + } else { + ToolbarItemLocation::Hidden + } + } + + fn pane_focus_update( + &mut self, + _pane_focused: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + } +} + +impl Render for UnstagedDiffToolbar { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(unstaged_diff) = self.unstaged_diff(cx) else { + return div(); + }; + let focus_handle = unstaged_diff.focus_handle(cx); + let button_states = unstaged_diff.read(cx).button_states(cx); + + let diff = unstaged_diff.read(cx).diff.read(cx); + let (additions, deletions) = diff.calculate_changed_lines(cx); + let is_multibuffer_empty = diff.multibuffer().read(cx).is_empty(); + + h_flex() + .my_neg_1() + .py_1() + .gap_1p5() + .flex_wrap() + .justify_between() + .when(!is_multibuffer_empty, |this| { + this.child(DiffStat::new( + "unstaged-diff-stat", + additions as usize, + deletions as usize, + )) + .child(Divider::vertical().ml_1()) + }) + // n.b. the only reason these arrows are here is because we don't + // support "undo" for staging so we need a way to go back. + .child( + h_group_sm() + .child( + IconButton::new("up", IconName::ArrowUp) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Previous Hunk", + &GoToPreviousHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToPreviousHunk, window, cx) + })), + ) + .child( + IconButton::new("down", IconName::ArrowDown) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Next Hunk", + &GoToHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToHunk, window, cx) + })), + ), + ) + .child(Divider::vertical()) + .child( + h_group_sm() + .when(button_states.selection, |this| { + this.child( + Button::new("stage", "Stage") + .disabled(!button_states.stage) + .tooltip(Tooltip::text("Stage Selected Hunks")) + .on_click(cx.listener(|this, _, window, cx| { + this.stage_selected_unstaged_hunks(false, window, cx) + })), + ) + }) + .when(!button_states.selection, |this| { + this.child( + Button::new("stage", "Stage") + .disabled(!button_states.stage) + .tooltip(Tooltip::for_action_title_in( + "Stage and Go to Next Hunk", + &StageAndNext, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.stage_selected_unstaged_hunks(true, window, cx) + })), + ) + }), + ) + .child(Divider::vertical()) + .child( + Button::new("stage-all", "Stage All") + .width(rems_from_px(80.)) + .disabled(!button_states.stage_all) + .tooltip(Tooltip::for_action_title_in( + "Stage All Changes", + &StageAll, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| this.stage_all(window, cx))), + ) + } +} diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 5b61e6237e7..805ffc73550 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -626,7 +626,7 @@ impl DiffState { this.buffer_diff_changed(diff, range, cx); cx.emit(Event::BufferDiffChanged); } - BufferDiffEvent::BaseTextChanged | BufferDiffEvent::HunksStagedOrUnstaged(_) => {} + BufferDiffEvent::BaseTextChanged => {} }), diff, main_buffer: None, @@ -660,8 +660,7 @@ impl DiffState { ); cx.emit(Event::BufferDiffChanged); } - BufferDiffEvent::BaseTextChanged - | BufferDiffEvent::HunksStagedOrUnstaged(_) => {} + BufferDiffEvent::BaseTextChanged => {} } } }), diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index fd3efdae5c8..74efd3f0c86 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1,5 +1,5 @@ -pub mod branch_diff; mod conflict_set; +pub mod diff_buffer_list; pub mod git_traversal; pub mod job_debug_queue; pub mod pending_op; @@ -15,7 +15,7 @@ use crate::{ }; use anyhow::{Context as _, Result, anyhow, bail}; use askpass::{AskPassDelegate, EncryptedPassword, IKnowWhatIAmDoingAndIHaveReadTheDocs}; -use buffer_diff::{BufferDiff, BufferDiffEvent}; +use buffer_diff::{BufferDiff, DiffHunk, DiffHunkSecondaryStatus, PendingHunk, PendingSense}; use client::ProjectId; use collections::HashMap; pub use conflict_set::{ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate}; @@ -51,7 +51,7 @@ use gpui::{ Subscription, Task, TaskExt, WeakEntity, }; use language::{ - Buffer, BufferEvent, Capability, Language, LanguageRegistry, + Anchor, Buffer, BufferEvent, Capability, Language, LanguageRegistry, proto::{deserialize_version, serialize_version}, }; use parking_lot::Mutex; @@ -82,7 +82,7 @@ use std::{ }; use sum_tree::{Edit, SumTree, TreeMap}; use task::Shell; -use text::{Bias, BufferId}; +use text::{Bias, BufferId, OffsetRangeExt, Rope, ToOffset}; use util::{ ResultExt, debug_panic, paths::{PathStyle, SanitizedPath}, @@ -106,6 +106,7 @@ pub struct GitStore { loading_diffs: HashMap<(BufferId, DiffKind), Shared, Arc>>>>, diffs: HashMap>, + buffer_ids_by_index_text_buffer_id: HashMap, shared_diffs: HashMap>, _subscriptions: Vec, } @@ -142,6 +143,16 @@ struct BufferGitState { head_text: Option>, index_text: Option>, + /// The optimistic, in-flight index state: the sole input to the index write, + /// expressed relative to the currently-loaded index text (`I0`). Never shown + /// to any view (views use per-diff pending hunks). Cleared when a + /// recalculation settles. + /// + /// `I0` is immutable for the lifetime of any pending edit (the index text + /// buffer is only fast-forwarded when a recalculation settles, which clears + /// this state in the same update), so byte offsets stay valid for the whole + /// window. + pending_index_edits: Option, Arc)>>, oid_texts: HashMap>, head_text_buffer: WeakEntity, index_text_buffer: WeakEntity, @@ -151,6 +162,24 @@ struct BufferGitState { language_changed: bool, } +fn pending_hunks( + hunks: &[DiffHunk], + version: &clock::Global, + sense: PendingSense, +) -> Vec { + hunks + .iter() + .map(|hunk| { + PendingHunk::new( + hunk.buffer_range.clone(), + hunk.diff_base_byte_range.clone(), + version.clone(), + sense, + ) + }) + .collect() +} + #[derive(Clone, Debug)] enum DiffBasesChange { SetIndex(Option), @@ -170,6 +199,73 @@ enum DiffKind { SinceOid(Option), } +struct IndexTextFile { + path: Arc, + full_path: PathBuf, + path_style: PathStyle, + file_name: String, + worktree_id: WorktreeId, + is_private: bool, +} + +impl IndexTextFile { + fn new(file: &dyn language::File, cx: &App) -> Self { + Self { + path: file.path().clone(), + full_path: file.full_path(cx), + path_style: file.path_style(cx), + file_name: file.file_name(cx).to_string(), + worktree_id: file.worktree_id(cx), + is_private: file.is_private(), + } + } +} + +impl language::File for IndexTextFile { + fn as_local(&self) -> Option<&dyn language::LocalFile> { + None + } + + fn disk_state(&self) -> language::DiskState { + language::DiskState::Historic { was_deleted: false } + } + + fn path(&self) -> &Arc { + &self.path + } + + fn full_path(&self, _: &App) -> PathBuf { + self.full_path.clone() + } + + fn path_style(&self, _: &App) -> PathStyle { + self.path_style + } + + fn file_name<'a>(&'a self, _: &'a App) -> &'a str { + &self.file_name + } + + fn worktree_id(&self, _: &App) -> WorktreeId { + self.worktree_id + } + + fn to_proto(&self, _: &App) -> rpc::proto::File { + rpc::proto::File { + worktree_id: self.worktree_id.to_proto(), + entry_id: None, + path: self.path.as_ref().to_proto(), + mtime: None, + is_deleted: false, + is_historic: true, + } + } + + fn is_private(&self) -> bool { + self.is_private + } +} + #[derive(Debug, Clone, Copy)] pub enum GitAccess { /// Either: @@ -648,6 +744,7 @@ impl GitStore { loading_diffs: HashMap::default(), shared_diffs: HashMap::default(), diffs: HashMap::default(), + buffer_ids_by_index_text_buffer_id: HashMap::default(), } } @@ -897,6 +994,16 @@ impl GitStore { .as_ref() .and_then(|weak| weak.upgrade()) { + // If this unstaged diff was first opened as the uncommitted diff's + // secondary, its index text wasn't highlighted. Enable it now and + // recalc so the language gets applied to the deleted (base) side. + diff_state.update(cx, |diff_state, cx| { + if !diff_state.index_text_buffer_language_enabled { + diff_state.index_text_buffer_language_enabled = true; + let buffer_snapshot = buffer.read(cx).text_snapshot(); + diff_state.recalculate_diffs(buffer_snapshot, cx); + } + }); if let Some(task) = diff_state.update(cx, |diff_state, _| diff_state.wait_for_recalculation()) { @@ -944,15 +1051,17 @@ impl GitStore { cx.background_spawn(async move { task.await.map_err(|e| anyhow!("{e}")) }) } + /// Opens the staged (HEAD-vs-index) diff for the given buffer, along with + /// the index text buffer that is the diff's main buffer. pub fn open_staged_diff( &mut self, buffer: Entity, cx: &mut Context, - ) -> Task>> { + ) -> Task, Entity)>> { let buffer_id = buffer.read(cx).remote_id(); if let Some(diff_state) = self.diffs.get(&buffer_id) - && let Some(staged_diff) = diff_state.read(cx).staged_diff() + && let Some(staged_diff) = diff_state.read(cx).staged_diff_and_index_text_buffer() { if let Some(task) = diff_state.update(cx, |diff_state, _| diff_state.wait_for_recalculation()) @@ -988,7 +1097,270 @@ impl GitStore { }) .clone(); - cx.background_spawn(async move { task.await.map_err(|e| anyhow!("{e}")) }) + cx.spawn(async move |this, cx| { + let diff = task.await.map_err(|e| anyhow!("{e}"))?; + this.update(cx, |this, cx| { + let index_text_buffer = this + .diffs + .get(&buffer_id) + .and_then(|diff_state| { + let (_, index_text_buffer) = diff_state.read(cx).staged_diff.as_ref()?; + Some(index_text_buffer.clone()) + }) + .context("index text buffer missing after opening staged diff")?; + Ok((diff, index_text_buffer)) + })? + }) + } + + /// Stages the worktree changes covered by `worktree_ranges`, acting on the + /// given unstaged (index-vs-worktree) diff. Used by both the unstaged-changes + /// view and the uncommitted (gutter) controls: "stage" means the same index + /// change regardless of which view it was invoked from, so callers holding an + /// uncommitted diff pass its unstaged secondary. + /// + /// Decomposes the worktree region into the unstaged hunks it covers, so no + /// worktree->index projection is needed. Optimistically suppresses the staged + /// hunks from the unstaged diff and, if the uncommitted diff happens to be + /// open, marks the corresponding uncommitted hunks as staging. + pub fn stage_hunks( + &mut self, + buffer: Entity, + unstaged_diff: Entity, + worktree_ranges: Vec>, + cx: &mut Context, + ) -> Result<()> { + if worktree_ranges.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 unstaged_snapshot = unstaged_diff.read(cx).snapshot(cx); + + // Decompose: the unstaged hunks the worktree region covers carry the + // index range directly. Sorting by buffer offset also sorts by index + // offset, since the hunks are non-overlapping. We read the raw hunks + // (ignoring optimistic suppression) so that re-staging a hunk that was + // optimistically staged and then unstaged still finds it. The footprints + // let the patch drop earlier edits the region covers even where the raw + // diff is empty (re-staging a hunk that is staged on disk but + // optimistically unstaged). + let mut unstaged_hunks = Vec::new(); + let mut index_footprints = Vec::new(); + for range in &worktree_ranges { + unstaged_hunks.extend( + unstaged_snapshot.raw_hunks_intersecting_range(range.clone(), &buffer_snapshot), + ); + index_footprints.push( + unstaged_snapshot.base_text_range_for_buffer_range(range.clone(), &buffer_snapshot), + ); + } + unstaged_hunks.sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&buffer_snapshot)); + unstaged_hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start); + + // The uncommitted hunks the region covers get the optimistic + // "staging" secondary status (free cross-view update). + let uncommitted_diff = self.get_uncommitted_diff(buffer_id, cx); + let mut uncommitted_hunks = Vec::new(); + if let Some(uncommitted_diff) = &uncommitted_diff { + let uncommitted_snapshot = uncommitted_diff.read(cx).snapshot(cx); + for range in &worktree_ranges { + uncommitted_hunks.extend( + uncommitted_snapshot + .hunks_intersecting_range(range.clone(), &buffer_snapshot) + .filter(|hunk| { + hunk.secondary_status != DiffHunkSecondaryStatus::NoSecondaryHunk + }), + ); + } + uncommitted_hunks + .sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&buffer_snapshot)); + uncommitted_hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start); + } + + let index_edits = if !file_exists { + // The worktree file is gone: staging removes it from the index. + None + } else { + Some( + unstaged_hunks + .iter() + .map(|hunk| { + let worktree_range = hunk.buffer_range.to_offset(&buffer_snapshot); + let replacement: Arc = Arc::from( + buffer_snapshot + .text_for_range(worktree_range) + .collect::(), + ); + (hunk.diff_base_byte_range.clone(), replacement) + }) + .collect::>(), + ) + }; + + let version = buffer_snapshot.version().clone(); + let unstaged_pending = pending_hunks(&unstaged_hunks, &version, PendingSense::Suppress); + let uncommitted_pending = pending_hunks( + &uncommitted_hunks, + &version, + PendingSense::SetSecondaryStatus { stage: true }, + ); + drop(unstaged_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); + diff_state.insert_pending_index_edits(index_edits); + }); + + if let Some(uncommitted_diff) = uncommitted_diff { + uncommitted_diff.update(cx, |diff, cx| { + diff.set_pending_hunks(&uncommitted_pending, &buffer_snapshot, cx); + }); + } + 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) + /// because the acted-on hunks are HEAD-vs-worktree. + pub fn unstage_uncommitted_hunks( + &mut self, + buffer: Entity, + uncommitted_diff: Entity, + worktree_ranges: Vec>, + cx: &mut Context, + ) -> Result<()> { + if worktree_ranges.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_snapshot = uncommitted_diff.read(cx).snapshot(cx); + let unstaged_snapshot = uncommitted_snapshot + .secondary_diff() + .context("diff has no unstaged secondary")?; + + let mut hunks = Vec::new(); + for range in &worktree_ranges { + hunks.extend( + uncommitted_snapshot.hunks_intersecting_range(range.clone(), &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 (index_edits, pending) = uncommitted_snapshot.compute_uncommitted_index_edits( + unstaged_snapshot, + false, + &hunks, + &buffer_snapshot, + file_exists, + ); + 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.insert_pending_index_edits(index_edits) + }); + + uncommitted_diff.update(cx, |diff, cx| { + diff.set_pending_hunks(&pending, &buffer_snapshot, cx); + }); + + self.write_optimistic_index(buffer_id, cx); + Ok(()) + } + + /// Unstages staged (HEAD-vs-index) hunks covered by `index_ranges` (in the + /// index text buffer's coordinates), acting on the given staged diff, + /// invoked from the staged-changes view. The acted-on hunks already carry + /// an index range, so no projection is needed; optimistically suppresses + /// them from the staged diff. + pub fn unstage_staged_hunks( + &mut self, + staged_diff: Entity, + index_ranges: Vec>, + cx: &mut Context, + ) -> Result<()> { + if index_ranges.is_empty() { + return Ok(()); + } + let index_buffer_id = staged_diff.read(cx).buffer_id; + let buffer_id = *self + .buffer_ids_by_index_text_buffer_id + .get(&index_buffer_id) + .context("failed to find git state for index text buffer")?; + let diff_state = self + .diffs + .get(&buffer_id) + .cloned() + .context("failed to find git state for buffer")?; + let index_buffer = diff_state + .read(cx) + .index_text_buffer() + .context("index text is not loaded")?; + let index_snapshot = index_buffer.read(cx).text_snapshot(); + let staged_snapshot = staged_diff.read(cx).snapshot(cx); + + let mut hunks = Vec::new(); + for range in &index_ranges { + hunks.extend(staged_snapshot.hunks_intersecting_range(range.clone(), &index_snapshot)); + } + hunks.sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&index_snapshot)); + hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start); + + let index_edits = staged_diff + .read(cx) + .unstage_staged_hunks(&hunks, &index_snapshot); + let version = index_snapshot.version().clone(); + let pending = pending_hunks(&hunks, &version, PendingSense::Suppress); + drop(staged_snapshot); + + diff_state.update(cx, |diff_state, _| { + diff_state.insert_pending_index_edits(index_edits) + }); + staged_diff.update(cx, |diff, cx| { + diff.set_pending_hunks(&pending, &index_snapshot, cx); + }); + + self.write_optimistic_index(buffer_id, cx); + Ok(()) + } + + /// Derives the desired index text from the buffer's optimistic patch and + /// schedules the write. + fn write_optimistic_index(&mut self, buffer_id: BufferId, cx: &mut Context) { + let Some(diff_state) = self.diffs.get(&buffer_id) else { + return; + }; + let new_index_text = diff_state + .read(cx) + .pending_index_text(cx) + .map(|rope| rope.to_string()); + self.write_index_text_for_buffer_id(buffer_id, new_index_text, cx); } pub fn open_diff_since( @@ -1058,9 +1430,6 @@ impl GitStore { }); this.update(cx, |this, cx| { - cx.subscribe(&buffer_diff, Self::on_buffer_diff_event) - .detach(); - this.loading_diffs.remove(&(buffer_id, diff_kind)); let git_store = cx.weak_entity(); @@ -1174,6 +1543,9 @@ impl GitStore { let buffer_id = buffer.remote_id(); let language = buffer.language().cloned(); let language_registry = buffer.language_registry(); + let index_text_file = buffer.file().map(|file| { + Arc::new(IndexTextFile::new(file.as_ref(), cx)) as Arc + }); let text_snapshot = buffer.text_snapshot(); this.loading_diffs.remove(&(buffer_id, kind)); @@ -1194,7 +1566,7 @@ impl GitStore { let diff = match kind { DiffKind::Unstaged => { let base_text_buffer = diff_state.update(cx, |diff_state, cx| { - diff_state.get_or_create_index_text_buffer(cx) + diff_state.get_or_create_index_text_buffer(index_text_file.clone(), cx) }); cx.new(|cx| { BufferDiff::new_with_base_text_buffer( @@ -1208,7 +1580,10 @@ impl GitStore { let (index_text_buffer, base_text_buffer) = diff_state.update(cx, |diff_state, cx| { ( - diff_state.get_or_create_index_text_buffer(cx), + diff_state.get_or_create_index_text_buffer( + index_text_file.clone(), + cx, + ), diff_state.get_or_create_head_text_buffer(cx), ) }); @@ -1244,16 +1619,19 @@ impl GitStore { unreachable!("open_diff_internal is not used for OID diffs") } }; - cx.subscribe(&diff, Self::on_buffer_diff_event).detach(); diff }; - diff_state.update(cx, |diff_state, cx| { + let rx = diff_state.update(cx, |diff_state, cx| { diff_state.language = language; diff_state.language_registry = language_registry; match kind { DiffKind::Unstaged => { diff_state.unstaged_diff = Some(diff.downgrade()); + // The deleted (base) side of a standalone unstaged diff + // is the index, so highlight it. The recalc kicked off by + // `diff_bases_changed` below applies the language. + diff_state.index_text_buffer_language_enabled = true; } DiffKind::Staged => { diff_state.index_text_buffer_language_enabled = true; @@ -1266,7 +1644,8 @@ impl GitStore { let unstaged_diff = if let Some(diff) = existing_unstaged_diff { diff } else { - let base_text_buffer = diff_state.get_or_create_index_text_buffer(cx); + let base_text_buffer = + diff_state.get_or_create_index_text_buffer(index_text_file, cx); let unstaged_diff = cx.new(|cx| { BufferDiff::new_with_base_text_buffer( &text_snapshot, @@ -1295,7 +1674,15 @@ impl GitStore { } Ok(diff) }) - }) + }); + let diff_state = this.diffs.get(&buffer_id).cloned(); + if let Some(index_text_buffer) = + diff_state.and_then(|diff_state| diff_state.read(cx).index_text_buffer()) + { + this.buffer_ids_by_index_text_buffer_id + .insert(index_text_buffer.read(cx).remote_id(), buffer_id); + } + rx })?? .await } @@ -1997,6 +2384,8 @@ impl GitStore { } BufferStoreEvent::BufferDropped(buffer_id) => { self.diffs.remove(buffer_id); + self.buffer_ids_by_index_text_buffer_id + .retain(|_, main_buffer_id| main_buffer_id != buffer_id); for diffs in self.shared_diffs.values_mut() { diffs.remove(buffer_id); } @@ -2073,48 +2462,45 @@ impl GitStore { } } - fn on_buffer_diff_event( + fn write_index_text_for_buffer_id( &mut self, - diff: Entity, - event: &BufferDiffEvent, + buffer_id: BufferId, + new_index_text: Option, cx: &mut Context, ) { - if let BufferDiffEvent::HunksStagedOrUnstaged(new_index_text) = event { - let buffer_id = diff.read(cx).buffer_id; - if let Some(diff_state) = self.diffs.get(&buffer_id) { - let new_index_text = new_index_text.as_ref().map(|rope| rope.to_string()); - if new_index_text.as_deref() == diff_state.read(cx).index_text.as_deref() { - return; - } - let hunk_staging_operation_count = diff_state.update(cx, |diff_state, _| { - diff_state.hunk_staging_operation_count += 1; - diff_state.hunk_staging_operation_count - }); - if let Some((repo, path)) = self.repository_and_path_for_buffer_id(buffer_id, cx) { - let recv = repo.update(cx, |repo, cx| { - log::debug!("hunks changed for {}", path.as_unix_str()); - repo.spawn_set_index_text_job( - path, - new_index_text, - Some(hunk_staging_operation_count), - cx, - ) - }); - let diff = diff.downgrade(); - cx.spawn(async move |this, cx| { - if let Ok(Err(error)) = cx.background_spawn(recv).await { - diff.update(cx, |diff, cx| { - diff.clear_pending_hunks(cx); - }) - .ok(); - this.update(cx, |_, cx| cx.emit(GitStoreEvent::IndexWriteError(error))) - .ok(); - } - }) - .detach(); - } + let Some(diff_state) = self.diffs.get(&buffer_id) else { + return; + }; + let hunk_staging_operation_count = diff_state.update(cx, |diff_state, _| { + diff_state.hunk_staging_operation_count += 1; + diff_state.hunk_staging_operation_count + }); + let Some((repo, path)) = self.repository_and_path_for_buffer_id(buffer_id, cx) else { + return; + }; + let recv = repo.update(cx, |repo, cx| { + log::debug!("hunks changed for {}", path.as_unix_str()); + repo.spawn_set_index_text_job( + path, + new_index_text, + Some(hunk_staging_operation_count), + cx, + ) + }); + cx.spawn(async move |this, cx| { + if let Ok(Err(error)) = cx.background_spawn(recv).await { + this.update(cx, |this, cx| { + if let Some(diff_state) = this.diffs.get(&buffer_id).cloned() { + diff_state.update(cx, |diff_state, cx| { + diff_state.clear_pending_index_edits_and_hunks(cx); + }); + } + cx.emit(GitStoreEvent::IndexWriteError(error)); + }) + .ok(); } - } + }) + .detach(); } fn local_worktree_git_repos_changed( @@ -3951,6 +4337,7 @@ impl BufferGitState { hunk_staging_operation_count_as_of_write: 0, head_text: Default::default(), index_text: Default::default(), + pending_index_edits: Some(Vec::new()), oid_texts: Default::default(), head_text_buffer: WeakEntity::new_invalid(), index_text_buffer: WeakEntity::new_invalid(), @@ -3978,13 +4365,27 @@ impl BufferGitState { buffer } - fn get_or_create_index_text_buffer(&mut self, cx: &mut Context) -> Entity { + fn index_text_buffer(&self) -> Option> { + self.index_text_buffer.upgrade() + } + + fn get_or_create_index_text_buffer( + &mut self, + file: Option>, + cx: &mut Context, + ) -> Entity { if let Some(buffer) = self.index_text_buffer.upgrade() { + if let Some(file) = file { + buffer.update(cx, |buffer, cx| buffer.file_updated(file, cx)); + } return buffer; } let index_text = self.index_text.clone(); let buffer = cx.new(|cx| { let mut buffer = Buffer::local(index_text.as_deref().unwrap_or(""), cx); + if let Some(file) = file { + buffer.file_updated(file, cx); + } buffer.set_capability(Capability::ReadOnly, cx); buffer }); @@ -4055,6 +4456,11 @@ impl BufferGitState { self.staged_diff.as_ref().and_then(|(set, _)| set.upgrade()) } + fn staged_diff_and_index_text_buffer(&self) -> Option<(Entity, Entity)> { + let (diff, index_text_buffer) = self.staged_diff.as_ref()?; + Some((diff.upgrade()?, index_text_buffer.clone())) + } + fn uncommitted_diff(&self) -> Option> { self.uncommitted_diff.as_ref().and_then(|set| set.upgrade()) } @@ -4076,6 +4482,100 @@ impl BufferGitState { } } + fn remove_overlapping_pending_index_edits(&mut self, ranges: &[Range]) { + if let Some(edits) = &mut self.pending_index_edits { + edits.retain(|(existing, _)| { + ranges.iter().all(|footprint| { + existing.end < footprint.start || footprint.end < existing.start + }) + }); + } + } + + fn insert_pending_index_edits(&mut self, edits: Option, Arc)>>) { + match edits { + None => { + self.pending_index_edits = None; + } + Some(new_edits) => { + let mut edits = self.pending_index_edits.take().unwrap_or_default(); + for (range, replacement) in new_edits { + edits.retain(|(existing, _)| { + existing.end < range.start || range.end < existing.start + }); + let position = + edits.partition_point(|(existing, _)| existing.start < range.start); + edits.insert(position, (range, replacement)); + } + self.pending_index_edits = Some(edits); + } + } + } + + fn pending_index_text(&self, cx: &App) -> Option { + let index_text_buffer = self.index_text_buffer.upgrade()?; + let edits = self.pending_index_edits.as_ref()?; + #[cfg(debug_assertions)] + for window in edits.windows(2) { + debug_assert!(window[0].0.end <= window[1].0.start); + } + let mut index_text = index_text_buffer.read(cx).text_snapshot().as_rope().clone(); + for (old_range, replacement_text) in edits.iter().rev() { + index_text.replace(old_range.clone(), replacement_text); + } + Some(index_text) + } + + fn clear_pending_index_edits(&mut self) { + self.pending_index_edits = Some(Vec::new()); + } + + fn clear_pending_hunks(&mut self, cx: &mut Context) { + for diff in [ + self.uncommitted_diff(), + self.unstaged_diff(), + self.staged_diff(), + ] + .into_iter() + .flatten() + { + diff.update(cx, |diff, cx| diff.clear_pending_hunks(cx)); + } + } + + fn mark_whole_file_stage_or_unstage_pending( + &mut self, + stage: bool, + buffer_snapshot: &text::BufferSnapshot, + cx: &mut Context, + ) { + if let Some(uncommitted_diff) = self.uncommitted_diff() { + uncommitted_diff.update(cx, |uncommitted_diff, cx| { + uncommitted_diff.mark_all_hunks_pending(stage, buffer_snapshot, cx); + }); + } + + if stage { + if let Some(unstaged_diff) = self.unstaged_diff() { + unstaged_diff.update(cx, |unstaged_diff, cx| { + unstaged_diff.suppress_all_hunks_pending(buffer_snapshot, cx); + }); + } + } else if let Some(staged_diff) = self.staged_diff() + && let Some(index_text_buffer) = self.index_text_buffer() + { + let index_snapshot = index_text_buffer.read(cx).text_snapshot(); + staged_diff.update(cx, |staged_diff, cx| { + staged_diff.suppress_all_hunks_pending(&index_snapshot, cx); + }); + } + } + + fn clear_pending_index_edits_and_hunks(&mut self, cx: &mut Context) { + self.clear_pending_index_edits(); + self.clear_pending_hunks(cx); + } + fn handle_base_texts_updated( &mut self, buffer: text::BufferSnapshot, @@ -4089,16 +4589,17 @@ impl BufferGitState { }; let diff_bases_change = match mode { - Mode::HeadOnly => DiffBasesChange::SetHead(message.committed_text), - Mode::IndexOnly => DiffBasesChange::SetIndex(message.staged_text), - Mode::IndexMatchesHead => DiffBasesChange::SetBoth(message.committed_text), - Mode::IndexAndHead => DiffBasesChange::SetEach { + Mode::HeadOnly => Some(DiffBasesChange::SetHead(message.committed_text)), + Mode::IndexOnly => Some(DiffBasesChange::SetIndex(message.staged_text)), + Mode::IndexMatchesHead => Some(DiffBasesChange::SetBoth(message.committed_text)), + Mode::IndexAndHead => Some(DiffBasesChange::SetEach { index: message.staged_text, head: message.committed_text, - }, + }), + Mode::Unchanged => None, }; - self.diff_bases_changed(buffer, Some(diff_bases_change), cx); + self.diff_bases_changed(buffer, diff_bases_change, cx); } pub fn wait_for_recalculation(&mut self) -> Option + use<>> { @@ -4403,7 +4904,9 @@ impl BufferGitState { return Ok(()); } - this.update(cx, |_, cx| { + this.update(cx, |this, cx| { + this.clear_pending_index_edits(); + if let (Some(staged_diff), Some(new_staged_diff)) = (staged_diff.as_ref(), new_staged_diff.clone()) { @@ -4422,7 +4925,7 @@ impl BufferGitState { head_text_buffer.fast_forward(edited_head_text, cx) }); } - diff.set_snapshot(new_staged_diff, cx) + diff.set_snapshot_with_secondary(new_staged_diff, None, true, cx) }); } @@ -4437,7 +4940,7 @@ impl BufferGitState { index_text_buffer.fast_forward(edited_index_text, cx) }); } - diff.set_snapshot(new_unstaged_diff, cx) + diff.set_snapshot_with_secondary(new_unstaged_diff, None, true, cx) })) } else { None @@ -5244,21 +5747,23 @@ impl Repository { diff_state.update(cx, |diff_state, cx| { use proto::update_diff_bases::Mode; - if let Some((diff_bases_change, (client, project_id))) = - diff_bases_change.clone().zip(downstream_client) - { - let (staged_text, committed_text, mode) = match diff_bases_change { - DiffBasesChange::SetIndex(index) => { - (index, None, Mode::IndexOnly) - } - DiffBasesChange::SetHead(head) => (None, head, Mode::HeadOnly), - DiffBasesChange::SetEach { index, head } => { - (index, head, Mode::IndexAndHead) - } - DiffBasesChange::SetBoth(text) => { - (None, text, Mode::IndexMatchesHead) - } - }; + if let Some((client, project_id)) = downstream_client { + let (staged_text, committed_text, mode) = + match diff_bases_change.clone() { + Some(DiffBasesChange::SetIndex(index)) => { + (index, None, Mode::IndexOnly) + } + Some(DiffBasesChange::SetHead(head)) => { + (None, head, Mode::HeadOnly) + } + Some(DiffBasesChange::SetEach { index, head }) => { + (index, head, Mode::IndexAndHead) + } + Some(DiffBasesChange::SetBoth(text)) => { + (None, text, Mode::IndexMatchesHead) + } + None => (None, None, Mode::Unchanged), + }; client .send(proto::UpdateDiffBases { project_id: project_id.to_proto(), @@ -6454,33 +6959,15 @@ impl Repository { else { continue; }; - let Some(uncommitted_diff) = - diff_state.read(cx).uncommitted_diff.as_ref().and_then( - |uncommitted_diff| uncommitted_diff.upgrade(), - ) - else { - continue; - }; let buffer_snapshot = buffer.read(cx).text_snapshot(); - let file_exists = buffer - .read(cx) - .file() - .is_some_and(|file| file.disk_state().exists()); let hunk_staging_operation_count = diff_state.update(cx, |diff_state, cx| { - uncommitted_diff.update( - cx, - |uncommitted_diff, cx| { - uncommitted_diff - .stage_or_unstage_all_hunks( - stage, - &buffer_snapshot, - file_exists, - cx, - ); - }, - ); - + diff_state + .mark_whole_file_stage_or_unstage_pending( + stage, + &buffer_snapshot, + cx, + ); diff_state.hunk_staging_operation_count += 1; diff_state.hunk_staging_operation_count }); @@ -6547,14 +7034,8 @@ impl Repository { if result.is_ok() { diff_state.hunk_staging_operation_count_as_of_write = hunk_staging_operation_count; - } else if let Some(uncommitted_diff) = - &diff_state.uncommitted_diff - { - uncommitted_diff - .update(cx, |uncommitted_diff, cx| { - uncommitted_diff.clear_pending_hunks(cx); - }) - .ok(); + } else { + diff_state.clear_pending_hunks(cx); } }) .ok(); diff --git a/crates/project/src/git_store/branch_diff.rs b/crates/project/src/git_store/diff_buffer_list.rs similarity index 76% rename from crates/project/src/git_store/branch_diff.rs rename to crates/project/src/git_store/diff_buffer_list.rs index 67aa198945d..2f1586d3e04 100644 --- a/crates/project/src/git_store/branch_diff.rs +++ b/crates/project/src/git_store/diff_buffer_list.rs @@ -24,6 +24,8 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum DiffBase { Head, + Index, + Staged, Merge { base_ref: SharedString }, } @@ -33,7 +35,7 @@ impl DiffBase { } } -pub struct BranchDiff { +pub struct DiffBufferList { diff_base: DiffBase, repo: Option>, project: Entity, @@ -52,9 +54,9 @@ pub enum BranchDiffEvent { DiffBaseChanged, } -impl EventEmitter for BranchDiff {} +impl EventEmitter for DiffBufferList {} -impl BranchDiff { +impl DiffBufferList { pub fn new( source: DiffBase, project: Entity, @@ -350,8 +352,13 @@ impl BranchDiff { .as_ref() .and_then(|t| t.entries.get(&item.repo_path)) .cloned(); - let Some(status) = self.merge_statuses(Some(item.status), branch_diff.as_ref()) - else { + let Some(status) = (match self.diff_base { + DiffBase::Head | DiffBase::Merge { .. } => { + self.merge_statuses(Some(item.status), branch_diff.as_ref()) + } + DiffBase::Index => item.status.staging().has_unstaged().then_some(item.status), + DiffBase::Staged => item.status.staging().has_staged().then_some(item.status), + }) else { continue; }; if !status.has_changes() { @@ -363,7 +370,13 @@ impl BranchDiff { else { continue; }; - let task = Self::load_buffer(branch_diff, project_path, repo.clone(), cx); + let task = Self::load_buffer( + self.diff_base.clone(), + branch_diff, + project_path, + repo.clone(), + cx, + ); output.push(DiffBuffer { repo_path: item.repo_path.clone(), @@ -383,8 +396,13 @@ impl BranchDiff { let Some(project_path) = repo.read(cx).repo_path_to_project_path(&path, cx) else { continue; }; - let task = - Self::load_buffer(Some(branch_diff.clone()), project_path, repo.clone(), cx); + let task = Self::load_buffer( + self.diff_base.clone(), + Some(branch_diff.clone()), + project_path, + repo.clone(), + cx, + ); let file_status = diff_status_to_file_status(branch_diff); @@ -400,44 +418,87 @@ impl BranchDiff { #[instrument(skip_all)] fn load_buffer( + diff_base: DiffBase, branch_diff: Option, project_path: crate::ProjectPath, repo: Entity, cx: &Context<'_, Project>, - ) -> Task, Entity, Entity)>> { + ) -> Task> { let task = cx.spawn(async move |project, cx| { let buffer = project .update(cx, |project, cx| project.open_buffer(project_path, cx))? .await?; - let changes = if let Some(entry) = branch_diff { - let oid = match entry { - git::status::TreeDiffStatus::Added { .. } => None, - git::status::TreeDiffStatus::Modified { old, .. } - | git::status::TreeDiffStatus::Deleted { old } => Some(old), - }; - project - .update(cx, |project, cx| { - project.git_store().update(cx, |git_store, cx| { - git_store.open_diff_since(oid, buffer.clone(), repo, cx) - }) - })? - .await? - } else { - project - .update(cx, |project, cx| { - project.open_uncommitted_diff(buffer.clone(), cx) - })? - .await? + let main_buffer = buffer.clone(); + let load_conflict_set = diff_base != DiffBase::Staged; + let (display_buffer, changes) = match diff_base { + DiffBase::Head => { + let diff = project + .update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + })? + .await?; + (buffer, diff) + } + DiffBase::Index => { + let diff = project + .update(cx, |project, cx| { + project.open_unstaged_diff(buffer.clone(), cx) + })? + .await?; + (buffer, diff) + } + DiffBase::Staged => { + let (diff, index_buffer) = project + .update(cx, |project, cx| { + project.open_staged_diff(buffer.clone(), cx) + })? + .await?; + (index_buffer, diff) + } + DiffBase::Merge { .. } => { + let diff = if let Some(entry) = branch_diff { + let oid = match entry { + git::status::TreeDiffStatus::Added { .. } => None, + git::status::TreeDiffStatus::Modified { old, .. } + | git::status::TreeDiffStatus::Deleted { old } => Some(old), + }; + project + .update(cx, |project, cx| { + project.git_store().update(cx, |git_store, cx| { + git_store.open_diff_since(oid, buffer.clone(), repo, cx) + }) + })? + .await? + } else { + project + .update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + })? + .await? + }; + (buffer, diff) + } }; - let conflict_set = project - .update(cx, |project, cx| { - project.git_store().update(cx, |git_store, cx| { - git_store.open_conflict_set(buffer.clone(), cx) - }) - })? - .await; - Ok((buffer, changes, conflict_set)) + let conflict_set = if load_conflict_set { + Some( + project + .update(cx, |project, cx| { + project.git_store().update(cx, |git_store, cx| { + git_store.open_conflict_set(main_buffer.clone(), cx) + }) + })? + .await, + ) + } else { + None + }; + Ok(LoadedDiffBuffer { + display_buffer, + main_buffer, + diff: changes, + conflict_set, + }) }); task } @@ -461,9 +522,17 @@ fn diff_status_to_file_status(branch_diff: &git::status::TreeDiffStatus) -> File file_status } +#[derive(Debug)] +pub struct LoadedDiffBuffer { + pub display_buffer: Entity, + pub main_buffer: Entity, + pub diff: Entity, + pub conflict_set: Option>, +} + #[derive(Debug)] pub struct DiffBuffer { pub repo_path: RepoPath, pub file_status: FileStatus, - pub load: Task, Entity, Entity)>>, + pub load: Task>, } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 7043c243d83..6a08a291e31 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -3245,12 +3245,14 @@ impl Project { .update(cx, |git_store, cx| git_store.open_unstaged_diff(buffer, cx)) } + /// Opens the staged (HEAD-vs-index) diff for the given buffer, along with + /// the index text buffer that is the diff's main buffer. #[ztracing::instrument(skip_all)] pub fn open_staged_diff( &mut self, buffer: Entity, cx: &mut Context, - ) -> Task>> { + ) -> Task, Entity)>> { if self.is_disconnected(cx) { return Task::ready(Err(anyhow!(ErrorCode::Disconnected))); } @@ -3272,6 +3274,59 @@ impl Project { }) } + /// Stages the worktree changes covered by `worktree_ranges` (in the worktree + /// buffer's coordinates), acting on the given unstaged diff. Used by both the + /// unstaged-changes view and the uncommitted (gutter) controls. + pub fn stage_hunks( + &mut self, + buffer: Entity, + unstaged_diff: Entity, + worktree_ranges: 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_hunks(buffer, unstaged_diff, worktree_ranges, 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. + pub fn unstage_uncommitted_hunks( + &mut self, + buffer: Entity, + uncommitted_diff: Entity, + worktree_ranges: 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.unstage_uncommitted_hunks(buffer, uncommitted_diff, worktree_ranges, cx) + }) + } + + /// Unstages the staged changes covered by `index_ranges` (in the index + /// buffer's coordinates), acting on the given staged diff. Used by the + /// staged-changes view. + pub fn unstage_staged_hunks( + &mut self, + staged_diff: Entity, + index_ranges: 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.unstage_staged_hunks(staged_diff, index_ranges, cx) + }) + } + pub fn open_buffer_by_id( &mut self, id: BufferId, diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index da62617d286..7b4f5839e94 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -21,8 +21,7 @@ mod yarn; use anyhow::Result; use async_trait::async_trait; use buffer_diff::{ - BufferDiffEvent, DiffChanged, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind, - assert_hunks, + BufferDiff, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind, assert_hunks, }; use collections::{BTreeSet, HashMap, HashSet}; use encoding_rs; @@ -90,7 +89,7 @@ use task::{ResolvedTask, ShellKind, TaskContext}; use text::{Anchor, PointUtf16, ReplicaId, ToOffset, Unclipped}; use unindent::Unindent as _; use util::{ - TryFutureExt as _, assert_set_eq, maybe, path, + RandomCharIter, TryFutureExt as _, assert_set_eq, maybe, path, paths::{PathMatcher, PathStyle}, rel_path::{RelPath, rel_path}, test::{TempTree, marked_text_offsets}, @@ -9671,10 +9670,13 @@ async fn test_staged_diff_for_buffer(cx: &mut gpui::TestAppContext) { .unwrap(); cx.run_until_parked(); unstaged_diff.read_with(cx, |diff, cx| { - assert_eq!(diff.base_text(cx).language().cloned(), None); + assert_eq!( + diff.base_text(cx).language().cloned(), + Some(language.clone()) + ); }); - let staged_diff = project + let (staged_diff, index_text_buffer) = project .update(cx, |project, cx| { project.open_staged_diff(buffer.clone(), cx) }) @@ -9682,6 +9684,14 @@ async fn test_staged_diff_for_buffer(cx: &mut gpui::TestAppContext) { .unwrap(); cx.run_until_parked(); + index_text_buffer.read_with(cx, |buffer, cx| { + let file = buffer.file().unwrap(); + assert_eq!(file.file_name(cx), "main.rs"); + assert_eq!( + file.disk_state(), + DiskState::Historic { was_deleted: false } + ); + }); unstaged_diff.read_with(cx, |diff, cx| { assert_eq!( diff.base_text(cx).language().cloned(), @@ -9870,7 +9880,7 @@ async fn test_staged_diff_without_unstaged_diff(cx: &mut gpui::TestAppContext) { .await .unwrap(); - let staged_diff = project + let (staged_diff, _) = project .update(cx, |project, cx| { project.open_staged_diff(buffer.clone(), cx) }) @@ -10157,7 +10167,6 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { }) .await .unwrap(); - let mut diff_events = cx.events(&uncommitted_diff); // The hunks are initially unstaged. uncommitted_diff.read_with(cx, |diff, cx| { @@ -10189,15 +10198,14 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { }); // Stage a hunk. It appears as optimistically staged. - uncommitted_diff.update(cx, |diff, cx| { - let range = - snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_before(Point::new(2, 0)); - let hunks = diff - .snapshot(cx) - .hunks_intersecting_range(range, &snapshot) - .collect::>(); - diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx); - + let range = snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_before(Point::new(2, 0)); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) + }) + .unwrap(); + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10225,28 +10233,9 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { ); }); - // The diff emits a change event for the range of the staged hunk. - assert!(matches!( - diff_events.next().await.unwrap(), - BufferDiffEvent::HunksStagedOrUnstaged(_) - )); - let event = diff_events.next().await.unwrap(); - if let BufferDiffEvent::DiffChanged(DiffChanged { - changed_range: Some(changed_range), - base_text_changed_range: _, - extended_range: _, - base_text_changed: _, - }) = event - { - let changed_range = changed_range.to_point(&snapshot); - assert_eq!(changed_range, Point::new(1, 0)..Point::new(2, 0)); - } else { - panic!("Unexpected event {event:?}"); - } - // When the write to the index completes, it appears as staged. cx.run_until_parked(); - uncommitted_diff.update(cx, |diff, cx| { + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10274,21 +10263,6 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { ); }); - // The diff emits a change event for the changed index text. - let event = diff_events.next().await.unwrap(); - if let BufferDiffEvent::DiffChanged(DiffChanged { - changed_range: Some(changed_range), - base_text_changed_range: _, - extended_range: _, - base_text_changed: _, - }) = event - { - let changed_range = changed_range.to_point(&snapshot); - assert_eq!(changed_range, Point::new(1, 0)..Point::new(2, 0)); - } else { - panic!("Unexpected event {event:?}"); - } - // Simulate a problem writing to the git index. fs.set_error_message_for_index_write( "/dir/.git".as_ref(), @@ -10296,15 +10270,14 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { ); // Stage another hunk. - uncommitted_diff.update(cx, |diff, cx| { - let range = - snapshot.anchor_before(Point::new(3, 0))..snapshot.anchor_before(Point::new(4, 0)); - let hunks = diff - .snapshot(cx) - .hunks_intersecting_range(range, &snapshot) - .collect::>(); - diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx); - + let range = snapshot.anchor_before(Point::new(3, 0))..snapshot.anchor_before(Point::new(4, 0)); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) + }) + .unwrap(); + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10331,27 +10304,10 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { ], ); }); - assert!(matches!( - diff_events.next().await.unwrap(), - BufferDiffEvent::HunksStagedOrUnstaged(_) - )); - let event = diff_events.next().await.unwrap(); - if let BufferDiffEvent::DiffChanged(DiffChanged { - changed_range: Some(changed_range), - base_text_changed_range: _, - extended_range: _, - base_text_changed: _, - }) = event - { - let changed_range = changed_range.to_point(&snapshot); - assert_eq!(changed_range, Point::new(3, 0)..Point::new(4, 0)); - } else { - panic!("Unexpected event {event:?}"); - } // When the write fails, the hunk returns to being unstaged. cx.run_until_parked(); - uncommitted_diff.update(cx, |diff, cx| { + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10379,32 +10335,29 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { ); }); - let event = diff_events.next().await.unwrap(); - if let BufferDiffEvent::DiffChanged(DiffChanged { - changed_range: Some(changed_range), - base_text_changed_range: _, - extended_range: _, - base_text_changed: _, - }) = event - { - let changed_range = changed_range.to_point(&snapshot); - assert_eq!(changed_range, Point::new(0, 0)..Point::new(5, 0)); - } else { - panic!("Unexpected event {event:?}"); - } - // Allow writing to the git index to succeed again. fs.set_error_message_for_index_write("/dir/.git".as_ref(), None); // Stage two hunks with separate operations. - uncommitted_diff.update(cx, |diff, cx| { + let (range0, range2) = uncommitted_diff.read_with(cx, |diff, cx| { let hunks = diff.snapshot(cx).hunks(&snapshot).collect::>(); - diff.stage_or_unstage_hunks(true, &hunks[0..1], &snapshot, true, cx); - diff.stage_or_unstage_hunks(true, &hunks[2..3], &snapshot, true, cx); + (hunks[0].buffer_range.clone(), hunks[2].buffer_range.clone()) }); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![range0], cx) + }) + .unwrap(); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![range2], cx) + }) + .unwrap(); // Both staged hunks appear as pending. - uncommitted_diff.update(cx, |diff, cx| { + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10434,7 +10387,7 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { // Both staging operations take effect. cx.run_until_parked(); - uncommitted_diff.update(cx, |diff, cx| { + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10631,9 +10584,20 @@ async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext) fs.pause_events(); // Stage the first hunk. - uncommitted_diff.update(cx, |diff, cx| { - let hunk = diff.snapshot(cx).hunks(&snapshot).next().unwrap(); - diff.stage_or_unstage_hunks(true, &[hunk], &snapshot, true, cx); + let range = uncommitted_diff.read_with(cx, |diff, cx| { + diff.snapshot(cx) + .hunks(&snapshot) + .next() + .unwrap() + .buffer_range + }); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) + }) + .unwrap(); + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10663,9 +10627,20 @@ async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext) // Stage the second hunk *before* receiving the FS event for the first hunk. cx.run_until_parked(); - uncommitted_diff.update(cx, |diff, cx| { - let hunk = diff.snapshot(cx).hunks(&snapshot).nth(1).unwrap(); - diff.stage_or_unstage_hunks(true, &[hunk], &snapshot, true, cx); + let range = uncommitted_diff.read_with(cx, |diff, cx| { + diff.snapshot(cx) + .hunks(&snapshot) + .nth(1) + .unwrap() + .buffer_range + }); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) + }) + .unwrap(); + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10698,10 +10673,19 @@ async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext) cx.run_until_parked(); // Stage the third hunk before receiving the second FS event. - uncommitted_diff.update(cx, |diff, cx| { - let hunk = diff.snapshot(cx).hunks(&snapshot).nth(2).unwrap(); - diff.stage_or_unstage_hunks(true, &[hunk], &snapshot, true, cx); + let range = uncommitted_diff.read_with(cx, |diff, cx| { + diff.snapshot(cx) + .hunks(&snapshot) + .nth(2) + .unwrap() + .buffer_range }); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) + }) + .unwrap(); // Wait for all remaining IO. cx.run_until_parked(); @@ -10709,7 +10693,7 @@ async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext) // Now all hunks are staged. cx.run_until_parked(); - uncommitted_diff.update(cx, |diff, cx| { + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10733,6 +10717,167 @@ async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext) }); } +#[gpui::test] +async fn test_restaging_hunk_after_optimistic_unstage(cx: &mut gpui::TestAppContext) { + use DiffHunkSecondaryStatus::*; + init_test(cx); + + let committed_contents = r#" + one + two + three + "# + .unindent(); + let file_contents = r#" + one + TWO + three + "# + .unindent(); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + ".git": {}, + "file.txt": file_contents.clone() + }), + ) + .await; + + // The hunk starts out staged: the index already matches the worktree. + fs.set_head_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", committed_contents.clone())], + "deadbeef", + ); + fs.set_index_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", file_contents.clone())], + ); + let repo = fs + .open_repo(path!("/dir/.git").as_ref(), Some("git".as_ref())) + .unwrap(); + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/file.txt"), cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + let uncommitted_diff = project + .update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + + let hunk_range = uncommitted_diff.read_with(cx, |diff, cx| { + assert_hunks( + diff.snapshot(cx).hunks(&snapshot), + &snapshot, + &diff.base_text_string(cx).unwrap(), + &[( + 1..2, + "two\n", + "TWO\n", + DiffHunkStatus::modified(NoSecondaryHunk), + )], + ); + diff.snapshot(cx) + .hunks(&snapshot) + .next() + .unwrap() + .buffer_range + }); + + // Hold back FS events so that the index writes below stay un-reconciled: + // the second operation must run while the first one's optimistic state is + // still pending. + fs.pause_events(); + + // Unstage the hunk. It appears as optimistically unstaged. + project + .update(cx, |project, cx| { + project.unstage_uncommitted_hunks( + buffer.clone(), + uncommitted_diff.clone(), + vec![hunk_range.clone()], + cx, + ) + }) + .unwrap(); + uncommitted_diff.read_with(cx, |diff, cx| { + assert_hunks( + diff.snapshot(cx).hunks(&snapshot), + &snapshot, + &diff.base_text_string(cx).unwrap(), + &[( + 1..2, + "two\n", + "TWO\n", + DiffHunkStatus::modified(SecondaryHunkAdditionPending), + )], + ); + }); + + // Re-stage the same hunk before the unstage write has been reconciled. + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![hunk_range.clone()], cx) + }) + .unwrap(); + uncommitted_diff.read_with(cx, |diff, cx| { + assert_hunks( + diff.snapshot(cx).hunks(&snapshot), + &snapshot, + &diff.base_text_string(cx).unwrap(), + &[( + 1..2, + "two\n", + "TWO\n", + DiffHunkStatus::modified(SecondaryHunkRemovalPending), + )], + ); + }); + + cx.run_until_parked(); + assert_eq!( + repo.load_index_text(RepoPath::from_rel_path(rel_path("file.txt"))) + .await + .unwrap(), + file_contents, + "the re-stage should have overridden the in-flight unstage" + ); + + fs.unpause_events_and_flush(); + cx.run_until_parked(); + + // Once everything settles, the hunk is staged again. + uncommitted_diff.read_with(cx, |diff, cx| { + assert_hunks( + diff.snapshot(cx).hunks(&snapshot), + &snapshot, + &diff.base_text_string(cx).unwrap(), + &[( + 1..2, + "two\n", + "TWO\n", + DiffHunkStatus::modified(NoSecondaryHunk), + )], + ); + }); + assert_eq!( + repo.load_index_text(RepoPath::from_rel_path(rel_path("file.txt"))) + .await + .unwrap(), + file_contents + ); +} + #[gpui::test(iterations = 25)] async fn test_staging_random_hunks( mut rng: StdRng, @@ -10746,11 +10891,12 @@ async fn test_staging_random_hunks( use DiffHunkSecondaryStatus::*; init_test(cx); - let committed_text = (0..30).map(|i| format!("line {i}\n")).collect::(); + let committed_text = (0..40).map(|i| format!("line {i}\n")).collect::(); let index_text = committed_text.clone(); - let buffer_text = (0..30) - .map(|i| match i % 5 { - 0 => format!("line {i} (modified)\n"), + let buffer_text = (0..40) + .map(|i| match i { + 35 => format!("line {i}\ninserted after {i}\n"), + _ if i < 30 && i % 5 == 0 => format!("line {i} (modified)\n"), _ => format!("line {i}\n"), }) .collect::(); @@ -10795,24 +10941,75 @@ async fn test_staging_random_hunks( let mut hunks = uncommitted_diff.update(cx, |diff, cx| { diff.snapshot(cx).hunks(&snapshot).collect::>() }); - assert_eq!(hunks.len(), 6); + assert_eq!(hunks.len(), 7); + let insertion_hunk = hunks + .iter() + .find(|hunk| hunk.diff_base_byte_range.is_empty()) + .unwrap() + .clone(); + fs.pause_events(); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks( + buffer.clone(), + unstaged_diff, + vec![insertion_hunk.buffer_range.clone()], + cx, + ) + }) + .unwrap(); + project + .update(cx, |project, cx| { + project.unstage_uncommitted_hunks( + buffer.clone(), + uncommitted_diff.clone(), + vec![insertion_hunk.buffer_range], + cx, + ) + }) + .unwrap(); + cx.executor().run_until_parked(); + assert_eq!( + repo.load_index_text(RepoPath::from_rel_path(rel_path("file.txt"))) + .await + .unwrap(), + index_text + ); + fs.unpause_events_and_flush(); + cx.run_until_parked(); + hunks = uncommitted_diff.update(cx, |diff, cx| { + diff.snapshot(cx).hunks(&snapshot).collect::>() + }); + assert_eq!(hunks.len(), 7); for _i in 0..operations { let hunk_ix = rng.random_range(0..hunks.len()); let hunk = &mut hunks[hunk_ix]; let row = hunk.range.start.row; + let range = hunk.buffer_range.clone(); if hunk.status().has_secondary_hunk() { log::info!("staging hunk at {row}"); - uncommitted_diff.update(cx, |diff, cx| { - diff.stage_or_unstage_hunks(true, std::slice::from_ref(hunk), &snapshot, true, cx); - }); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) + }) + .unwrap(); hunk.secondary_status = SecondaryHunkRemovalPending; } else { log::info!("unstaging hunk at {row}"); - uncommitted_diff.update(cx, |diff, cx| { - diff.stage_or_unstage_hunks(false, std::slice::from_ref(hunk), &snapshot, true, cx); - }); + project + .update(cx, |project, cx| { + project.unstage_uncommitted_hunks( + buffer.clone(), + uncommitted_diff.clone(), + vec![range], + cx, + ) + }) + .unwrap(); hunk.secondary_status = SecondaryHunkAdditionPending; } @@ -10853,6 +11050,508 @@ async fn test_staging_random_hunks( }); } +#[gpui::test(iterations = 25)] +async fn test_staging_random_hunks_with_edits( + mut rng: StdRng, + _executor: BackgroundExecutor, + cx: &mut gpui::TestAppContext, +) { + let operations = env::var("OPERATIONS") + .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) + .unwrap_or(30); + + init_test(cx); + + fn disk_index_text(fs: &FakeFs) -> String { + fs.with_git_state(path!("/dir/.git").as_ref(), false, |state| { + state + .index_contents + .get(&repo_path("file.txt")) + .cloned() + .expect("file is always present in the index") + }) + .unwrap() + } + + fn random_row_range( + snapshot: &text::BufferSnapshot, + rng: &mut StdRng, + ) -> (Range, Range) { + let max_row = snapshot.max_point().row; + let start_row = rng.random_range(0..=max_row); + let end_row = rng.random_range(start_row..=max_row.min(start_row + 5)); + let start = Point::new(start_row, 0); + let end = Point::new(end_row, snapshot.line_len(end_row)); + ( + snapshot.anchor_before(start)..snapshot.anchor_after(end), + start_row..end_row, + ) + } + + #[allow(clippy::type_complexity)] + fn capture_diff_state( + diff: &Entity, + cx: &mut gpui::TestAppContext, + ) -> ( + String, + Option, + Vec<(Range, Range, DiffHunkSecondaryStatus)>, + ) { + diff.read_with(cx, |diff, cx| { + let snapshot = diff.snapshot(cx); + let buffer_snapshot = snapshot.buffer_snapshot().clone(); + let hunks = snapshot + .hunks(&buffer_snapshot) + .map(|hunk| { + ( + hunk.range.clone(), + hunk.diff_base_byte_range.clone(), + hunk.secondary_status, + ) + }) + .collect(); + (buffer_snapshot.text(), snapshot.base_text_string(), hunks) + }) + } + + fn assert_settled(diff: &Entity, name: &str, cx: &mut gpui::TestAppContext) { + diff.read_with(cx, |diff, cx| { + let snapshot = diff.snapshot(cx); + let buffer_snapshot = snapshot.buffer_snapshot().clone(); + let cooked = snapshot + .hunks(&buffer_snapshot) + .map(|hunk| (hunk.range.clone(), hunk.secondary_status)) + .collect::>(); + for (range, status) in &cooked { + assert!( + !matches!( + status, + DiffHunkSecondaryStatus::SecondaryHunkAdditionPending + | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending + ), + "{name} hunk at {range:?} still has pending status {status:?} after settling" + ); + } + let full_range = Anchor::min_max_range_for_buffer(buffer_snapshot.remote_id()); + let raw_ranges = snapshot + .raw_hunks_intersecting_range(full_range, &buffer_snapshot) + .map(|hunk| hunk.range) + .collect::>(); + let cooked_ranges = cooked + .into_iter() + .map(|(range, _)| range) + .collect::>(); + assert_eq!( + raw_ranges, cooked_ranges, + "{name} still has suppressed hunks after settling" + ); + }) + } + + let mut committed_text = (0..40) + .map(|i| match i { + _ if i % 5 == 0 => format!("line {i} αβ🍐\n"), + _ => format!("line {i}\n"), + }) + .collect::(); + let index_text = (0..40) + .map(|i| match i { + 10 => "line 10 (modified ✅)\n".to_string(), + 20 => "line 20 (staged-only ⭐)\n".to_string(), + _ if i % 5 == 0 => format!("line {i} αβ🍐\n"), + _ => format!("line {i}\n"), + }) + .collect::(); + let buffer_text = (0..40) + .map(|i| match i { + 35 => format!("line {i} αβ🍐\ninserted after {i}\n"), + _ if i < 30 && i % 5 == 0 => format!("line {i} (modified ✅)\n"), + _ if i % 5 == 0 => format!("line {i} αβ🍐\n"), + _ => format!("line {i}\n"), + }) + .collect::(); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + ".git": {}, + "file.txt": buffer_text.clone() + }), + ) + .await; + fs.set_head_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", committed_text.clone())], + "deadbeef", + ); + fs.set_index_for_repo(path!("/dir/.git").as_ref(), &[("file.txt", index_text)]); + let repo = fs + .open_repo(path!("/dir/.git").as_ref(), Some("git".as_ref())) + .unwrap(); + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/file.txt"), cx) + }) + .await + .unwrap(); + let unstaged_diff = project + .update(cx, |project, cx| { + project.open_unstaged_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + let (staged_diff, index_buffer) = project + .update(cx, |project, cx| { + project.open_staged_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + let uncommitted_diff = project + .update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let mut events_paused = false; + let mut commit_count = 0; + for _ in 0..operations { + match rng.random_range(0..100) { + 0..20 => { + let snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); + let (range, rows) = random_row_range(&snapshot, &mut rng); + log::info!("staging rows {rows:?}"); + project + .update(cx, |project, cx| { + project.stage_hunks(buffer.clone(), unstaged_diff.clone(), vec![range], cx) + }) + .unwrap(); + } + 20..35 => { + let snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); + let (range, rows) = random_row_range(&snapshot, &mut rng); + log::info!("unstaging rows {rows:?} via the uncommitted diff"); + project + .update(cx, |project, cx| { + project.unstage_uncommitted_hunks( + buffer.clone(), + uncommitted_diff.clone(), + vec![range], + cx, + ) + }) + .unwrap(); + } + 35..50 => { + let snapshot = index_buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); + let (range, rows) = random_row_range(&snapshot, &mut rng); + log::info!("unstaging index rows {rows:?} via the staged diff"); + project + .update(cx, |project, cx| { + project.unstage_staged_hunks(staged_diff.clone(), vec![range], cx) + }) + .unwrap(); + } + // Line-oriented edits keep the file hunk-rich; uniform random + // splices (`randomly_edit`) delete a quarter of the file on + // average and quickly collapse it into a single hunk. + 50..75 => { + let snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); + let max_row = snapshot.max_point().row; + let row = rng.random_range(0..=max_row); + let row_start = snapshot.point_to_offset(Point::new(row, 0)); + let row_end = snapshot.point_to_offset(Point::new(row, snapshot.line_len(row))); + let new_text_len = rng.random_range(0..8); + let new_text = RandomCharIter::new(&mut rng) + .take(new_text_len) + .collect::(); + let (range, new_text) = match rng.random_range(0..10) { + 0..3 => (row_start..row_start, format!("{new_text}\n")), + 3..5 if max_row > 0 => { + let end = if row < max_row { + snapshot.point_to_offset(Point::new(row + 1, 0)) + } else { + snapshot.len() + }; + (row_start..end, String::new()) + } + 5..8 => (row_start..row_end, new_text), + _ => { + let start = snapshot + .clip_offset(rng.random_range(0..=snapshot.len()), text::Bias::Left); + let end = snapshot.clip_offset( + (start + rng.random_range(0..10)).min(snapshot.len()), + text::Bias::Right, + ); + (start..end, new_text) + } + }; + log::info!("editing buffer: {range:?} -> {new_text:?}"); + buffer.update(cx, |buffer, cx| buffer.edit([(range, new_text)], None, cx)); + } + // Commits and whole-file round-trip checkpoints. The round trips + // settle first so the resulting index bytes are exactly + // predictable, which catches wrong-content writes that the + // settled-consistency checks can't see (a corrupted write becomes + // the on-disk truth that everything else then agrees with). + 75..82 => { + let buffer_full_range = Anchor::min_max_range_for_buffer( + buffer.read_with(cx, |buffer, _| buffer.remote_id()), + ); + let index_full_range = Anchor::min_max_range_for_buffer( + index_buffer.read_with(cx, |buffer, _| buffer.remote_id()), + ); + match rng.random_range(0..4) { + // Commit: HEAD becomes whatever the index currently + // contains on disk, which may lag in-flight optimistic + // writes, just like running `git commit` in a terminal. + 0 => { + let new_head = disk_index_text(&fs); + commit_count += 1; + log::info!("committing (commit {commit_count})"); + fs.set_head_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", new_head.clone())], + format!("sha-{commit_count}"), + ); + committed_text = new_head; + } + // Stage everything and commit it, like `git add -A && + // git commit`. + 1 => { + log::info!("checkpoint: stage-all, then commit"); + if events_paused { + fs.unpause_events_and_flush(); + events_paused = false; + } + cx.run_until_parked(); + project + .update(cx, |project, cx| { + project.stage_hunks( + buffer.clone(), + unstaged_diff.clone(), + vec![buffer_full_range], + cx, + ) + }) + .unwrap(); + cx.run_until_parked(); + let disk = disk_index_text(&fs); + let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text()); + assert_eq!( + disk, buffer_text, + "after staging everything, the index must equal the worktree" + ); + commit_count += 1; + log::info!("committing (commit {commit_count})"); + fs.set_head_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", disk.clone())], + format!("sha-{commit_count}"), + ); + committed_text = disk; + } + // Unstage everything via the staged diff, which by + // definition covers every index-vs-HEAD difference. + 2 => { + log::info!("checkpoint: unstage-all"); + if events_paused { + fs.unpause_events_and_flush(); + events_paused = false; + } + cx.run_until_parked(); + project + .update(cx, |project, cx| { + project.unstage_staged_hunks( + staged_diff.clone(), + vec![index_full_range], + cx, + ) + }) + .unwrap(); + cx.run_until_parked(); + let disk = disk_index_text(&fs); + assert_eq!( + disk, committed_text, + "after unstaging everything, the index must equal HEAD" + ); + } + // Unstage everything and immediately re-stage everything + // with no settling in between: the re-stage must supersede + // the in-flight unstage (the footprint mechanism), leaving + // the index equal to the worktree. + _ => { + log::info!("checkpoint: unstage-all then stage-all"); + if events_paused { + fs.unpause_events_and_flush(); + events_paused = false; + } + cx.run_until_parked(); + project + .update(cx, |project, cx| { + project.unstage_staged_hunks( + staged_diff.clone(), + vec![index_full_range], + cx, + ) + }) + .unwrap(); + project + .update(cx, |project, cx| { + project.stage_hunks( + buffer.clone(), + unstaged_diff.clone(), + vec![buffer_full_range], + cx, + ) + }) + .unwrap(); + cx.run_until_parked(); + let disk = disk_index_text(&fs); + let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text()); + assert_eq!( + disk, buffer_text, + "re-staging everything must supersede the in-flight unstage" + ); + } + } + } + 82..90 => { + if events_paused { + log::info!("unpausing fs events"); + fs.unpause_events_and_flush(); + events_paused = false; + } else { + log::info!("pausing fs events"); + fs.pause_events(); + events_paused = true; + } + } + _ => { + log::info!("settling"); + if events_paused { + fs.unpause_events_and_flush(); + events_paused = false; + } + cx.run_until_parked(); + } + } + + for _ in 0..rng.random_range(0..5) { + cx.executor().simulate_random_delay().await; + } + } + + if events_paused { + fs.unpause_events_and_flush(); + } + cx.run_until_parked(); + + assert_settled(&unstaged_diff, "unstaged diff", cx); + assert_settled(&staged_diff, "staged diff", cx); + assert_settled(&uncommitted_diff, "uncommitted diff", cx); + + let old_unstaged_state = capture_diff_state(&unstaged_diff, cx); + let old_staged_state = capture_diff_state(&staged_diff, cx); + let old_uncommitted_state = capture_diff_state(&uncommitted_diff, cx); + + let disk_index_text = repo + .load_index_text(RepoPath::from_rel_path(rel_path("file.txt"))) + .await + .unwrap(); + assert_eq!( + old_unstaged_state.1.as_deref(), + Some(disk_index_text.as_str()), + "unstaged diff base text differs from the index on disk" + ); + assert_eq!( + old_staged_state.0, disk_index_text, + "staged diff buffer differs from the index on disk" + ); + assert_eq!( + old_staged_state.1.as_deref(), + Some(committed_text.as_str()), + "staged diff base text differs from HEAD" + ); + assert_eq!( + old_uncommitted_state.1.as_deref(), + Some(committed_text.as_str()), + "uncommitted diff base text differs from HEAD" + ); + + // Differential check: a from-scratch computation over (HEAD, index, + // worktree) must agree with the incrementally-maintained state that just + // settled. + let old_entity_ids = [ + unstaged_diff.entity_id(), + staged_diff.entity_id(), + uncommitted_diff.entity_id(), + ]; + drop(unstaged_diff); + drop(staged_diff); + drop(uncommitted_diff); + // An empty update flushes effects so that the dropped entities are + // actually released before we reopen. + cx.update(|_| {}); + cx.run_until_parked(); + + let unstaged_diff = project + .update(cx, |project, cx| { + project.open_unstaged_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + let (staged_diff, _) = project + .update(cx, |project, cx| { + project.open_staged_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + let uncommitted_diff = project + .update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + assert_ne!( + unstaged_diff.entity_id(), + old_entity_ids[0], + "the unstaged diff was not actually closed" + ); + assert_ne!( + staged_diff.entity_id(), + old_entity_ids[1], + "the staged diff was not actually closed" + ); + assert_ne!( + uncommitted_diff.entity_id(), + old_entity_ids[2], + "the uncommitted diff was not actually closed" + ); + + assert_eq!( + capture_diff_state(&unstaged_diff, cx), + old_unstaged_state, + "reopened unstaged diff differs from the settled one" + ); + assert_eq!( + capture_diff_state(&staged_diff, cx), + old_staged_state, + "reopened staged diff differs from the settled one" + ); + assert_eq!( + capture_diff_state(&uncommitted_diff, cx), + old_uncommitted_state, + "reopened uncommitted diff differs from the settled one" + ); +} + #[gpui::test] async fn test_single_file_diffs(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -10975,10 +11674,18 @@ async fn test_staging_hunk_preserve_executable_permission(cx: &mut gpui::TestApp .await .unwrap(); - uncommitted_diff.update(cx, |diff, cx| { - let hunks = diff.snapshot(cx).hunks(&snapshot).collect::>(); - diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx); + let ranges = uncommitted_diff.read_with(cx, |diff, cx| { + diff.snapshot(cx) + .hunks(&snapshot) + .map(|hunk| hunk.buffer_range) + .collect::>() }); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, ranges, cx) + }) + .unwrap(); cx.run_until_parked(); diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index c4c838042c0..8d589f947cd 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -25,6 +25,11 @@ message UpdateDiffBases { // and the contents of the index and HEAD differ for this path, // where None means the path doesn't exist in that state of the repo. INDEX_AND_HEAD = 3; + // The contents of the index and HEAD are unchanged. Sent so that + // remote clients still recalculate their diffs in response to git + // events, just as the host does, even when an index write ends up + // changing nothing (e.g. writing content identical to what's there). + UNCHANGED = 4; } optional string staged_text = 3; diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index d0c0a5385ff..2c51c97a0eb 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -101,7 +101,6 @@ impl EventEmitter for BufferSearchBar {} impl Render for BufferSearchBar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let focus_handle = self.focus_handle(cx); - let has_splittable_editor = self.splittable_editor.is_some(); let split_buttons = self .splittable_editor @@ -539,7 +538,9 @@ impl ToolbarItemView for BufferSearchBar { .and_then(|entity| entity.downcast::().ok()) { self._splittable_editor_subscription = - Some(cx.observe(&splittable_editor, |_, _, cx| cx.notify())); + Some(cx.observe(&splittable_editor, |_, _, cx| { + cx.notify(); + })); self.splittable_editor = Some(splittable_editor.downgrade()); } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 69fc69d5469..2ecd422be6e 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -31,10 +31,13 @@ use feature_flags::{FeatureFlagAppExt as _, PanicFeatureFlag}; use fs::Fs; use futures::FutureExt as _; use futures::{StreamExt, channel::mpsc, select_biased}; +use git_ui::branch_diff::BranchDiffToolbar; use git_ui::commit_view::CommitViewToolbar; use git_ui::git_panel::GitPanel; -use git_ui::project_diff::{BranchDiffToolbar, ProjectDiffToolbar}; +use git_ui::project_diff::ProjectDiffToolbar; use git_ui::solo_diff_view::{SoloDiffGitToolbar, SoloDiffStyleToolbar}; +use git_ui::staged_diff::StagedDiffToolbar; +use git_ui::unstaged_diff::UnstagedDiffToolbar; use gpui::{ Action, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Element, Entity, FocusHandle, Focusable, Image, ImageFormat, KeyBinding, ParentElement, @@ -1413,6 +1416,10 @@ fn initialize_pane( toolbar.add_item(highlights_tree_item, window, cx); let project_diff_toolbar = cx.new(|cx| ProjectDiffToolbar::new(workspace, cx)); toolbar.add_item(project_diff_toolbar, window, cx); + let staged_diff_toolbar = cx.new(|cx| StagedDiffToolbar::new(workspace, cx)); + toolbar.add_item(staged_diff_toolbar, window, cx); + let unstaged_diff_toolbar = cx.new(|cx| UnstagedDiffToolbar::new(workspace, cx)); + toolbar.add_item(unstaged_diff_toolbar, window, cx); let branch_diff_toolbar = cx.new(BranchDiffToolbar::new); toolbar.add_item(branch_diff_toolbar, window, cx); let solo_diff_git_toolbar = cx.new(SoloDiffGitToolbar::new); diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 7eb78ca0ecc..c70e8d3273c 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -352,6 +352,12 @@ pub mod git { /// Opens the git branch selector. #[action(deprecated_aliases = ["branches::OpenRecent"])] Branch, + /// Shows uncommitted changes across the project. + ViewUncommittedChanges, + /// Shows unstaged changes across the project. + ViewUnstagedChanges, + /// Shows staged changes across the project. + ViewStagedChanges, /// Opens the git stash selector. ViewStash, /// Opens the git worktree selector.