Allow viewing past commits in Zed (#27636)

Max Brunsfeld created

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.

Change summary

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   | 137 ++--
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                     |   0 
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 +++++++++
crates/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, 1,739 insertions(+), 600 deletions(-)

Detailed changes

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",

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,
                                 )

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<Arc<String>>,
+        language: Option<Arc<Language>>,
+        language_registry: Option<Arc<LanguageRegistry>>,
+        cx: &mut App,
+    ) -> impl Future<Output = Self> + 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<Arc<String>>,
+        base_text_snapshot: language::BufferSnapshot,
+        cx: &App,
+    ) -> impl Future<Output = Self> + 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<Range<Anchor>> {
+        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<Range<Anchor>> {
         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<BufferDiffEvent> 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<Arc<String>>,
-        language: Option<Arc<Language>>,
-        language_registry: Option<Arc<LanguageRegistry>>,
-        cx: &mut App,
-    ) -> impl Future<Output = BufferDiffInner> + 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<Arc<String>>,
-        base_text_snapshot: language::BufferSnapshot,
-        cx: &App,
-    ) -> impl Future<Output = BufferDiffInner> + 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<language::Buffer>,
+        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<Arc<LanguageRegistry>>,
         cx: &mut AsyncApp,
     ) -> anyhow::Result<BufferDiffSnapshot> {
-        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<Self>) {
+        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<Range<Anchor>>,
         cx: &mut Context<Self>,
     ) -> Option<Range<Anchor>> {
-        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<Range<Anchor>> {
-        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<language::Buffer>,
         buffer: text::BufferSnapshot,
         cx: &mut Context<Self>,
     ) -> 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<Arc<LanguageRegistry>>,
+        buffer: text::BufferSnapshot,
+        cx: &mut Context<Self>,
+    ) -> 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<language::Buffer>,
-        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<Self>) {
         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<BufferDiff> {
-            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);

crates/collab/src/rpc.rs 🔗

@@ -413,6 +413,7 @@ impl Server {
             .add_request_handler(forward_mutating_project_request::<proto::GitInit>)
             .add_request_handler(forward_read_only_project_request::<proto::GetRemotes>)
             .add_request_handler(forward_read_only_project_request::<proto::GitShow>)
+            .add_request_handler(forward_read_only_project_request::<proto::LoadCommitDiff>)
             .add_request_handler(forward_read_only_project_request::<proto::GitReset>)
             .add_request_handler(forward_read_only_project_request::<proto::GitCheckoutFiles>)
             .add_request_handler(forward_mutating_project_request::<proto::SetIndexText>)

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 }

crates/editor/src/actions.rs 🔗

@@ -419,6 +419,7 @@ actions!(
         EditLogBreakpoint,
         ToggleAutoSignatureHelp,
         ToggleGitBlameInline,
+        OpenGitBlameCommit,
         ToggleIndentGuides,
         ToggleInlayHints,
         ToggleInlineDiagnostics,

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<Self>) {
+        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<Item = language::BufferId>,

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<BufferId>,
+    buffers_with_disabled_headers: HashSet<BufferId>,
 }
 
 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<BufferId>,
         range: R,
         wrap_snapshot: &'a WrapSnapshot,
     ) -> impl Iterator<Item = (BlockPlacement<WrapRow>, 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();
-
-            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
+            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;
+                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;
+                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;
+                        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 += self.buffer_header_height;
+                } else {
+                    height += self.excerpt_header_height;
                 }
-            }
 
-            if new_buffer_id.is_some() {
-                height += buffer_header_height;
-            } else {
-                height += excerpt_header_height;
+                return Some((
+                    BlockPlacement::Above(WrapRow(wrap_row)),
+                    Block::ExcerptBoundary {
+                        excerpt: excerpt_boundary.next,
+                        height,
+                        starts_new_buffer: new_buffer_id.is_some(),
+                    },
+                ));
             }
-
-            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<Item = BufferId>,
@@ -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,
             ));

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::<Editor>(cx);
     workspace::FollowableViewRegistry::register::<Editor>(cx);
     workspace::register_serializable_item::<Editor>(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<Task<()>>,
-    git_blame_inline_tooltip: Option<WeakEntity<crate::commit_tooltip::CommitTooltip>>,
+    pub git_blame_inline_tooltip: Option<AnyWeakEntity>,
     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<Pixels>,
+        context_menu: Entity<ContextMenu>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        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>) {
+        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<T: ToOffset + Clone>(
         &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>,
+    ) {
+        self.open_git_blame_commit_internal(window, cx);
+    }
+
+    fn open_git_blame_commit_internal(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<()> {
+        let blame = self.blame.as_ref()?;
+        let snapshot = self.snapshot(window, cx);
+        let cursor = self.selections.newest::<Point>(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::<GlobalBlameRenderer>().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::<GlobalBlameRenderer>().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;

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::<GlobalBlameRenderer>().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<Editor>,
-    blame: &gpui::Entity<GitBlame>,
+    workspace: WeakEntity<Workspace>,
+    blame: &Entity<GitBlame>,
     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<AnyElement> {
+    let renderer = cx.global::<GlobalBlameRenderer>().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<GitBlame>,
+    blame: &Entity<GitBlame>,
     blame_entry: BlameEntry,
     style: &EditorStyle,
     last_used_color: &mut Option<(PlayerColor, Oid)>,
     editor: Entity<Editor>,
+    workspace: Entity<Workspace>,
+    renderer: Arc<dyn BlameRenderer>,
     cx: &mut App,
-) -> AnyElement {
+) -> Option<AnyElement> {
     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("<no name>");
-    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<Editor>,
-    position: gpui::Point<Pixels>,
-    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,

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<dyn GitHostingProvider + Send + Sync + 'static>,
-    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<dyn HttpClient>,
-    ) -> Option<Url> {
-        self.host
-            .commit_author_avatar_url(&self.owner, &self.repo, commit, client)
-            .await
-            .ok()
-            .flatten()
-    }
-}
 pub struct GitBlame {
     project: Entity<Project>,
     buffer: Entity<Buffer>,
     entries: SumTree<GitBlameEntry>,
-    commit_details: HashMap<Oid, crate::commit_tooltip::ParsedCommitMessage>,
+    commit_details: HashMap<Oid, ParsedCommitMessage>,
     buffer_snapshot: BufferSnapshot,
     buffer_edits: text::Subscription,
     task: Task<Result<()>>,
@@ -109,6 +75,91 @@ pub struct GitBlame {
     _regenerate_subscriptions: Vec<Subscription>,
 }
 
+pub trait BlameRenderer {
+    fn max_author_length(&self) -> usize;
+
+    fn render_blame_entry(
+        &self,
+        _: &TextStyle,
+        _: BlameEntry,
+        _: Option<ParsedCommitMessage>,
+        _: Entity<Repository>,
+        _: WeakEntity<Workspace>,
+        _: Entity<Editor>,
+        _: usize,
+        _: Hsla,
+        _: &mut App,
+    ) -> Option<AnyElement>;
+
+    fn render_inline_blame_entry(
+        &self,
+        _: &TextStyle,
+        _: BlameEntry,
+        _: Option<ParsedCommitMessage>,
+        _: Entity<Repository>,
+        _: WeakEntity<Workspace>,
+        _: Entity<Editor>,
+        _: &mut App,
+    ) -> Option<AnyElement>;
+
+    fn open_blame_commit(
+        &self,
+        _: BlameEntry,
+        _: Entity<Repository>,
+        _: WeakEntity<Workspace>,
+        _: &mut Window,
+        _: &mut App,
+    );
+}
+
+impl BlameRenderer for () {
+    fn max_author_length(&self) -> usize {
+        0
+    }
+
+    fn render_blame_entry(
+        &self,
+        _: &TextStyle,
+        _: BlameEntry,
+        _: Option<ParsedCommitMessage>,
+        _: Entity<Repository>,
+        _: WeakEntity<Workspace>,
+        _: Entity<Editor>,
+        _: usize,
+        _: Hsla,
+        _: &mut App,
+    ) -> Option<AnyElement> {
+        None
+    }
+
+    fn render_inline_blame_entry(
+        &self,
+        _: &TextStyle,
+        _: BlameEntry,
+        _: Option<ParsedCommitMessage>,
+        _: Entity<Repository>,
+        _: WeakEntity<Workspace>,
+        _: Entity<Editor>,
+        _: &mut App,
+    ) -> Option<AnyElement> {
+        None
+    }
+
+    fn open_blame_commit(
+        &self,
+        _: BlameEntry,
+        _: Entity<Repository>,
+        _: WeakEntity<Workspace>,
+        _: &mut Window,
+        _: &mut App,
+    ) {
+    }
+}
+
+pub(crate) struct GlobalBlameRenderer(pub Arc<dyn BlameRenderer>);
+
+impl gpui::Global for GlobalBlameRenderer {}
+
 impl GitBlame {
     pub fn new(
         buffer: Entity<Buffer>,
@@ -181,6 +232,15 @@ impl GitBlame {
         this
     }
 
+    pub fn repository(&self, cx: &App) -> Option<Entity<Repository>> {
+        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
     }

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::<Vec<_>>()
@@ -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,

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<Result<git::repository::CommitDiff>> {
+        unimplemented!()
+    }
+
     fn set_index_text(
         &self,
         path: RepoPath,

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<String>,
 }
 
+#[derive(Clone, Debug, Default)]
+pub struct ParsedCommitMessage {
+    pub message: SharedString,
+    pub permalink: Option<url::Url>,
+    pub pull_request: Option<crate::hosting_provider::PullRequest>,
+    pub remote: Option<GitRemote>,
+}
+
 impl Blame {
     pub async fn for_path(
         git_binary: &Path,

crates/git/src/hosting_provider.rs 🔗

@@ -15,6 +15,41 @@ pub struct PullRequest {
     pub url: Url,
 }
 
+#[derive(Clone)]
+pub struct GitRemote {
+    pub host: Arc<dyn GitHostingProvider + Send + Sync + 'static>,
+    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<dyn HttpClient>,
+    ) -> Option<Url> {
+        self.host
+            .commit_author_avatar_url(&self.owner, &self.repo, commit, client)
+            .await
+            .ok()
+            .flatten()
+    }
+}
+
 pub struct BuildCommitPermalinkParams<'a> {
     pub sha: &'a str,
 }

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<CommitFile>,
+}
+
+#[derive(Debug)]
+pub struct CommitFile {
+    pub path: RepoPath,
+    pub old_text: Option<String>,
+    pub new_text: Option<String>,
+}
+
 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<Result<CommitDetails>>;
 
+    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<Result<CommitDiff>>;
     fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<Result<crate::blame::Blame>>;
 
     /// 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<Result<CommitDiff>> {
+        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::<CommitFile>::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,

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

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<ParsedCommitMessage>,
+        repository: Entity<Repository>,
+        workspace: WeakEntity<Workspace>,
+        editor: Entity<Editor>,
+        ix: usize,
+        sha_color: Hsla,
+        cx: &mut App,
+    ) -> Option<AnyElement> {
+        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("<no name>");
+        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<ParsedCommitMessage>,
+        repository: Entity<Repository>,
+        workspace: WeakEntity<Workspace>,
+        editor: Entity<Editor>,
+        cx: &mut App,
+    ) -> Option<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),
+        };
+
+        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<Repository>,
+        workspace: WeakEntity<Workspace>,
+        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<Editor>,
+    position: gpui::Point<Pixels>,
+    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(),
+    }
+}

crates/editor/src/commit_tooltip.rs → 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<ParsedCommitMessage>,
 }
 
-#[derive(Clone, Debug, Default)]
-pub struct ParsedCommitMessage {
-    pub message: SharedString,
-    pub permalink: Option<Url>,
-    pub pull_request: Option<PullRequest>,
-    pub remote: Option<GitRemote>,
-}
-
 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::<CommitAvatarAsset>(&avatar_url, cx) {
             // Loading or no avatar found
@@ -115,12 +108,16 @@ pub struct CommitTooltip {
     commit: CommitDetails,
     scroll_handle: ScrollHandle,
     markdown: Entity<Markdown>,
+    repository: Entity<Repository>,
+    workspace: WeakEntity<Workspace>,
 }
 
 impl CommitTooltip {
     pub fn blame_entry(
         blame: &BlameEntry,
         details: Option<ParsedCommitMessage>,
+        repository: Entity<Repository>,
+        workspace: WeakEntity<Workspace>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> 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>) -> Self {
+    pub fn new(
+        commit: CommitDetails,
+        repository: Entity<Repository>,
+        workspace: WeakEntity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> 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();
                                                 },
                                             ),
                                         )

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<Editor>,
+    multibuffer: Entity<MultiBuffer>,
+}
+
+struct GitBlob {
+    path: RepoPath,
+    worktree_id: WorktreeId,
+    is_deleted: bool,
+}
+
+struct CommitMetadataFile {
+    title: Arc<Path>,
+    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<Repository>,
+        workspace: WeakEntity<Workspace>,
+        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::<CommitView>();
+                                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<Repository>,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> 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<dyn language::File>;
+
+                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::<Vec<_>>();
+                        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<Path> {
+        &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<Path> {
+        &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<dyn File>,
+    language_registry: &Arc<language::LanguageRegistry>,
+    cx: &mut AsyncApp,
+) -> Result<Entity<Buffer>> {
+    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<String>,
+    buffer: &Entity<Buffer>,
+    language_registry: &Arc<LanguageRegistry>,
+    cx: &mut AsyncApp,
+) -> Result<Entity<BufferDiff>> {
+    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<EditorEvent> 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<Icon> {
+        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<ui::SharedString> {
+        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>) {
+        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<Self>,
+        _: &'a App,
+    ) -> Option<AnyView> {
+        if type_id == TypeId::of::<Self>() {
+            Some(self_handle.to_any())
+        } else if type_id == TypeId::of::<Editor>() {
+            Some(self.editor.to_any())
+        } else {
+            None
+        }
+    }
+
+    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+        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>,
+    ) {
+        self.editor.update(cx, |editor, _| {
+            editor.set_nav_history(Some(nav_history));
+        });
+    }
+
+    fn navigate(
+        &mut self,
+        data: Box<dyn Any>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> 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<Vec<BreadcrumbText>> {
+        self.editor.breadcrumbs(theme, cx)
+    }
+
+    fn added_to_workspace(
+        &mut self,
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        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<Self>) -> impl IntoElement {
+        self.editor.clone()
+    }
+}

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<GitPanel>,
         sha: SharedString,
+        repository: Entity<Repository>,
         window: &mut Window,
         cx: &mut App,
     ) -> Entity<Self> {
         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();
                 })
             })

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);

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 {

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<Self>,
+        envelope: TypedEnvelope<proto::LoadCommitDiff>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::LoadCommitDiffResponse> {
+        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<Self>,
         envelope: TypedEnvelope<proto::GitReset>,
@@ -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<Result<CommitDiff>> {
+        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<Entity<BufferStore>> {
         Some(self.git_store.upgrade()?.read(cx).buffer_store.clone())
     }

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;

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,

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;
         }