Allow viewing past commits in Zed (#27636)

This PR adds functionality for loading the diff for an arbitrary git
commit, and displaying it in a tab. To retrieve the diff for the commit,
I'm using a single `git cat-file --batch` invocation to efficiently load
both the old and new versions of each file that was changed in the
commit.

Todo

* Features
* [x] Open the commit view when clicking the most recent commit message
in the commit panel
  * [x] Open the commit view when clicking a SHA in a git blame column
  * [x] Open the commit view when clicking a SHA in a commit tooltip
  * [x] Make it work over RPC
  * [x] Allow buffer search in commit view
* [x] Command palette action to open the commit for the current blame
line
* Styling
* [x] Add a header that shows the author, timestamp, and the full commit
message
  * [x] Remove stage/unstage buttons in commit view
  * [x] Truncate the commit message in the tab
* Bugs
  * [x] Dedup commit tabs within a pane
  * [x] Add a tooltip to the tab

Release Notes:

- Added the ability to show past commits in Zed. You can view the most
recent commit by clicking its message in the commit panel. And when
viewing a git blame, you can show any commit by clicking its sha.
This commit is contained in:
Max Brunsfeld 2025-03-31 16:26:47 -07:00 committed by GitHub
parent 33912011b7
commit 8546dc101d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1742 additions and 603 deletions

View file

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