From 33c64e89adaf3bd29e9d106f154113ecde99fa5b Mon Sep 17 00:00:00 2001 From: Trong Nguyen Date: Tue, 12 May 2026 12:03:40 +0700 Subject: [PATCH] Add file compare actions for git refs --- crates/git/src/git.rs | 4 + crates/git_ui/src/git_ui.rs | 395 ++++++++++++++++++-- crates/git_ui/src/project_diff.rs | 138 ++++++- crates/project/src/git_store/branch_diff.rs | 96 ++++- crates/project_panel/src/project_panel.rs | 54 ++- 5 files changed, 625 insertions(+), 62 deletions(-) diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index cc3fbe1e2590cb..1fed95e299b852 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -49,6 +49,10 @@ actions!( Blame, /// Shows the git history for the selected file, folder, or project. FileHistory, + /// Compares the current file with a branch. + CompareWithBranch, + /// Compares the current file with a commit. + CompareWithCommit, /// Stages the current file. StageFile, /// Unstages the current file. diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 4fda322cc89a23..687655c1f6e32d 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -12,15 +12,17 @@ mod blame_ui; pub mod clone; use git::{ - repository::{Branch, CommitDetails, Upstream, UpstreamTracking, UpstreamTrackingStatus}, + repository::{ + Branch, CommitDetails, RepoPath, Upstream, UpstreamTracking, UpstreamTrackingStatus, + }, status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode}, }; use gpui::{ App, ClipboardItem, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - SharedString, Subscription, Task, TaskExt, Window, + SharedString, Subscription, Task, TaskExt, WeakEntity, Window, }; use menu::{Cancel, Confirm}; -use project::git_store::Repository; +use project::{ProjectPath, git_store::Repository}; use project_diff::ProjectDiff; use time::OffsetDateTime; use ui::prelude::*; @@ -272,6 +274,8 @@ pub fn init(cx: &mut App) { copy_branch_name(workspace, cx); }); workspace.register_action(show_ref_picker); + workspace.register_action(compare_file_with_branch); + workspace.register_action(compare_file_with_commit); workspace.register_action( |workspace, action: &DiffClipboardWithSelectionData, window, cx| { if let Some(task) = TextDiffView::open(action, workspace, window, cx) { @@ -445,10 +449,215 @@ fn copy_branch_name(workspace: &mut Workspace, cx: &mut Context) { } } +#[derive(Clone)] +struct FileCompareTarget { + project_path: ProjectPath, + repo_path: RepoPath, + repository: Entity, +} + +#[derive(Clone)] +enum RefPickerMode { + ViewCommit, + CompareFile(FileCompareTarget), +} + +fn resolve_file_compare_target(workspace: &Workspace, cx: &App) -> Option { + let editor = workspace.active_item_as::(cx)?; + let editor = editor.read(cx); + let file = editor.file_at(editor.selections.newest_anchor().head(), cx)?; + let project_path = ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }; + + file_compare_target_for_project_path(workspace, project_path, cx) +} + +fn file_compare_target_for_project_path( + workspace: &Workspace, + project_path: ProjectPath, + cx: &App, +) -> Option { + let git_store = workspace.project().read(cx).git_store(); + let (repository, repo_path) = git_store + .read(cx) + .repository_and_path_for_project_path(&project_path, cx)?; + if repo_path.is_empty() { + return None; + } + + Some(FileCompareTarget { + project_path, + repo_path, + repository, + }) +} + +fn compare_file_with_branch( + workspace: &mut Workspace, + _: &git::CompareWithBranch, + window: &mut Window, + cx: &mut Context, +) { + let Some(target) = resolve_file_compare_target(workspace, cx) else { + return; + }; + compare_file_with_branch_for_target(target, workspace.weak_handle(), window, cx); +} + +pub fn compare_file_with_branch_at_path( + workspace: &mut Workspace, + project_path: ProjectPath, + window: &mut Window, + cx: &mut Context, +) { + let Some(target) = file_compare_target_for_project_path(workspace, project_path, cx) else { + return; + }; + compare_file_with_branch_for_target(target, workspace.weak_handle(), window, cx); +} + +fn compare_file_with_branch_for_target( + target: FileCompareTarget, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, +) { + let repo = target.repository.clone(); + window + .spawn(cx, async move |cx| -> anyhow::Result<()> { + let mut branches = repo.update(cx, |repo, _| repo.branches()).await??; + branches.sort_by_key(|branch| (branch.is_remote(), branch.name().to_string())); + branches.dedup_by(|left, right| left.ref_name == right.ref_name); + + let options = branches + .iter() + .map(|branch| SharedString::from(branch.name().to_string())) + .collect::>(); + let Some(selection) = cx + .update(|window, cx| { + picker_prompt::prompt( + "Compare with branch...", + options, + workspace.clone(), + window, + cx, + ) + })? + .await + else { + return Ok(()); + }; + let Some(branch) = branches.get(selection) else { + return Ok(()); + }; + + cx.update(|window, cx| { + deploy_compare_file_with_ref( + target, + branch.ref_name.to_string(), + branch.name().to_string(), + workspace, + window, + cx, + ); + })?; + + Ok(()) + }) + .detach_and_log_err(cx); +} + +fn compare_file_with_commit( + workspace: &mut Workspace, + _: &git::CompareWithCommit, + window: &mut Window, + cx: &mut Context, +) { + let Some(target) = resolve_file_compare_target(workspace, cx) else { + return; + }; + compare_file_with_commit_for_target(target, workspace, window, cx); +} + +pub fn compare_file_with_commit_at_path( + workspace: &mut Workspace, + project_path: ProjectPath, + window: &mut Window, + cx: &mut Context, +) { + let Some(target) = file_compare_target_for_project_path(workspace, project_path, cx) else { + return; + }; + compare_file_with_commit_for_target(target, workspace, window, cx); +} + +fn compare_file_with_commit_for_target( + target: FileCompareTarget, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, +) { + let workspace_entity = cx.entity(); + let repo = target.repository.clone(); + + workspace.toggle_modal(window, cx, |window, cx| { + RefPickerModal::new( + repo, + workspace_entity, + RefPickerMode::CompareFile(target), + window, + cx, + ) + }); +} + +fn deploy_compare_file_with_ref( + target: FileCompareTarget, + git_ref: String, + base_label: String, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, +) { + workspace + .update(cx, |workspace, cx| { + ProjectDiff::deploy_file_compare( + workspace, + target.repository, + target.project_path, + target.repo_path, + git_ref.into(), + base_label.into(), + window, + cx, + ); + }) + .ok(); +} + +fn show_git_error_toast( + title: &str, + git_ref: Option<&str>, + error: anyhow::Error, + workspace: &mut Workspace, + cx: &mut Context, +) { + let message = if let Some(git_ref) = git_ref { + format!("{title}: {git_ref}: {error}") + } else { + format!("{title}: {error}") + }; + let toast = Toast::new(NotificationId::unique::<()>(), message); + workspace.show_toast(toast, cx); +} + struct RefPickerModal { editor: Entity, repo: Entity, workspace: Entity, + mode: RefPickerMode, commit_details: Option, lookup_task: Option>, _editor_subscription: Subscription, @@ -458,6 +667,7 @@ impl RefPickerModal { fn new( repo: Entity, workspace: Entity, + mode: RefPickerMode, window: &mut Window, cx: &mut Context, ) -> Self { @@ -481,6 +691,7 @@ impl RefPickerModal { editor, repo, workspace, + mode, commit_details: None, lookup_task: None, _editor_subscription, @@ -543,6 +754,7 @@ impl RefPickerModal { let repo = self.repo.clone(); let workspace = self.workspace.clone(); + let mode = self.mode.clone(); window .spawn(cx, async move |cx| -> anyhow::Result<()> { @@ -550,23 +762,49 @@ impl RefPickerModal { let show_result = show_future.await; match show_result { - Ok(Ok(details)) => { - workspace.update_in(cx, |workspace, window, cx| { - CommitView::open( - details.sha.to_string(), - repo.downgrade(), - workspace.weak_handle(), - None, - None, - window, - cx, - ); - })?; - } + Ok(Ok(details)) => match mode { + RefPickerMode::ViewCommit => { + workspace.update_in(cx, |workspace, window, cx| { + CommitView::open( + details.sha.to_string(), + repo.downgrade(), + workspace.weak_handle(), + None, + None, + window, + cx, + ); + })?; + } + RefPickerMode::CompareFile(target) => { + workspace.update_in(cx, |workspace, window, cx| { + ProjectDiff::deploy_file_compare( + workspace, + target.repository, + target.project_path, + target.repo_path, + details.sha.to_string().into(), + details.sha.to_string().into(), + window, + cx, + ); + })?; + } + }, Ok(Err(_)) | Err(_) => { workspace.update(cx, |workspace, cx| { - let error = anyhow::anyhow!("View commit failed"); - Self::show_git_error_toast(&git_ref_string, error, workspace, cx); + let title = match mode { + RefPickerMode::ViewCommit => "View commit failed", + RefPickerMode::CompareFile(_) => "Compare file failed", + }; + let error = anyhow::anyhow!("invalid git ref"); + show_git_error_toast( + title, + Some(&git_ref_string), + error, + workspace, + cx, + ); }); } } @@ -576,16 +814,6 @@ impl RefPickerModal { .detach(); cx.emit(DismissEvent); } - - fn show_git_error_toast( - _git_ref: &str, - error: anyhow::Error, - workspace: &mut Workspace, - cx: &mut Context, - ) { - let toast = Toast::new(NotificationId::unique::<()>(), error.to_string()); - workspace.show_toast(toast, cx); - } } impl EventEmitter for RefPickerModal {} @@ -599,6 +827,10 @@ impl Focusable for RefPickerModal { impl Render for RefPickerModal { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let has_commit_details = self.commit_details.is_some(); + let title = match self.mode { + RefPickerMode::ViewCommit => "View Commit", + RefPickerMode::CompareFile(_) => "Compare with Commit", + }; let commit_preview = self.commit_details.as_ref().map(|details| { let commit_time = OffsetDateTime::from_unix_timestamp(details.commit_timestamp) .unwrap_or_else(|_| OffsetDateTime::now_utc()); @@ -648,7 +880,7 @@ impl Render for RefPickerModal { .w_full() .gap_1p5() .child(Icon::new(IconName::Hash).size(IconSize::XSmall)) - .child(Headline::new("View Commit").size(HeadlineSize::XSmall)), + .child(Headline::new(title).size(HeadlineSize::XSmall)), ) .child(div().px_3().w_full().child(self.editor.clone())) .when_some(commit_preview, |el, preview| { @@ -670,7 +902,13 @@ fn show_ref_picker( let workspace_entity = cx.entity(); workspace.toggle_modal(window, cx, |window, cx| { - RefPickerModal::new(repo, workspace_entity, window, cx) + RefPickerModal::new( + repo, + workspace_entity, + RefPickerMode::ViewCommit, + window, + cx, + ) }); } @@ -1148,6 +1386,7 @@ mod view_commit_tests { use super::*; use gpui::{TestAppContext, VisualTestContext, WindowHandle}; use language::language_settings::AllLanguageSettings; + use project::git_store::branch_diff::{DiffBase, DiffScope}; use project::project_settings::ProjectSettings; use project::{FakeFs, Project, WorktreeSettings}; use serde_json::json; @@ -1156,6 +1395,7 @@ mod view_commit_tests { use std::sync::Arc; use theme::LoadThemes; use util::path; + use util::rel_path::RelPath; use workspace::WorkspaceSettings; fn init_test(cx: &mut TestAppContext) { @@ -1166,6 +1406,7 @@ mod view_commit_tests { theme_settings::init(LoadThemes::JustBase, cx); AllLanguageSettings::register(cx); editor::init(cx); + crate::init(cx); ProjectSettings::register(cx); WorktreeSettings::register(cx); WorkspaceSettings::register(cx); @@ -1243,4 +1484,98 @@ mod view_commit_tests { assert!(!initial_modal_state); assert!(final_modal_state); } + + #[gpui::test] + async fn test_compare_file_with_commit_opens_ref_picker(cx: &mut TestAppContext) { + init_test(cx); + let fs = setup_git_repo(cx).await; + let (project, workspace) = create_test_workspace(fs, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + let project_path = ProjectPath { + worktree_id, + path: RelPath::unix("src/main.rs").unwrap().into_arc(), + }; + + workspace + .update(cx, |workspace, window, cx| { + compare_file_with_commit_at_path(workspace, project_path, window, cx); + }) + .unwrap(); + + let modal_is_compare = workspace + .read_with(cx, |workspace, cx| { + workspace + .active_modal::(cx) + .is_some_and(|modal| { + matches!(&modal.read(cx).mode, RefPickerMode::CompareFile(_)) + }) + }) + .unwrap_or(false); + + assert!(modal_is_compare); + } + + #[gpui::test] + async fn test_file_compare_deploys_project_diff_for_selected_file(cx: &mut TestAppContext) { + init_test(cx); + let fs = setup_git_repo(cx).await; + let (project, workspace) = create_test_workspace(fs, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + let project_path = ProjectPath { + worktree_id, + path: RelPath::unix("src/main.rs").unwrap().into_arc(), + }; + let target = workspace + .read_with(cx, |workspace, cx| { + file_compare_target_for_project_path(workspace, project_path.clone(), cx) + }) + .unwrap() + .unwrap(); + + workspace + .update(cx, |workspace, window, cx| { + ProjectDiff::deploy_file_compare( + workspace, + target.repository, + target.project_path, + target.repo_path.clone(), + "refs/heads/feature".into(), + "feature".into(), + window, + cx, + ); + }) + .unwrap(); + + let project_diff = workspace + .read_with(cx, |workspace, cx| { + workspace.active_item_as::(cx) + }) + .unwrap() + .unwrap(); + project_diff.read_with(cx, |project_diff, cx| { + assert_eq!( + project_diff.diff_base(cx), + &DiffBase::Compare { + base_ref: "refs/heads/feature".into(), + base_label: "feature".into(), + } + ); + assert_eq!( + project_diff.scope(cx), + &DiffScope::File { + project_path, + repo_path: target.repo_path, + } + ); + }); + } } diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 3301fbc66f76fd..27666817799bcc 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -30,7 +30,7 @@ use project::{ Project, ProjectPath, git_store::{ Repository, - branch_diff::{self, BranchDiffEvent, DiffBase}, + branch_diff::{self, BranchDiffEvent, DiffBase, DiffScope}, }, }; use settings::{Settings, SettingsStore}; @@ -144,6 +144,60 @@ impl ProjectDiff { .detach_and_notify_err(workspace_weak, window, cx); } + pub fn deploy_file_compare( + workspace: &mut Workspace, + repository: Entity, + project_path: ProjectPath, + repo_path: RepoPath, + base_ref: SharedString, + base_label: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + telemetry::event!("Git File Compare Opened"); + let project = workspace.project().clone(); + let workspace_handle = cx.entity(); + let diff_base = DiffBase::Compare { + base_ref, + base_label, + }; + let scope = DiffScope::File { + project_path, + repo_path, + }; + + let existing = workspace.items_of_type::(cx).find(|item| { + let project_diff = item.read(cx); + project_diff.diff_base(cx) == &diff_base && project_diff.scope(cx) == &scope + }); + if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + existing.update(cx, |project_diff, cx| { + let project_path = match project_diff.scope(cx) { + DiffScope::File { project_path, .. } => project_path.clone(), + DiffScope::All => return, + }; + project_diff.move_to_project_path(&project_path, window, cx); + }); + return; + } + + let project_diff = cx.new(|cx| { + let branch_diff = cx.new(|cx| { + branch_diff::BranchDiff::new_with_scope( + diff_base, + scope, + Some(repository), + project.clone(), + window, + cx, + ) + }); + Self::new_impl(branch_diff, project, workspace_handle, window, cx) + }); + workspace.add_item_to_active_pane(Box::new(project_diff), None, true, window, cx); + } + fn review_diff(&mut self, _: &ReviewDiff, window: &mut Window, cx: &mut Context) { let diff_base = self.diff_base(cx).clone(); let DiffBase::Merge { base_ref } = diff_base else { @@ -359,7 +413,9 @@ impl ProjectDiff { ); match branch_diff.read(cx).diff_base() { DiffBase::Head => {} - DiffBase::Merge { .. } => diff_display_editor.disable_diff_hunk_controls(cx), + DiffBase::Merge { .. } | DiffBase::Compare { .. } => { + diff_display_editor.disable_diff_hunk_controls(cx) + } } diff_display_editor.rhs_editor().update(cx, |editor, cx| { editor.set_show_diff_review_button(true, cx); @@ -370,7 +426,7 @@ impl ProjectDiff { workspace: workspace.downgrade(), }); } - DiffBase::Merge { .. } => { + DiffBase::Merge { .. } | DiffBase::Compare { .. } => { editor.register_addon(BranchDiffAddon { branch_diff: branch_diff.clone(), }); @@ -452,6 +508,10 @@ impl ProjectDiff { self.branch_diff.read(cx).diff_base() } + pub fn scope<'a>(&'a self, cx: &'a App) -> &'a DiffScope { + self.branch_diff.read(cx).scope() + } + pub fn move_to_entry( &mut self, entry: GitStatusEntry, @@ -541,6 +601,20 @@ impl ProjectDiff { self.multibuffer.read(cx).snapshot(cx).total_changed_lines() } + fn compare_details(&self, cx: &App) -> Option<(SharedString, SharedString, SharedString)> { + let DiffBase::Compare { base_label, .. } = self.diff_base(cx) else { + return None; + }; + let DiffScope::File { repo_path, .. } = self.scope(cx) else { + return None; + }; + Some(( + base_label.clone(), + "HEAD / Working Tree".into(), + repo_path.as_ref().as_unix_str().into(), + )) + } + /// Returns the total count of review comments across all hunks/files. pub fn total_review_comment_count(&self) -> usize { self.review_comment_count @@ -957,6 +1031,9 @@ impl Item for ProjectDiff { match self.diff_base(cx) { DiffBase::Head => Some("Project Diff".into()), DiffBase::Merge { .. } => Some("Branch Diff".into()), + DiffBase::Compare { base_label, .. } => { + Some(format!("Compare with {}", base_label).into()) + } } } @@ -974,6 +1051,12 @@ impl Item for ProjectDiff { match self.branch_diff.read(cx).diff_base() { DiffBase::Head => "Uncommitted Changes".into(), DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(), + DiffBase::Compare { .. } => match self.scope(cx) { + DiffScope::File { repo_path, .. } => { + format!("Compare {}", repo_path.as_ref().as_unix_str()).into() + } + DiffScope::All => "Compare".into(), + }, } } @@ -1026,8 +1109,21 @@ impl Item for ProjectDiff { let Some(workspace) = self.workspace.upgrade() else { return Task::ready(None); }; + let diff_base = self.diff_base(cx).clone(); + let scope = self.scope(cx).clone(); + let repo = self.branch_diff.read(cx).repo().cloned(); Task::ready(Some(cx.new(|cx| { - ProjectDiff::new(self.project.clone(), workspace, window, cx) + let branch_diff = cx.new(|cx| { + branch_diff::BranchDiff::new_with_scope( + diff_base, + scope, + repo, + self.project.clone(), + window, + cx, + ) + }); + ProjectDiff::new_impl(branch_diff, self.project.clone(), workspace, window, cx) }))) } @@ -1113,6 +1209,7 @@ impl Render for ProjectDiff { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_empty = self.multibuffer.read(cx).is_empty(); let is_branch_diff_view = matches!(self.diff_base(cx), DiffBase::Merge { .. }); + let compare_path = self.compare_details(cx).map(|(_, _, path)| path); div() .track_focus(&self.focus_handle) @@ -1136,15 +1233,15 @@ impl Render for ProjectDiff { None }; let keybinding_focus_handle = self.focus_handle(cx); + let empty_label = compare_path + .as_ref() + .map(|path| format!("No changes in {path}")) + .unwrap_or_else(|| "No uncommitted changes".to_string()); el.child( v_flex() .gap_1() - .child( - h_flex() - .justify_around() - .child(Label::new("No uncommitted changes")), - ) - .map(|el| match remote_button { + .child(h_flex().justify_around().child(Label::new(empty_label))) + .when(compare_path.is_none(), |el| match remote_button { Some(button) => el.child(h_flex().justify_around().child(button)), None => el.child( h_flex() @@ -1619,7 +1716,12 @@ impl ToolbarItemView for BranchDiffToolbar { ) -> ToolbarItemLocation { self.project_diff = active_pane_item .and_then(|item| item.act_as::(cx)) - .filter(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. })) + .filter(|item| { + matches!( + item.read(cx).diff_base(cx), + DiffBase::Merge { .. } | DiffBase::Compare { .. } + ) + }) .map(|entity| entity.downgrade()); if self.project_diff.is_some() { ToolbarItemLocation::PrimaryRight @@ -1645,6 +1747,7 @@ impl Render for BranchDiffToolbar { let focus_handle = project_diff.focus_handle(cx); let review_count = project_diff.read(cx).total_review_comment_count(); let (additions, deletions) = project_diff.read(cx).calculate_changed_lines(cx); + let compare_details = project_diff.read(cx).compare_details(cx); let is_multibuffer_empty = project_diff.read(cx).multibuffer.read(cx).is_empty(); let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx); @@ -1658,6 +1761,19 @@ impl Render for BranchDiffToolbar { .flex_wrap() .justify_end() .gap_2() + .when_some( + compare_details, + |this, (base_label, current_label, repo_path)| { + this.child( + h_group_sm() + .child(Label::new(format!("Base: {base_label}")).color(Color::Muted)) + .child( + Label::new(format!("Current: {current_label}")).color(Color::Muted), + ) + .child(Label::new(repo_path).color(Color::Muted)), + ) + }, + ) .when(!is_multibuffer_empty, |this| { this.child(DiffStat::new( "branch-diff-stat", diff --git a/crates/project/src/git_store/branch_diff.rs b/crates/project/src/git_store/branch_diff.rs index dc7c8bf647585d..8655cc1c4d724e 100644 --- a/crates/project/src/git_store/branch_diff.rs +++ b/crates/project/src/git_store/branch_diff.rs @@ -24,7 +24,13 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum DiffBase { Head, - Merge { base_ref: SharedString }, + Merge { + base_ref: SharedString, + }, + Compare { + base_ref: SharedString, + base_label: SharedString, + }, } impl DiffBase { @@ -33,8 +39,18 @@ impl DiffBase { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DiffScope { + All, + File { + project_path: crate::ProjectPath, + repo_path: RepoPath, + }, +} + pub struct BranchDiff { diff_base: DiffBase, + scope: DiffScope, repo: Option>, project: Entity, base_commit: Option, @@ -57,9 +73,20 @@ impl BranchDiff { project: Entity, window: &mut Window, cx: &mut Context, + ) -> Self { + Self::new_with_scope(source, DiffScope::All, None, project, window, cx) + } + + pub fn new_with_scope( + source: DiffBase, + scope: DiffScope, + repo: Option>, + project: Entity, + window: &mut Window, + cx: &mut Context, ) -> Self { let git_store = project.read(cx).git_store().clone(); - let repo = git_store.read(cx).active_repository(); + let repo = repo.or_else(|| git_store.read(cx).active_repository()); let git_store_subscription = cx.subscribe_in( &git_store, window, @@ -95,6 +122,7 @@ impl BranchDiff { Self { diff_base: source, + scope, repo, project, tree_diff: None, @@ -110,6 +138,10 @@ impl BranchDiff { &self.diff_base } + pub fn scope(&self) -> &DiffScope { + &self.scope + } + pub fn set_repo(&mut self, repo: Option>, cx: &mut Context) { self.repo = repo; self.tree_diff = None; @@ -228,7 +260,7 @@ impl BranchDiff { (Some(FileStatus::Tracked(_)), Some(tree_status)) => { Some(FileStatus::Tracked(TrackedStatus { index_status: match tree_status { - TreeDiffStatus::Added { .. } => StatusCode::Added, + TreeDiffStatus::Added => StatusCode::Added, _ => StatusCode::Modified, }, worktree_status: match tree_status { @@ -249,22 +281,22 @@ impl BranchDiff { cx: &mut AsyncWindowContext, ) -> Result<()> { let task = this.update(cx, |this, cx| { - let DiffBase::Merge { base_ref } = this.diff_base.clone() else { - return None; + let diff_type = match this.diff_base.clone() { + DiffBase::Head => return None, + DiffBase::Merge { base_ref } => DiffTreeType::MergeBase { + base: base_ref, + head: "HEAD".into(), + }, + DiffBase::Compare { base_ref, .. } => DiffTreeType::Since { + base: base_ref, + head: "HEAD".into(), + }, }; let Some(repo) = this.repo.as_ref() else { this.tree_diff.take(); return None; }; - repo.update(cx, |repo, cx| { - Some(repo.diff_tree( - DiffTreeType::MergeBase { - base: base_ref, - head: "HEAD".into(), - }, - cx, - )) - }) + repo.update(cx, |repo, cx| Some(repo.diff_tree(diff_type, cx))) })?; let Some(task) = task else { return Ok(()) }; @@ -288,6 +320,36 @@ impl BranchDiff { }; self.project.update(cx, |_project, cx| { + if let DiffScope::File { + project_path, + repo_path, + } = &self.scope + { + let diff_from_head = repo + .read(cx) + .status_for_path(repo_path) + .map(|entry| entry.status); + let branch_diff = self + .tree_diff + .as_ref() + .and_then(|tree_diff| tree_diff.entries.get(repo_path)) + .cloned(); + let Some(status) = self.merge_statuses(diff_from_head, branch_diff.as_ref()) else { + return; + }; + if !status.has_changes() { + return; + } + + let task = Self::load_buffer(branch_diff, project_path.clone(), repo.clone(), cx); + output.push(DiffBuffer { + repo_path: repo_path.clone(), + load: task, + file_status: status, + }); + return; + } + let mut seen = HashSet::default(); for item in repo.read(cx).cached_status() { @@ -359,8 +421,8 @@ impl BranchDiff { let changes = if let Some(entry) = branch_diff { let oid = match entry { - git::status::TreeDiffStatus::Added { .. } => None, - git::status::TreeDiffStatus::Modified { old, .. } + git::status::TreeDiffStatus::Added => None, + git::status::TreeDiffStatus::Modified { old } | git::status::TreeDiffStatus::Deleted { old } => Some(old), }; project @@ -385,7 +447,7 @@ impl BranchDiff { fn diff_status_to_file_status(branch_diff: &git::status::TreeDiffStatus) -> FileStatus { let file_status = match branch_diff { - git::status::TreeDiffStatus::Added { .. } => FileStatus::Tracked(TrackedStatus { + git::status::TreeDiffStatus::Added => FileStatus::Tracked(TrackedStatus { index_status: StatusCode::Added, worktree_status: StatusCode::Added, }), diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 780d8c9274e754..4a98b9d7908618 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1047,6 +1047,7 @@ impl ProjectPanel { let is_remote = project.is_remote(); let is_collab = project.is_via_collab(); let is_local = project.is_local() || project.is_via_wsl_with_host_interop(cx); + let has_git_changes = self.has_git_changes(entry_id); let settings = ProjectPanelSettings::get_global(cx); let visible_worktrees_count = project.visible_worktrees(cx).count(); @@ -1135,9 +1136,9 @@ impl ProjectPanel { "Copy Relative Path", Box::new(zed_actions::workspace::CopyRelativePath), ) - .when(has_git_repo, |menu| { - menu.separator() - .when(!is_dir && self.has_git_changes(entry_id), |menu| { + .when(has_git_repo && !is_dir, |menu| { + menu.separator().submenu("Git", move |menu, _, _| { + menu.when(has_git_changes, |menu| { menu.action( "Restore File", Box::new(git::RestoreFile { skip_prompt: false }), @@ -1145,8 +1146,19 @@ impl ProjectPanel { }) .action("Add to .gitignore", Box::new(git::AddToGitignore)) .when(has_history, |menu| { - menu.action("View History", Box::new(git::FileHistory)) + menu.separator() + .action("File History", Box::new(git::FileHistory)) }) + .when(!has_history, |menu| menu.separator()) + .action( + "Compare with Branch...", + Box::new(git::CompareWithBranch), + ) + .action( + "Compare with Commit...", + Box::new(git::CompareWithCommit), + ) + }) }) .when(!should_hide_rename, |menu| { menu.separator().action("Rename", Box::new(Rename)) @@ -3512,6 +3524,38 @@ impl ProjectPanel { } } + fn compare_with_branch( + &mut self, + _: &git::CompareWithBranch, + window: &mut Window, + cx: &mut Context, + ) { + let Some(project_path) = self.selected_entry_project_path(cx) else { + return; + }; + self.workspace + .update(cx, |workspace, cx| { + git_ui::compare_file_with_branch_at_path(workspace, project_path, window, cx); + }) + .ok(); + } + + fn compare_with_commit( + &mut self, + _: &git::CompareWithCommit, + window: &mut Window, + cx: &mut Context, + ) { + let Some(project_path) = self.selected_entry_project_path(cx) else { + return; + }; + self.workspace + .update(cx, |workspace, cx| { + git_ui::compare_file_with_commit_at_path(workspace, project_path, window, cx); + }) + .ok(); + } + fn open_system(&mut self, _: &OpenWithSystem, _: &mut Window, cx: &mut Context) { if let Some((worktree, entry)) = self.selected_entry(cx) { let abs_path = worktree.absolutize(&entry.path); @@ -6676,6 +6720,8 @@ impl Render for ProjectPanel { .on_action(cx.listener(Self::fold_directory)) .on_action(cx.listener(Self::remove_from_project)) .on_action(cx.listener(Self::compare_marked_files)) + .on_action(cx.listener(Self::compare_with_branch)) + .on_action(cx.listener(Self::compare_with_commit)) .when(cx.has_flag::(), |el| { el.on_action(cx.listener(Self::undo)) .on_action(cx.listener(Self::redo))