Files
zed/crates/git_ui/src/project_diff.rs
T
Cole Miller 8612c626d9 Fix some overcomputation in the project diff (#57859)
- Don't emit unnecessary edited events from the multibuffer when setting
excerpts is a no-op
- Limit excerpt recomputation in the project diff when diffs or
conflicts change to just the affected buffer
- Ignore diff events in the project diff that can't affect excerpt
boundaries

Also includes some drive-by improvements to the handling of merge
conflicts:
- Await initial conflicts parse when opening a conflict set (like
diffs), so we don't see an initial incorrect "no conflicts" state
- Make the project diff subscribe to conflicts directly instead of
indirecting through the editor (also like diffs)
- Instead of saving the affected buffer when resolving a conflict, just
avoid re-registering dirty buffers with the project diff, so that
excerpts for conflicts don't disappear quite so eagerly (once more: like
diffs)

The combination of these changes greatly cuts down the amount of time
spent syncing the `DisplayMap`, which is especially bad for the split
view.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- Improved the performance of the project diff, especially in the
presence of merge conflicts
- Fixed conflicts disappearing too quickly from the project diff when
editing
- Fixed conflicts sometimes taking a while to appear in the project diff
during large merges
2026-06-04 21:54:03 +00:00

3055 lines
104 KiB
Rust

use crate::{
branch_picker, conflict_view,
git_panel::{GitPanel, GitPanelAddon, GitStatusEntry},
git_panel_settings::GitPanelSettings,
};
use agent_settings::AgentSettings;
use anyhow::{Context as _, Result, anyhow};
use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
use collections::HashMap;
use editor::{
Addon, Editor, EditorEvent, EditorSettings, SelectionEffects, SplittableEditor,
actions::{GoToHunk, GoToPreviousHunk, SendReviewToAgent},
multibuffer_context_lines,
scroll::Autoscroll,
};
use futures_lite::future::yield_now;
use git::repository::DiffType;
use git::{
Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext, repository::RepoPath,
status::FileStatus,
};
use gpui::{
Action, AnyElement, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter,
FocusHandle, Focusable, Render, Subscription, Task, WeakEntity, actions,
};
use language::{Anchor, Buffer, BufferId, Capability, OffsetRangeExt};
use multi_buffer::{MultiBuffer, PathKey};
use project::{
ConflictSet, Project, ProjectPath,
git_store::{
Repository,
branch_diff::{self, BranchDiffEvent, DiffBase},
},
};
use settings::{Settings, SettingsStore};
use std::any::{Any, TypeId};
use std::collections::BTreeMap;
use std::sync::Arc;
use theme::ActiveTheme;
use ui::{
CommonAnimationExt as _, DiffStat, Divider, KeyBinding, PopoverMenu, Tooltip, prelude::*,
vertical_divider,
};
use util::{ResultExt as _, rel_path::RelPath};
use workspace::{
CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
ToolbarItemView, Workspace,
item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
notifications::NotifyTaskExt,
searchable::SearchableItemHandle,
};
use zed_actions::agent::ReviewBranchDiff;
use ztracing::instrument;
actions!(
git,
[
/// Shows the diff between the working directory and the index.
Diff,
/// Adds files to the git staging area.
Add,
/// Shows the diff between the working directory and your default
/// branch (typically main or master).
BranchDiff,
/// Opens a new agent thread with the branch diff for review.
ReviewDiff,
LeaderAndFollower,
/// Compare with a specific branch
CompareWithBranch,
]
);
struct BufferSubscriptions {
_diff: Entity<BufferDiff>,
_diff_subscription: Subscription,
_conflict_set: Entity<ConflictSet>,
_conflict_set_subscription: Subscription,
}
pub struct ProjectDiff {
project: Entity<Project>,
multibuffer: Entity<MultiBuffer>,
branch_diff: Entity<branch_diff::BranchDiff>,
editor: Entity<SplittableEditor>,
buffer_subscriptions: HashMap<Arc<RelPath>, BufferSubscriptions>,
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
pending_scroll: Option<PathKey>,
review_comment_count: usize,
_task: Task<Result<()>>,
_subscription: Subscription,
}
const CONFLICT_SORT_PREFIX: u64 = 1;
const TRACKED_SORT_PREFIX: u64 = 2;
const NEW_SORT_PREFIX: u64 = 3;
impl ProjectDiff {
pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
workspace.register_action(Self::deploy);
workspace.register_action(Self::deploy_branch_diff);
workspace.register_action(Self::compare_with_branch);
workspace.register_action(|workspace, _: &Add, window, cx| {
Self::deploy(workspace, &Diff, window, cx);
});
workspace::register_serializable_item::<ProjectDiff>(cx);
}
fn deploy(
workspace: &mut Workspace,
_: &Diff,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
Self::deploy_at(workspace, None, window, cx)
}
fn deploy_branch_diff(
workspace: &mut Workspace,
_: &BranchDiff,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
telemetry::event!("Git Branch Diff Opened");
let project = workspace.project().clone();
let Some(intended_repo) = project.read(cx).active_repository(cx) else {
let workspace = cx.entity().downgrade();
window
.spawn(cx, async |_cx| {
let result: Result<()> = Err(anyhow!("No active repository"));
result
})
.detach_and_notify_err(workspace, window, cx);
return;
};
let default_branch = intended_repo.update(cx, |repo, _| repo.default_branch(true));
let workspace = cx.entity();
let workspace_weak = workspace.downgrade();
window
.spawn(cx, async move |cx| {
let base_ref = default_branch
.await??
.context("Could not determine default branch")?;
workspace.update_in(cx, |workspace, window, cx| {
Self::deploy_branch_diff_with_base_ref(
workspace,
project,
intended_repo,
base_ref,
window,
cx,
);
})?;
anyhow::Ok(())
})
.detach_and_notify_err(workspace_weak, window, cx);
}
fn compare_with_branch(
workspace: &mut Workspace,
_: &CompareWithBranch,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let project = workspace.project().clone();
let Some(repository) = project.read(cx).active_repository(cx) else {
let workspace = cx.entity().downgrade();
window
.spawn(cx, async |_cx| {
let result: Result<()> = Err(anyhow!("No active repository"));
result
})
.detach_and_notify_err(workspace, window, cx);
return;
};
let selected_branch = workspace.active_item_as::<Self>(cx).and_then(|item| {
match item.read(cx).diff_base(cx) {
DiffBase::Merge { base_ref } => Some(base_ref.clone()),
DiffBase::Head => None,
}
});
let workspace_handle = workspace.weak_handle();
let on_select = Arc::new({
let repository = repository.clone();
let workspace = workspace_handle.clone();
move |branch: git::repository::Branch, window: &mut Window, cx: &mut App| {
let base_ref: SharedString = branch.name().to_owned().into();
workspace
.update(cx, |workspace, cx| {
Self::deploy_branch_diff_with_base_ref(
workspace,
project.clone(),
repository.clone(),
base_ref,
window,
cx,
);
})
.ok();
}
});
workspace.toggle_modal(window, cx, |window, cx| {
branch_picker::select_modal(
workspace_handle,
Some(repository),
selected_branch,
on_select,
window,
cx,
)
});
}
fn deploy_branch_diff_with_base_ref(
workspace: &mut Workspace,
project: Entity<Project>,
intended_repo: Entity<Repository>,
base_ref: SharedString,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let existing = workspace.items_of_type::<Self>(cx).find(|item| {
let item = item.read(cx);
matches!(
item.diff_base(cx),
DiffBase::Merge { base_ref: existing_base_ref } if existing_base_ref == &base_ref
)
});
if let Some(existing) = existing {
workspace.activate_item(&existing, true, true, window, cx);
let needs_switch = existing
.read(cx)
.branch_diff
.read(cx)
.repo()
.map_or(true, |current| {
current.read(cx).id != intended_repo.read(cx).id
});
if needs_switch {
existing.update(cx, |project_diff, cx| {
project_diff.branch_diff.update(cx, |branch_diff, cx| {
branch_diff.set_repo(Some(intended_repo), cx);
});
});
}
return;
}
let workspace = cx.entity();
let workspace_weak = workspace.downgrade();
window
.spawn(cx, async move |cx| {
let this = cx
.update(|window, cx| {
Self::new_with_branch_base(
project,
workspace.clone(),
base_ref,
intended_repo,
window,
cx,
)
})?
.await?;
workspace
.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx);
})
.ok();
anyhow::Ok(())
})
.detach_and_notify_err(workspace_weak, window, cx);
}
fn review_diff(&mut self, _: &ReviewDiff, window: &mut Window, cx: &mut Context<Self>) {
let diff_base = self.diff_base(cx).clone();
let DiffBase::Merge { base_ref } = diff_base else {
return;
};
let Some(repo) = self.branch_diff.read(cx).repo().cloned() else {
return;
};
let diff_receiver = repo.update(cx, |repo, cx| {
repo.diff(
DiffType::MergeBase {
base_ref: base_ref.clone(),
},
cx,
)
});
let workspace = self.workspace.clone();
window
.spawn(cx, {
let workspace = workspace.clone();
async move |cx| {
let diff_text = diff_receiver.await??;
if let Some(workspace) = workspace.upgrade() {
workspace.update_in(cx, |_workspace, window, cx| {
window.dispatch_action(
ReviewBranchDiff {
diff_text: diff_text.into(),
base_ref,
}
.boxed_clone(),
cx,
);
})?;
}
anyhow::Ok(())
}
})
.detach_and_notify_err(workspace, window, cx);
}
pub fn deploy_at(
workspace: &mut Workspace,
entry: Option<GitStatusEntry>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
telemetry::event!(
"Git Diff Opened",
source = if entry.is_some() {
"Git Panel"
} else {
"Action"
}
);
let intended_repo = workspace.project().read(cx).active_repository(cx);
let existing = workspace
.items_of_type::<Self>(cx)
.find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head));
let project_diff = if let Some(existing) = existing {
existing.update(cx, |project_diff, cx| {
project_diff.move_to_beginning(window, cx);
});
workspace.activate_item(&existing, true, true, window, cx);
existing
} else {
let workspace_handle = cx.entity();
let project_diff =
cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
workspace.add_item_to_active_pane(
Box::new(project_diff.clone()),
None,
true,
window,
cx,
);
project_diff
};
if let Some(intended) = &intended_repo {
let needs_switch = project_diff
.read(cx)
.branch_diff
.read(cx)
.repo()
.map_or(true, |current| current.read(cx).id != intended.read(cx).id);
if needs_switch {
project_diff.update(cx, |project_diff, cx| {
project_diff.branch_diff.update(cx, |branch_diff, cx| {
branch_diff.set_repo(Some(intended.clone()), cx);
});
});
}
}
if let Some(entry) = entry {
project_diff.update(cx, |project_diff, cx| {
project_diff.move_to_entry(entry, window, cx);
})
}
}
pub fn deploy_at_project_path(
workspace: &mut Workspace,
project_path: ProjectPath,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
telemetry::event!("Git Diff Opened", source = "Agent Panel");
let existing = workspace
.items_of_type::<Self>(cx)
.find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head));
let project_diff = if let Some(existing) = existing {
workspace.activate_item(&existing, true, true, window, cx);
existing
} else {
let workspace_handle = cx.entity();
let project_diff =
cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
workspace.add_item_to_active_pane(
Box::new(project_diff.clone()),
None,
true,
window,
cx,
);
project_diff
};
project_diff.update(cx, |project_diff, cx| {
project_diff.move_to_project_path(&project_path, window, cx);
});
}
pub fn autoscroll(&self, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |editor, cx| {
editor.request_autoscroll(Autoscroll::fit(), cx);
})
})
}
#[cfg(test)]
#[allow(dead_code)]
fn new_with_default_branch(
project: Entity<Project>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else {
return Task::ready(Err(anyhow!("No active repository")));
};
let main_branch = repo.update(cx, |repo, _| repo.default_branch(true));
window.spawn(cx, async move |cx| {
let main_branch = main_branch
.await??
.context("Could not determine default branch")?;
let branch_diff = cx.new_window_entity(|window, cx| {
let mut branch_diff = branch_diff::BranchDiff::new(
DiffBase::Merge {
base_ref: main_branch,
},
project.clone(),
window,
cx,
);
branch_diff.set_repo(Some(repo.clone()), cx);
branch_diff
})?;
cx.new_window_entity(|window, cx| {
Self::new_impl(branch_diff, project, workspace, window, cx)
})
})
}
fn new_with_branch_base(
project: Entity<Project>,
workspace: Entity<Workspace>,
base_ref: SharedString,
repo: Entity<Repository>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
window.spawn(cx, async move |cx| {
let branch_diff = cx.new_window_entity(|window, cx| {
let mut branch_diff = branch_diff::BranchDiff::new(
DiffBase::Merge { base_ref },
project.clone(),
window,
cx,
);
branch_diff.set_repo(Some(repo.clone()), cx);
branch_diff
})?;
cx.new_window_entity(|window, cx| {
Self::new_impl(branch_diff, project, workspace, window, cx)
})
})
}
fn new(
project: Entity<Project>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let branch_diff =
cx.new(|cx| branch_diff::BranchDiff::new(DiffBase::Head, project.clone(), window, cx));
Self::new_impl(branch_diff, project, workspace, window, cx)
}
fn new_impl(
branch_diff: Entity<branch_diff::BranchDiff>,
project: Entity<Project>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
let multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
multibuffer.set_all_diff_hunks_expanded(cx);
multibuffer
});
let editor = cx.new(|cx| {
let diff_display_editor = SplittableEditor::new(
EditorSettings::get_global(cx).diff_view_style,
multibuffer.clone(),
project.clone(),
workspace.clone(),
window,
cx,
);
match branch_diff.read(cx).diff_base() {
DiffBase::Head => {}
DiffBase::Merge { .. } => 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);
match branch_diff.read(cx).diff_base() {
DiffBase::Head => {
editor.register_addon(GitPanelAddon {
workspace: workspace.downgrade(),
});
}
DiffBase::Merge { .. } => {
editor.register_addon(BranchDiffAddon {
branch_diff: branch_diff.clone(),
});
}
}
});
diff_display_editor
});
let editor_subscription = cx.subscribe_in(&editor, window, Self::handle_editor_event);
let primary_editor = editor.read(cx).rhs_editor().clone();
let review_comment_subscription =
cx.subscribe(&primary_editor, |this, _editor, event: &EditorEvent, cx| {
if let EditorEvent::ReviewCommentsChanged { total_count } = event {
this.review_comment_count = *total_count;
cx.notify();
}
});
let branch_diff_subscription = cx.subscribe_in(
&branch_diff,
window,
move |this, _git_store, event, window, cx| match event {
BranchDiffEvent::FileListChanged => {
this._task = window.spawn(cx, {
let this = cx.weak_entity();
async |cx| Self::refresh(this, cx).await
})
}
BranchDiffEvent::DiffBaseChanged => {
this.pending_scroll.take();
this._task = window.spawn(cx, {
let this = cx.weak_entity();
async |cx| Self::refresh(this, cx).await
})
}
},
);
let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
let mut was_collapse_untracked_diff =
GitPanelSettings::get_global(cx).collapse_untracked_diff;
cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
let is_collapse_untracked_diff =
GitPanelSettings::get_global(cx).collapse_untracked_diff;
if is_sort_by_path != was_sort_by_path
|| is_collapse_untracked_diff != was_collapse_untracked_diff
{
this._task = {
window.spawn(cx, {
let this = cx.weak_entity();
async |cx| Self::refresh(this, cx).await
})
}
}
was_sort_by_path = is_sort_by_path;
was_collapse_untracked_diff = is_collapse_untracked_diff;
})
.detach();
let task = window.spawn(cx, {
let this = cx.weak_entity();
async |cx| Self::refresh(this, cx).await
});
Self {
project,
workspace: workspace.downgrade(),
branch_diff,
focus_handle,
editor,
multibuffer,
buffer_subscriptions: Default::default(),
pending_scroll: None,
review_comment_count: 0,
_task: task,
_subscription: Subscription::join(
branch_diff_subscription,
Subscription::join(editor_subscription, review_comment_subscription),
),
}
}
pub fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
self.branch_diff.read(cx).diff_base()
}
pub fn move_to_entry(
&mut self,
entry: GitStatusEntry,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(git_repo) = self.branch_diff.read(cx).repo() else {
return;
};
let repo = git_repo.read(cx);
let sort_prefix = sort_prefix(repo, &entry.repo_path, entry.status, cx);
let path_key = PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone());
self.move_to_path(path_key, window, cx)
}
pub fn move_to_project_path(
&mut self,
project_path: &ProjectPath,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(git_repo) = self.branch_diff.read(cx).repo() else {
return;
};
let Some(repo_path) = git_repo
.read(cx)
.project_path_to_repo_path(project_path, cx)
else {
return;
};
let status = git_repo
.read(cx)
.status_for_path(&repo_path)
.map(|entry| entry.status)
.unwrap_or(FileStatus::Untracked);
let sort_prefix = sort_prefix(&git_repo.read(cx), &repo_path, status, cx);
let path_key = PathKey::with_sort_prefix(sort_prefix, repo_path.as_ref().clone());
self.move_to_path(path_key, window, cx)
}
fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |editor, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges(vec![multi_buffer::Anchor::Min..multi_buffer::Anchor::Min]);
});
});
});
}
fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
self.editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |editor, cx| {
editor.change_selections(
SelectionEffects::scroll(Autoscroll::focused()),
window,
cx,
|s| {
s.select_ranges([position..position]);
},
)
})
});
} else {
self.pending_scroll = Some(path_key);
}
}
pub fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) {
self.multibuffer.read(cx).snapshot(cx).total_changed_lines()
}
/// Returns the total count of review comments across all hunks/files.
pub fn total_review_comment_count(&self) -> usize {
self.review_comment_count
}
/// Returns a reference to the splittable editor.
pub fn editor(&self) -> &Entity<SplittableEditor> {
&self.editor
}
fn button_states(&self, cx: &App) -> ButtonStates {
let editor = self.editor.read(cx).rhs_editor().read(cx);
let snapshot = self.multibuffer.read(cx).snapshot(cx);
let prev_next = snapshot.diff_hunks().nth(1).is_some();
let mut selection = true;
let mut ranges = editor
.selections
.disjoint_anchor_ranges()
.collect::<Vec<_>>();
if !ranges.iter().any(|range| range.start != range.end) {
selection = false;
let anchor = editor.selections.newest_anchor().head();
if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor)
&& let Some(range) = snapshot
.anchor_in_buffer(excerpt_range.context.start)
.zip(snapshot.anchor_in_buffer(excerpt_range.context.end))
.map(|(start, end)| start..end)
{
ranges = vec![range];
} else {
ranges = Vec::default();
};
}
let mut has_staged_hunks = false;
let mut has_unstaged_hunks = false;
for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
match hunk.status.secondary {
DiffHunkSecondaryStatus::HasSecondaryHunk
| DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => {
has_unstaged_hunks = true;
}
DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
has_staged_hunks = true;
has_unstaged_hunks = true;
}
DiffHunkSecondaryStatus::NoSecondaryHunk
| DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
has_staged_hunks = true;
}
}
}
let mut stage_all = false;
let mut unstage_all = false;
self.workspace
.read_with(cx, |workspace, cx| {
if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
let git_panel = git_panel.read(cx);
stage_all = git_panel.can_stage_all();
unstage_all = git_panel.can_unstage_all();
}
})
.ok();
ButtonStates {
stage: has_unstaged_hunks,
unstage: has_staged_hunks,
prev_next,
selection,
stage_all,
unstage_all,
}
}
fn handle_editor_event(
&mut self,
editor: &Entity<SplittableEditor>,
event: &EditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
EditorEvent::SelectionsChanged { local: true } => {
let Some(project_path) = self.active_project_path(cx) else {
return;
};
self.workspace
.update(cx, |workspace, cx| {
if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
git_panel.update(cx, |git_panel, cx| {
git_panel.select_entry_by_path(project_path, window, cx)
})
}
})
.ok();
}
EditorEvent::Saved => {
self._task =
cx.spawn_in(window, async move |this, cx| Self::refresh(this, cx).await);
}
_ => {}
}
if editor.focus_handle(cx).contains_focused(window, cx)
&& self.multibuffer.read(cx).is_empty()
{
self.focus_handle.focus(window, cx)
}
}
#[instrument(skip_all)]
fn register_buffer(
&mut self,
path_key: PathKey,
file_status: FileStatus,
buffer: Entity<Buffer>,
diff: Entity<BufferDiff>,
conflict_set: Entity<ConflictSet>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<BufferId> {
let diff_subscription = cx.subscribe_in(&diff, window, {
let path_key = path_key.clone();
let buffer = buffer.clone();
let diff = diff.clone();
let conflict_set = conflict_set.clone();
move |this, _, event, window, cx| match event {
buffer_diff::BufferDiffEvent::DiffChanged(_) => {
this.buffer_ranges_changed(
path_key.clone(),
file_status,
buffer.clone(),
diff.clone(),
conflict_set.clone(),
window,
cx,
);
}
buffer_diff::BufferDiffEvent::BaseTextChanged
| buffer_diff::BufferDiffEvent::LanguageChanged
| buffer_diff::BufferDiffEvent::HunksStagedOrUnstaged(_) => {}
}
});
let conflict_set_subscription = cx.subscribe_in(&conflict_set, window, {
let path_key = path_key.clone();
let buffer = buffer.clone();
let diff = diff.clone();
let conflict_set = conflict_set.clone();
move |this, _, _, window, cx| {
this.buffer_ranges_changed(
path_key.clone(),
file_status,
buffer.clone(),
diff.clone(),
conflict_set.clone(),
window,
cx,
)
}
});
self.buffer_subscriptions.insert(
path_key.path.clone(),
BufferSubscriptions {
_diff: diff.clone(),
_diff_subscription: diff_subscription,
_conflict_set: conflict_set.clone(),
_conflict_set_subscription: conflict_set_subscription,
},
);
let snapshot = buffer.read(cx).snapshot();
let diff_snapshot = diff.read(cx).snapshot(cx);
let excerpt_ranges = {
let diff_hunk_ranges = diff_snapshot
.hunks_intersecting_range(
Anchor::min_max_range_for_buffer(snapshot.remote_id()),
&snapshot,
)
.map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot));
let conflicts = conflict_set.read(cx).snapshot();
let mut conflicts = conflicts
.conflicts
.iter()
.map(|conflict| conflict.range.to_point(&snapshot))
.peekable();
if conflicts.peek().is_some() {
conflicts.collect::<Vec<_>>()
} else {
diff_hunk_ranges.collect()
}
};
let mut needs_fold = None;
let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| {
let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty();
let is_newly_added = editor.update_excerpts_for_path(
path_key.clone(),
buffer,
excerpt_ranges,
multibuffer_context_lines(cx),
diff,
cx,
);
editor.rhs_editor().update(cx, |editor, cx| {
conflict_view::buffer_ranges_updated(editor, conflict_set, cx);
});
(was_empty, is_newly_added)
});
self.editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |editor, cx| {
if was_empty {
editor.change_selections(
SelectionEffects::no_scroll(),
window,
cx,
|selections| {
selections.select_ranges([
multi_buffer::Anchor::Min..multi_buffer::Anchor::Min
])
},
);
}
if is_excerpt_newly_added
&& (file_status.is_deleted()
|| (file_status.is_untracked()
&& GitPanelSettings::get_global(cx).collapse_untracked_diff))
{
needs_fold = Some(snapshot.text.remote_id());
}
})
});
if self.multibuffer.read(cx).is_empty()
&& self
.editor
.read(cx)
.focus_handle(cx)
.contains_focused(window, cx)
{
self.focus_handle.focus(window, cx);
} else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
self.editor.update(cx, |editor, cx| {
editor.focus_handle(cx).focus(window, cx);
});
}
if self.pending_scroll.as_ref() == Some(&path_key) {
self.move_to_path(path_key, window, cx);
}
needs_fold
}
fn buffer_ranges_changed(
&mut self,
path_key: PathKey,
file_status: FileStatus,
buffer: Entity<Buffer>,
diff: Entity<BufferDiff>,
conflict_set: Entity<ConflictSet>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if buffer.read(cx).is_dirty() {
return;
}
self.register_buffer(
path_key,
file_status,
buffer,
diff,
conflict_set,
window,
cx,
);
}
#[instrument(skip(this, cx))]
pub async fn refresh(this: WeakEntity<Self>, cx: &mut AsyncWindowContext) -> Result<()> {
let entries = this.update(cx, |this, cx| {
let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| {
let load_buffers = branch_diff.load_buffers(cx);
(branch_diff.repo().cloned(), load_buffers)
});
let mut previous_paths = this
.multibuffer
.read(cx)
.snapshot(cx)
.buffers_with_paths()
.map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id()))
.collect::<HashMap<_, _>>();
let mut entries = BTreeMap::new();
if let Some(repo) = repo {
let repo = repo.read(cx);
for diff_buffer in buffers_to_load {
let sort_prefix =
sort_prefix(&repo, &diff_buffer.repo_path, diff_buffer.file_status, cx);
let path_key = PathKey::with_sort_prefix(
sort_prefix,
diff_buffer.repo_path.as_ref().clone(),
);
previous_paths.remove(&path_key);
entries.insert(path_key, diff_buffer);
}
}
this.editor.update(cx, |editor, cx| {
for (path, buffer_id) in previous_paths {
this.buffer_subscriptions.remove(&path.path);
editor.rhs_editor().update(cx, |editor, cx| {
conflict_view::buffers_removed(editor, &[buffer_id], cx);
});
let _span = ztracing::info_span!("remove_excerpts_for_path");
_span.enter();
editor.remove_excerpts_for_path(path, cx);
}
});
entries
})?;
let mut buffers_to_fold = Vec::new();
for (path_key, entry) in entries {
if let Some((buffer, diff, conflict_set)) = entry.load.await.log_err() {
// We might be lagging behind enough that all future entry.load futures are no longer pending.
// If that is the case, this task will never yield, starving the foreground thread of execution time.
yield_now().await;
cx.update(|window, cx| {
this.update(cx, |this, cx| {
if let Some(buffer_id) = this.register_buffer(
path_key,
entry.file_status,
buffer,
diff,
conflict_set,
window,
cx,
) {
buffers_to_fold.push(buffer_id);
}
})
.ok();
})?;
}
}
this.update(cx, |this, cx| {
if !buffers_to_fold.is_empty() {
this.editor.update(cx, |editor, cx| {
editor
.rhs_editor()
.update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx));
});
}
this.pending_scroll.take();
cx.notify();
})?;
Ok(())
}
#[cfg(any(test, feature = "test-support"))]
pub fn excerpt_paths(&self, cx: &App) -> Vec<std::sync::Arc<util::rel_path::RelPath>> {
let snapshot = self
.editor()
.read(cx)
.rhs_editor()
.read(cx)
.buffer()
.read(cx)
.snapshot(cx);
snapshot
.excerpts()
.map(|excerpt| {
snapshot
.path_for_buffer(excerpt.context.start.buffer_id)
.unwrap()
.path
.clone()
})
.collect()
}
}
fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 {
let settings = GitPanelSettings::get_global(cx);
if settings.sort_by_path && !settings.tree_view {
TRACKED_SORT_PREFIX
} else if repo.had_conflict_on_last_merge_head_change(repo_path) {
CONFLICT_SORT_PREFIX
} else if status.is_created() {
NEW_SORT_PREFIX
} else {
TRACKED_SORT_PREFIX
}
}
impl EventEmitter<EditorEvent> for ProjectDiff {}
impl Focusable for ProjectDiff {
fn focus_handle(&self, cx: &App) -> FocusHandle {
if self.multibuffer.read(cx).is_empty() {
self.focus_handle.clone()
} else {
self.editor.focus_handle(cx)
}
}
}
impl Item for ProjectDiff {
type Event = EditorEvent;
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
Some(Icon::new(IconName::GitBranch).color(Color::Muted))
}
fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
Editor::to_item_events(event, f)
}
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |primary_editor, cx| {
primary_editor.deactivated(window, cx);
})
});
}
fn navigate(
&mut self,
data: Arc<dyn Any + Send>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
self.editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |primary_editor, cx| {
primary_editor.navigate(data, window, cx)
})
})
}
fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
match self.diff_base(cx) {
DiffBase::Head => Some("Project Diff".into()),
DiffBase::Merge { .. } => Some("Branch Diff".into()),
}
}
fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
Label::new(self.tab_content_text(0, cx))
.color(if params.selected {
Color::Default
} else {
Color::Muted
})
.into_any_element()
}
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
match self.branch_diff.read(cx).diff_base() {
DiffBase::Head => "Uncommitted Changes".into(),
DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(),
}
}
fn telemetry_event_text(&self) -> Option<&'static str> {
Some("Project Diff Opened")
}
fn as_searchable(&self, _: &Entity<Self>, _cx: &App) -> 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
.read(cx)
.rhs_editor()
.read(cx)
.for_each_project_item(cx, f)
}
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
let editor = self.editor.read(cx).focused_editor().read(cx);
let multibuffer = editor.buffer().read(cx);
let position = editor.selections.newest_anchor().head();
let snapshot = multibuffer.snapshot(cx);
let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?;
let buffer = multibuffer.buffer(text_anchor.buffer_id)?;
let file = buffer.read(cx).file()?;
Some(ProjectPath {
worktree_id: file.worktree_id(cx),
path: file.path().clone(),
})
}
fn set_nav_history(
&mut self,
nav_history: ItemNavHistory,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |primary_editor, _| {
primary_editor.set_nav_history(Some(nav_history));
})
});
}
fn can_split(&self) -> bool {
true
}
fn clone_on_split(
&self,
_workspace_id: Option<workspace::WorkspaceId>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Option<Entity<Self>>>
where
Self: Sized,
{
let Some(workspace) = self.workspace.upgrade() else {
return Task::ready(None);
};
Task::ready(Some(cx.new(|cx| {
ProjectDiff::new(self.project.clone(), workspace, window, cx)
})))
}
fn is_dirty(&self, cx: &App) -> bool {
self.multibuffer.read(cx).is_dirty(cx)
}
fn has_conflict(&self, cx: &App) -> bool {
self.multibuffer.read(cx).has_conflict(cx)
}
fn can_save(&self, _: &App) -> bool {
true
}
fn save(
&mut self,
options: SaveOptions,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |primary_editor, cx| {
primary_editor.save(options, project, window, cx)
})
})
}
fn save_as(
&mut self,
_: Entity<Project>,
_: ProjectPath,
_window: &mut Window,
_: &mut Context<Self>,
) -> Task<Result<()>> {
unreachable!()
}
fn reload(
&mut self,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |primary_editor, cx| {
primary_editor.reload(project, window, cx)
})
})
}
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &'a Entity<Self>,
cx: &'a App,
) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
Some(self.editor.read(cx).rhs_editor().clone().into())
} else if type_id == TypeId::of::<SplittableEditor>() {
Some(self.editor.clone().into())
} else {
None
}
}
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 ProjectDiff {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_empty = self.multibuffer.read(cx).is_empty();
let is_loading = self.branch_diff.read(cx).is_tree_base_loading() || !self._task.is_ready();
let is_branch_diff_view = matches!(self.diff_base(cx), DiffBase::Merge { .. });
div()
.track_focus(&self.focus_handle)
.key_context(if is_empty { "EmptyPane" } else { "GitDiff" })
.when(is_branch_diff_view, |this| {
this.on_action(cx.listener(Self::review_diff))
})
.bg(cx.theme().colors().editor_background)
.flex()
.items_center()
.justify_center()
.size_full()
.when(is_empty && is_loading, |el| {
let rems = TextSize::Large.rems(cx);
el.child(
Icon::new(IconName::LoadCircle)
.size(IconSize::Custom(rems))
.color(Color::Accent)
.with_rotate_animation(3)
.into_any_element(),
)
})
.when(is_empty && !is_loading, |el| {
let remote_button = if let Some(panel) = self
.workspace
.upgrade()
.and_then(|workspace| workspace.read(cx).panel::<GitPanel>(cx))
{
panel.update(cx, |panel, cx| panel.render_remote_button(cx))
} else {
None
};
let keybinding_focus_handle = self.focus_handle(cx);
el.child(
v_flex()
.gap_1()
.child(
h_flex()
.justify_around()
.child(Label::new("No uncommitted changes")),
)
.map(|el| match remote_button {
Some(button) => el.child(h_flex().justify_around().child(button)),
None => el.child(
h_flex()
.justify_around()
.child(Label::new("Remote up to date")),
),
})
.child(
h_flex().justify_around().mt_1().child(
Button::new("project-diff-close-button", "Close")
// .style(ButtonStyle::Transparent)
.key_binding(KeyBinding::for_action_in(
&CloseActiveItem::default(),
&keybinding_focus_handle,
cx,
))
.on_click(move |_, window, cx| {
window.focus(&keybinding_focus_handle, cx);
window.dispatch_action(
Box::new(CloseActiveItem::default()),
cx,
);
}),
),
),
)
})
.when(!is_empty, |el| el.child(self.editor.clone()))
}
}
impl SerializableItem for ProjectDiff {
fn serialized_item_kind() -> &'static str {
"ProjectDiff"
}
fn cleanup(
_: workspace::WorkspaceId,
_: Vec<workspace::ItemId>,
_: &mut Window,
_: &mut App,
) -> Task<Result<()>> {
Task::ready(Ok(()))
}
fn deserialize(
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
workspace_id: workspace::WorkspaceId,
item_id: workspace::ItemId,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
let db = persistence::ProjectDiffDb::global(cx);
window.spawn(cx, async move |cx| {
let diff_base = db.get_diff_base(item_id, workspace_id)?;
let diff = cx.update(|window, cx| {
let branch_diff = cx
.new(|cx| branch_diff::BranchDiff::new(diff_base, project.clone(), window, cx));
let workspace = workspace.upgrade().context("workspace gone")?;
anyhow::Ok(
cx.new(|cx| ProjectDiff::new_impl(branch_diff, project, workspace, window, cx)),
)
})??;
Ok(diff)
})
}
fn serialize(
&mut self,
workspace: &mut Workspace,
item_id: workspace::ItemId,
_closing: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<Result<()>>> {
let workspace_id = workspace.database_id()?;
let diff_base = self.diff_base(cx).clone();
let db = persistence::ProjectDiffDb::global(cx);
Some(cx.background_spawn({
async move {
db.save_diff_base(item_id, workspace_id, diff_base.clone())
.await
}
}))
}
fn should_serialize(&self, _: &Self::Event) -> bool {
false
}
}
mod persistence {
use anyhow::Context as _;
use db::{
sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
sqlez_macros::sql,
};
use project::git_store::branch_diff::DiffBase;
use workspace::{ItemId, WorkspaceDb, WorkspaceId};
pub struct ProjectDiffDb(ThreadSafeConnection);
impl Domain for ProjectDiffDb {
const NAME: &str = stringify!(ProjectDiffDb);
const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE project_diffs(
workspace_id INTEGER,
item_id INTEGER UNIQUE,
diff_base TEXT,
PRIMARY KEY(workspace_id, item_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;
)];
}
db::static_connection!(ProjectDiffDb, [WorkspaceDb]);
impl ProjectDiffDb {
pub async fn save_diff_base(
&self,
item_id: ItemId,
workspace_id: WorkspaceId,
diff_base: DiffBase,
) -> anyhow::Result<()> {
self.write(move |connection| {
let sql_stmt = sql!(
INSERT OR REPLACE INTO project_diffs(item_id, workspace_id, diff_base) VALUES (?, ?, ?)
);
let diff_base_str = serde_json::to_string(&diff_base)?;
let mut query = connection.exec_bound::<(ItemId, WorkspaceId, String)>(sql_stmt)?;
query((item_id, workspace_id, diff_base_str)).context(format!(
"exec_bound failed to execute or parse for: {}",
sql_stmt
))
})
.await
}
pub fn get_diff_base(
&self,
item_id: ItemId,
workspace_id: WorkspaceId,
) -> anyhow::Result<DiffBase> {
let sql_stmt =
sql!(SELECT diff_base FROM project_diffs WHERE item_id = ?AND workspace_id = ?);
let diff_base_str = self.select_row_bound::<(ItemId, WorkspaceId), String>(sql_stmt)?(
(item_id, workspace_id),
)
.context(::std::format!(
"Error in get_diff_base, select_row_bound failed to execute or parse for: {}",
sql_stmt
))?;
let Some(diff_base_str) = diff_base_str else {
return Ok(DiffBase::Head);
};
serde_json::from_str(&diff_base_str).context("deserializing diff base")
}
}
}
pub struct ProjectDiffToolbar {
project_diff: Option<WeakEntity<ProjectDiff>>,
workspace: WeakEntity<Workspace>,
}
impl ProjectDiffToolbar {
pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
Self {
project_diff: None,
workspace: workspace.weak_handle(),
}
}
fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
self.project_diff.as_ref()?.upgrade()
}
fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
if let Some(project_diff) = self.project_diff(cx) {
project_diff.focus_handle(cx).focus(window, cx);
}
let action = action.boxed_clone();
cx.defer(move |cx| {
cx.dispatch_action(action.as_ref());
})
}
fn stage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.workspace
.update(cx, |workspace, cx| {
if let Some(panel) = workspace.panel::<GitPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.stage_all(&Default::default(), window, cx);
});
}
})
.ok();
}
fn unstage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.workspace
.update(cx, |workspace, cx| {
let Some(panel) = workspace.panel::<GitPanel>(cx) else {
return;
};
panel.update(cx, |panel, cx| {
panel.unstage_all(&Default::default(), window, cx);
});
})
.ok();
}
}
impl EventEmitter<ToolbarItemEvent> for ProjectDiffToolbar {}
impl ToolbarItemView for ProjectDiffToolbar {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn ItemHandle>,
_: &mut Window,
cx: &mut Context<Self>,
) -> ToolbarItemLocation {
self.project_diff = active_pane_item
.and_then(|item| item.act_as::<ProjectDiff>(cx))
.filter(|item| item.read(cx).diff_base(cx) == &DiffBase::Head)
.map(|entity| entity.downgrade());
if self.project_diff.is_some() {
ToolbarItemLocation::PrimaryRight
} else {
ToolbarItemLocation::Hidden
}
}
fn pane_focus_update(
&mut self,
_pane_focused: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
) {
}
}
struct ButtonStates {
stage: bool,
unstage: bool,
prev_next: bool,
selection: bool,
stage_all: bool,
unstage_all: bool,
}
impl Render for ProjectDiffToolbar {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(project_diff) = self.project_diff(cx) else {
return div();
};
let focus_handle = project_diff.focus_handle(cx);
let button_states = project_diff.read(cx).button_states(cx);
let review_count = project_diff.read(cx).total_review_comment_count();
h_group_xl()
.my_neg_1()
.py_1()
.items_center()
.flex_wrap()
.justify_between()
.child(
h_group_sm()
.when(button_states.selection, |el| {
el.child(
Button::new("stage", "Toggle Staged")
.tooltip(Tooltip::for_action_title_in(
"Toggle Staged",
&ToggleStaged,
&focus_handle,
))
.disabled(!button_states.stage && !button_states.unstage)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&ToggleStaged, window, cx)
})),
)
})
.when(!button_states.selection, |el| {
el.child(
Button::new("stage", "Stage")
.tooltip(Tooltip::for_action_title_in(
"Stage and go to next hunk",
&StageAndNext,
&focus_handle,
))
.disabled(
!button_states.prev_next
&& !button_states.stage_all
&& !button_states.unstage_all,
)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&StageAndNext, window, cx)
})),
)
.child(
Button::new("unstage", "Unstage")
.tooltip(Tooltip::for_action_title_in(
"Unstage and go to next hunk",
&UnstageAndNext,
&focus_handle,
))
.disabled(
!button_states.prev_next
&& !button_states.stage_all
&& !button_states.unstage_all,
)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&UnstageAndNext, window, cx)
})),
)
}),
)
// n.b. the only reason these arrows are here is because we don't
// support "undo" for staging so we need a way to go back.
.child(
h_group_sm()
.child(
IconButton::new("up", IconName::ArrowUp)
.shape(ui::IconButtonShape::Square)
.tooltip(Tooltip::for_action_title_in(
"Go to previous hunk",
&GoToPreviousHunk,
&focus_handle,
))
.disabled(!button_states.prev_next)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&GoToPreviousHunk, window, cx)
})),
)
.child(
IconButton::new("down", IconName::ArrowDown)
.shape(ui::IconButtonShape::Square)
.tooltip(Tooltip::for_action_title_in(
"Go to next hunk",
&GoToHunk,
&focus_handle,
))
.disabled(!button_states.prev_next)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&GoToHunk, window, cx)
})),
),
)
.child(vertical_divider())
.child(
h_group_sm()
.when(
button_states.unstage_all && !button_states.stage_all,
|el| {
el.child(
Button::new("unstage-all", "Unstage All")
.tooltip(Tooltip::for_action_title_in(
"Unstage all changes",
&UnstageAll,
&focus_handle,
))
.on_click(cx.listener(|this, _, window, cx| {
this.unstage_all(window, cx)
})),
)
},
)
.when(
!button_states.unstage_all || button_states.stage_all,
|el| {
el.child(
// todo make it so that changing to say "Unstaged"
// doesn't change the position.
div().child(
Button::new("stage-all", "Stage All")
.disabled(!button_states.stage_all)
.tooltip(Tooltip::for_action_title_in(
"Stage all changes",
&StageAll,
&focus_handle,
))
.on_click(cx.listener(|this, _, window, cx| {
this.stage_all(window, cx)
})),
),
)
},
)
.child(
Button::new("commit", "Commit")
.tooltip(Tooltip::for_action_title_in(
"Commit",
&Commit,
&focus_handle,
))
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&Commit, window, cx);
})),
),
)
// "Send Review to Agent" button (only shown when there are review comments)
.when(review_count > 0, |el| {
el.child(vertical_divider()).child(
render_send_review_to_agent_button(review_count, &focus_handle).on_click(
cx.listener(|this, _, window, cx| {
this.dispatch_action(&SendReviewToAgent, window, cx)
}),
),
)
})
}
}
fn render_send_review_to_agent_button(review_count: usize, focus_handle: &FocusHandle) -> Button {
Button::new(
"send-review",
format!("Send Review to Agent ({})", review_count),
)
.start_icon(
Icon::new(IconName::ZedAssistant)
.size(IconSize::Small)
.color(Color::Muted),
)
.tooltip(Tooltip::for_action_title_in(
"Send all review comments to the Agent panel",
&SendReviewToAgent,
focus_handle,
))
}
pub struct BranchDiffToolbar {
project_diff: Option<WeakEntity<ProjectDiff>>,
}
impl BranchDiffToolbar {
pub fn new(_cx: &mut Context<Self>) -> Self {
Self { project_diff: None }
}
fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
self.project_diff.as_ref()?.upgrade()
}
fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
if let Some(project_diff) = self.project_diff(cx) {
project_diff.focus_handle(cx).focus(window, cx);
}
let action = action.boxed_clone();
cx.defer(move |cx| {
cx.dispatch_action(action.as_ref());
})
}
}
impl EventEmitter<ToolbarItemEvent> for BranchDiffToolbar {}
impl ToolbarItemView for BranchDiffToolbar {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn ItemHandle>,
_: &mut Window,
cx: &mut Context<Self>,
) -> ToolbarItemLocation {
self.project_diff = active_pane_item
.and_then(|item| item.act_as::<ProjectDiff>(cx))
.filter(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. }))
.map(|entity| entity.downgrade());
if self.project_diff.is_some() {
ToolbarItemLocation::PrimaryRight
} else {
ToolbarItemLocation::Hidden
}
}
fn pane_focus_update(
&mut self,
_pane_focused: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
) {
}
}
impl Render for BranchDiffToolbar {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(project_diff) = self.project_diff(cx) else {
return div();
};
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 diff_base = project_diff.read(cx).diff_base(cx).clone();
let DiffBase::Merge { base_ref } = diff_base else {
return div();
};
let selected_base_ref = base_ref.clone();
let base_ref_label = format!("Base: {base_ref}");
let repository = project_diff.read(cx).branch_diff.read(cx).repo().cloned();
let workspace = project_diff.read(cx).workspace.clone();
let project_diff_for_picker = project_diff.downgrade();
let is_multibuffer_empty = project_diff.read(cx).multibuffer.read(cx).is_empty();
let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
let show_review_button = !is_multibuffer_empty && is_ai_enabled;
h_group_xl()
.my_neg_1()
.py_1()
.items_center()
.flex_wrap()
.justify_end()
.gap_2()
.child(
PopoverMenu::new("branch-diff-base-branch-picker")
.menu(move |window, cx| {
let project_diff = project_diff_for_picker.clone();
let on_select = Arc::new(
move |branch: git::repository::Branch,
_window: &mut Window,
cx: &mut App| {
let base_ref: SharedString = branch.name().to_owned().into();
project_diff
.update(cx, |project_diff, cx| {
let branch_diff = &mut project_diff.branch_diff;
branch_diff.update(cx, |branch_diff, cx| {
branch_diff
.set_diff_base(DiffBase::Merge { base_ref }, cx);
});
cx.notify();
})
.ok();
},
);
Some(branch_picker::select_popover(
workspace.clone(),
repository.clone(),
Some(selected_base_ref.clone()),
on_select,
window,
cx,
))
})
.trigger_with_tooltip(
Button::new("branch-diff-base-branch", base_ref_label)
.color(Color::Muted)
.end_icon(
Icon::new(IconName::ChevronDown)
.size(IconSize::XSmall)
.color(Color::Muted),
),
Tooltip::text("Select base branch"),
),
)
.when(!is_multibuffer_empty, |this| {
this.child(DiffStat::new(
"branch-diff-stat",
additions as usize,
deletions as usize,
))
})
.when(show_review_button, |this| {
let focus_handle = focus_handle.clone();
this.child(Divider::vertical()).child(
Button::new("review-diff", "Review Diff")
.start_icon(
Icon::new(IconName::ZedAssistant)
.size(IconSize::Small)
.color(Color::Muted),
)
.key_binding(KeyBinding::for_action_in(&ReviewDiff, &focus_handle, cx))
.tooltip(move |_, cx| {
Tooltip::with_meta_in(
"Review Diff",
Some(&ReviewDiff),
"Send this diff for your last agent to review.",
&focus_handle,
cx,
)
})
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&ReviewDiff, window, cx);
})),
)
})
.when(review_count > 0, |this| {
this.child(vertical_divider()).child(
render_send_review_to_agent_button(review_count, &focus_handle).on_click(
cx.listener(|this, _, window, cx| {
this.dispatch_action(&SendReviewToAgent, window, cx)
}),
),
)
})
}
}
struct BranchDiffAddon {
branch_diff: Entity<branch_diff::BranchDiff>,
}
impl Addon for BranchDiffAddon {
fn to_any(&self) -> &dyn std::any::Any {
self
}
fn override_status_for_buffer_id(
&self,
buffer_id: language::BufferId,
cx: &App,
) -> Option<FileStatus> {
self.branch_diff
.read(cx)
.status_for_buffer_id(buffer_id, cx)
}
}
#[cfg(test)]
mod tests {
use collections::HashMap;
use db::indoc;
use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
use git::status::{TrackedStatus, UnmergedStatus, UnmergedStatusCode};
use gpui::TestAppContext;
use project::FakeFs;
use serde_json::json;
use settings::{DiffViewStyle, SettingsStore};
use std::path::Path;
use unindent::Unindent as _;
use util::{
path,
rel_path::{RelPath, rel_path},
};
use workspace::MultiWorkspace;
use super::*;
#[ctor::ctor(unsafe)]
fn init_logger() {
zlog::init_test();
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let store = SettingsStore::test(cx);
cx.set_global(store);
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
});
});
theme_settings::init(theme::LoadThemes::JustBase, cx);
editor::init(cx);
crate::init(cx);
});
}
#[gpui::test]
async fn test_save_after_restore(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"foo.txt": "FOO\n",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
fs.set_head_for_repo(
path!("/project/.git").as_ref(),
&[("foo.txt", "foo\n".into())],
"deadbeef",
);
fs.set_index_for_repo(
path!("/project/.git").as_ref(),
&[("foo.txt", "foo\n".into())],
);
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let diff = cx.new_window_entity(|window, cx| {
ProjectDiff::new(project.clone(), workspace, window, cx)
});
cx.run_until_parked();
let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
assert_state_with_diff(
&editor,
cx,
&"
- ˇfoo
+ FOO
"
.unindent(),
);
editor
.update_in(cx, |editor, window, cx| {
editor.git_restore(&Default::default(), window, cx);
editor.save(SaveOptions::default(), project.clone(), window, cx)
})
.await
.unwrap();
cx.run_until_parked();
assert_state_with_diff(&editor, cx, &"ˇ".unindent());
let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap();
assert_eq!(text, "foo\n");
}
#[gpui::test]
async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"bar": "BAR\n",
"foo": "FOO\n",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let diff = cx.new_window_entity(|window, cx| {
ProjectDiff::new(project.clone(), workspace, window, cx)
});
cx.run_until_parked();
fs.set_head_and_index_for_repo(
path!("/project/.git").as_ref(),
&[("bar", "bar\n".into()), ("foo", "foo\n".into())],
);
cx.run_until_parked();
let editor = cx.update_window_entity(&diff, |diff, window, cx| {
diff.move_to_path(
PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("foo").into_arc()),
window,
cx,
);
diff.editor.read(cx).rhs_editor().clone()
});
assert_state_with_diff(
&editor,
cx,
&"
- bar
+ BAR
- ˇfoo
+ FOO
"
.unindent(),
);
let editor = cx.update_window_entity(&diff, |diff, window, cx| {
diff.move_to_path(
PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("bar").into_arc()),
window,
cx,
);
diff.editor.read(cx).rhs_editor().clone()
});
assert_state_with_diff(
&editor,
cx,
&"
- ˇbar
+ BAR
- foo
+ FOO
"
.unindent(),
);
}
#[gpui::test]
async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"foo": "modified\n",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
fs.set_head_for_repo(
path!("/project/.git").as_ref(),
&[("foo", "original\n".into())],
"deadbeef",
);
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/project/foo"), cx)
})
.await
.unwrap();
let buffer_editor = cx.new_window_entity(|window, cx| {
Editor::for_buffer(buffer, Some(project.clone()), window, cx)
});
let diff = cx.new_window_entity(|window, cx| {
ProjectDiff::new(project.clone(), workspace, window, cx)
});
cx.run_until_parked();
let diff_editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
assert_state_with_diff(
&diff_editor,
cx,
&"
- ˇoriginal
+ modified
"
.unindent(),
);
let prev_buffer_hunks =
cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
let snapshot = buffer_editor.snapshot(window, cx);
let snapshot = &snapshot.buffer_snapshot();
let prev_buffer_hunks = buffer_editor
.diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], snapshot)
.collect::<Vec<_>>();
buffer_editor.git_restore(&Default::default(), window, cx);
prev_buffer_hunks
});
assert_eq!(prev_buffer_hunks.len(), 1);
cx.run_until_parked();
let new_buffer_hunks =
cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
let snapshot = buffer_editor.snapshot(window, cx);
let snapshot = &snapshot.buffer_snapshot();
buffer_editor
.diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], snapshot)
.collect::<Vec<_>>()
});
assert_eq!(new_buffer_hunks.as_slice(), &[]);
cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
buffer_editor.set_text("different\n", window, cx);
buffer_editor.save(
SaveOptions {
format: false,
force_format: false,
autosave: false,
},
project.clone(),
window,
cx,
)
})
.await
.unwrap();
cx.run_until_parked();
cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
buffer_editor.expand_all_diff_hunks(&Default::default(), window, cx);
});
assert_state_with_diff(
&buffer_editor,
cx,
&"
- original
+ different
ˇ"
.unindent(),
);
assert_state_with_diff(
&diff_editor,
cx,
&"
- ˇoriginal
+ different
"
.unindent(),
);
}
use crate::project_diff::{self, ProjectDiff};
#[gpui::test]
async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/a"),
json!({
".git": {},
"a.txt": "created\n",
"b.txt": "really changed\n",
"c.txt": "unchanged\n"
}),
)
.await;
fs.set_head_and_index_for_repo(
Path::new(path!("/a/.git")),
&[
("b.txt", "before\n".to_string()),
("c.txt", "unchanged\n".to_string()),
("d.txt", "deleted\n".to_string()),
],
);
let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
cx.run_until_parked();
cx.focus(&workspace);
cx.update(|window, cx| {
window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
});
cx.run_until_parked();
let item = workspace.update(cx, |workspace, cx| {
workspace.active_item_as::<ProjectDiff>(cx).unwrap()
});
cx.focus(&item);
let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
cx.set_selections_state(indoc!(
"
before
really changed
deleted
ˇcreated
"
));
cx.dispatch_action(editor::actions::GoToPreviousHunk);
cx.assert_excerpts_with_selections(indoc!(
"
[EXCERPT]
before
really changed
[EXCERPT]
ˇ[FOLDED]
[EXCERPT]
created
"
));
cx.dispatch_action(editor::actions::GoToPreviousHunk);
cx.assert_excerpts_with_selections(indoc!(
"
[EXCERPT]
ˇbefore
really changed
[EXCERPT]
[FOLDED]
[EXCERPT]
created
"
));
}
#[gpui::test]
async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) {
init_test(cx);
let git_contents = indoc! {r#"
#[rustfmt::skip]
fn main() {
let x = 0.0; // this line will be removed
// 1
// 2
// 3
let y = 0.0; // this line will be removed
// 1
// 2
// 3
let arr = [
0.0, // this line will be removed
0.0, // this line will be removed
0.0, // this line will be removed
0.0, // this line will be removed
];
}
"#};
let buffer_contents = indoc! {"
#[rustfmt::skip]
fn main() {
// 1
// 2
// 3
// 1
// 2
// 3
let arr = [
];
}
"};
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/a"),
json!({
".git": {},
"main.rs": buffer_contents,
}),
)
.await;
fs.set_head_and_index_for_repo(
Path::new(path!("/a/.git")),
&[("main.rs", git_contents.to_owned())],
);
let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
cx.run_until_parked();
cx.focus(&workspace);
cx.update(|window, cx| {
window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
});
cx.run_until_parked();
let item = workspace.update(cx, |workspace, cx| {
workspace.active_item_as::<ProjectDiff>(cx).unwrap()
});
cx.focus(&item);
let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
cx.dispatch_action(editor::actions::GoToHunk);
cx.dispatch_action(editor::actions::GoToHunk);
cx.dispatch_action(git::Restore);
cx.dispatch_action(editor::actions::MoveToBeginning);
cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
}
#[gpui::test(iterations = 50)]
async fn test_split_diff_conflict_path_transition_with_dirty_buffer_invalid_anchor_panics(
cx: &mut TestAppContext,
) {
init_test(cx);
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.diff_view_style = Some(DiffViewStyle::Split);
});
});
});
let build_conflict_text: fn(usize) -> String = |tag: usize| {
let mut lines = (0..80)
.map(|line_index| format!("line {line_index}"))
.collect::<Vec<_>>();
for offset in [5usize, 20, 37, 61] {
lines[offset] = format!("base-{tag}-line-{offset}");
}
format!("{}\n", lines.join("\n"))
};
let initial_conflict_text = build_conflict_text(0);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"helper.txt": "same\n",
"conflict.txt": initial_conflict_text,
}),
)
.await;
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
state
.refs
.insert("MERGE_HEAD".into(), "conflict-head".into());
})
.unwrap();
fs.set_status_for_repo(
path!("/project/.git").as_ref(),
&[(
"conflict.txt",
FileStatus::Unmerged(UnmergedStatus {
first_head: UnmergedStatusCode::Updated,
second_head: UnmergedStatusCode::Updated,
}),
)],
);
fs.set_merge_base_content_for_repo(
path!("/project/.git").as_ref(),
&[
("conflict.txt", build_conflict_text(1)),
("helper.txt", "same\n".to_string()),
],
);
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let _project_diff = cx
.update(|window, cx| {
ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx)
})
.await
.unwrap();
cx.run_until_parked();
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/project/conflict.txt"), cx)
})
.await
.unwrap();
buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "dirty\n")], None, cx));
assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty()));
cx.run_until_parked();
cx.update(|window, cx| {
let fs = fs.clone();
window
.spawn(cx, async move |cx| {
cx.background_executor().simulate_random_delay().await;
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
state.refs.insert("HEAD".into(), "head-1".into());
state.refs.remove("MERGE_HEAD");
})
.unwrap();
fs.set_status_for_repo(
path!("/project/.git").as_ref(),
&[
(
"conflict.txt",
FileStatus::Tracked(TrackedStatus {
index_status: git::status::StatusCode::Modified,
worktree_status: git::status::StatusCode::Modified,
}),
),
(
"helper.txt",
FileStatus::Tracked(TrackedStatus {
index_status: git::status::StatusCode::Modified,
worktree_status: git::status::StatusCode::Modified,
}),
),
],
);
// FakeFs assigns deterministic OIDs by entry position; flipping order churns
// conflict diff identity without reaching into ProjectDiff internals.
fs.set_merge_base_content_for_repo(
path!("/project/.git").as_ref(),
&[
("helper.txt", "helper-base\n".to_string()),
("conflict.txt", build_conflict_text(2)),
],
);
})
.detach();
});
cx.update(|window, cx| {
let buffer = buffer.clone();
window
.spawn(cx, async move |cx| {
cx.background_executor().simulate_random_delay().await;
for edit_index in 0..10 {
if edit_index > 0 {
cx.background_executor().simulate_random_delay().await;
}
buffer.update(cx, |buffer, cx| {
let len = buffer.len();
if edit_index % 2 == 0 {
buffer.edit(
[(0..0, format!("status-burst-head-{edit_index}\n"))],
None,
cx,
);
} else {
buffer.edit(
[(len..len, format!("status-burst-tail-{edit_index}\n"))],
None,
cx,
);
}
});
}
})
.detach();
});
cx.run_until_parked();
}
#[gpui::test]
async fn test_new_hunk_in_modified_file(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"foo.txt": "
one
two
three
four
five
six
seven
eight
nine
ten
ELEVEN
twelve
".unindent()
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let diff = cx.new_window_entity(|window, cx| {
ProjectDiff::new(project.clone(), workspace, window, cx)
});
cx.run_until_parked();
fs.set_head_and_index_for_repo(
Path::new(path!("/project/.git")),
&[(
"foo.txt",
"
one
two
three
four
five
six
seven
eight
nine
ten
eleven
twelve
"
.unindent(),
)],
);
cx.run_until_parked();
let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
assert_state_with_diff(
&editor,
cx,
&"
ˇnine
ten
- eleven
+ ELEVEN
twelve
"
.unindent(),
);
// The project diff updates its excerpts when a new hunk appears in a buffer that already has a diff.
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/project/foo.txt"), cx)
})
.await
.unwrap();
buffer.update(cx, |buffer, cx| {
buffer.edit_via_marked_text(
&"
one
«TWO»
three
four
five
six
seven
eight
nine
ten
ELEVEN
twelve
"
.unindent(),
None,
cx,
);
});
project
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
.await
.unwrap();
cx.run_until_parked();
assert_state_with_diff(
&editor,
cx,
&"
one
- two
+ TWO
three
four
five
ˇnine
ten
- eleven
+ ELEVEN
twelve
"
.unindent(),
);
}
#[gpui::test]
async fn test_branch_diff(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"a.txt": "C",
"b.txt": "new",
"c.txt": "in-merge-base-and-work-tree",
"d.txt": "created-in-head",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let diff = cx
.update(|window, cx| {
ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx)
})
.await
.unwrap();
cx.run_until_parked();
fs.set_head_for_repo(
Path::new(path!("/project/.git")),
&[("a.txt", "B".into()), ("d.txt", "created-in-head".into())],
"sha",
);
// fs.set_index_for_repo(dot_git, index_state);
fs.set_merge_base_content_for_repo(
Path::new(path!("/project/.git")),
&[
("a.txt", "A".into()),
("c.txt", "in-merge-base-and-work-tree".into()),
],
);
cx.run_until_parked();
let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone());
assert_state_with_diff(
&editor,
cx,
&"
- A
+ ˇC
+ new
+ created-in-head"
.unindent(),
);
let statuses: HashMap<Arc<RelPath>, Option<FileStatus>> =
editor.update(cx, |editor, cx| {
editor
.buffer()
.read(cx)
.all_buffers()
.iter()
.map(|buffer| {
(
buffer.read(cx).file().unwrap().path().clone(),
editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx),
)
})
.collect()
});
assert_eq!(
statuses,
HashMap::from_iter([
(
rel_path("a.txt").into_arc(),
Some(FileStatus::Tracked(TrackedStatus {
index_status: git::status::StatusCode::Modified,
worktree_status: git::status::StatusCode::Modified
}))
),
(rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)),
(
rel_path("d.txt").into_arc(),
Some(FileStatus::Tracked(TrackedStatus {
index_status: git::status::StatusCode::Added,
worktree_status: git::status::StatusCode::Added
}))
)
])
);
}
#[gpui::test]
async fn test_branch_diff_action_matches_existing_item_by_base_ref(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"a.txt": "changed",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let target_branch_diff = cx
.update(|window, cx| {
let Some(repository) = project.read(cx).active_repository(cx) else {
return Task::ready(Err(anyhow!("No active repository")));
};
ProjectDiff::new_with_branch_base(
project.clone(),
workspace.clone(),
"topic".into(),
repository,
window,
cx,
)
})
.await
.unwrap();
workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(
Box::new(target_branch_diff.clone()),
None,
true,
window,
cx,
);
});
cx.run_until_parked();
cx.focus(&workspace);
cx.update(|window, cx| {
window.dispatch_action(BranchDiff.boxed_clone(), cx);
});
cx.run_until_parked();
let (active_base_ref, mut base_refs) = workspace.update(cx, |workspace, cx| {
let active_item = workspace.active_item_as::<ProjectDiff>(cx).unwrap();
let active_base_ref = match active_item.read(cx).diff_base(cx) {
DiffBase::Merge { base_ref } => base_ref.to_string(),
DiffBase::Head => panic!("expected active item to be a branch diff"),
};
let base_refs = workspace
.items_of_type::<ProjectDiff>(cx)
.filter_map(|item| match item.read(cx).diff_base(cx) {
DiffBase::Merge { base_ref } => Some(base_ref.to_string()),
DiffBase::Head => None,
})
.collect::<Vec<_>>();
(active_base_ref, base_refs)
});
base_refs.sort();
assert_eq!(active_base_ref, "origin/main");
assert_eq!(base_refs, vec!["origin/main", "topic"]);
}
#[gpui::test]
async fn test_update_on_uncommit(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"README.md": "# My cool project\n".to_owned()
}),
)
.await;
fs.set_head_and_index_for_repo(
Path::new(path!("/project/.git")),
&[("README.md", "# My cool project\n".to_owned())],
);
let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
let worktree_id = project.read_with(cx, |project, cx| {
project.worktrees(cx).next().unwrap().read(cx).id()
});
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
cx.run_until_parked();
let _editor = workspace
.update_in(cx, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
cx.focus(&workspace);
cx.update(|window, cx| {
window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
});
cx.run_until_parked();
let item = workspace.update(cx, |workspace, cx| {
workspace.active_item_as::<ProjectDiff>(cx).unwrap()
});
cx.focus(&item);
let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone());
fs.set_head_and_index_for_repo(
Path::new(path!("/project/.git")),
&[(
"README.md",
"# My cool project\nDetails to come.\n".to_owned(),
)],
);
cx.run_until_parked();
let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n");
}
#[gpui::test]
async fn test_deploy_at_respects_active_repository_selection(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project_a"),
json!({
".git": {},
"a.txt": "CHANGED_A\n",
}),
)
.await;
fs.insert_tree(
path!("/project_b"),
json!({
".git": {},
"b.txt": "CHANGED_B\n",
}),
)
.await;
fs.set_head_and_index_for_repo(
Path::new(path!("/project_a/.git")),
&[("a.txt", "original_a\n".to_string())],
);
fs.set_head_and_index_for_repo(
Path::new(path!("/project_b/.git")),
&[("b.txt", "original_b\n".to_string())],
);
let project = Project::test(
fs.clone(),
[
Path::new(path!("/project_a")),
Path::new(path!("/project_b")),
],
cx,
)
.await;
let (worktree_a_id, worktree_b_id) = project.read_with(cx, |project, cx| {
let mut worktrees: Vec<_> = project.worktrees(cx).collect();
worktrees.sort_by_key(|w| w.read(cx).abs_path());
(worktrees[0].read(cx).id(), worktrees[1].read(cx).id())
});
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
cx.run_until_parked();
// Select project A explicitly and open the diff.
workspace.update(cx, |workspace, cx| {
let git_store = workspace.project().read(cx).git_store().clone();
git_store.update(cx, |git_store, cx| {
git_store.set_active_repo_for_worktree(worktree_a_id, cx);
});
});
cx.focus(&workspace);
cx.update(|window, cx| {
window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
});
cx.run_until_parked();
let diff_item = workspace.update(cx, |workspace, cx| {
workspace.active_item_as::<ProjectDiff>(cx).unwrap()
});
let paths_a = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
assert_eq!(paths_a.len(), 1);
assert_eq!(*paths_a[0], *"a.txt");
// Switch the explicit active repository to project B and re-run the diff action.
workspace.update(cx, |workspace, cx| {
let git_store = workspace.project().read(cx).git_store().clone();
git_store.update(cx, |git_store, cx| {
git_store.set_active_repo_for_worktree(worktree_b_id, cx);
});
});
cx.focus(&workspace);
cx.update(|window, cx| {
window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
});
cx.run_until_parked();
let same_diff_item = workspace.update(cx, |workspace, cx| {
workspace.active_item_as::<ProjectDiff>(cx).unwrap()
});
assert_eq!(diff_item.entity_id(), same_diff_item.entity_id());
let paths_b = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
assert_eq!(paths_b.len(), 1);
assert_eq!(*paths_b[0], *"b.txt");
}
}