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

4
Cargo.lock generated
View file

@ -4402,7 +4402,6 @@ dependencies = [
"anyhow", "anyhow",
"assets", "assets",
"buffer_diff", "buffer_diff",
"chrono",
"client", "client",
"clock", "clock",
"collections", "collections",
@ -4451,7 +4450,6 @@ dependencies = [
"text", "text",
"theme", "theme",
"time", "time",
"time_format",
"tree-sitter-html", "tree-sitter-html",
"tree-sitter-rust", "tree-sitter-rust",
"tree-sitter-typescript", "tree-sitter-typescript",
@ -5715,6 +5713,7 @@ dependencies = [
"askpass", "askpass",
"assistant_settings", "assistant_settings",
"buffer_diff", "buffer_diff",
"chrono",
"collections", "collections",
"command_palette_hooks", "command_palette_hooks",
"component", "component",
@ -5732,6 +5731,7 @@ dependencies = [
"linkify", "linkify",
"linkme", "linkme",
"log", "log",
"markdown",
"menu", "menu",
"multi_buffer", "multi_buffer",
"notifications", "notifications",

View file

@ -483,7 +483,7 @@ impl TrackedBuffer {
buffer_without_edits buffer_without_edits
.update(cx, |buffer, cx| buffer.undo_operations(edits_to_undo, cx)); .update(cx, |buffer, cx| buffer.undo_operations(edits_to_undo, cx));
let primary_diff_update = self.diff.update(cx, |diff, cx| { let primary_diff_update = self.diff.update(cx, |diff, cx| {
diff.set_base_text( diff.set_base_text_buffer(
buffer_without_edits, buffer_without_edits,
self.buffer.read(cx).text_snapshot(), self.buffer.read(cx).text_snapshot(),
cx, cx,
@ -500,7 +500,7 @@ impl TrackedBuffer {
buffer.undo_operations(unreviewed_edits_to_undo, cx) buffer.undo_operations(unreviewed_edits_to_undo, cx)
}); });
let secondary_diff_update = self.secondary_diff.update(cx, |diff, 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(), buffer_without_unreviewed_edits.clone(),
self.buffer.read(cx).text_snapshot(), self.buffer.read(cx).text_snapshot(),
cx, cx,
@ -559,13 +559,7 @@ impl TrackedBuffer {
if let Ok(primary_diff_snapshot) = primary_diff_snapshot { if let Ok(primary_diff_snapshot) = primary_diff_snapshot {
primary_diff primary_diff
.update(cx, |diff, cx| { .update(cx, |diff, cx| {
diff.set_snapshot( diff.set_snapshot(primary_diff_snapshot, &buffer_snapshot, None, cx)
&buffer_snapshot,
primary_diff_snapshot,
false,
None,
cx,
)
}) })
.ok(); .ok();
} }
@ -574,9 +568,8 @@ impl TrackedBuffer {
secondary_diff secondary_diff
.update(cx, |diff, cx| { .update(cx, |diff, cx| {
diff.set_snapshot( diff.set_snapshot(
&buffer_snapshot,
secondary_diff_snapshot, secondary_diff_snapshot,
false, &buffer_snapshot,
None, None,
cx, cx,
) )

View file

@ -142,6 +142,96 @@ impl std::fmt::Debug for BufferDiffInner {
} }
impl BufferDiffSnapshot { 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 { pub fn is_empty(&self) -> bool {
self.inner.hunks.is_empty() 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>> { fn compare(&self, old: &Self, new_snapshot: &text::BufferSnapshot) -> Option<Range<Anchor>> {
let mut new_cursor = self.hunks.cursor::<()>(new_snapshot); let mut new_cursor = self.hunks.cursor::<()>(new_snapshot);
let mut old_cursor = old.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 EventEmitter<BufferDiffEvent> for BufferDiff {}
impl BufferDiff { impl BufferDiff {
#[cfg(test)] pub fn new(buffer: &text::BufferSnapshot, cx: &mut App) -> Self {
fn build_sync( BufferDiff {
buffer: text::BufferSnapshot, buffer_id: buffer.remote_id(),
diff_base: String, inner: BufferDiffSnapshot::empty(buffer, cx).inner,
cx: &mut gpui::TestAppContext, secondary_diff: None,
) -> 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),
}
} }
} }
fn build_with_base_buffer( #[cfg(any(test, feature = "test-support"))]
buffer: text::BufferSnapshot, pub fn new_with_base_text(
base_text: Option<Arc<String>>, base_text: &str,
base_text_snapshot: language::BufferSnapshot, buffer: &Entity<language::Buffer>,
cx: &App, cx: &mut App,
) -> impl Future<Output = BufferDiffInner> + use<> { ) -> Self {
let base_text_exists = base_text.is_some(); let mut base_text = base_text.to_owned();
let base_text_pair = base_text.map(|text| (text, base_text_snapshot.as_rope().clone())); text::LineEnding::normalize(&mut base_text);
cx.background_spawn(async move { let snapshot = BufferDiffSnapshot::new_with_base_text(
BufferDiffInner { buffer.read(cx).text_snapshot(),
base_text: base_text_snapshot, Some(base_text.into()),
pending_hunks: SumTree::new(&buffer), None,
hunks: compute_hunks(base_text_pair, buffer), None,
base_text_exists, cx,
} );
}) let snapshot = cx.background_executor().block(snapshot);
} Self {
buffer_id: buffer.read(cx).remote_id(),
fn build_empty(buffer: &text::BufferSnapshot, cx: &mut App) -> BufferDiffInner { inner: snapshot.inner,
BufferDiffInner { secondary_diff: None,
base_text: language::Buffer::build_empty_snapshot(cx),
hunks: SumTree::new(buffer),
pending_hunks: SumTree::new(buffer),
base_text_exists: false,
} }
} }
@ -917,9 +979,9 @@ impl BufferDiff {
language_registry: Option<Arc<LanguageRegistry>>, language_registry: Option<Arc<LanguageRegistry>>,
cx: &mut AsyncApp, cx: &mut AsyncApp,
) -> anyhow::Result<BufferDiffSnapshot> { ) -> anyhow::Result<BufferDiffSnapshot> {
let inner = if base_text_changed || language_changed { Ok(if base_text_changed || language_changed {
cx.update(|cx| { cx.update(|cx| {
Self::build( BufferDiffSnapshot::new_with_base_text(
buffer.clone(), buffer.clone(),
base_text, base_text,
language.clone(), language.clone(),
@ -930,7 +992,7 @@ impl BufferDiff {
.await .await
} else { } else {
this.read_with(cx, |this, cx| { this.read_with(cx, |this, cx| {
Self::build_with_base_buffer( BufferDiffSnapshot::new_with_base_buffer(
buffer.clone(), buffer.clone(),
base_text, base_text,
this.base_text().clone(), this.base_text().clone(),
@ -938,25 +1000,21 @@ impl BufferDiff {
) )
})? })?
.await .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( pub fn set_snapshot(
&mut self, &mut self,
buffer: &text::BufferSnapshot,
new_snapshot: BufferDiffSnapshot, new_snapshot: BufferDiffSnapshot,
language_changed: bool, buffer: &text::BufferSnapshot,
secondary_changed_range: Option<Range<Anchor>>, secondary_changed_range: Option<Range<Anchor>>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Option<Range<Anchor>> { ) -> Option<Range<Anchor>> {
let changed_range = self.set_state(new_snapshot.inner, buffer); let changed_range = self.inner.set_state(new_snapshot.inner, buffer);
if language_changed {
cx.emit(BufferDiffEvent::LanguageChanged);
}
let changed_range = match (secondary_changed_range, changed_range) { let changed_range = match (secondary_changed_range, changed_range) {
(None, None) => None, (None, None) => None,
@ -980,31 +1038,6 @@ impl BufferDiff {
changed_range 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 { pub fn base_text(&self) -> &language::BufferSnapshot {
&self.inner.base_text &self.inner.base_text
} }
@ -1065,21 +1098,31 @@ impl BufferDiff {
self.hunks_intersecting_range(start..end, buffer, cx) 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_buffer(
pub fn set_base_text(
&mut self, &mut self,
base_buffer: Entity<language::Buffer>, base_buffer: Entity<language::Buffer>,
buffer: text::BufferSnapshot, buffer: text::BufferSnapshot,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> oneshot::Receiver<()> { ) -> oneshot::Receiver<()> {
let (tx, rx) = oneshot::channel();
let this = cx.weak_entity();
let base_buffer = base_buffer.read(cx); let base_buffer = base_buffer.read(cx);
let language_registry = base_buffer.language_registry(); let language_registry = base_buffer.language_registry();
let base_buffer = base_buffer.snapshot(); 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 base_text = Arc::new(base_buffer.text());
let snapshot = BufferDiff::build( let snapshot = BufferDiffSnapshot::new_with_base_text(
buffer.clone(), buffer.clone(),
Some(base_text), Some(base_text),
base_buffer.language().cloned(), base_buffer.language().cloned(),
@ -1094,8 +1137,8 @@ impl BufferDiff {
let Some(this) = this.upgrade() else { let Some(this) = this.upgrade() else {
return; return;
}; };
this.update(cx, |this, _| { this.update(cx, |this, cx| {
this.set_state(snapshot, &buffer); this.set_snapshot(snapshot, &buffer, None, cx);
}) })
.log_err(); .log_err();
drop(complete_on_drop) drop(complete_on_drop)
@ -1110,49 +1153,17 @@ impl BufferDiff {
.then(|| self.inner.base_text.text()) .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"))] #[cfg(any(test, feature = "test-support"))]
pub fn recalculate_diff_sync(&mut self, buffer: text::BufferSnapshot, cx: &mut Context<Self>) { 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 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(), buffer.clone(),
base_text, base_text,
self.inner.base_text.clone(), self.inner.base_text.clone(),
cx, cx,
); );
let snapshot = cx.background_executor().block(snapshot); let snapshot = cx.background_executor().block(snapshot);
let changed_range = self.set_state(snapshot, &buffer); self.set_snapshot(snapshot, &buffer, None, cx);
cx.emit(BufferDiffEvent::DiffChanged { changed_range });
} }
} }
@ -1325,18 +1336,18 @@ mod tests {
.unindent(); .unindent();
let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text); 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( assert_hunks(
diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, None), diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer),
&buffer, &buffer,
&diff_base, &diff_base,
&[(1..2, "two\n", "HELLO\n", DiffHunkStatus::modified_none())], &[(1..2, "two\n", "HELLO\n", DiffHunkStatus::modified_none())],
); );
buffer.edit([(0..0, "point five\n")]); 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( assert_hunks(
diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, None), diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer),
&buffer, &buffer,
&diff_base, &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, _>( assert_hunks::<&str, _>(
diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, None), diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer),
&buffer, &buffer,
&diff_base, &diff_base,
&[], &[],
@ -1399,9 +1410,10 @@ mod tests {
.unindent(); .unindent();
let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text); let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text);
let unstaged_diff = BufferDiff::build_sync(buffer.clone(), index_text.clone(), cx); let unstaged_diff = BufferDiffSnapshot::new_sync(buffer.clone(), index_text.clone(), cx);
let mut uncommitted_diff =
let uncommitted_diff = BufferDiff::build_sync(buffer.clone(), head_text.clone(), cx); BufferDiffSnapshot::new_sync(buffer.clone(), head_text.clone(), cx);
uncommitted_diff.secondary_diff = Some(Box::new(unstaged_diff));
let expected_hunks = vec![ let expected_hunks = vec![
(2..3, "two\n", "TWO\n", DiffHunkStatus::modified_none()), (2..3, "two\n", "TWO\n", DiffHunkStatus::modified_none()),
@ -1420,11 +1432,7 @@ mod tests {
]; ];
assert_hunks( assert_hunks(
uncommitted_diff.hunks_intersecting_range( uncommitted_diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer),
Anchor::MIN..Anchor::MAX,
&buffer,
Some(&unstaged_diff),
),
&buffer, &buffer,
&head_text, &head_text,
&expected_hunks, &expected_hunks,
@ -1473,11 +1481,17 @@ mod tests {
let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text); let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text);
let diff = cx let diff = cx
.update(|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; .await;
assert_eq!( assert_eq!(
diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, None) diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer)
.count(), .count(),
8 8
); );
@ -1486,7 +1500,6 @@ mod tests {
diff.hunks_intersecting_range( diff.hunks_intersecting_range(
buffer.anchor_before(Point::new(7, 0))..buffer.anchor_before(Point::new(12, 0)), buffer.anchor_before(Point::new(7, 0))..buffer.anchor_before(Point::new(12, 0)),
&buffer, &buffer,
None,
), ),
&buffer, &buffer,
&diff_base, &diff_base,
@ -1732,18 +1745,20 @@ mod tests {
let hunk_range = let hunk_range =
buffer.anchor_before(ranges[0].start)..buffer.anchor_before(ranges[0].end); 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 unstaged =
let uncommitted = BufferDiff::build_sync(buffer.clone(), example.head_text.clone(), cx); 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 unstaged_diff = cx.new(|cx| {
let mut diff = BufferDiff::new(&buffer, cx); let mut diff = BufferDiff::new(&buffer, cx);
diff.set_state(unstaged, &buffer); diff.set_snapshot(unstaged, &buffer, None, cx);
diff diff
}); });
let uncommitted_diff = cx.new(|cx| { let uncommitted_diff = cx.new(|cx| {
let mut diff = BufferDiff::new(&buffer, 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.set_secondary_diff(unstaged_diff);
diff diff
}); });
@ -1800,16 +1815,16 @@ mod tests {
.unindent(); .unindent();
let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text.clone()); let buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text.clone());
let unstaged = BufferDiff::build_sync(buffer.clone(), index_text, cx); let unstaged = BufferDiffSnapshot::new_sync(buffer.clone(), index_text, cx);
let uncommitted = BufferDiff::build_sync(buffer.clone(), head_text.clone(), cx); let uncommitted = BufferDiffSnapshot::new_sync(buffer.clone(), head_text.clone(), cx);
let unstaged_diff = cx.new(|cx| { let unstaged_diff = cx.new(|cx| {
let mut diff = BufferDiff::new(&buffer, cx); let mut diff = BufferDiff::new(&buffer, cx);
diff.set_state(unstaged, &buffer); diff.set_snapshot(unstaged, &buffer, None, cx);
diff diff
}); });
let uncommitted_diff = cx.new(|cx| { let uncommitted_diff = cx.new(|cx| {
let mut diff = BufferDiff::new(&buffer, 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.set_secondary_diff(unstaged_diff.clone());
diff diff
}); });
@ -1874,9 +1889,9 @@ mod tests {
let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), buffer_text_1); 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 empty_diff = cx.update(|cx| BufferDiffSnapshot::empty(&buffer, cx));
let diff_1 = BufferDiff::build_sync(buffer.clone(), base_text.clone(), cx); let diff_1 = BufferDiffSnapshot::new_sync(buffer.clone(), base_text.clone(), cx);
let range = diff_1.compare(&empty_diff, &buffer).unwrap(); 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)); assert_eq!(range.to_point(&buffer), Point::new(0, 0)..Point::new(8, 0));
// Edit does not affect the diff. // Edit does not affect the diff.
@ -1893,8 +1908,8 @@ mod tests {
" "
.unindent(), .unindent(),
); );
let diff_2 = BufferDiff::build_sync(buffer.clone(), base_text.clone(), cx); let diff_2 = BufferDiffSnapshot::new_sync(buffer.clone(), base_text.clone(), cx);
assert_eq!(None, diff_2.compare(&diff_1, &buffer)); assert_eq!(None, diff_2.inner.compare(&diff_1.inner, &buffer));
// Edit turns a deletion hunk into a modification. // Edit turns a deletion hunk into a modification.
buffer.edit_via_marked_text( buffer.edit_via_marked_text(
@ -1910,8 +1925,8 @@ mod tests {
" "
.unindent(), .unindent(),
); );
let diff_3 = BufferDiff::build_sync(buffer.clone(), base_text.clone(), cx); let diff_3 = BufferDiffSnapshot::new_sync(buffer.clone(), base_text.clone(), cx);
let range = diff_3.compare(&diff_2, &buffer).unwrap(); 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)); assert_eq!(range.to_point(&buffer), Point::new(1, 0)..Point::new(2, 0));
// Edit turns a modification hunk into a deletion. // Edit turns a modification hunk into a deletion.
@ -1927,8 +1942,8 @@ mod tests {
" "
.unindent(), .unindent(),
); );
let diff_4 = BufferDiff::build_sync(buffer.clone(), base_text.clone(), cx); let diff_4 = BufferDiffSnapshot::new_sync(buffer.clone(), base_text.clone(), cx);
let range = diff_4.compare(&diff_3, &buffer).unwrap(); 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)); assert_eq!(range.to_point(&buffer), Point::new(3, 4)..Point::new(4, 0));
// Edit introduces a new insertion hunk. // Edit introduces a new insertion hunk.
@ -1945,8 +1960,8 @@ mod tests {
" "
.unindent(), .unindent(),
); );
let diff_5 = BufferDiff::build_sync(buffer.snapshot(), base_text.clone(), cx); let diff_5 = BufferDiffSnapshot::new_sync(buffer.snapshot(), base_text.clone(), cx);
let range = diff_5.compare(&diff_4, &buffer).unwrap(); 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)); assert_eq!(range.to_point(&buffer), Point::new(3, 0)..Point::new(4, 0));
// Edit removes a hunk. // Edit removes a hunk.
@ -1963,8 +1978,8 @@ mod tests {
" "
.unindent(), .unindent(),
); );
let diff_6 = BufferDiff::build_sync(buffer.snapshot(), base_text, cx); let diff_6 = BufferDiffSnapshot::new_sync(buffer.snapshot(), base_text, cx);
let range = diff_6.compare(&diff_5, &buffer).unwrap(); 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)); assert_eq!(range.to_point(&buffer), Point::new(7, 0)..Point::new(8, 0));
} }
@ -2038,14 +2053,16 @@ mod tests {
head_text: String, head_text: String,
cx: &mut TestAppContext, cx: &mut TestAppContext,
) -> Entity<BufferDiff> { ) -> 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 { let secondary = BufferDiff {
buffer_id: working_copy.remote_id(), buffer_id: working_copy.remote_id(),
inner: BufferDiff::build_sync( inner: BufferDiffSnapshot::new_sync(
working_copy.text.clone(), working_copy.text.clone(),
index_text.to_string(), index_text.to_string(),
cx, cx,
), )
.inner,
secondary_diff: None, secondary_diff: None,
}; };
let secondary = cx.new(|_| secondary); let secondary = cx.new(|_| secondary);

View file

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

View file

@ -32,7 +32,6 @@ test-support = [
aho-corasick.workspace = true aho-corasick.workspace = true
anyhow.workspace = true anyhow.workspace = true
assets.workspace = true assets.workspace = true
chrono.workspace = true
client.workspace = true client.workspace = true
clock.workspace = true clock.workspace = true
collections.workspace = true collections.workspace = true
@ -47,7 +46,6 @@ fuzzy.workspace = true
fs.workspace = true fs.workspace = true
git.workspace = true git.workspace = true
gpui.workspace = true gpui.workspace = true
http_client.workspace = true
indoc.workspace = true indoc.workspace = true
inline_completion.workspace = true inline_completion.workspace = true
itertools.workspace = true itertools.workspace = true
@ -76,7 +74,6 @@ task.workspace = true
telemetry.workspace = true telemetry.workspace = true
text.workspace = true text.workspace = true
time.workspace = true time.workspace = true
time_format.workspace = true
theme.workspace = true theme.workspace = true
tree-sitter-html = { workspace = true, optional = true } tree-sitter-html = { workspace = true, optional = true }
tree-sitter-rust = { workspace = true, optional = true } tree-sitter-rust = { workspace = true, optional = true }

View file

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

View file

@ -321,6 +321,20 @@ impl DisplayMap {
block_map.remove_intersecting_replace_blocks(offset_ranges, inclusive); 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( pub fn fold_buffers(
&mut self, &mut self,
buffer_ids: impl IntoIterator<Item = language::BufferId>, buffer_ids: impl IntoIterator<Item = language::BufferId>,

View file

@ -40,6 +40,7 @@ pub struct BlockMap {
buffer_header_height: u32, buffer_header_height: u32,
excerpt_header_height: u32, excerpt_header_height: u32,
pub(super) folded_buffers: HashSet<BufferId>, pub(super) folded_buffers: HashSet<BufferId>,
buffers_with_disabled_headers: HashSet<BufferId>,
} }
pub struct BlockMapReader<'a> { pub struct BlockMapReader<'a> {
@ -422,6 +423,7 @@ impl BlockMap {
custom_blocks: Vec::new(), custom_blocks: Vec::new(),
custom_blocks_by_id: TreeMap::default(), custom_blocks_by_id: TreeMap::default(),
folded_buffers: HashSet::default(), folded_buffers: HashSet::default(),
buffers_with_disabled_headers: HashSet::default(),
transforms: RefCell::new(transforms), transforms: RefCell::new(transforms),
wrap_snapshot: RefCell::new(wrap_snapshot.clone()), wrap_snapshot: RefCell::new(wrap_snapshot.clone()),
buffer_header_height, buffer_header_height,
@ -642,11 +644,8 @@ impl BlockMap {
); );
if buffer.show_headers() { if buffer.show_headers() {
blocks_in_edit.extend(BlockMap::header_and_footer_blocks( blocks_in_edit.extend(self.header_and_footer_blocks(
self.buffer_header_height,
self.excerpt_header_height,
buffer, buffer,
&self.folded_buffers,
(start_bound, end_bound), (start_bound, end_bound),
wrap_snapshot, wrap_snapshot,
)); ));
@ -714,10 +713,8 @@ impl BlockMap {
} }
fn header_and_footer_blocks<'a, R, T>( fn header_and_footer_blocks<'a, R, T>(
buffer_header_height: u32, &'a self,
excerpt_header_height: u32,
buffer: &'a multi_buffer::MultiBufferSnapshot, buffer: &'a multi_buffer::MultiBufferSnapshot,
folded_buffers: &'a HashSet<BufferId>,
range: R, range: R,
wrap_snapshot: &'a WrapSnapshot, wrap_snapshot: &'a WrapSnapshot,
) -> impl Iterator<Item = (BlockPlacement<WrapRow>, Block)> + 'a ) -> impl Iterator<Item = (BlockPlacement<WrapRow>, Block)> + 'a
@ -728,73 +725,78 @@ impl BlockMap {
let mut boundaries = buffer.excerpt_boundaries_in_range(range).peekable(); let mut boundaries = buffer.excerpt_boundaries_in_range(range).peekable();
std::iter::from_fn(move || { std::iter::from_fn(move || {
let excerpt_boundary = boundaries.next()?; loop {
let wrap_row = wrap_snapshot let excerpt_boundary = boundaries.next()?;
.make_wrap_point(Point::new(excerpt_boundary.row.0, 0), Bias::Left) let wrap_row = wrap_snapshot
.row(); .make_wrap_point(Point::new(excerpt_boundary.row.0, 0), Bias::Left)
.row();
let new_buffer_id = match (&excerpt_boundary.prev, &excerpt_boundary.next) { let new_buffer_id = match (&excerpt_boundary.prev, &excerpt_boundary.next) {
(None, next) => Some(next.buffer_id), (None, next) => Some(next.buffer_id),
(Some(prev), next) => { (Some(prev), next) => {
if prev.buffer_id != next.buffer_id { if prev.buffer_id != next.buffer_id {
Some(next.buffer_id) Some(next.buffer_id)
} else {
None
}
}
};
let mut height = 0;
if let Some(new_buffer_id) = new_buffer_id {
let first_excerpt = excerpt_boundary.next.clone();
if folded_buffers.contains(&new_buffer_id) {
let mut last_excerpt_end_row = first_excerpt.end_row;
while let Some(next_boundary) = boundaries.peek() {
if next_boundary.next.buffer_id == new_buffer_id {
last_excerpt_end_row = next_boundary.next.end_row;
} else { } else {
break; None
}
}
};
let mut height = 0;
if let Some(new_buffer_id) = new_buffer_id {
let first_excerpt = excerpt_boundary.next.clone();
if self.buffers_with_disabled_headers.contains(&new_buffer_id) {
continue;
}
if self.folded_buffers.contains(&new_buffer_id) {
let mut last_excerpt_end_row = first_excerpt.end_row;
while let Some(next_boundary) = boundaries.peek() {
if next_boundary.next.buffer_id == new_buffer_id {
last_excerpt_end_row = next_boundary.next.end_row;
} else {
break;
}
boundaries.next();
} }
boundaries.next(); let wrap_end_row = wrap_snapshot
.make_wrap_point(
Point::new(
last_excerpt_end_row.0,
buffer.line_len(last_excerpt_end_row),
),
Bias::Right,
)
.row();
return Some((
BlockPlacement::Replace(WrapRow(wrap_row)..=WrapRow(wrap_end_row)),
Block::FoldedBuffer {
height: height + self.buffer_header_height,
first_excerpt,
},
));
} }
let wrap_end_row = wrap_snapshot
.make_wrap_point(
Point::new(
last_excerpt_end_row.0,
buffer.line_len(last_excerpt_end_row),
),
Bias::Right,
)
.row();
return Some((
BlockPlacement::Replace(WrapRow(wrap_row)..=WrapRow(wrap_end_row)),
Block::FoldedBuffer {
height: height + buffer_header_height,
first_excerpt,
},
));
} }
}
if new_buffer_id.is_some() { if new_buffer_id.is_some() {
height += buffer_header_height; height += self.buffer_header_height;
} else { } else {
height += excerpt_header_height; height += self.excerpt_header_height;
} }
Some(( return Some((
BlockPlacement::Above(WrapRow(wrap_row)), BlockPlacement::Above(WrapRow(wrap_row)),
Block::ExcerptBoundary { Block::ExcerptBoundary {
excerpt: excerpt_boundary.next, excerpt: excerpt_boundary.next,
height, height,
starts_new_buffer: new_buffer_id.is_some(), starts_new_buffer: new_buffer_id.is_some(),
}, },
)) ));
}
}) })
} }
@ -1168,6 +1170,10 @@ impl BlockMapWriter<'_> {
self.remove(blocks_to_remove); 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( pub fn fold_buffers(
&mut self, &mut self,
buffer_ids: impl IntoIterator<Item = BufferId>, 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 // Note that this needs to be synced with the related section in BlockMap::sync
expected_blocks.extend(BlockMap::header_and_footer_blocks( expected_blocks.extend(block_map.header_and_footer_blocks(
buffer_start_header_height,
excerpt_header_height,
&buffer_snapshot, &buffer_snapshot,
&block_map.folded_buffers,
0.., 0..,
&wraps_snapshot, &wraps_snapshot,
)); ));

View file

@ -16,7 +16,6 @@ pub mod actions;
mod blink_manager; mod blink_manager;
mod clangd_ext; mod clangd_ext;
mod code_context_menus; mod code_context_menus;
pub mod commit_tooltip;
pub mod display_map; pub mod display_map;
mod editor_settings; mod editor_settings;
mod editor_settings_controls; mod editor_settings_controls;
@ -82,19 +81,21 @@ use code_context_menus::{
AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu, AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu,
CompletionsMenu, ContextMenuOrigin, CompletionsMenu, ContextMenuOrigin,
}; };
use git::blame::GitBlame; use git::blame::{GitBlame, GlobalBlameRenderer};
use gpui::{ use gpui::{
Action, Animation, AnimationExt, AnyElement, App, AppContext, AsyncWindowContext, Action, Animation, AnimationExt, AnyElement, AnyWeakEntity, App, AppContext,
AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry, ClipboardItem, Context, AsyncWindowContext, AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry,
DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent, ClipboardItem, Context, DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter,
Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, KeyContext, Modifiers, FocusHandle, FocusOutEvent, Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla,
MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render, SharedString, Size, KeyContext, Modifiers, MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render,
Stateful, Styled, StyledText, Subscription, Task, TextStyle, TextStyleRefinement, SharedString, Size, Stateful, Styled, StyledText, Subscription, Task, TextStyle,
UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity, WeakFocusHandle, Window, TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity,
div, impl_actions, point, prelude::*, pulsating_between, px, relative, size, WeakFocusHandle, Window, div, impl_actions, point, prelude::*, pulsating_between, px, relative,
size,
}; };
use highlight_matching_bracket::refresh_matching_bracket_highlights; use highlight_matching_bracket::refresh_matching_bracket_highlights;
use hover_links::{HoverLink, HoveredLinkState, InlayHighlight, find_file}; use hover_links::{HoverLink, HoveredLinkState, InlayHighlight, find_file};
pub use hover_popover::hover_markdown_style;
use hover_popover::{HoverState, hide_hover}; use hover_popover::{HoverState, hide_hover};
use indent_guides::ActiveIndentGuidesState; use indent_guides::ActiveIndentGuidesState;
use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy}; use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy};
@ -124,6 +125,7 @@ use project::{
}, },
}; };
pub use git::blame::BlameRenderer;
pub use proposed_changes_editor::{ pub use proposed_changes_editor::{
ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar, ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar,
}; };
@ -187,8 +189,8 @@ use theme::{
observe_buffer_font_size_adjustment, observe_buffer_font_size_adjustment,
}; };
use ui::{ use ui::{
ButtonSize, ButtonStyle, Disclosure, IconButton, IconButtonShape, IconName, IconSize, Key, ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName,
Tooltip, h_flex, prelude::*, IconSize, Key, Tooltip, h_flex, prelude::*,
}; };
use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc}; use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc};
use workspace::{ use workspace::{
@ -302,6 +304,8 @@ pub fn init_settings(cx: &mut App) {
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
init_settings(cx); init_settings(cx);
cx.set_global(GlobalBlameRenderer(Arc::new(())));
workspace::register_project_item::<Editor>(cx); workspace::register_project_item::<Editor>(cx);
workspace::FollowableViewRegistry::register::<Editor>(cx); workspace::FollowableViewRegistry::register::<Editor>(cx);
workspace::register_serializable_item::<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; pub struct SearchWithinRange;
trait InvalidationRegion { trait InvalidationRegion {
@ -766,7 +774,7 @@ pub struct Editor {
show_git_blame_gutter: bool, show_git_blame_gutter: bool,
show_git_blame_inline: bool, show_git_blame_inline: bool,
show_git_blame_inline_delay_task: Option<Task<()>>, 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, git_blame_inline_enabled: bool,
render_diff_hunk_controls: RenderDiffHunkControlsFn, render_diff_hunk_controls: RenderDiffHunkControlsFn,
serialize_dirty_buffers: bool, serialize_dirty_buffers: bool,
@ -848,8 +856,6 @@ pub struct EditorSnapshot {
gutter_hovered: bool, gutter_hovered: bool,
} }
const GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED: usize = 20;
#[derive(Default, Debug, Clone, Copy)] #[derive(Default, Debug, Clone, Copy)]
pub struct GutterDimensions { pub struct GutterDimensions {
pub left_padding: Pixels, pub left_padding: Pixels,
@ -1643,6 +1649,21 @@ impl Editor {
this 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 { pub fn mouse_menu_is_focused(&self, window: &Window, cx: &App) -> bool {
self.mouse_context_menu self.mouse_context_menu
.as_ref() .as_ref()
@ -14922,6 +14943,13 @@ impl Editor {
self.display_map.read(cx).folded_buffers() 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. /// Removes any folds with the given ranges.
pub fn remove_folds_with_type<T: ToOffset + Clone>( pub fn remove_folds_with_type<T: ToOffset + Clone>(
&mut self, &mut self,
@ -15861,6 +15889,45 @@ impl Editor {
cx.notify(); 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 { pub fn git_blame_inline_enabled(&self) -> bool {
self.git_blame_inline_enabled self.git_blame_inline_enabled
} }
@ -17794,7 +17861,9 @@ fn get_uncommitted_diff_for_buffer(
let mut tasks = Vec::new(); let mut tasks = Vec::new();
project.update(cx, |project, cx| { project.update(cx, |project, cx| {
for buffer in buffers { 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| { cx.spawn(async move |cx| {
@ -18911,13 +18980,13 @@ impl EditorSnapshot {
let git_blame_entries_width = let git_blame_entries_width =
self.git_blame_gutter_max_author_length self.git_blame_gutter_max_author_length
.map(|max_author_length| { .map(|max_author_length| {
let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
const MAX_RELATIVE_TIMESTAMP: &str = "60 minutes ago"; const MAX_RELATIVE_TIMESTAMP: &str = "60 minutes ago";
/// The number of characters to dedicate to gaps and margins. /// The number of characters to dedicate to gaps and margins.
const SPACING_WIDTH: usize = 4; const SPACING_WIDTH: usize = 4;
let max_char_count = max_author_length let max_char_count = max_author_length.min(renderer.max_author_length())
.min(GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED)
+ ::git::SHORT_SHA_LENGTH + ::git::SHORT_SHA_LENGTH
+ MAX_RELATIVE_TIMESTAMP.len() + MAX_RELATIVE_TIMESTAMP.len()
+ SPACING_WIDTH; + SPACING_WIDTH;

View file

@ -3,13 +3,12 @@ use crate::{
ContextMenuPlacement, CursorShape, CustomBlockId, DisplayDiffHunk, DisplayPoint, DisplayRow, ContextMenuPlacement, CursorShape, CustomBlockId, DisplayDiffHunk, DisplayPoint, DisplayRow,
DocumentHighlightRead, DocumentHighlightWrite, EditDisplayMode, Editor, EditorMode, DocumentHighlightRead, DocumentHighlightWrite, EditDisplayMode, Editor, EditorMode,
EditorSettings, EditorSnapshot, EditorStyle, FILE_HEADER_HEIGHT, FocusedBlock, EditorSettings, EditorSnapshot, EditorStyle, FILE_HEADER_HEIGHT, FocusedBlock,
GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor, InlayHintRefreshReason,
HoveredCursor, InlayHintRefreshReason, InlineCompletion, JumpData, LineDown, LineHighlight, InlineCompletion, JumpData, LineDown, LineHighlight, LineUp, MAX_LINE_LEN,
LineUp, MAX_LINE_LEN, MIN_LINE_NUMBER_DIGITS, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, MIN_LINE_NUMBER_DIGITS, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, PageDown, PageUp,
PageDown, PageUp, Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight, Selection, Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight, Selection, SoftWrap,
SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, StickyHeaderExcerpt, ToPoint, ToggleFold,
code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP},
commit_tooltip::{CommitTooltip, ParsedCommitMessage, blame_entry_relative_timestamp},
display_map::{ display_map::{
Block, BlockContext, BlockStyle, DisplaySnapshot, HighlightedChunk, ToDisplayPoint, Block, BlockContext, BlockStyle, DisplaySnapshot, HighlightedChunk, ToDisplayPoint,
}, },
@ -17,13 +16,13 @@ use crate::{
CurrentLineHighlight, DoubleClickInMultibuffer, MultiCursorModifier, ScrollBeyondLastLine, CurrentLineHighlight, DoubleClickInMultibuffer, MultiCursorModifier, ScrollBeyondLastLine,
ScrollbarAxes, ScrollbarDiagnostics, ShowScrollbar, ScrollbarAxes, ScrollbarDiagnostics, ShowScrollbar,
}, },
git::blame::GitBlame, git::blame::{BlameRenderer, GitBlame, GlobalBlameRenderer},
hover_popover::{ hover_popover::{
self, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT, hover_at, self, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT, hover_at,
}, },
inlay_hint_settings, inlay_hint_settings,
items::BufferSearchHighlights, items::BufferSearchHighlights,
mouse_context_menu::{self, MenuPosition, MouseContextMenu}, mouse_context_menu::{self, MenuPosition},
scroll::scroll_amount::ScrollAmount, scroll::scroll_amount::ScrollAmount,
}; };
use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
@ -34,12 +33,12 @@ use file_icons::FileIcons;
use git::{Oid, blame::BlameEntry, status::FileStatus}; use git::{Oid, blame::BlameEntry, status::FileStatus};
use gpui::{ use gpui::{
Action, Along, AnyElement, App, AvailableSpace, Axis as ScrollbarAxis, BorderStyle, Bounds, Action, Along, AnyElement, App, AvailableSpace, Axis as ScrollbarAxis, BorderStyle, Bounds,
ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, ClickEvent, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges, Element,
Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, Hsla,
Hsla, InteractiveElement, IntoElement, Keystroke, Length, ModifiersChangedEvent, MouseButton, InteractiveElement, IntoElement, Keystroke, Length, ModifiersChangedEvent, MouseButton,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta,
ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled, 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, linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background,
transparent_black, transparent_black,
}; };
@ -76,10 +75,10 @@ use std::{
use sum_tree::Bias; use sum_tree::Bias;
use text::BufferId; use text::BufferId;
use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor}; 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 unicode_segmentation::UnicodeSegmentation;
use util::{RangeExt, ResultExt, debug_panic}; 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.; 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::copy_file_location);
register_action(editor, window, Editor::toggle_git_blame); register_action(editor, window, Editor::toggle_git_blame);
register_action(editor, window, Editor::toggle_git_blame_inline); 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_selected_diff_hunks);
register_action(editor, window, Editor::toggle_staged_selected_diff_hunks); register_action(editor, window, Editor::toggle_staged_selected_diff_hunks);
register_action(editor, window, Editor::stage_and_next); register_action(editor, window, Editor::stage_and_next);
@ -1759,14 +1759,21 @@ impl EditorElement {
padding * em_width padding * em_width
}; };
let workspace = editor.workspace()?.downgrade();
let blame_entry = blame let blame_entry = blame
.update(cx, |blame, cx| { .update(cx, |blame, cx| {
blame.blame_for_rows(&[*row_info], cx).next() blame.blame_for_rows(&[*row_info], cx).next()
}) })
.flatten()?; .flatten()?;
let mut element = let mut element = render_inline_blame_entry(
render_inline_blame_entry(self.editor.clone(), &blame, blame_entry, &self.style, cx); self.editor.clone(),
workspace,
&blame,
blame_entry,
&self.style,
cx,
)?;
let start_y = content_origin.y let start_y = content_origin.y
+ line_height * (display_row.as_f32() - scroll_pixel_position.y / line_height); + 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 blame = self.editor.read(cx).blame.clone()?;
let workspace = self.editor.read(cx).workspace()?;
let blamed_rows: Vec<_> = blame.update(cx, |blame, cx| { let blamed_rows: Vec<_> = blame.update(cx, |blame, cx| {
blame.blame_for_rows(buffer_rows, cx).collect() blame.blame_for_rows(buffer_rows, cx).collect()
}); });
@ -1829,36 +1837,35 @@ impl EditorElement {
let start_x = em_width; let start_x = em_width;
let mut last_used_color: Option<(PlayerColor, Oid)> = None; let mut last_used_color: Option<(PlayerColor, Oid)> = None;
let blame_renderer = cx.global::<GlobalBlameRenderer>().0.clone();
let shaped_lines = blamed_rows let shaped_lines = blamed_rows
.into_iter() .into_iter()
.enumerate() .enumerate()
.flat_map(|(ix, blame_entry)| { .flat_map(|(ix, blame_entry)| {
if let Some(blame_entry) = blame_entry { let mut element = render_blame_entry(
let mut element = render_blame_entry( ix,
ix, &blame,
&blame, blame_entry?,
blame_entry, &self.style,
&self.style, &mut last_used_color,
&mut last_used_color, self.editor.clone(),
self.editor.clone(), workspace.clone(),
cx, blame_renderer.clone(),
); cx,
)?;
let start_y = ix as f32 * line_height - (scroll_top % line_height); let start_y = ix as f32 * line_height - (scroll_top % line_height);
let absolute_offset = gutter_hitbox.origin + point(start_x, start_y); let absolute_offset = gutter_hitbox.origin + point(start_x, start_y);
element.prepaint_as_root( element.prepaint_as_root(
absolute_offset, absolute_offset,
size(width, AvailableSpace::MinContent), size(width, AvailableSpace::MinContent),
window, window,
cx, cx,
); );
Some(element) Some(element)
} else {
None
}
}) })
.collect(); .collect();
@ -5725,61 +5732,43 @@ fn prepaint_gutter_button(
fn render_inline_blame_entry( fn render_inline_blame_entry(
editor: Entity<Editor>, editor: Entity<Editor>,
blame: &gpui::Entity<GitBlame>, workspace: WeakEntity<Workspace>,
blame: &Entity<GitBlame>,
blame_entry: BlameEntry, blame_entry: BlameEntry,
style: &EditorStyle, style: &EditorStyle,
cx: &mut App, cx: &mut App,
) -> AnyElement { ) -> Option<AnyElement> {
let relative_timestamp = blame_entry_relative_timestamp(&blame_entry); let renderer = cx.global::<GlobalBlameRenderer>().0.clone();
let blame = blame.read(cx);
let author = blame_entry.author.as_deref().unwrap_or_default(); let details = blame.details_for_entry(&blame_entry);
let summary_enabled = ProjectSettings::get_global(cx) let repository = blame.repository(cx)?.clone();
.git renderer.render_inline_blame_entry(
.show_inline_commit_summary(); &style.text,
blame_entry,
let text = match blame_entry.summary.as_ref() { details,
Some(summary) if summary_enabled => { repository,
format!("{}, {} - {}", author, relative_timestamp, summary) workspace,
} editor,
_ => format!("{}, {}", author, relative_timestamp), cx,
}; )
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()
} }
fn render_blame_entry( fn render_blame_entry(
ix: usize, ix: usize,
blame: &gpui::Entity<GitBlame>, blame: &Entity<GitBlame>,
blame_entry: BlameEntry, blame_entry: BlameEntry,
style: &EditorStyle, style: &EditorStyle,
last_used_color: &mut Option<(PlayerColor, Oid)>, last_used_color: &mut Option<(PlayerColor, Oid)>,
editor: Entity<Editor>, editor: Entity<Editor>,
workspace: Entity<Workspace>,
renderer: Arc<dyn BlameRenderer>,
cx: &mut App, cx: &mut App,
) -> AnyElement { ) -> Option<AnyElement> {
let mut sha_color = cx let mut sha_color = cx
.theme() .theme()
.players() .players()
.color_for_participant(blame_entry.sha.into()); .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 // 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. // the commit SHAs are different, then we try again to get a different color.
match *last_used_color { match *last_used_color {
@ -5791,97 +5780,20 @@ fn render_blame_entry(
}; };
last_used_color.replace((sha_color, blame_entry.sha)); last_used_color.replace((sha_color, blame_entry.sha));
let relative_timestamp = blame_entry_relative_timestamp(&blame_entry); let blame = blame.read(cx);
let details = blame.details_for_entry(&blame_entry);
let short_commit_id = blame_entry.sha.display_short(); let repository = blame.repository(cx)?;
renderer.render_blame_entry(
let author_name = blame_entry.author.as_deref().unwrap_or("<no name>"); &style.text,
let name = util::truncate_and_trailoff(author_name, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED); blame_entry,
let details = blame.read(cx).details_for_entry(&blame_entry); details,
repository,
h_flex() workspace.downgrade(),
.w_full() editor,
.justify_between() ix,
.font_family(style.text.font().family) sha_color.cursor,
.line_height(style.text.line_height) cx,
.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();
});
} }
#[derive(Debug)] #[derive(Debug)]
@ -6588,9 +6500,9 @@ impl Element for EditorElement {
window.with_rem_size(rem_size, |window| { window.with_rem_size(rem_size, |window| {
window.with_text_style(Some(text_style), |window| { window.with_text_style(Some(text_style), |window| {
window.with_content_mask(Some(ContentMask { bounds }), |window| { window.with_content_mask(Some(ContentMask { bounds }), |window| {
let mut snapshot = self let (mut snapshot, is_read_only) = self.editor.update(cx, |editor, cx| {
.editor (editor.snapshot(window, cx), editor.read_only(cx))
.update(cx, |editor, cx| editor.snapshot(window, cx)); });
let style = self.style.clone(); let style = self.style.clone();
let font_id = window.text_system().resolve_font(&style.text.font()); let font_id = window.text_system().resolve_font(&style.text.font());
@ -6970,11 +6882,12 @@ impl Element for EditorElement {
.flatten()?; .flatten()?;
let mut element = render_inline_blame_entry( let mut element = render_inline_blame_entry(
self.editor.clone(), self.editor.clone(),
editor.workspace()?.downgrade(),
blame, blame,
blame_entry, blame_entry,
&style, &style,
cx, cx,
); )?;
let inline_blame_padding = INLINE_BLAME_PADDING_EM_WIDTHS * em_advance; let inline_blame_padding = INLINE_BLAME_PADDING_EM_WIDTHS * em_advance;
Some( Some(
element element
@ -7507,19 +7420,23 @@ impl Element for EditorElement {
editor.last_position_map = Some(position_map.clone()) editor.last_position_map = Some(position_map.clone())
}); });
let diff_hunk_controls = self.layout_diff_hunk_controls( let diff_hunk_controls = if is_read_only {
start_row..end_row, vec![]
&row_infos, } else {
&text_hitbox, self.layout_diff_hunk_controls(
&position_map, start_row..end_row,
newest_selection_head, &row_infos,
line_height, &text_hitbox,
scroll_pixel_position, &position_map,
&display_hunks, newest_selection_head,
self.editor.clone(), line_height,
window, scroll_pixel_position,
cx, &display_hunks,
); self.editor.clone(),
window,
cx,
)
};
EditorLayout { EditorLayout {
mode, mode,

View file

@ -1,22 +1,22 @@
use crate::Editor;
use anyhow::Result; use anyhow::Result;
use collections::HashMap; use collections::HashMap;
use git::{ use git::{
GitHostingProvider, GitHostingProviderRegistry, Oid, GitHostingProviderRegistry, GitRemote, Oid,
blame::{Blame, BlameEntry}, blame::{Blame, BlameEntry, ParsedCommitMessage},
parse_git_remote_url, parse_git_remote_url,
}; };
use gpui::{App, AppContext as _, Context, Entity, Subscription, Task}; use gpui::{
use http_client::HttpClient; AnyElement, App, AppContext as _, Context, Entity, Hsla, Subscription, Task, TextStyle,
WeakEntity, Window,
};
use language::{Bias, Buffer, BufferSnapshot, Edit}; use language::{Bias, Buffer, BufferSnapshot, Edit};
use multi_buffer::RowInfo; use multi_buffer::RowInfo;
use project::{Project, ProjectItem}; use project::{Project, ProjectItem, git_store::Repository};
use smallvec::SmallVec; use smallvec::SmallVec;
use std::{sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
use sum_tree::SumTree; use sum_tree::SumTree;
use ui::SharedString; use workspace::Workspace;
use url::Url;
use crate::commit_tooltip::ParsedCommitMessage;
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct GitBlameEntry { 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 { pub struct GitBlame {
project: Entity<Project>, project: Entity<Project>,
buffer: Entity<Buffer>, buffer: Entity<Buffer>,
entries: SumTree<GitBlameEntry>, entries: SumTree<GitBlameEntry>,
commit_details: HashMap<Oid, crate::commit_tooltip::ParsedCommitMessage>, commit_details: HashMap<Oid, ParsedCommitMessage>,
buffer_snapshot: BufferSnapshot, buffer_snapshot: BufferSnapshot,
buffer_edits: text::Subscription, buffer_edits: text::Subscription,
task: Task<Result<()>>, task: Task<Result<()>>,
@ -109,6 +75,91 @@ pub struct GitBlame {
_regenerate_subscriptions: Vec<Subscription>, _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 { impl GitBlame {
pub fn new( pub fn new(
buffer: Entity<Buffer>, buffer: Entity<Buffer>,
@ -181,6 +232,15 @@ impl GitBlame {
this 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 { pub fn has_generated_entries(&self) -> bool {
self.generated self.generated
} }

View file

@ -109,7 +109,7 @@ impl ProposedChangesEditor {
let diff = let diff =
this.multibuffer.read(cx).diff_for(buffer.remote_id())?; this.multibuffer.read(cx).diff_for(buffer.remote_id())?;
Some(diff.update(cx, |diff, cx| { 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<_>>() .collect::<Vec<_>>()
@ -185,7 +185,7 @@ impl ProposedChangesEditor {
branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx)); branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx));
new_diffs.push(cx.new(|cx| { new_diffs.push(cx.new(|cx| {
let mut diff = BufferDiff::new(&branch_buffer.read(cx).snapshot(), 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(), location.buffer.clone(),
branch_buffer.read(cx).text_snapshot(), branch_buffer.read(cx).text_snapshot(),
cx, cx,

View file

@ -111,6 +111,14 @@ impl GitRepository for FakeGitRepository {
.boxed() .boxed()
} }
fn load_commit(
&self,
_commit: String,
_cx: AsyncApp,
) -> BoxFuture<Result<git::repository::CommitDiff>> {
unimplemented!()
}
fn set_index_text( fn set_index_text(
&self, &self,
path: RepoPath, path: RepoPath,

View file

@ -1,8 +1,9 @@
use crate::Oid;
use crate::commit::get_messages; use crate::commit::get_messages;
use crate::{GitRemote, Oid};
use anyhow::{Context as _, Result, anyhow}; use anyhow::{Context as _, Result, anyhow};
use collections::{HashMap, HashSet}; use collections::{HashMap, HashSet};
use futures::AsyncWriteExt; use futures::AsyncWriteExt;
use gpui::SharedString;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::process::Stdio; use std::process::Stdio;
use std::{ops::Range, path::Path}; use std::{ops::Range, path::Path};
@ -20,6 +21,14 @@ pub struct Blame {
pub remote_url: Option<String>, 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 { impl Blame {
pub async fn for_path( pub async fn for_path(
git_binary: &Path, git_binary: &Path,

Binary file not shown.

View file

@ -15,6 +15,41 @@ pub struct PullRequest {
pub url: Url, 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 struct BuildCommitPermalinkParams<'a> {
pub sha: &'a str, pub sha: &'a str,
} }

View file

@ -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 crate::{Oid, SHORT_SHA_LENGTH};
use anyhow::{Context as _, Result, anyhow}; use anyhow::{Context as _, Result, anyhow};
use collections::HashMap; use collections::HashMap;
use futures::future::BoxFuture; use futures::future::BoxFuture;
use futures::{AsyncWriteExt, FutureExt as _, select_biased}; use futures::{AsyncWriteExt, FutureExt as _, select_biased};
use git2::BranchType; use git2::BranchType;
use gpui::{AsyncApp, BackgroundExecutor, SharedString}; use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString};
use parking_lot::Mutex; use parking_lot::Mutex;
use rope::Rope; use rope::Rope;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use std::borrow::{Borrow, Cow}; use std::borrow::{Borrow, Cow};
use std::ffi::{OsStr, OsString}; use std::ffi::{OsStr, OsString};
use std::future;
use std::path::Component; use std::path::Component;
use std::process::{ExitStatus, Stdio}; use std::process::{ExitStatus, Stdio};
use std::sync::LazyLock; use std::sync::LazyLock;
@ -21,6 +21,10 @@ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc, sync::Arc,
}; };
use std::{
future,
io::{BufRead, BufReader, BufWriter, Read},
};
use sum_tree::MapSeekTarget; use sum_tree::MapSeekTarget;
use thiserror::Error; use thiserror::Error;
use util::ResultExt; use util::ResultExt;
@ -133,6 +137,18 @@ pub struct CommitDetails {
pub committer_name: SharedString, 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 { impl CommitDetails {
pub fn short_sha(&self) -> SharedString { pub fn short_sha(&self) -> SharedString {
self.sha[..SHORT_SHA_LENGTH].to_string().into() 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 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>>; 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 /// 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() .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( fn reset(
&self, &self,
commit: String, commit: String,

View file

@ -21,6 +21,7 @@ anyhow.workspace = true
askpass.workspace = true askpass.workspace = true
assistant_settings.workspace = true assistant_settings.workspace = true
buffer_diff.workspace = true buffer_diff.workspace = true
chrono.workspace = true
collections.workspace = true collections.workspace = true
command_palette_hooks.workspace = true command_palette_hooks.workspace = true
component.workspace = true component.workspace = true
@ -36,6 +37,7 @@ language_model.workspace = true
linkify.workspace = true linkify.workspace = true
linkme.workspace = true linkme.workspace = true
log.workspace = true log.workspace = true
markdown.workspace = true
menu.workspace = true menu.workspace = true
multi_buffer.workspace = true multi_buffer.workspace = true
notifications.workspace = true notifications.workspace = true

View file

@ -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(),
}
}

View file

@ -1,21 +1,22 @@
use crate::commit_view::CommitView;
use editor::hover_markdown_style;
use futures::Future; use futures::Future;
use git::PullRequest;
use git::blame::BlameEntry; use git::blame::BlameEntry;
use git::repository::CommitSummary;
use git::{GitRemote, blame::ParsedCommitMessage};
use gpui::{ use gpui::{
App, Asset, ClipboardItem, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle, App, Asset, ClipboardItem, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle,
StatefulInteractiveElement, StatefulInteractiveElement, WeakEntity, prelude::*,
}; };
use markdown::Markdown; use markdown::Markdown;
use project::git_store::Repository;
use settings::Settings; use settings::Settings;
use std::hash::Hash; use std::hash::Hash;
use theme::ThemeSettings; use theme::ThemeSettings;
use time::{OffsetDateTime, UtcOffset}; use time::{OffsetDateTime, UtcOffset};
use time_format::format_local_timestamp; use time_format::format_local_timestamp;
use ui::{Avatar, Divider, IconButtonShape, prelude::*, tooltip_container}; use ui::{Avatar, Divider, IconButtonShape, prelude::*, tooltip_container};
use url::Url; use workspace::Workspace;
use crate::git::blame::GitRemote;
use crate::hover_popover::hover_markdown_style;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct CommitDetails { pub struct CommitDetails {
@ -26,14 +27,6 @@ pub struct CommitDetails {
pub message: Option<ParsedCommitMessage>, 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> { struct CommitAvatar<'a> {
commit: &'a CommitDetails, commit: &'a CommitDetails,
} }
@ -54,10 +47,10 @@ impl<'a> CommitAvatar<'a> {
.commit .commit
.message .message
.as_ref() .as_ref()
.and_then(|details| details.remote.as_ref()) .and_then(|details| details.remote.clone())
.filter(|remote| remote.host_supports_avatars())?; .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) { let element = match window.use_asset::<CommitAvatarAsset>(&avatar_url, cx) {
// Loading or no avatar found // Loading or no avatar found
@ -115,12 +108,16 @@ pub struct CommitTooltip {
commit: CommitDetails, commit: CommitDetails,
scroll_handle: ScrollHandle, scroll_handle: ScrollHandle,
markdown: Entity<Markdown>, markdown: Entity<Markdown>,
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
} }
impl CommitTooltip { impl CommitTooltip {
pub fn blame_entry( pub fn blame_entry(
blame: &BlameEntry, blame: &BlameEntry,
details: Option<ParsedCommitMessage>, details: Option<ParsedCommitMessage>,
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
@ -141,12 +138,20 @@ impl CommitTooltip {
author_email: blame.author_mail.clone().unwrap_or("".to_string()).into(), author_email: blame.author_mail.clone().unwrap_or("".to_string()).into(),
message: details, message: details,
}, },
repository,
workspace,
window, window,
cx, 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); let mut style = hover_markdown_style(window, cx);
if let Some(code_block) = &style.code_block.text { if let Some(code_block) = &style.code_block.text {
style.base_text_style.refine(code_block); style.base_text_style.refine(code_block);
@ -166,6 +171,8 @@ impl CommitTooltip {
}); });
Self { Self {
commit, commit,
repository,
workspace,
scroll_handle: ScrollHandle::new(), scroll_handle: ScrollHandle::new(),
markdown, markdown,
} }
@ -208,6 +215,27 @@ impl Render for CommitTooltip {
let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); 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 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| { tooltip_container(window, cx, move |this, _, cx| {
this.occlude() this.occlude()
@ -283,24 +311,16 @@ impl Render for CommitTooltip {
.icon(IconName::FileGit) .icon(IconName::FileGit)
.icon_color(Color::Muted) .icon_color(Color::Muted)
.icon_position(IconPosition::Start) .icon_position(IconPosition::Start)
.disabled( .on_click(
self.commit move |_, window, cx| {
.message CommitView::open(
.as_ref() commit_summary.clone(),
.map_or(true, |details| { repo.downgrade(),
details.permalink.is_none() workspace.clone(),
}), window,
) cx,
.when_some( );
self.commit cx.stop_propagation();
.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())
})
}, },
), ),
) )

View file

@ -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()
}
}

View file

@ -1,11 +1,11 @@
use crate::askpass_modal::AskPassModal; use crate::askpass_modal::AskPassModal;
use crate::commit_modal::CommitModal; use crate::commit_modal::CommitModal;
use crate::commit_tooltip::CommitTooltip;
use crate::commit_view::CommitView;
use crate::git_panel_settings::StatusStyle; 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::remote_output::{self, RemoteAction, SuccessMessage};
use crate::{branch_picker, picker_prompt, render_remote_button};
use crate::{ProjectDiff, picker_prompt, project_diff};
use crate::{branch_picker, render_remote_button};
use crate::{ use crate::{
git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector, git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
}; };
@ -13,13 +13,13 @@ use anyhow::Result;
use askpass::AskPassDelegate; use askpass::AskPassDelegate;
use assistant_settings::AssistantSettings; use assistant_settings::AssistantSettings;
use db::kvp::KEY_VALUE_STORE; use db::kvp::KEY_VALUE_STORE;
use editor::commit_tooltip::CommitTooltip;
use editor::{ use editor::{
Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar, Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar,
scroll::ScrollbarAutoHide, scroll::ScrollbarAutoHide,
}; };
use futures::StreamExt as _; use futures::StreamExt as _;
use git::blame::ParsedCommitMessage;
use git::repository::{ use git::repository::{
Branch, CommitDetails, CommitSummary, DiffType, PushOptions, Remote, RemoteCommandOutput, Branch, CommitDetails, CommitSummary, DiffType, PushOptions, Remote, RemoteCommandOutput,
ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus, ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus,
@ -3001,6 +3001,7 @@ impl GitPanel {
let active_repository = self.active_repository.as_ref()?; let active_repository = self.active_repository.as_ref()?;
let branch = active_repository.read(cx).current_branch()?; let branch = active_repository.read(cx).current_branch()?;
let commit = branch.most_recent_commit.as_ref()?.clone(); let commit = branch.most_recent_commit.as_ref()?.clone();
let workspace = self.workspace.clone();
let this = cx.entity(); let this = cx.entity();
Some( Some(
@ -3023,14 +3024,31 @@ impl GitPanel {
.truncate(), .truncate(),
) )
.id("commit-msg-hover") .id("commit-msg-hover")
.hoverable_tooltip(move |window, cx| { .on_click({
GitPanelMessageTooltip::new( let commit = commit.clone();
this.clone(), let repo = active_repository.downgrade();
commit.sha.clone(), move |_, window, cx| {
window, CommitView::open(
cx, commit.clone(),
) repo.clone(),
.into() 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()) .child(div().flex_1())
@ -3938,31 +3956,35 @@ impl GitPanelMessageTooltip {
fn new( fn new(
git_panel: Entity<GitPanel>, git_panel: Entity<GitPanel>,
sha: SharedString, sha: SharedString,
repository: Entity<Repository>,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> Entity<Self> { ) -> Entity<Self> {
cx.new(|cx| { cx.new(|cx| {
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
let details = git_panel let (details, workspace) = git_panel.update(cx, |git_panel, cx| {
.update(cx, |git_panel, cx| { (
git_panel.load_commit_details(sha.to_string(), cx) git_panel.load_commit_details(sha.to_string(), cx),
})? git_panel.workspace.clone(),
.await?; )
})?;
let details = details.await?;
let commit_details = editor::commit_tooltip::CommitDetails { let commit_details = crate::commit_tooltip::CommitDetails {
sha: details.sha.clone(), sha: details.sha.clone(),
author_name: details.committer_name.clone(), author_name: details.committer_name.clone(),
author_email: details.committer_email.clone(), author_email: details.committer_email.clone(),
commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?, commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
message: Some(editor::commit_tooltip::ParsedCommitMessage { message: Some(ParsedCommitMessage {
message: details.message.clone(), message: details.message.clone(),
..Default::default() ..Default::default()
}), }),
}; };
this.update_in(cx, |this: &mut GitPanelMessageTooltip, window, cx| { this.update_in(cx, |this: &mut GitPanelMessageTooltip, window, cx| {
this.commit_tooltip = this.commit_tooltip = Some(cx.new(move |cx| {
Some(cx.new(move |cx| CommitTooltip::new(commit_details, window, cx))); CommitTooltip::new(commit_details, repository, workspace, window, cx)
}));
cx.notify(); cx.notify();
}) })
}) })

View file

@ -3,6 +3,7 @@ use std::any::Any;
use ::settings::Settings; use ::settings::Settings;
use command_palette_hooks::CommandPaletteFilter; use command_palette_hooks::CommandPaletteFilter;
use commit_modal::CommitModal; use commit_modal::CommitModal;
mod blame_ui;
use git::{ use git::{
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus}, repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode}, status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
@ -17,6 +18,8 @@ use workspace::Workspace;
mod askpass_modal; mod askpass_modal;
pub mod branch_picker; pub mod branch_picker;
mod commit_modal; mod commit_modal;
pub mod commit_tooltip;
mod commit_view;
pub mod git_panel; pub mod git_panel;
mod git_panel_settings; mod git_panel_settings;
pub mod onboarding; pub mod onboarding;
@ -30,6 +33,8 @@ actions!(git, [ResetOnboarding]);
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
GitPanelSettings::register(cx); GitPanelSettings::register(cx);
editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx);
cx.observe_new(|workspace: &mut Workspace, _, cx| { cx.observe_new(|workspace: &mut Workspace, _, cx| {
ProjectDiff::register(workspace, cx); ProjectDiff::register(workspace, cx);
CommitModal::register(workspace); CommitModal::register(workspace);

View file

@ -9,7 +9,7 @@ use serde_json::Value;
use std::{ops::Range, str::FromStr, sync::Arc}; use std::{ops::Range, str::FromStr, sync::Arc};
use text::*; use text::*;
pub use proto::{BufferState, Operation}; pub use proto::{BufferState, File, Operation};
/// Deserializes a `[text::LineEnding]` from the RPC representation. /// Deserializes a `[text::LineEnding]` from the RPC representation.
pub fn deserialize_line_ending(message: proto::LineEnding) -> text::LineEnding { pub fn deserialize_line_ending(message: proto::LineEnding) -> text::LineEnding {

View file

@ -21,8 +21,8 @@ use git::{
blame::Blame, blame::Blame,
parse_git_remote_url, parse_git_remote_url,
repository::{ repository::{
Branch, CommitDetails, DiffType, GitRepository, GitRepositoryCheckpoint, PushOptions, Branch, CommitDetails, CommitDiff, CommitFile, DiffType, GitRepository,
Remote, RemoteCommandOutput, RepoPath, ResetMode, GitRepositoryCheckpoint, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode,
}, },
status::FileStatus, status::FileStatus,
}; };
@ -289,6 +289,7 @@ impl GitStore {
client.add_entity_request_handler(Self::handle_commit); client.add_entity_request_handler(Self::handle_commit);
client.add_entity_request_handler(Self::handle_reset); client.add_entity_request_handler(Self::handle_reset);
client.add_entity_request_handler(Self::handle_show); 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_checkout_files);
client.add_entity_request_handler(Self::handle_open_commit_message_buffer); client.add_entity_request_handler(Self::handle_open_commit_message_buffer);
client.add_entity_request_handler(Self::handle_set_index_text); 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( async fn handle_reset(
this: Entity<Self>, this: Entity<Self>,
envelope: TypedEnvelope<proto::GitReset>, envelope: TypedEnvelope<proto::GitReset>,
@ -2389,7 +2416,10 @@ impl BufferDiffState {
unstaged_diff.as_ref().zip(new_unstaged_diff.clone()) unstaged_diff.as_ref().zip(new_unstaged_diff.clone())
{ {
unstaged_diff.update(cx, |diff, cx| { 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 { } else {
None None
@ -2398,14 +2428,11 @@ impl BufferDiffState {
if let Some((uncommitted_diff, new_uncommitted_diff)) = if let Some((uncommitted_diff, new_uncommitted_diff)) =
uncommitted_diff.as_ref().zip(new_uncommitted_diff.clone()) uncommitted_diff.as_ref().zip(new_uncommitted_diff.clone())
{ {
uncommitted_diff.update(cx, |uncommitted_diff, cx| { uncommitted_diff.update(cx, |diff, cx| {
uncommitted_diff.set_snapshot( if language_changed {
&buffer, diff.language_changed(cx);
new_uncommitted_diff, }
language_changed, diff.set_snapshot(new_uncommitted_diff, &buffer, unstaged_changed_range, cx);
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>> { fn buffer_store(&self, cx: &App) -> Option<Entity<BufferStore>> {
Some(self.git_store.upgrade()?.read(cx).buffer_store.clone()) Some(self.git_store.upgrade()?.read(cx).buffer_store.clone())
} }

View file

@ -365,6 +365,9 @@ message Envelope {
LanguageServerIdForName language_server_id_for_name = 332; LanguageServerIdForName language_server_id_for_name = 332;
LanguageServerIdForNameResponse language_server_id_for_name_response = 333; // current max 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; reserved 87 to 88;
@ -3365,6 +3368,23 @@ message GitCommitDetails {
string committer_name = 5; 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 { message GitReset {
uint64 project_id = 1; uint64 project_id = 1;
reserved 2; reserved 2;

View file

@ -340,6 +340,8 @@ messages!(
(ListRemoteDirectoryResponse, Background), (ListRemoteDirectoryResponse, Background),
(ListToolchains, Foreground), (ListToolchains, Foreground),
(ListToolchainsResponse, Foreground), (ListToolchainsResponse, Foreground),
(LoadCommitDiff, Foreground),
(LoadCommitDiffResponse, Foreground),
(LspExtExpandMacro, Background), (LspExtExpandMacro, Background),
(LspExtExpandMacroResponse, Background), (LspExtExpandMacroResponse, Background),
(LspExtOpenDocs, Background), (LspExtOpenDocs, Background),
@ -534,6 +536,7 @@ request_messages!(
(JoinRoom, JoinRoomResponse), (JoinRoom, JoinRoomResponse),
(LeaveChannelBuffer, Ack), (LeaveChannelBuffer, Ack),
(LeaveRoom, Ack), (LeaveRoom, Ack),
(LoadCommitDiff, LoadCommitDiffResponse),
(MarkNotificationRead, Ack), (MarkNotificationRead, Ack),
(MoveChannel, Ack), (MoveChannel, Ack),
(OnTypeFormatting, OnTypeFormattingResponse), (OnTypeFormatting, OnTypeFormattingResponse),
@ -668,6 +671,7 @@ entity_messages!(
JoinProject, JoinProject,
LeaveProject, LeaveProject,
LinkedEditingRange, LinkedEditingRange,
LoadCommitDiff,
MultiLspQuery, MultiLspQuery,
RestartLanguageServers, RestartLanguageServers,
OnTypeFormatting, OnTypeFormatting,

View file

@ -81,6 +81,7 @@ impl MultibufferHint {
if active_pane_item.is_singleton(cx) if active_pane_item.is_singleton(cx)
|| active_pane_item.breadcrumbs(cx.theme(), cx).is_none() || active_pane_item.breadcrumbs(cx.theme(), cx).is_none()
|| !active_pane_item.can_save(cx)
{ {
return ToolbarItemLocation::Hidden; return ToolbarItemLocation::Hidden;
} }