From 8546dc101dd3984b3e1a51acfbd809e2c8b1f564 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 31 Mar 2025 16:26:47 -0700 Subject: [PATCH] Allow viewing past commits in Zed (#27636) This PR adds functionality for loading the diff for an arbitrary git commit, and displaying it in a tab. To retrieve the diff for the commit, I'm using a single `git cat-file --batch` invocation to efficiently load both the old and new versions of each file that was changed in the commit. Todo * Features * [x] Open the commit view when clicking the most recent commit message in the commit panel * [x] Open the commit view when clicking a SHA in a git blame column * [x] Open the commit view when clicking a SHA in a commit tooltip * [x] Make it work over RPC * [x] Allow buffer search in commit view * [x] Command palette action to open the commit for the current blame line * Styling * [x] Add a header that shows the author, timestamp, and the full commit message * [x] Remove stage/unstage buttons in commit view * [x] Truncate the commit message in the tab * Bugs * [x] Dedup commit tabs within a pane * [x] Add a tooltip to the tab Release Notes: - Added the ability to show past commits in Zed. You can view the most recent commit by clicking its message in the commit panel. And when viewing a git blame, you can show any commit by clicking its sha. --- Cargo.lock | 4 +- crates/assistant_tool/src/action_log.rs | 15 +- crates/buffer_diff/src/buffer_diff.rs | 407 +++++++------- crates/collab/src/rpc.rs | 1 + crates/editor/Cargo.toml | 3 - crates/editor/src/actions.rs | 1 + crates/editor/src/display_map.rs | 14 + crates/editor/src/display_map/block_map.rs | 143 ++--- crates/editor/src/editor.rs | 105 +++- crates/editor/src/element.rs | 287 ++++------ crates/editor/src/git/blame.rs | 148 +++-- crates/editor/src/proposed_changes_editor.rs | 4 +- crates/fs/src/fake_git_repo.rs | 8 + crates/git/src/blame.rs | 11 +- crates/git/src/commit.rs | Bin 1093 -> 3592 bytes crates/git/src/hosting_provider.rs | 35 ++ crates/git/src/repository.rs | 125 ++++- crates/git_ui/Cargo.toml | 2 + crates/git_ui/src/blame_ui.rs | 234 ++++++++ .../{editor => git_ui}/src/commit_tooltip.rs | 90 +-- crates/git_ui/src/commit_view.rs | 527 ++++++++++++++++++ crates/git_ui/src/git_panel.rs | 66 ++- crates/git_ui/src/git_ui.rs | 5 + crates/language/src/proto.rs | 2 +- crates/project/src/git_store.rs | 83 ++- crates/proto/proto/zed.proto | 20 + crates/proto/src/proto.rs | 4 + crates/welcome/src/multibuffer_hint.rs | 1 + 28 files changed, 1742 insertions(+), 603 deletions(-) create mode 100644 crates/git_ui/src/blame_ui.rs rename crates/{editor => git_ui}/src/commit_tooltip.rs (83%) create mode 100644 crates/git_ui/src/commit_view.rs diff --git a/Cargo.lock b/Cargo.lock index 252c324e8b..724550ea0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4402,7 +4402,6 @@ dependencies = [ "anyhow", "assets", "buffer_diff", - "chrono", "client", "clock", "collections", @@ -4451,7 +4450,6 @@ dependencies = [ "text", "theme", "time", - "time_format", "tree-sitter-html", "tree-sitter-rust", "tree-sitter-typescript", @@ -5715,6 +5713,7 @@ dependencies = [ "askpass", "assistant_settings", "buffer_diff", + "chrono", "collections", "command_palette_hooks", "component", @@ -5732,6 +5731,7 @@ dependencies = [ "linkify", "linkme", "log", + "markdown", "menu", "multi_buffer", "notifications", diff --git a/crates/assistant_tool/src/action_log.rs b/crates/assistant_tool/src/action_log.rs index a455f1303e..2842aeddf2 100644 --- a/crates/assistant_tool/src/action_log.rs +++ b/crates/assistant_tool/src/action_log.rs @@ -483,7 +483,7 @@ impl TrackedBuffer { buffer_without_edits .update(cx, |buffer, cx| buffer.undo_operations(edits_to_undo, cx)); let primary_diff_update = self.diff.update(cx, |diff, cx| { - diff.set_base_text( + diff.set_base_text_buffer( buffer_without_edits, self.buffer.read(cx).text_snapshot(), cx, @@ -500,7 +500,7 @@ impl TrackedBuffer { buffer.undo_operations(unreviewed_edits_to_undo, cx) }); let secondary_diff_update = self.secondary_diff.update(cx, |diff, cx| { - diff.set_base_text( + diff.set_base_text_buffer( buffer_without_unreviewed_edits.clone(), self.buffer.read(cx).text_snapshot(), cx, @@ -559,13 +559,7 @@ impl TrackedBuffer { if let Ok(primary_diff_snapshot) = primary_diff_snapshot { primary_diff .update(cx, |diff, cx| { - diff.set_snapshot( - &buffer_snapshot, - primary_diff_snapshot, - false, - None, - cx, - ) + diff.set_snapshot(primary_diff_snapshot, &buffer_snapshot, None, cx) }) .ok(); } @@ -574,9 +568,8 @@ impl TrackedBuffer { secondary_diff .update(cx, |diff, cx| { diff.set_snapshot( - &buffer_snapshot, secondary_diff_snapshot, - false, + &buffer_snapshot, None, cx, ) diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 57873e9fba..308502b7ce 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -142,6 +142,96 @@ impl std::fmt::Debug for BufferDiffInner { } impl BufferDiffSnapshot { + fn empty(buffer: &text::BufferSnapshot, cx: &mut App) -> BufferDiffSnapshot { + BufferDiffSnapshot { + inner: BufferDiffInner { + base_text: language::Buffer::build_empty_snapshot(cx), + hunks: SumTree::new(buffer), + pending_hunks: SumTree::new(buffer), + base_text_exists: false, + }, + secondary_diff: None, + } + } + + fn new_with_base_text( + buffer: text::BufferSnapshot, + base_text: Option>, + language: Option>, + language_registry: Option>, + cx: &mut App, + ) -> impl Future + use<> { + let base_text_pair; + let base_text_exists; + let base_text_snapshot; + if let Some(text) = &base_text { + let base_text_rope = Rope::from(text.as_str()); + base_text_pair = Some((text.clone(), base_text_rope.clone())); + let snapshot = language::Buffer::build_snapshot( + base_text_rope, + language.clone(), + language_registry.clone(), + cx, + ); + base_text_snapshot = cx.background_spawn(snapshot); + base_text_exists = true; + } else { + base_text_pair = None; + base_text_snapshot = Task::ready(language::Buffer::build_empty_snapshot(cx)); + base_text_exists = false; + }; + + let hunks = cx.background_spawn({ + let buffer = buffer.clone(); + async move { compute_hunks(base_text_pair, buffer) } + }); + + async move { + let (base_text, hunks) = futures::join!(base_text_snapshot, hunks); + Self { + inner: BufferDiffInner { + base_text, + hunks, + base_text_exists, + pending_hunks: SumTree::new(&buffer), + }, + secondary_diff: None, + } + } + } + + pub fn new_with_base_buffer( + buffer: text::BufferSnapshot, + base_text: Option>, + base_text_snapshot: language::BufferSnapshot, + cx: &App, + ) -> impl Future + use<> { + let base_text_exists = base_text.is_some(); + let base_text_pair = base_text.map(|text| (text, base_text_snapshot.as_rope().clone())); + cx.background_spawn(async move { + Self { + inner: BufferDiffInner { + base_text: base_text_snapshot, + pending_hunks: SumTree::new(&buffer), + hunks: compute_hunks(base_text_pair, buffer), + base_text_exists, + }, + secondary_diff: None, + } + }) + } + + #[cfg(test)] + fn new_sync( + buffer: text::BufferSnapshot, + diff_base: String, + cx: &mut gpui::TestAppContext, + ) -> BufferDiffSnapshot { + cx.executor().block(cx.update(|cx| { + Self::new_with_base_text(buffer, Some(Arc::new(diff_base)), None, None, cx) + })) + } + pub fn is_empty(&self) -> bool { self.inner.hunks.is_empty() } @@ -541,6 +631,28 @@ impl BufferDiffInner { }) } + fn set_state( + &mut self, + new_state: Self, + buffer: &text::BufferSnapshot, + ) -> Option> { + let (base_text_changed, changed_range) = + match (self.base_text_exists, new_state.base_text_exists) { + (false, false) => (true, None), + (true, true) if self.base_text.remote_id() == new_state.base_text.remote_id() => { + (false, new_state.compare(&self, buffer)) + } + _ => (true, Some(text::Anchor::MIN..text::Anchor::MAX)), + }; + + let pending_hunks = mem::replace(&mut self.pending_hunks, SumTree::new(buffer)); + *self = new_state; + if !base_text_changed { + self.pending_hunks = pending_hunks; + } + changed_range + } + fn compare(&self, old: &Self, new_snapshot: &text::BufferSnapshot) -> Option> { let mut new_cursor = self.hunks.cursor::<()>(new_snapshot); let mut old_cursor = old.hunks.cursor::<()>(new_snapshot); @@ -762,84 +874,34 @@ pub enum BufferDiffEvent { impl EventEmitter for BufferDiff {} impl BufferDiff { - #[cfg(test)] - fn build_sync( - buffer: text::BufferSnapshot, - diff_base: String, - cx: &mut gpui::TestAppContext, - ) -> BufferDiffInner { - let snapshot = - cx.update(|cx| Self::build(buffer, Some(Arc::new(diff_base)), None, None, cx)); - cx.executor().block(snapshot) - } - - fn build( - buffer: text::BufferSnapshot, - base_text: Option>, - language: Option>, - language_registry: Option>, - cx: &mut App, - ) -> impl Future + use<> { - let base_text_pair; - let base_text_exists; - let base_text_snapshot; - if let Some(text) = &base_text { - let base_text_rope = Rope::from(text.as_str()); - base_text_pair = Some((text.clone(), base_text_rope.clone())); - let snapshot = language::Buffer::build_snapshot( - base_text_rope, - language.clone(), - language_registry.clone(), - cx, - ); - base_text_snapshot = cx.background_spawn(snapshot); - base_text_exists = true; - } else { - base_text_pair = None; - base_text_snapshot = Task::ready(language::Buffer::build_empty_snapshot(cx)); - base_text_exists = false; - }; - - let hunks = cx.background_spawn({ - let buffer = buffer.clone(); - async move { compute_hunks(base_text_pair, buffer) } - }); - - async move { - let (base_text, hunks) = futures::join!(base_text_snapshot, hunks); - BufferDiffInner { - base_text, - hunks, - base_text_exists, - pending_hunks: SumTree::new(&buffer), - } + pub fn new(buffer: &text::BufferSnapshot, cx: &mut App) -> Self { + BufferDiff { + buffer_id: buffer.remote_id(), + inner: BufferDiffSnapshot::empty(buffer, cx).inner, + secondary_diff: None, } } - fn build_with_base_buffer( - buffer: text::BufferSnapshot, - base_text: Option>, - base_text_snapshot: language::BufferSnapshot, - cx: &App, - ) -> impl Future + use<> { - let base_text_exists = base_text.is_some(); - let base_text_pair = base_text.map(|text| (text, base_text_snapshot.as_rope().clone())); - cx.background_spawn(async move { - BufferDiffInner { - base_text: base_text_snapshot, - pending_hunks: SumTree::new(&buffer), - hunks: compute_hunks(base_text_pair, buffer), - base_text_exists, - } - }) - } - - fn build_empty(buffer: &text::BufferSnapshot, cx: &mut App) -> BufferDiffInner { - BufferDiffInner { - base_text: language::Buffer::build_empty_snapshot(cx), - hunks: SumTree::new(buffer), - pending_hunks: SumTree::new(buffer), - base_text_exists: false, + #[cfg(any(test, feature = "test-support"))] + pub fn new_with_base_text( + base_text: &str, + buffer: &Entity, + cx: &mut App, + ) -> Self { + let mut base_text = base_text.to_owned(); + text::LineEnding::normalize(&mut base_text); + let snapshot = BufferDiffSnapshot::new_with_base_text( + buffer.read(cx).text_snapshot(), + Some(base_text.into()), + None, + None, + cx, + ); + let snapshot = cx.background_executor().block(snapshot); + Self { + buffer_id: buffer.read(cx).remote_id(), + inner: snapshot.inner, + secondary_diff: None, } } @@ -917,9 +979,9 @@ impl BufferDiff { language_registry: Option>, cx: &mut AsyncApp, ) -> anyhow::Result { - let inner = if base_text_changed || language_changed { + Ok(if base_text_changed || language_changed { cx.update(|cx| { - Self::build( + BufferDiffSnapshot::new_with_base_text( buffer.clone(), base_text, language.clone(), @@ -930,7 +992,7 @@ impl BufferDiff { .await } else { this.read_with(cx, |this, cx| { - Self::build_with_base_buffer( + BufferDiffSnapshot::new_with_base_buffer( buffer.clone(), base_text, this.base_text().clone(), @@ -938,25 +1000,21 @@ impl BufferDiff { ) })? .await - }; - Ok(BufferDiffSnapshot { - inner, - secondary_diff: None, }) } + pub fn language_changed(&mut self, cx: &mut Context) { + cx.emit(BufferDiffEvent::LanguageChanged); + } + pub fn set_snapshot( &mut self, - buffer: &text::BufferSnapshot, new_snapshot: BufferDiffSnapshot, - language_changed: bool, + buffer: &text::BufferSnapshot, secondary_changed_range: Option>, cx: &mut Context, ) -> Option> { - let changed_range = self.set_state(new_snapshot.inner, buffer); - if language_changed { - cx.emit(BufferDiffEvent::LanguageChanged); - } + let changed_range = self.inner.set_state(new_snapshot.inner, buffer); let changed_range = match (secondary_changed_range, changed_range) { (None, None) => None, @@ -980,31 +1038,6 @@ impl BufferDiff { changed_range } - fn set_state( - &mut self, - new_state: BufferDiffInner, - buffer: &text::BufferSnapshot, - ) -> Option> { - let (base_text_changed, changed_range) = - match (self.inner.base_text_exists, new_state.base_text_exists) { - (false, false) => (true, None), - (true, true) - if self.inner.base_text.remote_id() == new_state.base_text.remote_id() => - { - (false, new_state.compare(&self.inner, buffer)) - } - _ => (true, Some(text::Anchor::MIN..text::Anchor::MAX)), - }; - - let pending_hunks = mem::replace(&mut self.inner.pending_hunks, SumTree::new(buffer)); - - self.inner = new_state; - if !base_text_changed { - self.inner.pending_hunks = pending_hunks; - } - changed_range - } - pub fn base_text(&self) -> &language::BufferSnapshot { &self.inner.base_text } @@ -1065,21 +1098,31 @@ impl BufferDiff { self.hunks_intersecting_range(start..end, buffer, cx) } - /// Used in cases where the change set isn't derived from git. - pub fn set_base_text( + pub fn set_base_text_buffer( &mut self, base_buffer: Entity, buffer: text::BufferSnapshot, cx: &mut Context, ) -> oneshot::Receiver<()> { - let (tx, rx) = oneshot::channel(); - let this = cx.weak_entity(); let base_buffer = base_buffer.read(cx); let language_registry = base_buffer.language_registry(); let base_buffer = base_buffer.snapshot(); + self.set_base_text(base_buffer, language_registry, buffer, cx) + } + + /// Used in cases where the change set isn't derived from git. + pub fn set_base_text( + &mut self, + base_buffer: language::BufferSnapshot, + language_registry: Option>, + buffer: text::BufferSnapshot, + cx: &mut Context, + ) -> oneshot::Receiver<()> { + let (tx, rx) = oneshot::channel(); + let this = cx.weak_entity(); let base_text = Arc::new(base_buffer.text()); - let snapshot = BufferDiff::build( + let snapshot = BufferDiffSnapshot::new_with_base_text( buffer.clone(), Some(base_text), base_buffer.language().cloned(), @@ -1094,8 +1137,8 @@ impl BufferDiff { let Some(this) = this.upgrade() else { return; }; - this.update(cx, |this, _| { - this.set_state(snapshot, &buffer); + this.update(cx, |this, cx| { + this.set_snapshot(snapshot, &buffer, None, cx); }) .log_err(); drop(complete_on_drop) @@ -1110,49 +1153,17 @@ impl BufferDiff { .then(|| self.inner.base_text.text()) } - pub fn new(buffer: &text::BufferSnapshot, cx: &mut App) -> Self { - BufferDiff { - buffer_id: buffer.remote_id(), - inner: BufferDiff::build_empty(buffer, cx), - secondary_diff: None, - } - } - - #[cfg(any(test, feature = "test-support"))] - pub fn new_with_base_text( - base_text: &str, - buffer: &Entity, - cx: &mut App, - ) -> Self { - let mut base_text = base_text.to_owned(); - text::LineEnding::normalize(&mut base_text); - let snapshot = BufferDiff::build( - buffer.read(cx).text_snapshot(), - Some(base_text.into()), - None, - None, - cx, - ); - let snapshot = cx.background_executor().block(snapshot); - BufferDiff { - buffer_id: buffer.read(cx).remote_id(), - inner: snapshot, - secondary_diff: None, - } - } - #[cfg(any(test, feature = "test-support"))] pub fn recalculate_diff_sync(&mut self, buffer: text::BufferSnapshot, cx: &mut Context) { let base_text = self.base_text_string().map(Arc::new); - let snapshot = BufferDiff::build_with_base_buffer( + let snapshot = BufferDiffSnapshot::new_with_base_buffer( buffer.clone(), base_text, self.inner.base_text.clone(), cx, ); let snapshot = cx.background_executor().block(snapshot); - let changed_range = self.set_state(snapshot, &buffer); - cx.emit(BufferDiffEvent::DiffChanged { changed_range }); + self.set_snapshot(snapshot, &buffer, None, cx); } } @@ -1325,18 +1336,18 @@ mod tests { .unindent(); let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text); - let mut diff = BufferDiff::build_sync(buffer.clone(), diff_base.clone(), cx); + let mut diff = BufferDiffSnapshot::new_sync(buffer.clone(), diff_base.clone(), cx); assert_hunks( - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, None), + diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer), &buffer, &diff_base, &[(1..2, "two\n", "HELLO\n", DiffHunkStatus::modified_none())], ); buffer.edit([(0..0, "point five\n")]); - diff = BufferDiff::build_sync(buffer.clone(), diff_base.clone(), cx); + diff = BufferDiffSnapshot::new_sync(buffer.clone(), diff_base.clone(), cx); assert_hunks( - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, None), + diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer), &buffer, &diff_base, &[ @@ -1345,9 +1356,9 @@ mod tests { ], ); - diff = cx.update(|cx| BufferDiff::build_empty(&buffer, cx)); + diff = cx.update(|cx| BufferDiffSnapshot::empty(&buffer, cx)); assert_hunks::<&str, _>( - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, None), + diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer), &buffer, &diff_base, &[], @@ -1399,9 +1410,10 @@ mod tests { .unindent(); let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text); - let unstaged_diff = BufferDiff::build_sync(buffer.clone(), index_text.clone(), cx); - - let uncommitted_diff = BufferDiff::build_sync(buffer.clone(), head_text.clone(), cx); + let unstaged_diff = BufferDiffSnapshot::new_sync(buffer.clone(), index_text.clone(), cx); + let mut uncommitted_diff = + BufferDiffSnapshot::new_sync(buffer.clone(), head_text.clone(), cx); + uncommitted_diff.secondary_diff = Some(Box::new(unstaged_diff)); let expected_hunks = vec![ (2..3, "two\n", "TWO\n", DiffHunkStatus::modified_none()), @@ -1420,11 +1432,7 @@ mod tests { ]; assert_hunks( - uncommitted_diff.hunks_intersecting_range( - Anchor::MIN..Anchor::MAX, - &buffer, - Some(&unstaged_diff), - ), + uncommitted_diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer), &buffer, &head_text, &expected_hunks, @@ -1473,11 +1481,17 @@ mod tests { let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text); let diff = cx .update(|cx| { - BufferDiff::build(buffer.snapshot(), Some(diff_base.clone()), None, None, cx) + BufferDiffSnapshot::new_with_base_text( + buffer.snapshot(), + Some(diff_base.clone()), + None, + None, + cx, + ) }) .await; assert_eq!( - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, None) + diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer) .count(), 8 ); @@ -1486,7 +1500,6 @@ mod tests { diff.hunks_intersecting_range( buffer.anchor_before(Point::new(7, 0))..buffer.anchor_before(Point::new(12, 0)), &buffer, - None, ), &buffer, &diff_base, @@ -1732,18 +1745,20 @@ mod tests { let hunk_range = buffer.anchor_before(ranges[0].start)..buffer.anchor_before(ranges[0].end); - let unstaged = BufferDiff::build_sync(buffer.clone(), example.index_text.clone(), cx); - let uncommitted = BufferDiff::build_sync(buffer.clone(), example.head_text.clone(), cx); + let unstaged = + BufferDiffSnapshot::new_sync(buffer.clone(), example.index_text.clone(), cx); + let uncommitted = + BufferDiffSnapshot::new_sync(buffer.clone(), example.head_text.clone(), cx); let unstaged_diff = cx.new(|cx| { let mut diff = BufferDiff::new(&buffer, cx); - diff.set_state(unstaged, &buffer); + diff.set_snapshot(unstaged, &buffer, None, cx); diff }); let uncommitted_diff = cx.new(|cx| { let mut diff = BufferDiff::new(&buffer, cx); - diff.set_state(uncommitted, &buffer); + diff.set_snapshot(uncommitted, &buffer, None, cx); diff.set_secondary_diff(unstaged_diff); diff }); @@ -1800,16 +1815,16 @@ mod tests { .unindent(); let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text.clone()); - let unstaged = BufferDiff::build_sync(buffer.clone(), index_text, cx); - let uncommitted = BufferDiff::build_sync(buffer.clone(), head_text.clone(), cx); + let unstaged = BufferDiffSnapshot::new_sync(buffer.clone(), index_text, cx); + let uncommitted = BufferDiffSnapshot::new_sync(buffer.clone(), head_text.clone(), cx); let unstaged_diff = cx.new(|cx| { let mut diff = BufferDiff::new(&buffer, cx); - diff.set_state(unstaged, &buffer); + diff.set_snapshot(unstaged, &buffer, None, cx); diff }); let uncommitted_diff = cx.new(|cx| { let mut diff = BufferDiff::new(&buffer, cx); - diff.set_state(uncommitted, &buffer); + diff.set_snapshot(uncommitted, &buffer, None, cx); diff.set_secondary_diff(unstaged_diff.clone()); diff }); @@ -1874,9 +1889,9 @@ mod tests { let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text_1); - let empty_diff = cx.update(|cx| BufferDiff::build_empty(&buffer, cx)); - let diff_1 = BufferDiff::build_sync(buffer.clone(), base_text.clone(), cx); - let range = diff_1.compare(&empty_diff, &buffer).unwrap(); + let empty_diff = cx.update(|cx| BufferDiffSnapshot::empty(&buffer, cx)); + let diff_1 = BufferDiffSnapshot::new_sync(buffer.clone(), base_text.clone(), cx); + let range = diff_1.inner.compare(&empty_diff.inner, &buffer).unwrap(); assert_eq!(range.to_point(&buffer), Point::new(0, 0)..Point::new(8, 0)); // Edit does not affect the diff. @@ -1893,8 +1908,8 @@ mod tests { " .unindent(), ); - let diff_2 = BufferDiff::build_sync(buffer.clone(), base_text.clone(), cx); - assert_eq!(None, diff_2.compare(&diff_1, &buffer)); + let diff_2 = BufferDiffSnapshot::new_sync(buffer.clone(), base_text.clone(), cx); + assert_eq!(None, diff_2.inner.compare(&diff_1.inner, &buffer)); // Edit turns a deletion hunk into a modification. buffer.edit_via_marked_text( @@ -1910,8 +1925,8 @@ mod tests { " .unindent(), ); - let diff_3 = BufferDiff::build_sync(buffer.clone(), base_text.clone(), cx); - let range = diff_3.compare(&diff_2, &buffer).unwrap(); + let diff_3 = BufferDiffSnapshot::new_sync(buffer.clone(), base_text.clone(), cx); + let range = diff_3.inner.compare(&diff_2.inner, &buffer).unwrap(); assert_eq!(range.to_point(&buffer), Point::new(1, 0)..Point::new(2, 0)); // Edit turns a modification hunk into a deletion. @@ -1927,8 +1942,8 @@ mod tests { " .unindent(), ); - let diff_4 = BufferDiff::build_sync(buffer.clone(), base_text.clone(), cx); - let range = diff_4.compare(&diff_3, &buffer).unwrap(); + let diff_4 = BufferDiffSnapshot::new_sync(buffer.clone(), base_text.clone(), cx); + let range = diff_4.inner.compare(&diff_3.inner, &buffer).unwrap(); assert_eq!(range.to_point(&buffer), Point::new(3, 4)..Point::new(4, 0)); // Edit introduces a new insertion hunk. @@ -1945,8 +1960,8 @@ mod tests { " .unindent(), ); - let diff_5 = BufferDiff::build_sync(buffer.snapshot(), base_text.clone(), cx); - let range = diff_5.compare(&diff_4, &buffer).unwrap(); + let diff_5 = BufferDiffSnapshot::new_sync(buffer.snapshot(), base_text.clone(), cx); + let range = diff_5.inner.compare(&diff_4.inner, &buffer).unwrap(); assert_eq!(range.to_point(&buffer), Point::new(3, 0)..Point::new(4, 0)); // Edit removes a hunk. @@ -1963,8 +1978,8 @@ mod tests { " .unindent(), ); - let diff_6 = BufferDiff::build_sync(buffer.snapshot(), base_text, cx); - let range = diff_6.compare(&diff_5, &buffer).unwrap(); + let diff_6 = BufferDiffSnapshot::new_sync(buffer.snapshot(), base_text, cx); + let range = diff_6.inner.compare(&diff_5.inner, &buffer).unwrap(); assert_eq!(range.to_point(&buffer), Point::new(7, 0)..Point::new(8, 0)); } @@ -2038,14 +2053,16 @@ mod tests { head_text: String, cx: &mut TestAppContext, ) -> Entity { - let inner = BufferDiff::build_sync(working_copy.text.clone(), head_text, cx); + let inner = + BufferDiffSnapshot::new_sync(working_copy.text.clone(), head_text, cx).inner; let secondary = BufferDiff { buffer_id: working_copy.remote_id(), - inner: BufferDiff::build_sync( + inner: BufferDiffSnapshot::new_sync( working_copy.text.clone(), index_text.to_string(), cx, - ), + ) + .inner, secondary_diff: None, }; let secondary = cx.new(|_| secondary); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 7f2c5c782d..91403cea82 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -413,6 +413,7 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) + .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_mutating_project_request::) diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 5e153a690b..4bd2260cdd 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -32,7 +32,6 @@ test-support = [ aho-corasick.workspace = true anyhow.workspace = true assets.workspace = true -chrono.workspace = true client.workspace = true clock.workspace = true collections.workspace = true @@ -47,7 +46,6 @@ fuzzy.workspace = true fs.workspace = true git.workspace = true gpui.workspace = true -http_client.workspace = true indoc.workspace = true inline_completion.workspace = true itertools.workspace = true @@ -76,7 +74,6 @@ task.workspace = true telemetry.workspace = true text.workspace = true time.workspace = true -time_format.workspace = true theme.workspace = true tree-sitter-html = { workspace = true, optional = true } tree-sitter-rust = { workspace = true, optional = true } diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 6ac1df7259..5bd764eb1d 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -419,6 +419,7 @@ actions!( EditLogBreakpoint, ToggleAutoSignatureHelp, ToggleGitBlameInline, + OpenGitBlameCommit, ToggleIndentGuides, ToggleInlayHints, ToggleInlineDiagnostics, diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index f2135762e2..896b0c054b 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -321,6 +321,20 @@ impl DisplayMap { block_map.remove_intersecting_replace_blocks(offset_ranges, inclusive); } + pub fn disable_header_for_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { + let snapshot = self.buffer.read(cx).snapshot(cx); + let edits = self.buffer_subscription.consume().into_inner(); + let tab_size = Self::tab_size(&self.buffer, cx); + let (snapshot, edits) = self.inlay_map.sync(snapshot, edits); + let (snapshot, edits) = self.fold_map.read(snapshot, edits); + let (snapshot, edits) = self.tab_map.sync(snapshot, edits, tab_size); + let (snapshot, edits) = self + .wrap_map + .update(cx, |map, cx| map.sync(snapshot, edits, cx)); + let mut block_map = self.block_map.write(snapshot, edits); + block_map.disable_header_for_buffer(buffer_id) + } + pub fn fold_buffers( &mut self, buffer_ids: impl IntoIterator, diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 88849c9683..2102bdefab 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -40,6 +40,7 @@ pub struct BlockMap { buffer_header_height: u32, excerpt_header_height: u32, pub(super) folded_buffers: HashSet, + buffers_with_disabled_headers: HashSet, } pub struct BlockMapReader<'a> { @@ -422,6 +423,7 @@ impl BlockMap { custom_blocks: Vec::new(), custom_blocks_by_id: TreeMap::default(), folded_buffers: HashSet::default(), + buffers_with_disabled_headers: HashSet::default(), transforms: RefCell::new(transforms), wrap_snapshot: RefCell::new(wrap_snapshot.clone()), buffer_header_height, @@ -642,11 +644,8 @@ impl BlockMap { ); if buffer.show_headers() { - blocks_in_edit.extend(BlockMap::header_and_footer_blocks( - self.buffer_header_height, - self.excerpt_header_height, + blocks_in_edit.extend(self.header_and_footer_blocks( buffer, - &self.folded_buffers, (start_bound, end_bound), wrap_snapshot, )); @@ -714,10 +713,8 @@ impl BlockMap { } fn header_and_footer_blocks<'a, R, T>( - buffer_header_height: u32, - excerpt_header_height: u32, + &'a self, buffer: &'a multi_buffer::MultiBufferSnapshot, - folded_buffers: &'a HashSet, range: R, wrap_snapshot: &'a WrapSnapshot, ) -> impl Iterator, Block)> + 'a @@ -728,73 +725,78 @@ impl BlockMap { let mut boundaries = buffer.excerpt_boundaries_in_range(range).peekable(); std::iter::from_fn(move || { - let excerpt_boundary = boundaries.next()?; - let wrap_row = wrap_snapshot - .make_wrap_point(Point::new(excerpt_boundary.row.0, 0), Bias::Left) - .row(); + loop { + let excerpt_boundary = boundaries.next()?; + let wrap_row = wrap_snapshot + .make_wrap_point(Point::new(excerpt_boundary.row.0, 0), Bias::Left) + .row(); - let new_buffer_id = match (&excerpt_boundary.prev, &excerpt_boundary.next) { - (None, next) => Some(next.buffer_id), - (Some(prev), next) => { - if prev.buffer_id != next.buffer_id { - Some(next.buffer_id) - } else { - None - } - } - }; - - let mut height = 0; - - if let Some(new_buffer_id) = new_buffer_id { - let first_excerpt = excerpt_boundary.next.clone(); - if folded_buffers.contains(&new_buffer_id) { - let mut last_excerpt_end_row = first_excerpt.end_row; - - while let Some(next_boundary) = boundaries.peek() { - if next_boundary.next.buffer_id == new_buffer_id { - last_excerpt_end_row = next_boundary.next.end_row; + let new_buffer_id = match (&excerpt_boundary.prev, &excerpt_boundary.next) { + (None, next) => Some(next.buffer_id), + (Some(prev), next) => { + if prev.buffer_id != next.buffer_id { + Some(next.buffer_id) } else { - break; + None + } + } + }; + + let mut height = 0; + + if let Some(new_buffer_id) = new_buffer_id { + let first_excerpt = excerpt_boundary.next.clone(); + if self.buffers_with_disabled_headers.contains(&new_buffer_id) { + continue; + } + if self.folded_buffers.contains(&new_buffer_id) { + let mut last_excerpt_end_row = first_excerpt.end_row; + + while let Some(next_boundary) = boundaries.peek() { + if next_boundary.next.buffer_id == new_buffer_id { + last_excerpt_end_row = next_boundary.next.end_row; + } else { + break; + } + + boundaries.next(); } - boundaries.next(); + let wrap_end_row = wrap_snapshot + .make_wrap_point( + Point::new( + last_excerpt_end_row.0, + buffer.line_len(last_excerpt_end_row), + ), + Bias::Right, + ) + .row(); + + return Some(( + BlockPlacement::Replace(WrapRow(wrap_row)..=WrapRow(wrap_end_row)), + Block::FoldedBuffer { + height: height + self.buffer_header_height, + first_excerpt, + }, + )); } - - let wrap_end_row = wrap_snapshot - .make_wrap_point( - Point::new( - last_excerpt_end_row.0, - buffer.line_len(last_excerpt_end_row), - ), - Bias::Right, - ) - .row(); - - return Some(( - BlockPlacement::Replace(WrapRow(wrap_row)..=WrapRow(wrap_end_row)), - Block::FoldedBuffer { - height: height + buffer_header_height, - first_excerpt, - }, - )); } - } - if new_buffer_id.is_some() { - height += buffer_header_height; - } else { - height += excerpt_header_height; - } + if new_buffer_id.is_some() { + height += self.buffer_header_height; + } else { + height += self.excerpt_header_height; + } - Some(( - BlockPlacement::Above(WrapRow(wrap_row)), - Block::ExcerptBoundary { - excerpt: excerpt_boundary.next, - height, - starts_new_buffer: new_buffer_id.is_some(), - }, - )) + return Some(( + BlockPlacement::Above(WrapRow(wrap_row)), + Block::ExcerptBoundary { + excerpt: excerpt_boundary.next, + height, + starts_new_buffer: new_buffer_id.is_some(), + }, + )); + } }) } @@ -1168,6 +1170,10 @@ impl BlockMapWriter<'_> { self.remove(blocks_to_remove); } + pub fn disable_header_for_buffer(&mut self, buffer_id: BufferId) { + self.0.buffers_with_disabled_headers.insert(buffer_id); + } + pub fn fold_buffers( &mut self, buffer_ids: impl IntoIterator, @@ -3159,11 +3165,8 @@ mod tests { })); // Note that this needs to be synced with the related section in BlockMap::sync - expected_blocks.extend(BlockMap::header_and_footer_blocks( - buffer_start_header_height, - excerpt_header_height, + expected_blocks.extend(block_map.header_and_footer_blocks( &buffer_snapshot, - &block_map.folded_buffers, 0.., &wraps_snapshot, )); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 9a2620eda1..c68f6c8b4f 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -16,7 +16,6 @@ pub mod actions; mod blink_manager; mod clangd_ext; mod code_context_menus; -pub mod commit_tooltip; pub mod display_map; mod editor_settings; mod editor_settings_controls; @@ -82,19 +81,21 @@ use code_context_menus::{ AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu, CompletionsMenu, ContextMenuOrigin, }; -use git::blame::GitBlame; +use git::blame::{GitBlame, GlobalBlameRenderer}; use gpui::{ - Action, Animation, AnimationExt, AnyElement, App, AppContext, AsyncWindowContext, - AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry, ClipboardItem, Context, - DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent, - Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, KeyContext, Modifiers, - MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render, SharedString, Size, - Stateful, Styled, StyledText, Subscription, Task, TextStyle, TextStyleRefinement, - UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity, WeakFocusHandle, Window, - div, impl_actions, point, prelude::*, pulsating_between, px, relative, size, + Action, Animation, AnimationExt, AnyElement, AnyWeakEntity, App, AppContext, + AsyncWindowContext, AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry, + ClipboardItem, Context, DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter, + FocusHandle, FocusOutEvent, Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, + KeyContext, Modifiers, MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render, + SharedString, Size, Stateful, Styled, StyledText, Subscription, Task, TextStyle, + TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity, + WeakFocusHandle, Window, div, impl_actions, point, prelude::*, pulsating_between, px, relative, + size, }; use highlight_matching_bracket::refresh_matching_bracket_highlights; use hover_links::{HoverLink, HoveredLinkState, InlayHighlight, find_file}; +pub use hover_popover::hover_markdown_style; use hover_popover::{HoverState, hide_hover}; use indent_guides::ActiveIndentGuidesState; use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy}; @@ -124,6 +125,7 @@ use project::{ }, }; +pub use git::blame::BlameRenderer; pub use proposed_changes_editor::{ ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar, }; @@ -187,8 +189,8 @@ use theme::{ observe_buffer_font_size_adjustment, }; use ui::{ - ButtonSize, ButtonStyle, Disclosure, IconButton, IconButtonShape, IconName, IconSize, Key, - Tooltip, h_flex, prelude::*, + ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName, + IconSize, Key, Tooltip, h_flex, prelude::*, }; use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc}; use workspace::{ @@ -302,6 +304,8 @@ pub fn init_settings(cx: &mut App) { pub fn init(cx: &mut App) { init_settings(cx); + cx.set_global(GlobalBlameRenderer(Arc::new(()))); + workspace::register_project_item::(cx); workspace::FollowableViewRegistry::register::(cx); workspace::register_serializable_item::(cx); @@ -347,6 +351,10 @@ pub fn init(cx: &mut App) { }); } +pub fn set_blame_renderer(renderer: impl BlameRenderer + 'static, cx: &mut App) { + cx.set_global(GlobalBlameRenderer(Arc::new(renderer))); +} + pub struct SearchWithinRange; trait InvalidationRegion { @@ -766,7 +774,7 @@ pub struct Editor { show_git_blame_gutter: bool, show_git_blame_inline: bool, show_git_blame_inline_delay_task: Option>, - git_blame_inline_tooltip: Option>, + pub git_blame_inline_tooltip: Option, git_blame_inline_enabled: bool, render_diff_hunk_controls: RenderDiffHunkControlsFn, serialize_dirty_buffers: bool, @@ -848,8 +856,6 @@ pub struct EditorSnapshot { gutter_hovered: bool, } -const GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED: usize = 20; - #[derive(Default, Debug, Clone, Copy)] pub struct GutterDimensions { pub left_padding: Pixels, @@ -1643,6 +1649,21 @@ impl Editor { this } + pub fn deploy_mouse_context_menu( + &mut self, + position: gpui::Point, + context_menu: Entity, + window: &mut Window, + cx: &mut Context, + ) { + self.mouse_context_menu = Some(MouseContextMenu::new( + crate::mouse_context_menu::MenuPosition::PinnedToScreen(position), + context_menu, + window, + cx, + )); + } + pub fn mouse_menu_is_focused(&self, window: &Window, cx: &App) -> bool { self.mouse_context_menu .as_ref() @@ -14922,6 +14943,13 @@ impl Editor { self.display_map.read(cx).folded_buffers() } + pub fn disable_header_for_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { + self.display_map.update(cx, |display_map, cx| { + display_map.disable_header_for_buffer(buffer_id, cx); + }); + cx.notify(); + } + /// Removes any folds with the given ranges. pub fn remove_folds_with_type( &mut self, @@ -15861,6 +15889,45 @@ impl Editor { cx.notify(); } + pub fn open_git_blame_commit( + &mut self, + _: &OpenGitBlameCommit, + window: &mut Window, + cx: &mut Context, + ) { + self.open_git_blame_commit_internal(window, cx); + } + + fn open_git_blame_commit_internal( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option<()> { + let blame = self.blame.as_ref()?; + let snapshot = self.snapshot(window, cx); + let cursor = self.selections.newest::(cx).head(); + let (buffer, point, _) = snapshot.buffer_snapshot.point_to_buffer_point(cursor)?; + let blame_entry = blame + .update(cx, |blame, cx| { + blame + .blame_for_rows( + &[RowInfo { + buffer_id: Some(buffer.remote_id()), + buffer_row: Some(point.row), + ..Default::default() + }], + cx, + ) + .next() + }) + .flatten()?; + let renderer = cx.global::().0.clone(); + let repo = blame.read(cx).repository(cx)?; + let workspace = self.workspace()?.downgrade(); + renderer.open_blame_commit(blame_entry, repo, workspace, window, cx); + None + } + pub fn git_blame_inline_enabled(&self) -> bool { self.git_blame_inline_enabled } @@ -17794,7 +17861,9 @@ fn get_uncommitted_diff_for_buffer( let mut tasks = Vec::new(); project.update(cx, |project, cx| { for buffer in buffers { - tasks.push(project.open_uncommitted_diff(buffer.clone(), cx)) + if project::File::from_dyn(buffer.read(cx).file()).is_some() { + tasks.push(project.open_uncommitted_diff(buffer.clone(), cx)) + } } }); cx.spawn(async move |cx| { @@ -18911,13 +18980,13 @@ impl EditorSnapshot { let git_blame_entries_width = self.git_blame_gutter_max_author_length .map(|max_author_length| { + let renderer = cx.global::().0.clone(); const MAX_RELATIVE_TIMESTAMP: &str = "60 minutes ago"; /// The number of characters to dedicate to gaps and margins. const SPACING_WIDTH: usize = 4; - let max_char_count = max_author_length - .min(GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED) + let max_char_count = max_author_length.min(renderer.max_author_length()) + ::git::SHORT_SHA_LENGTH + MAX_RELATIVE_TIMESTAMP.len() + SPACING_WIDTH; diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index ede1df1e1e..caa47af418 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -3,13 +3,12 @@ use crate::{ ContextMenuPlacement, CursorShape, CustomBlockId, DisplayDiffHunk, DisplayPoint, DisplayRow, DocumentHighlightRead, DocumentHighlightWrite, EditDisplayMode, Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle, FILE_HEADER_HEIGHT, FocusedBlock, - GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, - HoveredCursor, InlayHintRefreshReason, InlineCompletion, JumpData, LineDown, LineHighlight, - LineUp, MAX_LINE_LEN, MIN_LINE_NUMBER_DIGITS, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, - PageDown, PageUp, Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight, Selection, - SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, + GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor, InlayHintRefreshReason, + InlineCompletion, JumpData, LineDown, LineHighlight, LineUp, MAX_LINE_LEN, + MIN_LINE_NUMBER_DIGITS, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, PageDown, PageUp, + Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight, Selection, SoftWrap, + StickyHeaderExcerpt, ToPoint, ToggleFold, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, - commit_tooltip::{CommitTooltip, ParsedCommitMessage, blame_entry_relative_timestamp}, display_map::{ Block, BlockContext, BlockStyle, DisplaySnapshot, HighlightedChunk, ToDisplayPoint, }, @@ -17,13 +16,13 @@ use crate::{ CurrentLineHighlight, DoubleClickInMultibuffer, MultiCursorModifier, ScrollBeyondLastLine, ScrollbarAxes, ScrollbarDiagnostics, ShowScrollbar, }, - git::blame::GitBlame, + git::blame::{BlameRenderer, GitBlame, GlobalBlameRenderer}, hover_popover::{ self, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT, hover_at, }, inlay_hint_settings, items::BufferSearchHighlights, - mouse_context_menu::{self, MenuPosition, MouseContextMenu}, + mouse_context_menu::{self, MenuPosition}, scroll::scroll_amount::ScrollAmount, }; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; @@ -34,12 +33,12 @@ use file_icons::FileIcons; use git::{Oid, blame::BlameEntry, status::FileStatus}; use gpui::{ Action, Along, AnyElement, App, AvailableSpace, Axis as ScrollbarAxis, BorderStyle, Bounds, - ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, - Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, - Hsla, InteractiveElement, IntoElement, Keystroke, Length, ModifiersChangedEvent, MouseButton, + ClickEvent, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges, Element, + ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, Hsla, + InteractiveElement, IntoElement, Keystroke, Length, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled, - Subscription, TextRun, TextStyleRefinement, Window, anchored, deferred, div, fill, + TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background, transparent_black, }; @@ -76,10 +75,10 @@ use std::{ use sum_tree::Bias; use text::BufferId; use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor}; -use ui::{ButtonLike, ContextMenu, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*}; +use ui::{ButtonLike, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*}; use unicode_segmentation::UnicodeSegmentation; use util::{RangeExt, ResultExt, debug_panic}; -use workspace::{item::Item, notifications::NotifyTaskExt}; +use workspace::{Workspace, item::Item, notifications::NotifyTaskExt}; const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 7.; @@ -426,6 +425,7 @@ impl EditorElement { register_action(editor, window, Editor::copy_file_location); register_action(editor, window, Editor::toggle_git_blame); register_action(editor, window, Editor::toggle_git_blame_inline); + register_action(editor, window, Editor::open_git_blame_commit); register_action(editor, window, Editor::toggle_selected_diff_hunks); register_action(editor, window, Editor::toggle_staged_selected_diff_hunks); register_action(editor, window, Editor::stage_and_next); @@ -1759,14 +1759,21 @@ impl EditorElement { padding * em_width }; + let workspace = editor.workspace()?.downgrade(); let blame_entry = blame .update(cx, |blame, cx| { blame.blame_for_rows(&[*row_info], cx).next() }) .flatten()?; - let mut element = - render_inline_blame_entry(self.editor.clone(), &blame, blame_entry, &self.style, cx); + let mut element = render_inline_blame_entry( + self.editor.clone(), + workspace, + &blame, + blame_entry, + &self.style, + cx, + )?; let start_y = content_origin.y + line_height * (display_row.as_f32() - scroll_pixel_position.y / line_height); @@ -1816,6 +1823,7 @@ impl EditorElement { } let blame = self.editor.read(cx).blame.clone()?; + let workspace = self.editor.read(cx).workspace()?; let blamed_rows: Vec<_> = blame.update(cx, |blame, cx| { blame.blame_for_rows(buffer_rows, cx).collect() }); @@ -1829,36 +1837,35 @@ impl EditorElement { let start_x = em_width; let mut last_used_color: Option<(PlayerColor, Oid)> = None; + let blame_renderer = cx.global::().0.clone(); let shaped_lines = blamed_rows .into_iter() .enumerate() .flat_map(|(ix, blame_entry)| { - if let Some(blame_entry) = blame_entry { - let mut element = render_blame_entry( - ix, - &blame, - blame_entry, - &self.style, - &mut last_used_color, - self.editor.clone(), - cx, - ); + let mut element = render_blame_entry( + ix, + &blame, + blame_entry?, + &self.style, + &mut last_used_color, + self.editor.clone(), + workspace.clone(), + blame_renderer.clone(), + cx, + )?; - let start_y = ix as f32 * line_height - (scroll_top % line_height); - let absolute_offset = gutter_hitbox.origin + point(start_x, start_y); + let start_y = ix as f32 * line_height - (scroll_top % line_height); + let absolute_offset = gutter_hitbox.origin + point(start_x, start_y); - element.prepaint_as_root( - absolute_offset, - size(width, AvailableSpace::MinContent), - window, - cx, - ); + element.prepaint_as_root( + absolute_offset, + size(width, AvailableSpace::MinContent), + window, + cx, + ); - Some(element) - } else { - None - } + Some(element) }) .collect(); @@ -5725,61 +5732,43 @@ fn prepaint_gutter_button( fn render_inline_blame_entry( editor: Entity, - blame: &gpui::Entity, + workspace: WeakEntity, + blame: &Entity, blame_entry: BlameEntry, style: &EditorStyle, cx: &mut App, -) -> AnyElement { - let relative_timestamp = blame_entry_relative_timestamp(&blame_entry); - - let author = blame_entry.author.as_deref().unwrap_or_default(); - let summary_enabled = ProjectSettings::get_global(cx) - .git - .show_inline_commit_summary(); - - let text = match blame_entry.summary.as_ref() { - Some(summary) if summary_enabled => { - format!("{}, {} - {}", author, relative_timestamp, summary) - } - _ => format!("{}, {}", author, relative_timestamp), - }; - let blame = blame.clone(); - let blame_entry = blame_entry.clone(); - - h_flex() - .id("inline-blame") - .w_full() - .font_family(style.text.font().family) - .text_color(cx.theme().status().hint) - .line_height(style.text.line_height) - .child(Icon::new(IconName::FileGit).color(Color::Hint)) - .child(text) - .gap_2() - .hoverable_tooltip(move |window, cx| { - let details = blame.read(cx).details_for_entry(&blame_entry); - let tooltip = - cx.new(|cx| CommitTooltip::blame_entry(&blame_entry, details, window, cx)); - editor.update(cx, |editor, _| { - editor.git_blame_inline_tooltip = Some(tooltip.downgrade()) - }); - tooltip.into() - }) - .into_any() +) -> Option { + let renderer = cx.global::().0.clone(); + let blame = blame.read(cx); + let details = blame.details_for_entry(&blame_entry); + let repository = blame.repository(cx)?.clone(); + renderer.render_inline_blame_entry( + &style.text, + blame_entry, + details, + repository, + workspace, + editor, + cx, + ) } fn render_blame_entry( ix: usize, - blame: &gpui::Entity, + blame: &Entity, blame_entry: BlameEntry, style: &EditorStyle, last_used_color: &mut Option<(PlayerColor, Oid)>, editor: Entity, + workspace: Entity, + renderer: Arc, cx: &mut App, -) -> AnyElement { +) -> Option { let mut sha_color = cx .theme() .players() .color_for_participant(blame_entry.sha.into()); + // If the last color we used is the same as the one we get for this line, but // the commit SHAs are different, then we try again to get a different color. match *last_used_color { @@ -5791,97 +5780,20 @@ fn render_blame_entry( }; last_used_color.replace((sha_color, blame_entry.sha)); - let relative_timestamp = blame_entry_relative_timestamp(&blame_entry); - - let short_commit_id = blame_entry.sha.display_short(); - - let author_name = blame_entry.author.as_deref().unwrap_or(""); - let name = util::truncate_and_trailoff(author_name, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED); - let details = blame.read(cx).details_for_entry(&blame_entry); - - h_flex() - .w_full() - .justify_between() - .font_family(style.text.font().family) - .line_height(style.text.line_height) - .id(("blame", ix)) - .text_color(cx.theme().status().hint) - .pr_2() - .gap_2() - .child( - h_flex() - .items_center() - .gap_2() - .child(div().text_color(sha_color.cursor).child(short_commit_id)) - .child(name), - ) - .child(relative_timestamp) - .on_mouse_down(MouseButton::Right, { - let blame_entry = blame_entry.clone(); - let details = details.clone(); - move |event, window, cx| { - deploy_blame_entry_context_menu( - &blame_entry, - details.as_ref(), - editor.clone(), - event.position, - window, - cx, - ); - } - }) - .hover(|style| style.bg(cx.theme().colors().element_hover)) - .when_some( - details - .as_ref() - .and_then(|details| details.permalink.clone()), - |this, url| { - this.cursor_pointer().on_click(move |_, _, cx| { - cx.stop_propagation(); - cx.open_url(url.as_str()) - }) - }, - ) - .hoverable_tooltip(move |window, cx| { - cx.new(|cx| CommitTooltip::blame_entry(&blame_entry, details.clone(), window, cx)) - .into() - }) - .into_any() -} - -fn deploy_blame_entry_context_menu( - blame_entry: &BlameEntry, - details: Option<&ParsedCommitMessage>, - editor: Entity, - position: gpui::Point, - window: &mut Window, - cx: &mut App, -) { - let context_menu = ContextMenu::build(window, cx, move |menu, _, _| { - let sha = format!("{}", blame_entry.sha); - menu.on_blur_subscription(Subscription::new(|| {})) - .entry("Copy commit SHA", None, move |_, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(sha.clone())); - }) - .when_some( - details.and_then(|details| details.permalink.clone()), - |this, url| { - this.entry("Open permalink", None, move |_, cx| { - cx.open_url(url.as_str()) - }) - }, - ) - }); - - editor.update(cx, move |editor, cx| { - editor.mouse_context_menu = Some(MouseContextMenu::new( - MenuPosition::PinnedToScreen(position), - context_menu, - window, - cx, - )); - cx.notify(); - }); + let blame = blame.read(cx); + let details = blame.details_for_entry(&blame_entry); + let repository = blame.repository(cx)?; + renderer.render_blame_entry( + &style.text, + blame_entry, + details, + repository, + workspace.downgrade(), + editor, + ix, + sha_color.cursor, + cx, + ) } #[derive(Debug)] @@ -6588,9 +6500,9 @@ impl Element for EditorElement { window.with_rem_size(rem_size, |window| { window.with_text_style(Some(text_style), |window| { window.with_content_mask(Some(ContentMask { bounds }), |window| { - let mut snapshot = self - .editor - .update(cx, |editor, cx| editor.snapshot(window, cx)); + let (mut snapshot, is_read_only) = self.editor.update(cx, |editor, cx| { + (editor.snapshot(window, cx), editor.read_only(cx)) + }); let style = self.style.clone(); let font_id = window.text_system().resolve_font(&style.text.font()); @@ -6970,11 +6882,12 @@ impl Element for EditorElement { .flatten()?; let mut element = render_inline_blame_entry( self.editor.clone(), + editor.workspace()?.downgrade(), blame, blame_entry, &style, cx, - ); + )?; let inline_blame_padding = INLINE_BLAME_PADDING_EM_WIDTHS * em_advance; Some( element @@ -7507,19 +7420,23 @@ impl Element for EditorElement { editor.last_position_map = Some(position_map.clone()) }); - let diff_hunk_controls = self.layout_diff_hunk_controls( - start_row..end_row, - &row_infos, - &text_hitbox, - &position_map, - newest_selection_head, - line_height, - scroll_pixel_position, - &display_hunks, - self.editor.clone(), - window, - cx, - ); + let diff_hunk_controls = if is_read_only { + vec![] + } else { + self.layout_diff_hunk_controls( + start_row..end_row, + &row_infos, + &text_hitbox, + &position_map, + newest_selection_head, + line_height, + scroll_pixel_position, + &display_hunks, + self.editor.clone(), + window, + cx, + ) + }; EditorLayout { mode, diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index 7b1b1692bd..e804bd5829 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -1,22 +1,22 @@ +use crate::Editor; use anyhow::Result; use collections::HashMap; use git::{ - GitHostingProvider, GitHostingProviderRegistry, Oid, - blame::{Blame, BlameEntry}, + GitHostingProviderRegistry, GitRemote, Oid, + blame::{Blame, BlameEntry, ParsedCommitMessage}, parse_git_remote_url, }; -use gpui::{App, AppContext as _, Context, Entity, Subscription, Task}; -use http_client::HttpClient; +use gpui::{ + AnyElement, App, AppContext as _, Context, Entity, Hsla, Subscription, Task, TextStyle, + WeakEntity, Window, +}; use language::{Bias, Buffer, BufferSnapshot, Edit}; use multi_buffer::RowInfo; -use project::{Project, ProjectItem}; +use project::{Project, ProjectItem, git_store::Repository}; use smallvec::SmallVec; use std::{sync::Arc, time::Duration}; use sum_tree::SumTree; -use ui::SharedString; -use url::Url; - -use crate::commit_tooltip::ParsedCommitMessage; +use workspace::Workspace; #[derive(Clone, Debug, Default)] pub struct GitBlameEntry { @@ -59,45 +59,11 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 { } } -#[derive(Clone)] -pub struct GitRemote { - pub host: Arc, - pub owner: String, - pub repo: String, -} - -impl std::fmt::Debug for GitRemote { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("GitRemote") - .field("host", &self.host.name()) - .field("owner", &self.owner) - .field("repo", &self.repo) - .finish() - } -} - -impl GitRemote { - pub fn host_supports_avatars(&self) -> bool { - self.host.supports_avatars() - } - - pub async fn avatar_url( - &self, - commit: SharedString, - client: Arc, - ) -> Option { - self.host - .commit_author_avatar_url(&self.owner, &self.repo, commit, client) - .await - .ok() - .flatten() - } -} pub struct GitBlame { project: Entity, buffer: Entity, entries: SumTree, - commit_details: HashMap, + commit_details: HashMap, buffer_snapshot: BufferSnapshot, buffer_edits: text::Subscription, task: Task>, @@ -109,6 +75,91 @@ pub struct GitBlame { _regenerate_subscriptions: Vec, } +pub trait BlameRenderer { + fn max_author_length(&self) -> usize; + + fn render_blame_entry( + &self, + _: &TextStyle, + _: BlameEntry, + _: Option, + _: Entity, + _: WeakEntity, + _: Entity, + _: usize, + _: Hsla, + _: &mut App, + ) -> Option; + + fn render_inline_blame_entry( + &self, + _: &TextStyle, + _: BlameEntry, + _: Option, + _: Entity, + _: WeakEntity, + _: Entity, + _: &mut App, + ) -> Option; + + fn open_blame_commit( + &self, + _: BlameEntry, + _: Entity, + _: WeakEntity, + _: &mut Window, + _: &mut App, + ); +} + +impl BlameRenderer for () { + fn max_author_length(&self) -> usize { + 0 + } + + fn render_blame_entry( + &self, + _: &TextStyle, + _: BlameEntry, + _: Option, + _: Entity, + _: WeakEntity, + _: Entity, + _: usize, + _: Hsla, + _: &mut App, + ) -> Option { + None + } + + fn render_inline_blame_entry( + &self, + _: &TextStyle, + _: BlameEntry, + _: Option, + _: Entity, + _: WeakEntity, + _: Entity, + _: &mut App, + ) -> Option { + None + } + + fn open_blame_commit( + &self, + _: BlameEntry, + _: Entity, + _: WeakEntity, + _: &mut Window, + _: &mut App, + ) { + } +} + +pub(crate) struct GlobalBlameRenderer(pub Arc); + +impl gpui::Global for GlobalBlameRenderer {} + impl GitBlame { pub fn new( buffer: Entity, @@ -181,6 +232,15 @@ impl GitBlame { this } + pub fn repository(&self, cx: &App) -> Option> { + self.project + .read(cx) + .git_store() + .read(cx) + .repository_and_path_for_buffer_id(self.buffer.read(cx).remote_id(), cx) + .map(|(repo, _)| repo) + } + pub fn has_generated_entries(&self) -> bool { self.generated } diff --git a/crates/editor/src/proposed_changes_editor.rs b/crates/editor/src/proposed_changes_editor.rs index 1ce61805ac..35c2ff40ee 100644 --- a/crates/editor/src/proposed_changes_editor.rs +++ b/crates/editor/src/proposed_changes_editor.rs @@ -109,7 +109,7 @@ impl ProposedChangesEditor { let diff = this.multibuffer.read(cx).diff_for(buffer.remote_id())?; Some(diff.update(cx, |diff, cx| { - diff.set_base_text(base_buffer.clone(), buffer, cx) + diff.set_base_text_buffer(base_buffer.clone(), buffer, cx) })) }) .collect::>() @@ -185,7 +185,7 @@ impl ProposedChangesEditor { branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx)); new_diffs.push(cx.new(|cx| { let mut diff = BufferDiff::new(&branch_buffer.read(cx).snapshot(), cx); - let _ = diff.set_base_text( + let _ = diff.set_base_text_buffer( location.buffer.clone(), branch_buffer.read(cx).text_snapshot(), cx, diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index eaed99f7e0..892b75a2fd 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -111,6 +111,14 @@ impl GitRepository for FakeGitRepository { .boxed() } + fn load_commit( + &self, + _commit: String, + _cx: AsyncApp, + ) -> BoxFuture> { + unimplemented!() + } + fn set_index_text( &self, path: RepoPath, diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs index e207576c88..185acd4a82 100644 --- a/crates/git/src/blame.rs +++ b/crates/git/src/blame.rs @@ -1,8 +1,9 @@ -use crate::Oid; use crate::commit::get_messages; +use crate::{GitRemote, Oid}; use anyhow::{Context as _, Result, anyhow}; use collections::{HashMap, HashSet}; use futures::AsyncWriteExt; +use gpui::SharedString; use serde::{Deserialize, Serialize}; use std::process::Stdio; use std::{ops::Range, path::Path}; @@ -20,6 +21,14 @@ pub struct Blame { pub remote_url: Option, } +#[derive(Clone, Debug, Default)] +pub struct ParsedCommitMessage { + pub message: SharedString, + pub permalink: Option, + pub pull_request: Option, + pub remote: Option, +} + impl Blame { pub async fn for_path( git_binary: &Path, diff --git a/crates/git/src/commit.rs b/crates/git/src/commit.rs index 74ed6854f68dfb01dc9421afc19a826d764435f4..3448426755ef32f3c91068954f3961c28647fad2 100644 GIT binary patch literal 3592 zcmXRdPE|-QN-RmWva0saOwmy&E=epYEw-`>2Gh>@DXF#ATp(46d6gOY{Bf*MGZjzV!pVzHHi zTC{&=O01@WuAKtJ6*f?-Y`|_0E-A{)OSiLAsOC~oP*BKB18dXEERIjjEhwqf(1c2W zB#Tl@N{jLo{IfNnMp#*;q^2d7=9Fk?YFdN!*K&cWiXC>Jv>=V~aIFA*`2Kl3Jvpsh68rpb?S}2~I1klKl8$aL6L-f%sJe*_86c z%o2p3auW;UQ;Ui;YEo;UNkvgZ$t^K6CpAT(Bp;N{ib@pHGfNbbauRb>6$*;-lT(X} ztrWm+Ox4u1hXyPpnORw-<`tI~r7CK`ZGgBy50YT@ic6D0`ZY9l;0lz~LB@lEOI;xi zVsUw9NrpmdQBi)8Lba8Btr9{pifLdYG@%ag&jyDLSPUFnkU&E4lXLR(Qd5wFqAIfh z;ZBeqSWu=F<>$timZVw4=j0a`S8Axitxd@l@w*>BAE;l17W@5f}G5f z_>$D3+|0bhlKdhKNOHtt0w^wPic5-W6pBlVpyufnr55BQCZ}pB*(m8KD5>R?SSx8F z1nt0r=^#O*;6#|M2hHtPR@n2OhNdQ0Ef<%*zP>_0Vo`CbLPX6_vLP)`p#Y>UK0OnZCDPL3L8{^*sx*@G^GZ_lN+3BAToh#H z7UU>+mZTPe0>Z|#BsEvTRzU+=a3IPtO<1`A%Bs1gB_K;miWO`Xpe91XR6{++Kpk2D zfQv3rY+G4@BQ7mZBR9V+RiUN^sUXS8&o4mYf?@|`0mQYSjH6%+u}Lp4wW34=l&(Q) zU>KwpR5}t?1u;>C_!8x%g zJzp;;KRKHLsceBtg6r90{eq(WtkmQZeTdqU{M;PEYKx1K^^;1|(o&1!i%as0QuT_8 zF*La_pjiOYl%83FO|2siwYW{kuR|Y{RZEHz%TkMq6LYZH=!?xpXiyNX9@IW4DN0Sn z?M!rQ;tLY@W4B7$ZmtgHvc6eyk{#WEyM7N=q_h`?z<6R9i=OHHIm*FTTAZqoT$EUnYGvi0nX*yEoCN@1R0o^@ diff --git a/crates/git/src/hosting_provider.rs b/crates/git/src/hosting_provider.rs index 2b875418bf..7ed996c73f 100644 --- a/crates/git/src/hosting_provider.rs +++ b/crates/git/src/hosting_provider.rs @@ -15,6 +15,41 @@ pub struct PullRequest { pub url: Url, } +#[derive(Clone)] +pub struct GitRemote { + pub host: Arc, + pub owner: String, + pub repo: String, +} + +impl std::fmt::Debug for GitRemote { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GitRemote") + .field("host", &self.host.name()) + .field("owner", &self.owner) + .field("repo", &self.repo) + .finish() + } +} + +impl GitRemote { + pub fn host_supports_avatars(&self) -> bool { + self.host.supports_avatars() + } + + pub async fn avatar_url( + &self, + commit: SharedString, + client: Arc, + ) -> Option { + self.host + .commit_author_avatar_url(&self.owner, &self.repo, commit, client) + .await + .ok() + .flatten() + } +} + pub struct BuildCommitPermalinkParams<'a> { pub sha: &'a str, } diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 0af0e49336..eec87aa2e7 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1,18 +1,18 @@ -use crate::status::GitStatus; +use crate::commit::parse_git_diff_name_status; +use crate::status::{GitStatus, StatusCode}; use crate::{Oid, SHORT_SHA_LENGTH}; use anyhow::{Context as _, Result, anyhow}; use collections::HashMap; use futures::future::BoxFuture; use futures::{AsyncWriteExt, FutureExt as _, select_biased}; use git2::BranchType; -use gpui::{AsyncApp, BackgroundExecutor, SharedString}; +use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString}; use parking_lot::Mutex; use rope::Rope; use schemars::JsonSchema; use serde::Deserialize; use std::borrow::{Borrow, Cow}; use std::ffi::{OsStr, OsString}; -use std::future; use std::path::Component; use std::process::{ExitStatus, Stdio}; use std::sync::LazyLock; @@ -21,6 +21,10 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; +use std::{ + future, + io::{BufRead, BufReader, BufWriter, Read}, +}; use sum_tree::MapSeekTarget; use thiserror::Error; use util::ResultExt; @@ -133,6 +137,18 @@ pub struct CommitDetails { pub committer_name: SharedString, } +#[derive(Debug)] +pub struct CommitDiff { + pub files: Vec, +} + +#[derive(Debug)] +pub struct CommitFile { + pub path: RepoPath, + pub old_text: Option, + pub new_text: Option, +} + impl CommitDetails { pub fn short_sha(&self) -> SharedString { self.sha[..SHORT_SHA_LENGTH].to_string().into() @@ -206,6 +222,7 @@ pub trait GitRepository: Send + Sync { fn show(&self, commit: String) -> BoxFuture>; + fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture>; fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture>; /// Returns the absolute path to the repository. For worktrees, this will be the path to the @@ -405,6 +422,108 @@ impl GitRepository for RealGitRepository { .boxed() } + fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture> { + let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned) + else { + return future::ready(Err(anyhow!("no working directory"))).boxed(); + }; + cx.background_spawn(async move { + let show_output = util::command::new_std_command("git") + .current_dir(&working_directory) + .args([ + "--no-optional-locks", + "show", + "--format=%P", + "-z", + "--no-renames", + "--name-status", + ]) + .arg(&commit) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| anyhow!("Failed to start git show process: {e}"))?; + + let show_stdout = String::from_utf8_lossy(&show_output.stdout); + let mut lines = show_stdout.split('\n'); + let parent_sha = lines.next().unwrap().trim().trim_end_matches('\0'); + let changes = parse_git_diff_name_status(lines.next().unwrap_or("")); + + let mut cat_file_process = util::command::new_std_command("git") + .current_dir(&working_directory) + .args(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| anyhow!("Failed to start git cat-file process: {e}"))?; + + use std::io::Write as _; + let mut files = Vec::::new(); + let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap()); + let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap()); + let mut info_line = String::new(); + let mut newline = [b'\0']; + for (path, status_code) in changes { + match status_code { + StatusCode::Modified => { + writeln!(&mut stdin, "{commit}:{}", path.display())?; + writeln!(&mut stdin, "{parent_sha}:{}", path.display())?; + } + StatusCode::Added => { + writeln!(&mut stdin, "{commit}:{}", path.display())?; + } + StatusCode::Deleted => { + writeln!(&mut stdin, "{parent_sha}:{}", path.display())?; + } + _ => continue, + } + stdin.flush()?; + + info_line.clear(); + stdout.read_line(&mut info_line)?; + + let len = info_line.trim_end().parse().with_context(|| { + format!("invalid object size output from cat-file {info_line}") + })?; + let mut text = vec![0; len]; + stdout.read_exact(&mut text)?; + stdout.read_exact(&mut newline)?; + let text = String::from_utf8_lossy(&text).to_string(); + + let mut old_text = None; + let mut new_text = None; + match status_code { + StatusCode::Modified => { + info_line.clear(); + stdout.read_line(&mut info_line)?; + let len = info_line.trim_end().parse().with_context(|| { + format!("invalid object size output from cat-file {}", info_line) + })?; + let mut parent_text = vec![0; len]; + stdout.read_exact(&mut parent_text)?; + stdout.read_exact(&mut newline)?; + old_text = Some(String::from_utf8_lossy(&parent_text).to_string()); + new_text = Some(text); + } + StatusCode::Added => new_text = Some(text), + StatusCode::Deleted => old_text = Some(text), + _ => continue, + } + + files.push(CommitFile { + path: path.into(), + old_text, + new_text, + }) + } + + Ok(CommitDiff { files }) + }) + .boxed() + } + fn reset( &self, commit: String, diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 5d53376f32..7ab389ef70 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -21,6 +21,7 @@ anyhow.workspace = true askpass.workspace = true assistant_settings.workspace = true buffer_diff.workspace = true +chrono.workspace = true collections.workspace = true command_palette_hooks.workspace = true component.workspace = true @@ -36,6 +37,7 @@ language_model.workspace = true linkify.workspace = true linkme.workspace = true log.workspace = true +markdown.workspace = true menu.workspace = true multi_buffer.workspace = true notifications.workspace = true diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs new file mode 100644 index 0000000000..ae608cfe1a --- /dev/null +++ b/crates/git_ui/src/blame_ui.rs @@ -0,0 +1,234 @@ +use crate::{commit_tooltip::CommitTooltip, commit_view::CommitView}; +use editor::{BlameRenderer, Editor}; +use git::{ + blame::{BlameEntry, ParsedCommitMessage}, + repository::CommitSummary, +}; +use gpui::{ + AnyElement, App, AppContext as _, ClipboardItem, Element as _, Entity, Hsla, + InteractiveElement as _, MouseButton, Pixels, StatefulInteractiveElement as _, Styled as _, + Subscription, TextStyle, WeakEntity, Window, div, +}; +use project::{git_store::Repository, project_settings::ProjectSettings}; +use settings::Settings as _; +use ui::{ + ActiveTheme, Color, ContextMenu, FluentBuilder as _, Icon, IconName, ParentElement as _, h_flex, +}; +use workspace::Workspace; + +const GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED: usize = 20; + +pub struct GitBlameRenderer; + +impl BlameRenderer for GitBlameRenderer { + fn max_author_length(&self) -> usize { + GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED + } + + fn render_blame_entry( + &self, + style: &TextStyle, + blame_entry: BlameEntry, + details: Option, + repository: Entity, + workspace: WeakEntity, + editor: Entity, + ix: usize, + sha_color: Hsla, + cx: &mut App, + ) -> Option { + let relative_timestamp = blame_entry_relative_timestamp(&blame_entry); + let short_commit_id = blame_entry.sha.display_short(); + let author_name = blame_entry.author.as_deref().unwrap_or(""); + let name = util::truncate_and_trailoff(author_name, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED); + + Some( + h_flex() + .w_full() + .justify_between() + .font_family(style.font().family) + .line_height(style.line_height) + .id(("blame", ix)) + .text_color(cx.theme().status().hint) + .pr_2() + .gap_2() + .child( + h_flex() + .items_center() + .gap_2() + .child(div().text_color(sha_color).child(short_commit_id)) + .child(name), + ) + .child(relative_timestamp) + .hover(|style| style.bg(cx.theme().colors().element_hover)) + .cursor_pointer() + .on_mouse_down(MouseButton::Right, { + let blame_entry = blame_entry.clone(); + let details = details.clone(); + move |event, window, cx| { + deploy_blame_entry_context_menu( + &blame_entry, + details.as_ref(), + editor.clone(), + event.position, + window, + cx, + ); + } + }) + .on_click({ + let blame_entry = blame_entry.clone(); + let repository = repository.clone(); + let workspace = workspace.clone(); + move |_, window, cx| { + CommitView::open( + CommitSummary { + sha: blame_entry.sha.to_string().into(), + subject: blame_entry.summary.clone().unwrap_or_default().into(), + commit_timestamp: blame_entry.committer_time.unwrap_or_default(), + has_parent: true, + }, + repository.downgrade(), + workspace.clone(), + window, + cx, + ) + } + }) + .hoverable_tooltip(move |window, cx| { + cx.new(|cx| { + CommitTooltip::blame_entry( + &blame_entry, + details.clone(), + repository.clone(), + workspace.clone(), + window, + cx, + ) + }) + .into() + }) + .into_any(), + ) + } + + fn render_inline_blame_entry( + &self, + style: &TextStyle, + blame_entry: BlameEntry, + details: Option, + repository: Entity, + workspace: WeakEntity, + editor: Entity, + cx: &mut App, + ) -> Option { + let relative_timestamp = blame_entry_relative_timestamp(&blame_entry); + let author = blame_entry.author.as_deref().unwrap_or_default(); + let summary_enabled = ProjectSettings::get_global(cx) + .git + .show_inline_commit_summary(); + + let text = match blame_entry.summary.as_ref() { + Some(summary) if summary_enabled => { + format!("{}, {} - {}", author, relative_timestamp, summary) + } + _ => format!("{}, {}", author, relative_timestamp), + }; + + Some( + h_flex() + .id("inline-blame") + .w_full() + .font_family(style.font().family) + .text_color(cx.theme().status().hint) + .line_height(style.line_height) + .child(Icon::new(IconName::FileGit).color(Color::Hint)) + .child(text) + .gap_2() + .hoverable_tooltip(move |window, cx| { + let tooltip = cx.new(|cx| { + CommitTooltip::blame_entry( + &blame_entry, + details.clone(), + repository.clone(), + workspace.clone(), + window, + cx, + ) + }); + editor.update(cx, |editor, _| { + editor.git_blame_inline_tooltip = Some(tooltip.downgrade().into()) + }); + tooltip.into() + }) + .into_any(), + ) + } + + fn open_blame_commit( + &self, + blame_entry: BlameEntry, + repository: Entity, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + ) { + CommitView::open( + CommitSummary { + sha: blame_entry.sha.to_string().into(), + subject: blame_entry.summary.clone().unwrap_or_default().into(), + commit_timestamp: blame_entry.committer_time.unwrap_or_default(), + has_parent: true, + }, + repository.downgrade(), + workspace.clone(), + window, + cx, + ) + } +} + +fn deploy_blame_entry_context_menu( + blame_entry: &BlameEntry, + details: Option<&ParsedCommitMessage>, + editor: Entity, + position: gpui::Point, + window: &mut Window, + cx: &mut App, +) { + let context_menu = ContextMenu::build(window, cx, move |menu, _, _| { + let sha = format!("{}", blame_entry.sha); + menu.on_blur_subscription(Subscription::new(|| {})) + .entry("Copy commit SHA", None, move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(sha.clone())); + }) + .when_some( + details.and_then(|details| details.permalink.clone()), + |this, url| { + this.entry("Open permalink", None, move |_, cx| { + cx.open_url(url.as_str()) + }) + }, + ) + }); + + editor.update(cx, move |editor, cx| { + editor.deploy_mouse_context_menu(position, context_menu, window, cx); + cx.notify(); + }); +} + +fn blame_entry_relative_timestamp(blame_entry: &BlameEntry) -> String { + match blame_entry.author_offset_date_time() { + Ok(timestamp) => { + let local = chrono::Local::now().offset().local_minus_utc(); + time_format::format_localized_timestamp( + timestamp, + time::OffsetDateTime::now_utc(), + time::UtcOffset::from_whole_seconds(local).unwrap(), + time_format::TimestampFormat::Relative, + ) + } + Err(_) => "Error parsing date".to_string(), + } +} diff --git a/crates/editor/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs similarity index 83% rename from crates/editor/src/commit_tooltip.rs rename to crates/git_ui/src/commit_tooltip.rs index ccd3151fa9..45871a020e 100644 --- a/crates/editor/src/commit_tooltip.rs +++ b/crates/git_ui/src/commit_tooltip.rs @@ -1,21 +1,22 @@ +use crate::commit_view::CommitView; +use editor::hover_markdown_style; use futures::Future; -use git::PullRequest; use git::blame::BlameEntry; +use git::repository::CommitSummary; +use git::{GitRemote, blame::ParsedCommitMessage}; use gpui::{ App, Asset, ClipboardItem, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle, - StatefulInteractiveElement, + StatefulInteractiveElement, WeakEntity, prelude::*, }; use markdown::Markdown; +use project::git_store::Repository; use settings::Settings; use std::hash::Hash; use theme::ThemeSettings; use time::{OffsetDateTime, UtcOffset}; use time_format::format_local_timestamp; use ui::{Avatar, Divider, IconButtonShape, prelude::*, tooltip_container}; -use url::Url; - -use crate::git::blame::GitRemote; -use crate::hover_popover::hover_markdown_style; +use workspace::Workspace; #[derive(Clone, Debug)] pub struct CommitDetails { @@ -26,14 +27,6 @@ pub struct CommitDetails { pub message: Option, } -#[derive(Clone, Debug, Default)] -pub struct ParsedCommitMessage { - pub message: SharedString, - pub permalink: Option, - pub pull_request: Option, - pub remote: Option, -} - struct CommitAvatar<'a> { commit: &'a CommitDetails, } @@ -54,10 +47,10 @@ impl<'a> CommitAvatar<'a> { .commit .message .as_ref() - .and_then(|details| details.remote.as_ref()) + .and_then(|details| details.remote.clone()) .filter(|remote| remote.host_supports_avatars())?; - let avatar_url = CommitAvatarAsset::new(remote.clone(), self.commit.sha.clone()); + let avatar_url = CommitAvatarAsset::new(remote, self.commit.sha.clone()); let element = match window.use_asset::(&avatar_url, cx) { // Loading or no avatar found @@ -115,12 +108,16 @@ pub struct CommitTooltip { commit: CommitDetails, scroll_handle: ScrollHandle, markdown: Entity, + repository: Entity, + workspace: WeakEntity, } impl CommitTooltip { pub fn blame_entry( blame: &BlameEntry, details: Option, + repository: Entity, + workspace: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -141,12 +138,20 @@ impl CommitTooltip { author_email: blame.author_mail.clone().unwrap_or("".to_string()).into(), message: details, }, + repository, + workspace, window, cx, ) } - pub fn new(commit: CommitDetails, window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + commit: CommitDetails, + repository: Entity, + workspace: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { let mut style = hover_markdown_style(window, cx); if let Some(code_block) = &style.code_block.text { style.base_text_style.refine(code_block); @@ -166,6 +171,8 @@ impl CommitTooltip { }); Self { commit, + repository, + workspace, scroll_handle: ScrollHandle::new(), markdown, } @@ -208,6 +215,27 @@ impl Render for CommitTooltip { let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); let message_max_height = window.line_height() * 12 + (ui_font_size / 0.4); + let repo = self.repository.clone(); + let workspace = self.workspace.clone(); + let commit_summary = CommitSummary { + sha: self.commit.sha.clone(), + subject: self + .commit + .message + .as_ref() + .map_or(Default::default(), |message| { + message + .message + .split('\n') + .next() + .unwrap() + .trim_end() + .to_string() + .into() + }), + commit_timestamp: self.commit.commit_time.unix_timestamp(), + has_parent: false, + }; tooltip_container(window, cx, move |this, _, cx| { this.occlude() @@ -283,24 +311,16 @@ impl Render for CommitTooltip { .icon(IconName::FileGit) .icon_color(Color::Muted) .icon_position(IconPosition::Start) - .disabled( - self.commit - .message - .as_ref() - .map_or(true, |details| { - details.permalink.is_none() - }), - ) - .when_some( - self.commit - .message - .as_ref() - .and_then(|details| details.permalink.clone()), - |this, url| { - this.on_click(move |_, _, cx| { - cx.stop_propagation(); - cx.open_url(url.as_str()) - }) + .on_click( + move |_, window, cx| { + CommitView::open( + commit_summary.clone(), + repo.downgrade(), + workspace.clone(), + window, + cx, + ); + cx.stop_propagation(); }, ), ) diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs new file mode 100644 index 0000000000..79c1964bb0 --- /dev/null +++ b/crates/git_ui/src/commit_view.rs @@ -0,0 +1,527 @@ +use anyhow::{Result, anyhow}; +use buffer_diff::{BufferDiff, BufferDiffSnapshot}; +use editor::{Editor, EditorEvent, MultiBuffer}; +use git::repository::{CommitDetails, CommitDiff, CommitSummary, RepoPath}; +use gpui::{ + AnyElement, AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, + FocusHandle, Focusable, IntoElement, Render, WeakEntity, Window, +}; +use language::{ + Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _, + Point, Rope, TextBuffer, +}; +use multi_buffer::PathKey; +use project::{Project, WorktreeId, git_store::Repository}; +use std::{ + any::{Any, TypeId}, + ffi::OsStr, + fmt::Write as _, + path::{Path, PathBuf}, + sync::Arc, +}; +use ui::{Color, Icon, IconName, Label, LabelCommon as _}; +use util::{ResultExt, truncate_and_trailoff}; +use workspace::{ + Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace, + item::{BreadcrumbText, ItemEvent, TabContentParams}, + searchable::SearchableItemHandle, +}; + +pub struct CommitView { + commit: CommitDetails, + editor: Entity, + multibuffer: Entity, +} + +struct GitBlob { + path: RepoPath, + worktree_id: WorktreeId, + is_deleted: bool, +} + +struct CommitMetadataFile { + title: Arc, + worktree_id: WorktreeId, +} + +const COMMIT_METADATA_NAMESPACE: &'static str = "0"; +const FILE_NAMESPACE: &'static str = "1"; + +impl CommitView { + pub fn open( + commit: CommitSummary, + repo: WeakEntity, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + ) { + let commit_diff = repo + .update(cx, |repo, _| repo.load_commit_diff(commit.sha.to_string())) + .ok(); + let commit_details = repo + .update(cx, |repo, _| repo.show(commit.sha.to_string())) + .ok(); + + window + .spawn(cx, async move |cx| { + let (commit_diff, commit_details) = futures::join!(commit_diff?, commit_details?); + let commit_diff = commit_diff.log_err()?.log_err()?; + let commit_details = commit_details.log_err()?.log_err()?; + let repo = repo.upgrade()?; + + workspace + .update_in(cx, |workspace, window, cx| { + let project = workspace.project(); + let commit_view = cx.new(|cx| { + CommitView::new( + commit_details, + commit_diff, + repo, + project.clone(), + window, + cx, + ) + }); + + let pane = workspace.active_pane(); + pane.update(cx, |pane, cx| { + let ix = pane.items().position(|item| { + let commit_view = item.downcast::(); + commit_view + .map_or(false, |view| view.read(cx).commit.sha == commit.sha) + }); + if let Some(ix) = ix { + pane.activate_item(ix, true, true, window, cx); + return; + } else { + pane.add_item(Box::new(commit_view), true, true, None, window, cx); + } + }) + }) + .log_err() + }) + .detach(); + } + + fn new( + commit: CommitDetails, + commit_diff: CommitDiff, + repository: Entity, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let language_registry = project.read(cx).languages().clone(); + let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadOnly)); + let editor = cx.new(|cx| { + let mut editor = + Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx); + editor.disable_inline_diagnostics(); + editor.set_expand_all_diff_hunks(cx); + editor + }); + + let first_worktree_id = project + .read(cx) + .worktrees(cx) + .next() + .map(|worktree| worktree.read(cx).id()); + + let mut metadata_buffer_id = None; + if let Some(worktree_id) = first_worktree_id { + let file = Arc::new(CommitMetadataFile { + title: PathBuf::from(format!("commit {}", commit.sha)).into(), + worktree_id, + }); + let buffer = cx.new(|cx| { + let buffer = TextBuffer::new_normalized( + 0, + cx.entity_id().as_non_zero_u64().into(), + LineEnding::default(), + format_commit(&commit).into(), + ); + metadata_buffer_id = Some(buffer.remote_id()); + Buffer::build(buffer, Some(file.clone()), Capability::ReadWrite) + }); + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.set_excerpts_for_path( + PathKey::namespaced(COMMIT_METADATA_NAMESPACE, file.title.clone()), + buffer.clone(), + vec![Point::zero()..buffer.read(cx).max_point()], + 0, + cx, + ); + }); + editor.update(cx, |editor, cx| { + editor.disable_header_for_buffer(metadata_buffer_id.unwrap(), cx); + editor.change_selections(None, window, cx, |selections| { + selections.select_ranges(vec![0..0]); + }); + }); + } + + cx.spawn(async move |this, mut cx| { + for file in commit_diff.files { + let is_deleted = file.new_text.is_none(); + let new_text = file.new_text.unwrap_or_default(); + let old_text = file.old_text; + let worktree_id = repository + .update(cx, |repository, cx| { + repository + .repo_path_to_project_path(&file.path, cx) + .map(|path| path.worktree_id) + .or(first_worktree_id) + })? + .ok_or_else(|| anyhow!("project has no worktrees"))?; + let file = Arc::new(GitBlob { + path: file.path.clone(), + is_deleted, + worktree_id, + }) as Arc; + + let buffer = build_buffer(new_text, file, &language_registry, &mut cx).await?; + let buffer_diff = + build_buffer_diff(old_text, &buffer, &language_registry, &mut cx).await?; + + this.update(cx, |this, cx| { + this.multibuffer.update(cx, |multibuffer, cx| { + let snapshot = buffer.read(cx).snapshot(); + let diff = buffer_diff.read(cx); + let diff_hunk_ranges = diff + .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx) + .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)) + .collect::>(); + let path = snapshot.file().unwrap().path().clone(); + let _is_newly_added = multibuffer.set_excerpts_for_path( + PathKey::namespaced(FILE_NAMESPACE, path), + buffer, + diff_hunk_ranges, + editor::DEFAULT_MULTIBUFFER_CONTEXT, + cx, + ); + multibuffer.add_diff(buffer_diff, cx); + }); + })?; + } + anyhow::Ok(()) + }) + .detach(); + + Self { + commit, + editor, + multibuffer, + } + } +} + +impl language::File for GitBlob { + fn as_local(&self) -> Option<&dyn language::LocalFile> { + None + } + + fn disk_state(&self) -> DiskState { + if self.is_deleted { + DiskState::Deleted + } else { + DiskState::New + } + } + + fn path(&self) -> &Arc { + &self.path.0 + } + + fn full_path(&self, _: &App) -> PathBuf { + self.path.to_path_buf() + } + + fn file_name<'a>(&'a self, _: &'a App) -> &'a OsStr { + self.path.file_name().unwrap() + } + + fn worktree_id(&self, _: &App) -> WorktreeId { + self.worktree_id + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn to_proto(&self, _cx: &App) -> language::proto::File { + unimplemented!() + } + + fn is_private(&self) -> bool { + false + } +} + +impl language::File for CommitMetadataFile { + fn as_local(&self) -> Option<&dyn language::LocalFile> { + None + } + + fn disk_state(&self) -> DiskState { + DiskState::New + } + + fn path(&self) -> &Arc { + &self.title + } + + fn full_path(&self, _: &App) -> PathBuf { + self.title.as_ref().into() + } + + fn file_name<'a>(&'a self, _: &'a App) -> &'a OsStr { + self.title.file_name().unwrap() + } + + fn worktree_id(&self, _: &App) -> WorktreeId { + self.worktree_id + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn to_proto(&self, _: &App) -> language::proto::File { + unimplemented!() + } + + fn is_private(&self) -> bool { + false + } +} + +async fn build_buffer( + mut text: String, + blob: Arc, + language_registry: &Arc, + cx: &mut AsyncApp, +) -> Result> { + let line_ending = LineEnding::detect(&text); + LineEnding::normalize(&mut text); + let text = Rope::from(text); + let language = cx.update(|cx| language_registry.language_for_file(&blob, Some(&text), cx))?; + let language = if let Some(language) = language { + language_registry + .load_language(&language) + .await + .ok() + .and_then(|e| e.log_err()) + } else { + None + }; + let buffer = cx.new(|cx| { + let buffer = TextBuffer::new_normalized( + 0, + cx.entity_id().as_non_zero_u64().into(), + line_ending, + text, + ); + let mut buffer = Buffer::build(buffer, Some(blob), Capability::ReadWrite); + buffer.set_language(language, cx); + buffer + })?; + Ok(buffer) +} + +async fn build_buffer_diff( + mut old_text: Option, + buffer: &Entity, + language_registry: &Arc, + cx: &mut AsyncApp, +) -> Result> { + if let Some(old_text) = &mut old_text { + LineEnding::normalize(old_text); + } + + let buffer = cx.update(|cx| buffer.read(cx).snapshot())?; + + let base_buffer = cx + .update(|cx| { + Buffer::build_snapshot( + old_text.as_deref().unwrap_or("").into(), + buffer.language().cloned(), + Some(language_registry.clone()), + cx, + ) + })? + .await; + + let diff_snapshot = cx + .update(|cx| { + BufferDiffSnapshot::new_with_base_buffer( + buffer.text.clone(), + old_text.map(Arc::new), + base_buffer, + cx, + ) + })? + .await; + + cx.new(|cx| { + let mut diff = BufferDiff::new(&buffer.text, cx); + diff.set_snapshot(diff_snapshot, &buffer.text, None, cx); + diff + }) +} + +fn format_commit(commit: &CommitDetails) -> String { + let mut result = String::new(); + writeln!(&mut result, "commit {}", commit.sha).unwrap(); + writeln!( + &mut result, + "Author: {} <{}>", + commit.committer_name, commit.committer_email + ) + .unwrap(); + writeln!( + &mut result, + "Date: {}", + time_format::format_local_timestamp( + time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp).unwrap(), + time::OffsetDateTime::now_utc(), + time_format::TimestampFormat::MediumAbsolute, + ), + ) + .unwrap(); + result.push('\n'); + for line in commit.message.split('\n') { + if line.is_empty() { + result.push('\n'); + } else { + writeln!(&mut result, " {}", line).unwrap(); + } + } + if result.ends_with("\n\n") { + result.pop(); + } + result +} + +impl EventEmitter for CommitView {} + +impl Focusable for CommitView { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.focus_handle(cx) + } +} + +impl Item for CommitView { + type Event = EditorEvent; + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::GitBranch).color(Color::Muted)) + } + + fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement { + let short_sha = self.commit.sha.get(0..7).unwrap_or(&*self.commit.sha); + let subject = truncate_and_trailoff(self.commit.message.split('\n').next().unwrap(), 20); + Label::new(format!("{short_sha} - {subject}",)) + .color(if params.selected { + Color::Default + } else { + Color::Muted + }) + .into_any_element() + } + + fn tab_tooltip_text(&self, _: &App) -> Option { + let short_sha = self.commit.sha.get(0..16).unwrap_or(&*self.commit.sha); + let subject = self.commit.message.split('\n').next().unwrap(); + Some(format!("{short_sha} - {subject}").into()) + } + + fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("Commit View Opened") + } + + fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.editor + .update(cx, |editor, cx| editor.deactivated(window, cx)); + } + + fn is_singleton(&self, _: &App) -> bool { + false + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + _: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.to_any()) + } else if type_id == TypeId::of::() { + Some(self.editor.to_any()) + } else { + None + } + } + + fn as_searchable(&self, _: &Entity) -> Option> { + Some(Box::new(self.editor.clone())) + } + + fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + self.editor.for_each_project_item(cx, f) + } + + fn set_nav_history( + &mut self, + nav_history: ItemNavHistory, + _: &mut Window, + cx: &mut Context, + ) { + self.editor.update(cx, |editor, _| { + editor.set_nav_history(Some(nav_history)); + }); + } + + fn navigate( + &mut self, + data: Box, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.editor + .update(cx, |editor, cx| editor.navigate(data, window, cx)) + } + + fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation { + ToolbarItemLocation::PrimaryLeft + } + + fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option> { + self.editor.breadcrumbs(theme, cx) + } + + 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) + }); + } +} + +impl Render for CommitView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + self.editor.clone() + } +} diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index f087897bcb..1449596151 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1,11 +1,11 @@ use crate::askpass_modal::AskPassModal; use crate::commit_modal::CommitModal; +use crate::commit_tooltip::CommitTooltip; +use crate::commit_view::CommitView; use crate::git_panel_settings::StatusStyle; -use crate::project_diff::Diff; +use crate::project_diff::{self, Diff, ProjectDiff}; use crate::remote_output::{self, RemoteAction, SuccessMessage}; - -use crate::{ProjectDiff, picker_prompt, project_diff}; -use crate::{branch_picker, render_remote_button}; +use crate::{branch_picker, picker_prompt, render_remote_button}; use crate::{ git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector, }; @@ -13,13 +13,13 @@ use anyhow::Result; use askpass::AskPassDelegate; use assistant_settings::AssistantSettings; use db::kvp::KEY_VALUE_STORE; -use editor::commit_tooltip::CommitTooltip; use editor::{ Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar, scroll::ScrollbarAutoHide, }; use futures::StreamExt as _; +use git::blame::ParsedCommitMessage; use git::repository::{ Branch, CommitDetails, CommitSummary, DiffType, PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus, @@ -3001,6 +3001,7 @@ impl GitPanel { let active_repository = self.active_repository.as_ref()?; let branch = active_repository.read(cx).current_branch()?; let commit = branch.most_recent_commit.as_ref()?.clone(); + let workspace = self.workspace.clone(); let this = cx.entity(); Some( @@ -3023,14 +3024,31 @@ impl GitPanel { .truncate(), ) .id("commit-msg-hover") - .hoverable_tooltip(move |window, cx| { - GitPanelMessageTooltip::new( - this.clone(), - commit.sha.clone(), - window, - cx, - ) - .into() + .on_click({ + let commit = commit.clone(); + let repo = active_repository.downgrade(); + move |_, window, cx| { + CommitView::open( + commit.clone(), + repo.clone(), + workspace.clone().clone(), + window, + cx, + ); + } + }) + .hoverable_tooltip({ + let repo = active_repository.clone(); + move |window, cx| { + GitPanelMessageTooltip::new( + this.clone(), + commit.sha.clone(), + repo.clone(), + window, + cx, + ) + .into() + } }), ) .child(div().flex_1()) @@ -3938,31 +3956,35 @@ impl GitPanelMessageTooltip { fn new( git_panel: Entity, sha: SharedString, + repository: Entity, window: &mut Window, cx: &mut App, ) -> Entity { cx.new(|cx| { cx.spawn_in(window, async move |this, cx| { - let details = git_panel - .update(cx, |git_panel, cx| { - git_panel.load_commit_details(sha.to_string(), cx) - })? - .await?; + let (details, workspace) = git_panel.update(cx, |git_panel, cx| { + ( + git_panel.load_commit_details(sha.to_string(), cx), + git_panel.workspace.clone(), + ) + })?; + let details = details.await?; - let commit_details = editor::commit_tooltip::CommitDetails { + let commit_details = crate::commit_tooltip::CommitDetails { sha: details.sha.clone(), author_name: details.committer_name.clone(), author_email: details.committer_email.clone(), commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?, - message: Some(editor::commit_tooltip::ParsedCommitMessage { + message: Some(ParsedCommitMessage { message: details.message.clone(), ..Default::default() }), }; this.update_in(cx, |this: &mut GitPanelMessageTooltip, window, cx| { - this.commit_tooltip = - Some(cx.new(move |cx| CommitTooltip::new(commit_details, window, cx))); + this.commit_tooltip = Some(cx.new(move |cx| { + CommitTooltip::new(commit_details, repository, workspace, window, cx) + })); cx.notify(); }) }) diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 63e397d335..7b5f343fbb 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -3,6 +3,7 @@ use std::any::Any; use ::settings::Settings; use command_palette_hooks::CommandPaletteFilter; use commit_modal::CommitModal; +mod blame_ui; use git::{ repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus}, status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode}, @@ -17,6 +18,8 @@ use workspace::Workspace; mod askpass_modal; pub mod branch_picker; mod commit_modal; +pub mod commit_tooltip; +mod commit_view; pub mod git_panel; mod git_panel_settings; pub mod onboarding; @@ -30,6 +33,8 @@ actions!(git, [ResetOnboarding]); pub fn init(cx: &mut App) { GitPanelSettings::register(cx); + editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx); + cx.observe_new(|workspace: &mut Workspace, _, cx| { ProjectDiff::register(workspace, cx); CommitModal::register(workspace); diff --git a/crates/language/src/proto.rs b/crates/language/src/proto.rs index c5179a0e3c..8069418072 100644 --- a/crates/language/src/proto.rs +++ b/crates/language/src/proto.rs @@ -9,7 +9,7 @@ use serde_json::Value; use std::{ops::Range, str::FromStr, sync::Arc}; use text::*; -pub use proto::{BufferState, Operation}; +pub use proto::{BufferState, File, Operation}; /// Deserializes a `[text::LineEnding]` from the RPC representation. pub fn deserialize_line_ending(message: proto::LineEnding) -> text::LineEnding { diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 63c18daf5b..9915daace3 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -21,8 +21,8 @@ use git::{ blame::Blame, parse_git_remote_url, repository::{ - Branch, CommitDetails, DiffType, GitRepository, GitRepositoryCheckpoint, PushOptions, - Remote, RemoteCommandOutput, RepoPath, ResetMode, + Branch, CommitDetails, CommitDiff, CommitFile, DiffType, GitRepository, + GitRepositoryCheckpoint, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, }, status::FileStatus, }; @@ -289,6 +289,7 @@ impl GitStore { client.add_entity_request_handler(Self::handle_commit); client.add_entity_request_handler(Self::handle_reset); client.add_entity_request_handler(Self::handle_show); + client.add_entity_request_handler(Self::handle_load_commit_diff); client.add_entity_request_handler(Self::handle_checkout_files); client.add_entity_request_handler(Self::handle_open_commit_message_buffer); client.add_entity_request_handler(Self::handle_set_index_text); @@ -1885,6 +1886,32 @@ impl GitStore { }) } + async fn handle_load_commit_diff( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id); + let repository_handle = Self::repository_for_request(&this, work_directory_id, &mut cx)?; + + let commit_diff = repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.load_commit_diff(envelope.payload.commit) + })? + .await??; + Ok(proto::LoadCommitDiffResponse { + files: commit_diff + .files + .into_iter() + .map(|file| proto::CommitFile { + path: file.path.to_string(), + old_text: file.old_text, + new_text: file.new_text, + }) + .collect(), + }) + } + async fn handle_reset( this: Entity, envelope: TypedEnvelope, @@ -2389,7 +2416,10 @@ impl BufferDiffState { unstaged_diff.as_ref().zip(new_unstaged_diff.clone()) { unstaged_diff.update(cx, |diff, cx| { - diff.set_snapshot(&buffer, new_unstaged_diff, language_changed, None, cx) + if language_changed { + diff.language_changed(cx); + } + diff.set_snapshot(new_unstaged_diff, &buffer, None, cx) })? } else { None @@ -2398,14 +2428,11 @@ impl BufferDiffState { if let Some((uncommitted_diff, new_uncommitted_diff)) = uncommitted_diff.as_ref().zip(new_uncommitted_diff.clone()) { - uncommitted_diff.update(cx, |uncommitted_diff, cx| { - uncommitted_diff.set_snapshot( - &buffer, - new_uncommitted_diff, - language_changed, - unstaged_changed_range, - cx, - ); + uncommitted_diff.update(cx, |diff, cx| { + if language_changed { + diff.language_changed(cx); + } + diff.set_snapshot(new_uncommitted_diff, &buffer, unstaged_changed_range, cx); })?; } @@ -2869,6 +2896,40 @@ impl Repository { }) } + pub fn load_commit_diff(&self, commit: String) -> oneshot::Receiver> { + self.send_job(|git_repo, cx| async move { + match git_repo { + RepositoryState::Local(git_repository) => { + git_repository.load_commit(commit, cx).await + } + RepositoryState::Remote { + client, + project_id, + work_directory_id, + } => { + let response = client + .request(proto::LoadCommitDiff { + project_id: project_id.0, + work_directory_id: work_directory_id.to_proto(), + commit, + }) + .await?; + Ok(CommitDiff { + files: response + .files + .into_iter() + .map(|file| CommitFile { + path: PathBuf::from(file.path).into(), + old_text: file.old_text, + new_text: file.new_text, + }) + .collect(), + }) + } + } + }) + } + fn buffer_store(&self, cx: &App) -> Option> { Some(self.git_store.upgrade()?.read(cx).buffer_store.clone()) } diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 07787c0e8d..d2c285124c 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -365,6 +365,9 @@ message Envelope { LanguageServerIdForName language_server_id_for_name = 332; LanguageServerIdForNameResponse language_server_id_for_name_response = 333; // current max + + LoadCommitDiff load_commit_diff = 334; + LoadCommitDiffResponse load_commit_diff_response = 335; // current max } reserved 87 to 88; @@ -3365,6 +3368,23 @@ message GitCommitDetails { string committer_name = 5; } +message LoadCommitDiff { + uint64 project_id = 1; + reserved 2; + uint64 work_directory_id = 3; + string commit = 4; +} + +message LoadCommitDiffResponse { + repeated CommitFile files = 1; +} + +message CommitFile { + string path = 1; + optional string old_text = 2; + optional string new_text = 3; +} + message GitReset { uint64 project_id = 1; reserved 2; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 3bebe4da2a..b8045c1749 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -340,6 +340,8 @@ messages!( (ListRemoteDirectoryResponse, Background), (ListToolchains, Foreground), (ListToolchainsResponse, Foreground), + (LoadCommitDiff, Foreground), + (LoadCommitDiffResponse, Foreground), (LspExtExpandMacro, Background), (LspExtExpandMacroResponse, Background), (LspExtOpenDocs, Background), @@ -534,6 +536,7 @@ request_messages!( (JoinRoom, JoinRoomResponse), (LeaveChannelBuffer, Ack), (LeaveRoom, Ack), + (LoadCommitDiff, LoadCommitDiffResponse), (MarkNotificationRead, Ack), (MoveChannel, Ack), (OnTypeFormatting, OnTypeFormattingResponse), @@ -668,6 +671,7 @@ entity_messages!( JoinProject, LeaveProject, LinkedEditingRange, + LoadCommitDiff, MultiLspQuery, RestartLanguageServers, OnTypeFormatting, diff --git a/crates/welcome/src/multibuffer_hint.rs b/crates/welcome/src/multibuffer_hint.rs index ed20200619..ea64cab9df 100644 --- a/crates/welcome/src/multibuffer_hint.rs +++ b/crates/welcome/src/multibuffer_hint.rs @@ -81,6 +81,7 @@ impl MultibufferHint { if active_pane_item.is_singleton(cx) || active_pane_item.breadcrumbs(cx.theme(), cx).is_none() + || !active_pane_item.can_save(cx) { return ToolbarItemLocation::Hidden; }