mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-23 21:05:08 +00:00
git: Add diff stats in git_panel (#49519)
This PR adds the small UI change of `git diff --numstat` to the git panel so you can see the number of additions/deletions per file. There is an option in the settings UI for this under `git_panel`.`diff_stats`. This option is set to `false` by default. <!-- initial version <img width="1648" height="977" alt="Screenshot 2026-02-18 at 18 42 47" src="https://github.com/user-attachments/assets/b8b7f07c-9c73-4d06-9734-8f1cf30ce296" /> --> <img width="1648" height="977" alt="Screenshot 2026-02-18 at 21 25 02" src="https://github.com/user-attachments/assets/73257854-6168-4d12-84f8-27c9e0abe89f" /> Release Notes: - Added git diff stats to git panel entries --------- Co-authored-by: Danilo Leal <daniloleal09@gmail.com> Co-authored-by: Anthony Eid <anthony@zed.dev>
This commit is contained in:
parent
f4920f4651
commit
bbbe7239af
13 changed files with 706 additions and 4 deletions
|
|
@ -915,6 +915,10 @@
|
|||
// Default: inherits editor scrollbar settings
|
||||
// "show": null
|
||||
},
|
||||
// Whether to show the addition/deletion change count next to each file in the Git panel.
|
||||
//
|
||||
// Default: false
|
||||
"diff_stats": false,
|
||||
},
|
||||
"message_editor": {
|
||||
// Whether to automatically replace emoji shortcodes with emoji characters.
|
||||
|
|
|
|||
|
|
@ -768,6 +768,136 @@ impl GitRepository for FakeGitRepository {
|
|||
unimplemented!()
|
||||
}
|
||||
|
||||
fn diff_stat(
|
||||
&self,
|
||||
diff_type: git::repository::DiffType,
|
||||
) -> BoxFuture<'_, Result<HashMap<RepoPath, git::status::DiffStat>>> {
|
||||
fn count_lines(s: &str) -> u32 {
|
||||
if s.is_empty() {
|
||||
0
|
||||
} else {
|
||||
s.lines().count() as u32
|
||||
}
|
||||
}
|
||||
|
||||
match diff_type {
|
||||
git::repository::DiffType::HeadToIndex => self
|
||||
.with_state_async(false, |state| {
|
||||
let mut result = HashMap::default();
|
||||
let all_paths: HashSet<&RepoPath> = state
|
||||
.head_contents
|
||||
.keys()
|
||||
.chain(state.index_contents.keys())
|
||||
.collect();
|
||||
for path in all_paths {
|
||||
let head = state.head_contents.get(path);
|
||||
let index = state.index_contents.get(path);
|
||||
match (head, index) {
|
||||
(Some(old), Some(new)) if old != new => {
|
||||
result.insert(
|
||||
path.clone(),
|
||||
git::status::DiffStat {
|
||||
added: count_lines(new),
|
||||
deleted: count_lines(old),
|
||||
},
|
||||
);
|
||||
}
|
||||
(Some(old), None) => {
|
||||
result.insert(
|
||||
path.clone(),
|
||||
git::status::DiffStat {
|
||||
added: 0,
|
||||
deleted: count_lines(old),
|
||||
},
|
||||
);
|
||||
}
|
||||
(None, Some(new)) => {
|
||||
result.insert(
|
||||
path.clone(),
|
||||
git::status::DiffStat {
|
||||
added: count_lines(new),
|
||||
deleted: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.boxed(),
|
||||
git::repository::DiffType::HeadToWorktree => {
|
||||
let workdir_path = self.dot_git_path.parent().unwrap().to_path_buf();
|
||||
let worktree_files: HashMap<RepoPath, String> = self
|
||||
.fs
|
||||
.files()
|
||||
.iter()
|
||||
.filter_map(|path| {
|
||||
let repo_path = path.strip_prefix(&workdir_path).ok()?;
|
||||
if repo_path.starts_with(".git") {
|
||||
return None;
|
||||
}
|
||||
let content = self
|
||||
.fs
|
||||
.read_file_sync(path)
|
||||
.ok()
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())?;
|
||||
let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?;
|
||||
Some((RepoPath::from_rel_path(&repo_path), content))
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.with_state_async(false, move |state| {
|
||||
let mut result = HashMap::default();
|
||||
let all_paths: HashSet<&RepoPath> = state
|
||||
.head_contents
|
||||
.keys()
|
||||
.chain(worktree_files.keys())
|
||||
.collect();
|
||||
for path in all_paths {
|
||||
let head = state.head_contents.get(path);
|
||||
let worktree = worktree_files.get(path);
|
||||
match (head, worktree) {
|
||||
(Some(old), Some(new)) if old != new => {
|
||||
result.insert(
|
||||
path.clone(),
|
||||
git::status::DiffStat {
|
||||
added: count_lines(new),
|
||||
deleted: count_lines(old),
|
||||
},
|
||||
);
|
||||
}
|
||||
(Some(old), None) => {
|
||||
result.insert(
|
||||
path.clone(),
|
||||
git::status::DiffStat {
|
||||
added: 0,
|
||||
deleted: count_lines(old),
|
||||
},
|
||||
);
|
||||
}
|
||||
(None, Some(new)) => {
|
||||
result.insert(
|
||||
path.clone(),
|
||||
git::status::DiffStat {
|
||||
added: count_lines(new),
|
||||
deleted: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
git::repository::DiffType::MergeBase { .. } => {
|
||||
future::ready(Ok(HashMap::default())).boxed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
|
||||
let executor = self.executor.clone();
|
||||
let fs = self.fs.clone();
|
||||
|
|
|
|||
|
|
@ -898,6 +898,11 @@ pub trait GitRepository: Send + Sync {
|
|||
/// Run git diff
|
||||
fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
|
||||
|
||||
fn diff_stat(
|
||||
&self,
|
||||
diff: DiffType,
|
||||
) -> BoxFuture<'_, Result<HashMap<RepoPath, crate::status::DiffStat>>>;
|
||||
|
||||
/// Creates a checkpoint for the repository.
|
||||
fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
|
||||
|
||||
|
|
@ -2031,6 +2036,57 @@ impl GitRepository for RealGitRepository {
|
|||
.boxed()
|
||||
}
|
||||
|
||||
fn diff_stat(
|
||||
&self,
|
||||
diff: DiffType,
|
||||
) -> BoxFuture<'_, Result<HashMap<RepoPath, crate::status::DiffStat>>> {
|
||||
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?;
|
||||
let output = match diff {
|
||||
DiffType::HeadToIndex => {
|
||||
new_command(&git_binary_path)
|
||||
.current_dir(&working_directory)
|
||||
.args(["diff", "--numstat", "--staged"])
|
||||
.output()
|
||||
.await?
|
||||
}
|
||||
DiffType::HeadToWorktree => {
|
||||
new_command(&git_binary_path)
|
||||
.current_dir(&working_directory)
|
||||
.args(["diff", "--numstat"])
|
||||
.output()
|
||||
.await?
|
||||
}
|
||||
DiffType::MergeBase { base_ref } => {
|
||||
new_command(&git_binary_path)
|
||||
.current_dir(&working_directory)
|
||||
.args([
|
||||
"diff",
|
||||
"--numstat",
|
||||
"--merge-base",
|
||||
base_ref.as_ref(),
|
||||
"HEAD",
|
||||
])
|
||||
.output()
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Failed to run git diff --numstat:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Ok(crate::status::parse_numstat(&String::from_utf8_lossy(
|
||||
&output.stdout,
|
||||
)))
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn stage_paths(
|
||||
&self,
|
||||
paths: Vec<RepoPath>,
|
||||
|
|
|
|||
|
|
@ -580,6 +580,45 @@ impl FromStr for TreeDiff {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct DiffStat {
|
||||
pub added: u32,
|
||||
pub deleted: u32,
|
||||
}
|
||||
|
||||
/// Parses the output of `git diff --numstat` where output looks like:
|
||||
///
|
||||
/// ```text
|
||||
/// 24 12 dir/file.txt
|
||||
/// ```
|
||||
pub fn parse_numstat(output: &str) -> HashMap<RepoPath, DiffStat> {
|
||||
let mut stats = HashMap::default();
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut parts = line.splitn(3, '\t');
|
||||
let (Some(added_str), Some(deleted_str), Some(path_str)) =
|
||||
(parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(added) = added_str.parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(deleted) = deleted_str.parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(path) = RepoPath::new(path_str) else {
|
||||
continue;
|
||||
};
|
||||
let stat = DiffStat { added, deleted };
|
||||
stats.insert(path, stat);
|
||||
}
|
||||
stats
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
|
|
@ -588,6 +627,94 @@ mod tests {
|
|||
status::{FileStatus, GitStatus, TreeDiff, TreeDiffStatus},
|
||||
};
|
||||
|
||||
use super::{DiffStat, parse_numstat};
|
||||
|
||||
#[test]
|
||||
fn test_parse_numstat_normal() {
|
||||
let input = "10\t5\tsrc/main.rs\n3\t1\tREADME.md\n";
|
||||
let result = parse_numstat(input);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(
|
||||
result.get(&RepoPath::new("src/main.rs").unwrap()),
|
||||
Some(&DiffStat {
|
||||
added: 10,
|
||||
deleted: 5
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
result.get(&RepoPath::new("README.md").unwrap()),
|
||||
Some(&DiffStat {
|
||||
added: 3,
|
||||
deleted: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_numstat_binary_files_skipped() {
|
||||
// git diff --numstat outputs "-\t-\tpath" for binary files
|
||||
let input = "-\t-\timage.png\n5\t2\tsrc/lib.rs\n";
|
||||
let result = parse_numstat(input);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(!result.contains_key(&RepoPath::new("image.png").unwrap()));
|
||||
assert_eq!(
|
||||
result.get(&RepoPath::new("src/lib.rs").unwrap()),
|
||||
Some(&DiffStat {
|
||||
added: 5,
|
||||
deleted: 2
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_numstat_empty_input() {
|
||||
assert!(parse_numstat("").is_empty());
|
||||
assert!(parse_numstat("\n\n").is_empty());
|
||||
assert!(parse_numstat(" \n \n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_numstat_malformed_lines_skipped() {
|
||||
let input = "not_a_number\t5\tfile.rs\n10\t5\tvalid.rs\n";
|
||||
let result = parse_numstat(input);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(
|
||||
result.get(&RepoPath::new("valid.rs").unwrap()),
|
||||
Some(&DiffStat {
|
||||
added: 10,
|
||||
deleted: 5
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_numstat_incomplete_lines_skipped() {
|
||||
// Lines with fewer than 3 tab-separated fields are skipped
|
||||
let input = "10\t5\n7\t3\tok.rs\n";
|
||||
let result = parse_numstat(input);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(
|
||||
result.get(&RepoPath::new("ok.rs").unwrap()),
|
||||
Some(&DiffStat {
|
||||
added: 7,
|
||||
deleted: 3
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_numstat_zero_stats() {
|
||||
let input = "0\t0\tunchanged_but_present.rs\n";
|
||||
let result = parse_numstat(input);
|
||||
assert_eq!(
|
||||
result.get(&RepoPath::new("unchanged_but_present.rs").unwrap()),
|
||||
Some(&DiffStat {
|
||||
added: 0,
|
||||
deleted: 0
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_untracked_entries() {
|
||||
// Regression test for ZED-2XA: git can produce duplicate untracked entries
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ use git::repository::{
|
|||
UpstreamTrackingStatus, get_git_committer,
|
||||
};
|
||||
use git::stash::GitStash;
|
||||
use git::status::StageStatus;
|
||||
use git::status::{DiffStat, StageStatus};
|
||||
use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
|
||||
use git::{
|
||||
ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll,
|
||||
|
|
@ -41,7 +41,7 @@ use gpui::{
|
|||
WeakEntity, actions, anchored, deferred, point, size, uniform_list,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use language::{Buffer, File};
|
||||
use language::{Buffer, BufferEvent, File};
|
||||
use language_model::{
|
||||
ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
|
||||
};
|
||||
|
|
@ -51,6 +51,7 @@ use notifications::status_toast::{StatusToast, ToastIcon};
|
|||
use panel::{PanelHeader, panel_button, panel_filled_button, panel_icon_button};
|
||||
use project::{
|
||||
Fs, Project, ProjectPath,
|
||||
buffer_store::BufferStoreEvent,
|
||||
git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op},
|
||||
project_settings::{GitPathStyle, ProjectSettings},
|
||||
};
|
||||
|
|
@ -651,6 +652,8 @@ pub struct GitPanel {
|
|||
local_committer_task: Option<Task<()>>,
|
||||
bulk_staging: Option<BulkStaging>,
|
||||
stash_entries: GitStash,
|
||||
diff_stats: HashMap<RepoPath, DiffStat>,
|
||||
diff_stats_task: Task<()>,
|
||||
_settings_subscription: Subscription,
|
||||
}
|
||||
|
||||
|
|
@ -711,9 +714,11 @@ impl GitPanel {
|
|||
|
||||
let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
|
||||
let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view;
|
||||
let mut was_diff_stats = GitPanelSettings::get_global(cx).diff_stats;
|
||||
cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
|
||||
let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
|
||||
let tree_view = GitPanelSettings::get_global(cx).tree_view;
|
||||
let diff_stats = GitPanelSettings::get_global(cx).diff_stats;
|
||||
if tree_view != was_tree_view {
|
||||
this.view_mode = GitPanelViewMode::from_settings(cx);
|
||||
}
|
||||
|
|
@ -721,8 +726,18 @@ impl GitPanel {
|
|||
this.bulk_staging.take();
|
||||
this.update_visible_entries(window, cx);
|
||||
}
|
||||
if diff_stats != was_diff_stats {
|
||||
if diff_stats {
|
||||
this.fetch_diff_stats(cx);
|
||||
} else {
|
||||
this.diff_stats.clear();
|
||||
this.diff_stats_task = Task::ready(());
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
was_sort_by_path = sort_by_path;
|
||||
was_tree_view = tree_view;
|
||||
was_diff_stats = diff_stats;
|
||||
})
|
||||
.detach();
|
||||
|
||||
|
|
@ -777,6 +792,33 @@ impl GitPanel {
|
|||
)
|
||||
.detach();
|
||||
|
||||
let buffer_store = project.read(cx).buffer_store().clone();
|
||||
|
||||
for buffer in project.read(cx).opened_buffers(cx) {
|
||||
cx.subscribe(&buffer, |this, _buffer, event, cx| {
|
||||
if matches!(event, BufferEvent::Saved) {
|
||||
if GitPanelSettings::get_global(cx).diff_stats {
|
||||
this.fetch_diff_stats(cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
cx.subscribe(&buffer_store, |_this, _store, event, cx| {
|
||||
if let BufferStoreEvent::BufferAdded(buffer) = event {
|
||||
cx.subscribe(buffer, |this, _buffer, event, cx| {
|
||||
if matches!(event, BufferEvent::Saved) {
|
||||
if GitPanelSettings::get_global(cx).diff_stats {
|
||||
this.fetch_diff_stats(cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
let mut this = Self {
|
||||
active_repository,
|
||||
commit_editor,
|
||||
|
|
@ -817,6 +859,8 @@ impl GitPanel {
|
|||
entry_count: 0,
|
||||
bulk_staging: None,
|
||||
stash_entries: Default::default(),
|
||||
diff_stats: HashMap::default(),
|
||||
diff_stats_task: Task::ready(()),
|
||||
_settings_subscription,
|
||||
};
|
||||
|
||||
|
|
@ -3699,9 +3743,60 @@ impl GitPanel {
|
|||
editor.set_placeholder_text(&placeholder_text, window, cx)
|
||||
});
|
||||
|
||||
if GitPanelSettings::get_global(cx).diff_stats {
|
||||
self.fetch_diff_stats(cx);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn fetch_diff_stats(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(repo) = self.active_repository.clone() else {
|
||||
self.diff_stats.clear();
|
||||
return;
|
||||
};
|
||||
|
||||
let unstaged_rx = repo.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToWorktree, cx));
|
||||
let staged_rx = repo.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToIndex, cx));
|
||||
|
||||
self.diff_stats_task = cx.spawn(async move |this, cx| {
|
||||
let (unstaged_result, staged_result) =
|
||||
futures::future::join(unstaged_rx, staged_rx).await;
|
||||
|
||||
let mut combined = match unstaged_result {
|
||||
Ok(Ok(stats)) => stats,
|
||||
Ok(Err(err)) => {
|
||||
log::warn!("Failed to fetch unstaged diff stats: {err:?}");
|
||||
HashMap::default()
|
||||
}
|
||||
Err(_) => HashMap::default(),
|
||||
};
|
||||
|
||||
let staged = match staged_result {
|
||||
Ok(Ok(stats)) => Some(stats),
|
||||
Ok(Err(err)) => {
|
||||
log::warn!("Failed to fetch staged diff stats: {err:?}");
|
||||
None
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
if let Some(staged) = staged {
|
||||
for (path, stat) in staged {
|
||||
let entry = combined.entry(path).or_default();
|
||||
entry.added += stat.added;
|
||||
entry.deleted += stat.deleted;
|
||||
}
|
||||
}
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.diff_stats = combined;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
|
||||
fn header_state(&self, header_type: Section) -> ToggleState {
|
||||
let (staged_count, count) = match header_type {
|
||||
Section::New => (self.new_staged_count, self.new_count),
|
||||
|
|
@ -5113,6 +5208,8 @@ impl GitPanel {
|
|||
}
|
||||
});
|
||||
|
||||
let id_for_diff_stat = id.clone();
|
||||
|
||||
h_flex()
|
||||
.id(id)
|
||||
.h(self.list_item_height())
|
||||
|
|
@ -5129,6 +5226,19 @@ impl GitPanel {
|
|||
.hover(|s| s.bg(hover_bg))
|
||||
.active(|s| s.bg(active_bg))
|
||||
.child(name_row)
|
||||
.when(GitPanelSettings::get_global(cx).diff_stats, |el| {
|
||||
el.when_some(
|
||||
self.diff_stats.get(&entry.repo_path).copied(),
|
||||
move |this, stat| {
|
||||
let id = format!("diff-stat-{}", id_for_diff_stat);
|
||||
this.child(ui::DiffStat::new(
|
||||
id,
|
||||
stat.added as usize,
|
||||
stat.deleted as usize,
|
||||
))
|
||||
},
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.id(checkbox_wrapper_id)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ pub struct GitPanelSettings {
|
|||
pub sort_by_path: bool,
|
||||
pub collapse_untracked_diff: bool,
|
||||
pub tree_view: bool,
|
||||
pub diff_stats: bool,
|
||||
}
|
||||
|
||||
impl ScrollbarVisibility for GitPanelSettings {
|
||||
|
|
@ -58,6 +59,7 @@ impl Settings for GitPanelSettings {
|
|||
sort_by_path: git_panel.sort_by_path.unwrap(),
|
||||
collapse_untracked_diff: git_panel.collapse_untracked_diff.unwrap(),
|
||||
tree_view: git_panel.tree_view.unwrap(),
|
||||
diff_stats: git_panel.diff_stats.unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -529,6 +529,7 @@ impl GitStore {
|
|||
client.add_entity_request_handler(Self::handle_askpass);
|
||||
client.add_entity_request_handler(Self::handle_check_for_pushed_commits);
|
||||
client.add_entity_request_handler(Self::handle_git_diff);
|
||||
client.add_entity_request_handler(Self::handle_git_diff_stat);
|
||||
client.add_entity_request_handler(Self::handle_tree_diff);
|
||||
client.add_entity_request_handler(Self::handle_get_blob_content);
|
||||
client.add_entity_request_handler(Self::handle_open_unstaged_diff);
|
||||
|
|
@ -2684,6 +2685,45 @@ impl GitStore {
|
|||
Ok(proto::GitDiffResponse { diff })
|
||||
}
|
||||
|
||||
async fn handle_git_diff_stat(
|
||||
this: Entity<Self>,
|
||||
envelope: TypedEnvelope<proto::GitDiffStat>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<proto::GitDiffStatResponse> {
|
||||
let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
|
||||
let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
|
||||
let diff_type = match envelope.payload.diff_type() {
|
||||
proto::git_diff_stat::DiffType::HeadToIndex => DiffType::HeadToIndex,
|
||||
proto::git_diff_stat::DiffType::HeadToWorktree => DiffType::HeadToWorktree,
|
||||
proto::git_diff_stat::DiffType::MergeBase => {
|
||||
let base_ref = envelope
|
||||
.payload
|
||||
.merge_base_ref
|
||||
.ok_or_else(|| anyhow!("merge_base_ref is required for MergeBase diff type"))?;
|
||||
DiffType::MergeBase {
|
||||
base_ref: base_ref.into(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let stats = repository_handle
|
||||
.update(&mut cx, |repository_handle, cx| {
|
||||
repository_handle.diff_stat(diff_type, cx)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let entries = stats
|
||||
.into_iter()
|
||||
.map(|(path, stat)| proto::GitDiffStatEntry {
|
||||
path: path.to_proto(),
|
||||
added: stat.added,
|
||||
deleted: stat.deleted,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(proto::GitDiffStatResponse { entries })
|
||||
}
|
||||
|
||||
async fn handle_tree_diff(
|
||||
this: Entity<Self>,
|
||||
request: TypedEnvelope<proto::GetTreeDiff>,
|
||||
|
|
@ -5690,6 +5730,63 @@ impl Repository {
|
|||
})
|
||||
}
|
||||
|
||||
/// Fetches per-line diff statistics (additions/deletions) via `git diff --numstat`.
|
||||
pub fn diff_stat(
|
||||
&mut self,
|
||||
diff_type: DiffType,
|
||||
_cx: &App,
|
||||
) -> oneshot::Receiver<
|
||||
Result<collections::HashMap<git::repository::RepoPath, git::status::DiffStat>>,
|
||||
> {
|
||||
let id = self.id;
|
||||
self.send_job(None, move |repo, _cx| async move {
|
||||
match repo {
|
||||
RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
|
||||
backend.diff_stat(diff_type).await
|
||||
}
|
||||
RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
|
||||
let (proto_diff_type, merge_base_ref) = match &diff_type {
|
||||
DiffType::HeadToIndex => {
|
||||
(proto::git_diff_stat::DiffType::HeadToIndex.into(), None)
|
||||
}
|
||||
DiffType::HeadToWorktree => {
|
||||
(proto::git_diff_stat::DiffType::HeadToWorktree.into(), None)
|
||||
}
|
||||
DiffType::MergeBase { base_ref } => (
|
||||
proto::git_diff_stat::DiffType::MergeBase.into(),
|
||||
Some(base_ref.to_string()),
|
||||
),
|
||||
};
|
||||
let response = client
|
||||
.request(proto::GitDiffStat {
|
||||
project_id: project_id.0,
|
||||
repository_id: id.to_proto(),
|
||||
diff_type: proto_diff_type,
|
||||
merge_base_ref,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let stats = response
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let path = RepoPath::from_proto(&entry.path).log_err()?;
|
||||
Some((
|
||||
path,
|
||||
git::status::DiffStat {
|
||||
added: entry.added,
|
||||
deleted: entry.deleted,
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_branch(
|
||||
&mut self,
|
||||
branch_name: String,
|
||||
|
|
|
|||
|
|
@ -229,6 +229,29 @@ message GitDiffResponse {
|
|||
string diff = 1;
|
||||
}
|
||||
|
||||
message GitDiffStat {
|
||||
uint64 project_id = 1;
|
||||
uint64 repository_id = 2;
|
||||
DiffType diff_type = 3;
|
||||
optional string merge_base_ref = 4;
|
||||
|
||||
enum DiffType {
|
||||
HEAD_TO_WORKTREE = 0;
|
||||
HEAD_TO_INDEX = 1;
|
||||
MERGE_BASE = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message GitDiffStatResponse {
|
||||
repeated GitDiffStatEntry entries = 1;
|
||||
}
|
||||
|
||||
message GitDiffStatEntry {
|
||||
string path = 1;
|
||||
uint32 added = 2;
|
||||
uint32 deleted = 3;
|
||||
}
|
||||
|
||||
message GitInit {
|
||||
uint64 project_id = 1;
|
||||
string abs_path = 2;
|
||||
|
|
|
|||
|
|
@ -476,7 +476,9 @@ message Envelope {
|
|||
|
||||
SpawnKernel spawn_kernel = 426;
|
||||
SpawnKernelResponse spawn_kernel_response = 427;
|
||||
KillKernel kill_kernel = 428; // current max
|
||||
KillKernel kill_kernel = 428;
|
||||
GitDiffStat git_diff_stat = 429;
|
||||
GitDiffStatResponse git_diff_stat_response = 430; // current max
|
||||
}
|
||||
|
||||
reserved 87 to 88;
|
||||
|
|
|
|||
|
|
@ -322,6 +322,8 @@ messages!(
|
|||
(CheckForPushedCommitsResponse, Background),
|
||||
(GitDiff, Background),
|
||||
(GitDiffResponse, Background),
|
||||
(GitDiffStat, Background),
|
||||
(GitDiffStatResponse, Background),
|
||||
(GitInit, Background),
|
||||
(GetDebugAdapterBinary, Background),
|
||||
(DebugAdapterBinary, Background),
|
||||
|
|
@ -539,6 +541,7 @@ request_messages!(
|
|||
(GitRenameBranch, Ack),
|
||||
(CheckForPushedCommits, CheckForPushedCommitsResponse),
|
||||
(GitDiff, GitDiffResponse),
|
||||
(GitDiffStat, GitDiffStatResponse),
|
||||
(GitInit, Ack),
|
||||
(ToggleBreakpoint, Ack),
|
||||
(GetDebugAdapterBinary, DebugAdapterBinary),
|
||||
|
|
@ -727,6 +730,7 @@ entity_messages!(
|
|||
GitRemoveRemote,
|
||||
CheckForPushedCommits,
|
||||
GitDiff,
|
||||
GitDiffStat,
|
||||
GitInit,
|
||||
BreakpointsForFile,
|
||||
ToggleBreakpoint,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use agent::{
|
|||
use client::{Client, UserStore};
|
||||
use clock::FakeSystemClock;
|
||||
use collections::{HashMap, HashSet};
|
||||
use git::repository::DiffType;
|
||||
use language_model::{LanguageModelToolResultContent, fake_provider::FakeLanguageModel};
|
||||
use prompt_store::ProjectContext;
|
||||
|
||||
|
|
@ -1919,6 +1920,129 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA
|
|||
assert_eq!(server_branch.name(), "totally-new-branch");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_remote_git_diff_stat(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
|
||||
let fs = FakeFs::new(server_cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/code"),
|
||||
json!({
|
||||
"project1": {
|
||||
".git": {},
|
||||
"src": {
|
||||
"lib.rs": "line1\nline2\nline3\n",
|
||||
"new_file.rs": "added1\nadded2\n",
|
||||
},
|
||||
"README.md": "# project 1",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let dot_git = Path::new(path!("/code/project1/.git"));
|
||||
|
||||
// HEAD: lib.rs (2 lines), deleted.rs (1 line)
|
||||
fs.set_head_for_repo(
|
||||
dot_git,
|
||||
&[
|
||||
("src/lib.rs", "line1\nold_line2\n".into()),
|
||||
("src/deleted.rs", "was_here\n".into()),
|
||||
],
|
||||
"deadbeef",
|
||||
);
|
||||
// Index: lib.rs modified (4 lines), staged_only.rs new (2 lines)
|
||||
fs.set_index_for_repo(
|
||||
dot_git,
|
||||
&[
|
||||
("src/lib.rs", "line1\nold_line2\nline3\nline4\n".into()),
|
||||
("src/staged_only.rs", "x\ny\n".into()),
|
||||
],
|
||||
);
|
||||
|
||||
let (project, _headless) = init_test(&fs, cx, server_cx).await;
|
||||
let (_worktree, _) = project
|
||||
.update(cx, |project, cx| {
|
||||
project.find_or_create_worktree(path!("/code/project1"), true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
let repo_path = |s: &str| git::repository::RepoPath::new(s).unwrap();
|
||||
|
||||
let repository = project.update(cx, |project, cx| project.active_repository(cx).unwrap());
|
||||
|
||||
// --- HeadToWorktree ---
|
||||
let stats = cx
|
||||
.update(|cx| repository.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToWorktree, cx)))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// src/lib.rs: worktree 3 lines vs HEAD 2 lines
|
||||
let stat = stats.get(&repo_path("src/lib.rs")).expect("src/lib.rs");
|
||||
assert_eq!((stat.added, stat.deleted), (3, 2));
|
||||
|
||||
// src/new_file.rs: only in worktree (2 lines)
|
||||
let stat = stats
|
||||
.get(&repo_path("src/new_file.rs"))
|
||||
.expect("src/new_file.rs");
|
||||
assert_eq!((stat.added, stat.deleted), (2, 0));
|
||||
|
||||
// src/deleted.rs: only in HEAD (1 line)
|
||||
let stat = stats
|
||||
.get(&repo_path("src/deleted.rs"))
|
||||
.expect("src/deleted.rs");
|
||||
assert_eq!((stat.added, stat.deleted), (0, 1));
|
||||
|
||||
// README.md: only in worktree (1 line)
|
||||
let stat = stats.get(&repo_path("README.md")).expect("README.md");
|
||||
assert_eq!((stat.added, stat.deleted), (1, 0));
|
||||
|
||||
// --- HeadToIndex ---
|
||||
let stats = cx
|
||||
.update(|cx| repository.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToIndex, cx)))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// src/lib.rs: index 4 lines vs HEAD 2 lines
|
||||
let stat = stats.get(&repo_path("src/lib.rs")).expect("src/lib.rs");
|
||||
assert_eq!((stat.added, stat.deleted), (4, 2));
|
||||
|
||||
// src/staged_only.rs: only in index (2 lines)
|
||||
let stat = stats
|
||||
.get(&repo_path("src/staged_only.rs"))
|
||||
.expect("src/staged_only.rs");
|
||||
assert_eq!((stat.added, stat.deleted), (2, 0));
|
||||
|
||||
// src/deleted.rs: in HEAD but not in index
|
||||
let stat = stats
|
||||
.get(&repo_path("src/deleted.rs"))
|
||||
.expect("src/deleted.rs");
|
||||
assert_eq!((stat.added, stat.deleted), (0, 1));
|
||||
|
||||
// --- MergeBase (not implemented in FakeGitRepository) ---
|
||||
let stats = cx
|
||||
.update(|cx| {
|
||||
repository.update(cx, |repo, cx| {
|
||||
repo.diff_stat(
|
||||
DiffType::MergeBase {
|
||||
base_ref: "main".into(),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
stats.is_empty(),
|
||||
"MergeBase diff_stat should return empty from FakeGitRepository"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
|
||||
let fs = FakeFs::new(server_cx.executor());
|
||||
|
|
|
|||
|
|
@ -619,6 +619,11 @@ pub struct GitPanelSettingsContent {
|
|||
///
|
||||
/// Default: false
|
||||
pub tree_view: Option<bool>,
|
||||
|
||||
/// Whether to show the addition/deletion change count next to each file in the Git panel.
|
||||
///
|
||||
/// Default: false
|
||||
pub diff_stats: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
|
|
|
|||
|
|
@ -5039,7 +5039,7 @@ fn panels_page() -> SettingsPage {
|
|||
]
|
||||
}
|
||||
|
||||
fn git_panel_section() -> [SettingsPageItem; 10] {
|
||||
fn git_panel_section() -> [SettingsPageItem; 11] {
|
||||
[
|
||||
SettingsPageItem::SectionHeader("Git Panel"),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
|
|
@ -5181,6 +5181,24 @@ fn panels_page() -> SettingsPage {
|
|||
metadata: None,
|
||||
files: USER,
|
||||
}),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Diff Stats",
|
||||
description: "Whether to show the addition/deletion change count next to each file in the Git panel.",
|
||||
field: Box::new(SettingField {
|
||||
json_path: Some("git_panel.diff_stats"),
|
||||
pick: |settings_content| {
|
||||
settings_content.git_panel.as_ref()?.diff_stats.as_ref()
|
||||
},
|
||||
write: |settings_content, value| {
|
||||
settings_content
|
||||
.git_panel
|
||||
.get_or_insert_default()
|
||||
.diff_stats = value;
|
||||
},
|
||||
}),
|
||||
metadata: None,
|
||||
files: USER,
|
||||
}),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Scroll Bar",
|
||||
description: "How and when the scrollbar should be displayed.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue