Add file history view (#42441)

Closes #16827

Release Notes:

- Added: File history view accessible via right-click context menu on
files in the editor or project panel. Shows commit history for the
selected file with author, timestamp, and commit message. Clicking a
commit opens a diff view filtered to show only changes for that specific
file.

<img width="1293" height="834" alt="Screenshot 2025-11-11 at 16 31 32"
src="https://github.com/user-attachments/assets/3780d21b-a719-40b3-955c-d928c45a47cc"
/>
<img width="1283" height="836" alt="Screenshot 2025-11-11 at 16 31 24"
src="https://github.com/user-attachments/assets/1dc4e56b-b225-4ffa-a2af-c5dcfb2efaa0"
/>

---------

Co-authored-by: cameron <cameron.studdstreet@gmail.com>
This commit is contained in:
ozzy
2025-12-01 13:25:33 +00:00
committed by GitHub
co-authored by cameron
parent 747dc23138
commit 05c2028068
19 changed files with 1703 additions and 394 deletions
+2
View File
@@ -43,6 +43,8 @@ actions!(
/// Shows git blame information for the current file.
#[action(deprecated_aliases = ["editor::ToggleGitBlame"])]
Blame,
/// Shows the git history for the current file.
FileHistory,
/// Stages the current file.
StageFile,
/// Unstages the current file.
+111
View File
@@ -207,6 +207,22 @@ pub struct CommitDetails {
pub author_name: SharedString,
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct FileHistoryEntry {
pub sha: SharedString,
pub subject: SharedString,
pub message: SharedString,
pub commit_timestamp: i64,
pub author_name: SharedString,
pub author_email: SharedString,
}
#[derive(Debug, Clone)]
pub struct FileHistory {
pub entries: Vec<FileHistoryEntry>,
pub path: RepoPath,
}
#[derive(Debug)]
pub struct CommitDiff {
pub files: Vec<CommitFile>,
@@ -464,6 +480,13 @@ pub trait GitRepository: Send + Sync {
fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>>;
fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>>;
fn file_history_paginated(
&self,
path: RepoPath,
skip: usize,
limit: Option<usize>,
) -> BoxFuture<'_, Result<FileHistory>>;
/// Returns the absolute path to the repository. For worktrees, this will be the path to the
/// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
@@ -1452,6 +1475,94 @@ impl GitRepository for RealGitRepository {
.boxed()
}
fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>> {
self.file_history_paginated(path, 0, None)
}
fn file_history_paginated(
&self,
path: RepoPath,
skip: usize,
limit: Option<usize>,
) -> BoxFuture<'_, Result<FileHistory>> {
let working_directory = self.working_directory();
let git_binary_path = self.any_git_binary_path.clone();
self.executor
.spawn(async move {
let working_directory = working_directory?;
// Use a unique delimiter with a hardcoded UUID to separate commits
// This essentially eliminates any chance of encountering the delimiter in actual commit data
let commit_delimiter =
concat!("<<COMMIT_END-", "3f8a9c2e-7d4b-4e1a-9f6c-8b5d2a1e4c3f>>",);
let format_string = format!(
"--pretty=format:%H%x00%s%x00%B%x00%at%x00%an%x00%ae{}",
commit_delimiter
);
let mut args = vec!["--no-optional-locks", "log", "--follow", &format_string];
let skip_str;
let limit_str;
if skip > 0 {
skip_str = skip.to_string();
args.push("--skip");
args.push(&skip_str);
}
if let Some(n) = limit {
limit_str = n.to_string();
args.push("-n");
args.push(&limit_str);
}
args.push("--");
let output = new_smol_command(&git_binary_path)
.current_dir(&working_directory)
.args(&args)
.arg(path.as_unix_str())
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git log failed: {stderr}");
}
let stdout = std::str::from_utf8(&output.stdout)?;
let mut entries = Vec::new();
for commit_block in stdout.split(commit_delimiter) {
let commit_block = commit_block.trim();
if commit_block.is_empty() {
continue;
}
let fields: Vec<&str> = commit_block.split('\0').collect();
if fields.len() >= 6 {
let sha = fields[0].trim().to_string().into();
let subject = fields[1].trim().to_string().into();
let message = fields[2].trim().to_string().into();
let commit_timestamp = fields[3].trim().parse().unwrap_or(0);
let author_name = fields[4].trim().to_string().into();
let author_email = fields[5].trim().to_string().into();
entries.push(FileHistoryEntry {
sha,
subject,
message,
commit_timestamp,
author_name,
author_email,
});
}
}
Ok(FileHistory { entries, path })
})
.boxed()
}
fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
let working_directory = self.working_directory();
let git_binary_path = self.any_git_binary_path.clone();