diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 9f51e53b29..079b043e46 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 e2ad7ca71e..faa760b73a 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 c300ace11a..5cba96c5a9 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 9b5df0b867..bfda1aae44 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 1d224ad40b..73a0c3ebfd 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 e5836446ce..a82b3ea448 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 6bb94a892d..d9871fa2cb 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 dd34391e19..93a6f96a44 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; +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 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()); - } + 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 0000000000..068d8e9af1 --- /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 9fff74d398..a45590260d 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 309a118078..1199afb9d4 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 0000000000..96c0530371 --- /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 477f18be54..b046380b28 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 e2b04699b2..90463f1b9e 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 08192d70c0..aefd0eff50 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 f8097e68f5..7bf7682938 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 91f351bec2..d674be5576 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 0000000000..9b607c72cb --- /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 5312ae6d08..991ebf2125 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 0000000000..44f700c722 --- /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 5b61e6237e..805ffc7355 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 fd3efdae5c..74efd3f0c8 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 67aa198945..2f1586d3e0 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 7043c243d8..6a08a291e3 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 da62617d28..7b4f5839e9 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 c4c838042c..8d589f947c 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 d0c0a5385f..2c51c97a0e 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 69fc69d546..2ecd422be6 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 7eb78ca0ec..c70e8d3273 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.