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
|
@ -1,8 +1,9 @@
|
|||
use crate::Oid;
|
||||
use crate::commit::get_messages;
|
||||
use crate::{GitRemote, Oid};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use collections::{HashMap, HashSet};
|
||||
use futures::AsyncWriteExt;
|
||||
use gpui::SharedString;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Stdio;
|
||||
use std::{ops::Range, path::Path};
|
||||
|
@ -20,6 +21,14 @@ pub struct Blame {
|
|||
pub remote_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ParsedCommitMessage {
|
||||
pub message: SharedString,
|
||||
pub permalink: Option<url::Url>,
|
||||
pub pull_request: Option<crate::hosting_provider::PullRequest>,
|
||||
pub remote: Option<GitRemote>,
|
||||
}
|
||||
|
||||
impl Blame {
|
||||
pub async fn for_path(
|
||||
git_binary: &Path,
|
||||
|
|
Binary file not shown.
|
@ -15,6 +15,41 @@ pub struct PullRequest {
|
|||
pub url: Url,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GitRemote {
|
||||
pub host: Arc<dyn GitHostingProvider + Send + Sync + 'static>,
|
||||
pub owner: String,
|
||||
pub repo: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GitRemote {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("GitRemote")
|
||||
.field("host", &self.host.name())
|
||||
.field("owner", &self.owner)
|
||||
.field("repo", &self.repo)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl GitRemote {
|
||||
pub fn host_supports_avatars(&self) -> bool {
|
||||
self.host.supports_avatars()
|
||||
}
|
||||
|
||||
pub async fn avatar_url(
|
||||
&self,
|
||||
commit: SharedString,
|
||||
client: Arc<dyn HttpClient>,
|
||||
) -> Option<Url> {
|
||||
self.host
|
||||
.commit_author_avatar_url(&self.owner, &self.repo, commit, client)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BuildCommitPermalinkParams<'a> {
|
||||
pub sha: &'a str,
|
||||
}
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
use crate::status::GitStatus;
|
||||
use crate::commit::parse_git_diff_name_status;
|
||||
use crate::status::{GitStatus, StatusCode};
|
||||
use crate::{Oid, SHORT_SHA_LENGTH};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use collections::HashMap;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::{AsyncWriteExt, FutureExt as _, select_biased};
|
||||
use git2::BranchType;
|
||||
use gpui::{AsyncApp, BackgroundExecutor, SharedString};
|
||||
use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString};
|
||||
use parking_lot::Mutex;
|
||||
use rope::Rope;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use std::borrow::{Borrow, Cow};
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::future;
|
||||
use std::path::Component;
|
||||
use std::process::{ExitStatus, Stdio};
|
||||
use std::sync::LazyLock;
|
||||
|
@ -21,6 +21,10 @@ use std::{
|
|||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{
|
||||
future,
|
||||
io::{BufRead, BufReader, BufWriter, Read},
|
||||
};
|
||||
use sum_tree::MapSeekTarget;
|
||||
use thiserror::Error;
|
||||
use util::ResultExt;
|
||||
|
@ -133,6 +137,18 @@ pub struct CommitDetails {
|
|||
pub committer_name: SharedString,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CommitDiff {
|
||||
pub files: Vec<CommitFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CommitFile {
|
||||
pub path: RepoPath,
|
||||
pub old_text: Option<String>,
|
||||
pub new_text: Option<String>,
|
||||
}
|
||||
|
||||
impl CommitDetails {
|
||||
pub fn short_sha(&self) -> SharedString {
|
||||
self.sha[..SHORT_SHA_LENGTH].to_string().into()
|
||||
|
@ -206,6 +222,7 @@ pub trait GitRepository: Send + Sync {
|
|||
|
||||
fn show(&self, commit: String) -> BoxFuture<Result<CommitDetails>>;
|
||||
|
||||
fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<Result<CommitDiff>>;
|
||||
fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<Result<crate::blame::Blame>>;
|
||||
|
||||
/// Returns the absolute path to the repository. For worktrees, this will be the path to the
|
||||
|
@ -405,6 +422,108 @@ impl GitRepository for RealGitRepository {
|
|||
.boxed()
|
||||
}
|
||||
|
||||
fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<Result<CommitDiff>> {
|
||||
let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
|
||||
else {
|
||||
return future::ready(Err(anyhow!("no working directory"))).boxed();
|
||||
};
|
||||
cx.background_spawn(async move {
|
||||
let show_output = util::command::new_std_command("git")
|
||||
.current_dir(&working_directory)
|
||||
.args([
|
||||
"--no-optional-locks",
|
||||
"show",
|
||||
"--format=%P",
|
||||
"-z",
|
||||
"--no-renames",
|
||||
"--name-status",
|
||||
])
|
||||
.arg(&commit)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.map_err(|e| anyhow!("Failed to start git show process: {e}"))?;
|
||||
|
||||
let show_stdout = String::from_utf8_lossy(&show_output.stdout);
|
||||
let mut lines = show_stdout.split('\n');
|
||||
let parent_sha = lines.next().unwrap().trim().trim_end_matches('\0');
|
||||
let changes = parse_git_diff_name_status(lines.next().unwrap_or(""));
|
||||
|
||||
let mut cat_file_process = util::command::new_std_command("git")
|
||||
.current_dir(&working_directory)
|
||||
.args(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| anyhow!("Failed to start git cat-file process: {e}"))?;
|
||||
|
||||
use std::io::Write as _;
|
||||
let mut files = Vec::<CommitFile>::new();
|
||||
let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
|
||||
let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
|
||||
let mut info_line = String::new();
|
||||
let mut newline = [b'\0'];
|
||||
for (path, status_code) in changes {
|
||||
match status_code {
|
||||
StatusCode::Modified => {
|
||||
writeln!(&mut stdin, "{commit}:{}", path.display())?;
|
||||
writeln!(&mut stdin, "{parent_sha}:{}", path.display())?;
|
||||
}
|
||||
StatusCode::Added => {
|
||||
writeln!(&mut stdin, "{commit}:{}", path.display())?;
|
||||
}
|
||||
StatusCode::Deleted => {
|
||||
writeln!(&mut stdin, "{parent_sha}:{}", path.display())?;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
stdin.flush()?;
|
||||
|
||||
info_line.clear();
|
||||
stdout.read_line(&mut info_line)?;
|
||||
|
||||
let len = info_line.trim_end().parse().with_context(|| {
|
||||
format!("invalid object size output from cat-file {info_line}")
|
||||
})?;
|
||||
let mut text = vec![0; len];
|
||||
stdout.read_exact(&mut text)?;
|
||||
stdout.read_exact(&mut newline)?;
|
||||
let text = String::from_utf8_lossy(&text).to_string();
|
||||
|
||||
let mut old_text = None;
|
||||
let mut new_text = None;
|
||||
match status_code {
|
||||
StatusCode::Modified => {
|
||||
info_line.clear();
|
||||
stdout.read_line(&mut info_line)?;
|
||||
let len = info_line.trim_end().parse().with_context(|| {
|
||||
format!("invalid object size output from cat-file {}", info_line)
|
||||
})?;
|
||||
let mut parent_text = vec![0; len];
|
||||
stdout.read_exact(&mut parent_text)?;
|
||||
stdout.read_exact(&mut newline)?;
|
||||
old_text = Some(String::from_utf8_lossy(&parent_text).to_string());
|
||||
new_text = Some(text);
|
||||
}
|
||||
StatusCode::Added => new_text = Some(text),
|
||||
StatusCode::Deleted => old_text = Some(text),
|
||||
_ => continue,
|
||||
}
|
||||
|
||||
files.push(CommitFile {
|
||||
path: path.into(),
|
||||
old_text,
|
||||
new_text,
|
||||
})
|
||||
}
|
||||
|
||||
Ok(CommitDiff { files })
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn reset(
|
||||
&self,
|
||||
commit: String,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue