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:
parent
33912011b7
commit
8546dc101d
28 changed files with 1742 additions and 603 deletions
527
crates/git_ui/src/commit_view.rs
Normal file
527
crates/git_ui/src/commit_view.rs
Normal 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()
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue