Files
zed/crates/git_ui/src/project_diff.rs
T
Kirill BulatovandKostia R 677e8174b5 Flush all persistence threads for the rest for various shutdown cases (#63213)
Follow-up to https://github.com/zed-industries/zed/pull/60477
Closes #25022

We have more places that need a similar synchronization before quitting
the app: restarts on update, reloading the workspace.

On top of it, two more findings:

* `on_app_will_quit` was redundant: the global `on_app_quit` hook
flushes a strict superset of the same tasks and runs before windows are
cleared, while the entity hook held only a weak handle and could never
flush the `Window`-dependent workspace state anyway.

* previously, shutdown was run as `cx.spawn`, yet main threads got
parked on app shutdown: now using `cx.background_spawn` instead

Release Notes:

- Improved data persistence during various app shutdown cases

---------

Co-authored-by: Kostia R <k-bx@k-bx.com>
2026-08-26 14:33:10 +00:00

2184 lines
72 KiB
Rust

use crate::{
branch_diff::BranchDiff,
diff_multibuffer::DiffMultibuffer,
git_panel::{GitPanel, GitPanelAddon, GitStatusEntry},
staged_diff::StagedDiff,
unstaged_diff::UnstagedDiff,
};
use anyhow::{Context as _, Result};
use buffer_diff::DiffHunkSecondaryStatus;
use editor::{
Editor, EditorEvent, SplittableEditor, UncommittedDiffHunkDelegate,
actions::{GoToHunk, GoToPreviousHunk, SendReviewToAgent},
};
use git::{Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext};
use gpui::{
Action, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, Render,
Subscription, Task, WeakEntity, actions,
};
use language::Capability;
use multi_buffer::MultiBuffer;
use project::{
Project, ProjectPath,
git_store::{
Repository,
diff_buffer_list::{self, DiffBase},
},
};
use schemars::JsonSchema;
use serde::Deserialize;
use settings::GitDiffBaseSetting;
use std::any::{Any, TypeId};
use std::sync::Arc;
use ui::{DiffStat, Divider, Tooltip, prelude::*};
use workspace::{
ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
Workspace,
item::{Item, ItemEvent, ItemHandle, SaveOptions},
searchable::SearchableItemHandle,
};
use zed_actions::git as git_actions;
actions!(
git,
[
/// Shows the diff against the configured diff base: working changes
/// relative to HEAD, or all branch changes relative to the default
/// branch, following the `git.diff_base` setting.
Diff,
/// Shows working changes relative to HEAD.
DiffHead,
/// Toggles the git diff base between HEAD and the default branch.
ToggleDiffBase,
/// Adds files to the git staging area.
Add,
/// Opens a new agent thread with the branch diff for review.
ReviewDiff,
LeaderAndFollower,
/// Compare with a specific branch
CompareWithBranch,
]
);
/// Shows the diff between the working directory and your default
/// branch (typically main or master).
#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
#[action(namespace = git, name = "DiffBranch", deprecated_aliases = ["git::BranchDiff"])]
pub(crate) struct DeployBranchDiff;
pub struct ProjectDiff {
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
diff: Entity<DiffMultibuffer>,
_diff_observation: Subscription,
}
impl ProjectDiff {
pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
workspace.register_action(Self::deploy);
workspace.register_action(|workspace, _: &DiffHead, window, cx| {
Self::deploy_at(workspace, None, window, cx);
});
workspace.register_action(|workspace, _: &ToggleDiffBase, _window, cx| {
settings::update_settings_file(
workspace.app_state().fs.clone(),
cx,
move |settings, _| {
let git = settings.git.get_or_insert_default();
git.diff_base = Some(match git.diff_base.unwrap_or_default() {
GitDiffBaseSetting::Head => GitDiffBaseSetting::DefaultBranch,
GitDiffBaseSetting::DefaultBranch => GitDiffBaseSetting::Head,
});
},
);
});
workspace.register_action(
|workspace, _: &git_actions::ViewUncommittedChanges, window, cx| {
Self::deploy_at(workspace, None, window, cx);
},
);
workspace.register_action(
|workspace, _: &git_actions::ViewUnstagedChanges, window, cx| {
UnstagedDiff::deploy_at(workspace, None, window, cx);
},
);
workspace.register_action(
|workspace, _: &git_actions::ViewStagedChanges, window, cx| {
StagedDiff::deploy_at(workspace, None, window, cx);
},
);
workspace.register_action(|workspace, _: &Add, window, cx| {
Self::deploy_at(workspace, None, window, cx);
});
workspace::register_serializable_item::<ProjectDiff>(cx);
}
fn deploy(
workspace: &mut Workspace,
_: &Diff,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let project = workspace.project().clone();
if project.read(cx).git_store().read(cx).diff_base() == GitDiffBaseSetting::DefaultBranch {
BranchDiff::deploy_branch_diff(workspace, window, cx);
return;
}
Self::deploy_at(workspace, None, 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).next();
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)
.repo(cx)
.map_or(true, |current| current.read(cx).id != intended.read(cx).id);
if needs_switch {
project_diff.update(cx, |project_diff, cx| {
project_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).next();
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.diff.update(cx, |diff, cx| diff.autoscroll(cx));
}
fn new(
project: Entity<Project>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let git_store = project.read(cx).git_store().clone();
let branch_diff =
cx.new(|cx| diff_buffer_list::DiffBufferList::new(DiffBase::Head, git_store, None, cx));
Self::new_impl(branch_diff, project, workspace, window, cx)
}
fn new_impl(
branch_diff: Entity<diff_buffer_list::DiffBufferList>,
project: Entity<Project>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let workspace_handle = workspace.downgrade();
let diff = cx.new(|cx| {
DiffMultibuffer::new(
branch_diff,
Capability::ReadWrite,
"No uncommitted changes",
move |editor, cx| {
editor.set_diff_hunk_delegate(Some(Arc::new(UncommittedDiffHunkDelegate)), cx);
editor.rhs_editor().update(cx, |rhs_editor, _cx| {
rhs_editor.set_read_only(false);
rhs_editor.register_addon(GitPanelAddon {
workspace: workspace_handle,
});
});
},
project.clone(),
workspace.clone(),
window,
cx,
)
});
Self::from_diff(diff, project, workspace, cx)
}
fn from_diff(
diff: Entity<DiffMultibuffer>,
project: Entity<Project>,
workspace: Entity<Workspace>,
cx: &mut Context<Self>,
) -> Self {
let observation = cx.observe(&diff, |_, _, cx| cx.notify());
Self {
project,
workspace: workspace.downgrade(),
diff,
_diff_observation: observation,
}
}
pub fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
self.diff.read(cx).diff_base(cx)
}
pub(crate) fn repo(&self, cx: &App) -> Option<Entity<Repository>> {
self.diff.read(cx).repo(cx)
}
pub(crate) fn set_repo(&mut self, repo: Option<Entity<Repository>>, cx: &mut Context<Self>) {
self.diff
.update(cx, |diff, cx| diff.set_repo(repo.clone(), cx));
}
pub fn move_to_entry(
&mut self,
entry: GitStatusEntry,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.diff
.update(cx, |diff, cx| diff.move_to_entry(entry, window, cx));
}
pub fn move_to_project_path(
&mut self,
project_path: &ProjectPath,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.diff.update(cx, |diff, cx| {
diff.move_to_project_path(project_path, window, cx)
});
}
fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.diff
.update(cx, |diff, cx| diff.move_to_beginning(window, cx));
}
pub fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) {
self.diff.read(cx).calculate_changed_lines(cx)
}
/// Returns the total count of review comments across all hunks/files.
pub fn total_review_comment_count(&self, cx: &App) -> usize {
self.diff.read(cx).total_review_comment_count()
}
/// Returns the splittable editor of the currently-shown diff view.
pub fn editor(&self, cx: &App) -> Entity<SplittableEditor> {
self.diff.read(cx).editor().clone()
}
/// Returns the multibuffer of the currently-shown diff view.
pub fn multibuffer(&self, cx: &App) -> Entity<MultiBuffer> {
self.diff.read(cx).multibuffer().clone()
}
fn button_states(&self, cx: &App) -> ButtonStates {
let diff = self.diff.read(cx);
let editor = diff.editor().read(cx).rhs_editor().clone();
let editor = editor.read(cx);
let snapshot = diff.multibuffer().read(cx).snapshot(cx);
let prev_next = snapshot.diff_hunks().next().is_some();
let (selection, ranges) = diff.selected_ranges(cx);
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,
}
}
#[cfg(any(test, feature = "test-support"))]
pub fn excerpt_paths(&self, cx: &App) -> Vec<std::sync::Arc<util::rel_path::RelPath>> {
self.diff.read(cx).excerpt_paths(cx)
}
#[cfg(any(test, feature = "test-support"))]
pub fn excerpt_file_paths(&self, cx: &App) -> Vec<String> {
self.diff.read(cx).excerpt_file_paths(cx)
}
}
struct ButtonStates {
stage: bool,
unstage: bool,
prev_next: bool,
selection: bool,
stage_all: bool,
unstage_all: bool,
}
impl EventEmitter<EditorEvent> for ProjectDiff {}
impl Focusable for ProjectDiff {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.diff.read(cx).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.diff
.update(cx, |diff, cx| diff.deactivated(window, cx));
}
fn navigate(
&mut self,
data: Arc<dyn Any + Send>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
self.diff
.update(cx, |diff, cx| diff.navigate(data, window, cx))
}
fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
Some(self.tab_content_text(0, cx))
}
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
"Uncommitted Changes".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.diff.read(cx).editor().clone()))
}
fn for_each_project_item(
&self,
cx: &App,
f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
) {
self.diff.read(cx).for_each_project_item(cx, f)
}
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
self.diff.read(cx).active_project_path(cx)
}
fn set_nav_history(
&mut self,
nav_history: ItemNavHistory,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.diff
.update(cx, |diff, cx| diff.set_nav_history(nav_history, cx));
}
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.diff.read(cx).is_dirty(cx)
}
fn has_conflict(&self, cx: &App) -> bool {
self.diff.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.diff
.update(cx, |diff, cx| diff.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.diff
.update(cx, |diff, cx| diff.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.diff
.read(cx)
.editor()
.read(cx)
.rhs_editor()
.clone()
.into(),
)
} else if type_id == TypeId::of::<SplittableEditor>() {
Some(self.diff.read(cx).editor().clone().into())
} else if type_id == TypeId::of::<diff_buffer_list::DiffBufferList>() {
Some(self.diff.read(cx).branch_diff().clone().into())
} else {
None
}
}
fn added_to_workspace(
&mut self,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.diff.update(cx, |diff, cx| {
diff.added_to_workspace(workspace, window, cx)
});
}
}
impl Render for ProjectDiff {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.diff.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>>> {
window.spawn(cx, async move |cx| {
cx.update(|window, cx| {
let git_store = project.read(cx).git_store().clone();
let branch_diff = cx.new(|cx| {
diff_buffer_list::DiffBufferList::new(DiffBase::Head, git_store, None, cx)
});
let workspace = workspace.upgrade().context("workspace gone")?;
anyhow::Ok(
cx.new(|cx| ProjectDiff::new_impl(branch_diff, project, workspace, window, cx)),
)
})?
})
}
fn serialize(
&mut self,
_: &mut Workspace,
_: workspace::ItemId,
_: bool,
_: &mut Context<Self>,
) -> Option<Task<Result<()>>> {
Some(Task::ready(Ok(())))
}
fn should_serialize(&self, _: &Self::Event) -> bool {
false
}
}
pub(crate) mod persistence {
use anyhow::Context as _;
use db::{
sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
sqlez_macros::sql,
};
use project::git_store::diff_buffer_list::DiffBase;
use workspace::{ItemId, WorkspaceDb, WorkspaceId};
pub struct ProjectDiffDb(ThreadSafeConnection);
impl Domain for ProjectDiffDb {
const NAME: &str = stringify!(ProjectDiffDb);
// Legacy databases stored branch diffs under the "ProjectDiff" item
// kind, disambiguated by the `diff_base` column. Step 1 rewrites those
// item kinds so that each diff view owns its serialized kind.
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;
),
r#"
UPDATE items SET kind = 'BranchDiff'
WHERE kind = 'ProjectDiff' AND EXISTS (
SELECT 1 FROM project_diffs
WHERE project_diffs.item_id = items.item_id
AND project_diffs.workspace_id = items.workspace_id
AND project_diffs.diff_base LIKE '{"Merge"%'
);
"#,
];
}
db::static_connection!(ProjectDiffDb, [WorkspaceDb]);
impl ProjectDiffDb {
pub async fn save_project_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_project_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))
.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 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(cx);
let (additions, deletions) = project_diff.read(cx).calculate_changed_lines(cx);
let is_multibuffer_empty = project_diff.read(cx).multibuffer(cx).read(cx).is_empty();
let stage_all_button_width = rems(5.);
h_flex()
.my_neg_1()
.py_1()
.gap_1p5()
.flex_wrap()
.justify_between()
.when(!is_multibuffer_empty, |this| {
this.child(DiffStat::new(
"project-diff-stat",
additions as usize,
deletions as usize,
))
.child(Divider::vertical().ml_1())
})
// 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)
.icon_size(IconSize::Small)
.disabled(!button_states.prev_next)
.tooltip(Tooltip::for_action_title_in(
"Go to Previous Hunk",
&GoToPreviousHunk,
&focus_handle,
))
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&GoToPreviousHunk, window, cx)
})),
)
.child(
IconButton::new("down", IconName::ArrowDown)
.icon_size(IconSize::Small)
.disabled(!button_states.prev_next)
.tooltip(Tooltip::for_action_title_in(
"Go to Next Hunk",
&GoToHunk,
&focus_handle,
))
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&GoToHunk, window, cx)
})),
),
)
.child(Divider::vertical())
.child(
h_group_sm()
.when(button_states.selection, |this| {
this.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, |this| {
this.child(
Button::new("stage", "Stage")
.disabled(!button_states.stage)
.tooltip(Tooltip::for_action_title_in(
"Stage and Go to Next Hunk",
&StageAndNext,
&focus_handle,
))
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&StageAndNext, window, cx)
})),
)
.child(
Button::new("unstage", "Unstage")
.disabled(!button_states.unstage)
.tooltip(Tooltip::for_action_title_in(
"Unstage and Go to Next Hunk",
&UnstageAndNext,
&focus_handle,
))
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&UnstageAndNext, window, cx)
})),
)
}),
)
.child(Divider::vertical())
.when(
button_states.unstage_all && !button_states.stage_all,
|this| {
this.child(
Button::new("unstage-all", "Unstage All")
.width(stage_all_button_width)
.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,
|this| {
this.child(
Button::new("stage-all", "Stage All")
.width(stage_all_button_width)
.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(Divider::vertical())
.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);
})),
)
.when(review_count > 0, |el| {
el.child(Divider::vertical()).child(
render_send_review_to_agent_button(review_count, &focus_handle).on_click(
cx.listener(|this, _, window, cx| {
this.dispatch_action(&SendReviewToAgent, window, cx)
}),
),
)
})
}
}
pub(crate) 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,
))
}
#[cfg(test)]
mod tests {
use buffer_diff::DiffHunkSecondaryStatus;
use db::indoc;
use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
use gpui::TestAppContext;
use multi_buffer::PathKey;
use project::FakeFs;
use serde_json::json;
use settings::{DiffViewStyle, GitPanelGroupBy, GitPanelSortBy, SettingsStore};
use std::path::Path;
use unindent::Unindent as _;
use util::{path, rel_path::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);
});
}
use zed_actions::git as git_actions;
use crate::project_diff::{self, ProjectDiff};
#[test]
fn test_legacy_branch_diff_rows_migrate_to_their_own_kind() {
use db::sqlez::{
connection::Connection,
domain::{Domain as _, Migrator as _},
};
let connection = Connection::open_memory(Some(
"test_legacy_branch_diff_rows_migrate_to_their_own_kind",
));
connection.exec("PRAGMA foreign_keys = OFF").unwrap()().unwrap();
workspace::WorkspaceDb::migrate(&connection).unwrap();
connection
.migrate(
persistence::ProjectDiffDb::NAME,
&persistence::ProjectDiffDb::MIGRATIONS[..1],
&mut |_, _, _| false,
)
.unwrap();
connection
.exec(
"INSERT INTO workspaces(workspace_id) VALUES (1);
INSERT INTO panes(pane_id, workspace_id, active) VALUES (1, 1, 1);
INSERT INTO items(item_id, workspace_id, pane_id, kind, position, active) VALUES
(1, 1, 1, 'ProjectDiff', 0, 1),
(2, 1, 1, 'ProjectDiff', 1, 0)",
)
.unwrap()()
.unwrap();
let head = serde_json::to_string(&DiffBase::Head).unwrap();
let merge = serde_json::to_string(&DiffBase::Merge {
base_ref: "main".into(),
})
.unwrap();
connection
.exec_bound::<(String, String)>(
"INSERT INTO project_diffs(workspace_id, item_id, diff_base) VALUES (1, 1, ?), (1, 2, ?)",
)
.unwrap()((head, merge))
.unwrap();
persistence::ProjectDiffDb::migrate(&connection).unwrap();
let kinds = connection
.select::<(i64, String)>("SELECT item_id, kind FROM items ORDER BY item_id")
.unwrap()()
.unwrap();
assert_eq!(
kinds,
[
(1, "ProjectDiff".to_string()),
(2, "BranchDiff".to_string())
]
);
}
#[gpui::test]
async fn test_diff_action_follows_diff_base_setting(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"committed.txt": "head\n",
}),
)
.await;
fs.set_head_and_index_for_repo(
Path::new(path!("/project/.git")),
&[("committed.txt", "head\n".into())],
);
fs.set_merge_base_content_for_repo(
Path::new(path!("/project/.git")),
&[("committed.txt", "base\n".into())],
);
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
project
.update(cx, |project, cx| project.git_scans_complete(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, |multi_workspace, _| multi_workspace.workspace().clone());
cx.focus(&workspace);
cx.update(|_window, cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.git.get_or_insert_default().diff_base =
Some(GitDiffBaseSetting::DefaultBranch);
});
});
});
cx.run_until_parked();
project.read_with(cx, |project, cx| {
assert_eq!(
project.git_store().read(cx).diff_base(),
GitDiffBaseSetting::DefaultBranch
);
});
cx.update(|window, cx| {
window.dispatch_action(ToggleDiffBase.boxed_clone(), cx);
});
cx.run_until_parked();
project.read_with(cx, |project, cx| {
assert_eq!(
project.git_store().read(cx).diff_base(),
GitDiffBaseSetting::DefaultBranch
);
});
cx.update(|window, cx| {
window.dispatch_action(ToggleDiffBase.boxed_clone(), cx);
});
cx.run_until_parked();
project.read_with(cx, |project, cx| {
assert_eq!(
project.git_store().read(cx).diff_base(),
GitDiffBaseSetting::Head
);
});
cx.update(|window, cx| {
window.dispatch_action(ToggleDiffBase.boxed_clone(), cx);
});
cx.run_until_parked();
cx.update(|window, cx| {
window.dispatch_action(Diff.boxed_clone(), cx);
});
cx.run_until_parked();
workspace.update(cx, |workspace, cx| {
assert!(workspace.active_item_as::<BranchDiff>(cx).is_some());
assert_eq!(workspace.items_of_type::<ProjectDiff>(cx).count(), 0);
});
cx.update(|window, cx| {
window.dispatch_action(DiffHead.boxed_clone(), cx);
});
cx.run_until_parked();
workspace.update(cx, |workspace, cx| {
assert!(workspace.active_item_as::<ProjectDiff>(cx).is_some());
});
cx.update(|_window, cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.git.get_or_insert_default().diff_base = Some(GitDiffBaseSetting::Head);
});
});
});
cx.run_until_parked();
let repository_id = project.read_with(cx, |project, cx| {
assert_eq!(
project.git_store().read(cx).diff_base(),
GitDiffBaseSetting::Head
);
project.active_repository(cx).unwrap().read(cx).id
});
cx.update(|window, cx| {
window.dispatch_action(DeployBranchDiff.boxed_clone(), cx);
});
cx.run_until_parked();
project.read_with(cx, |project, cx| {
assert!(
project
.git_store()
.read(cx)
.display_diff_for_repo(repository_id)
.is_none()
);
});
}
#[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(cx).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");
}
#[gpui::test]
async fn test_project_diff_actions_filter_mixed_staged_and_unstaged_hunks(
cx: &mut TestAppContext,
) {
init_test(cx);
let committed_contents = r#"
fn main() {
println!("hello world");
}
"#
.unindent();
let staged_contents = r#"
fn main() {
println!("goodbye world");
}
"#
.unindent();
let file_contents = r#"
// print goodbye
fn main() {
println!("goodbye world");
}
"#
.unindent();
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"src": {
"main.rs": file_contents,
}
}),
)
.await;
fs.set_head_for_repo(
Path::new(path!("/project/.git")),
&[("src/main.rs", committed_contents)],
"deadbeef",
);
fs.set_index_for_repo(
Path::new(path!("/project/.git")),
&[("src/main.rs", staged_contents)],
);
let project = Project::test(fs, [path!("/project").as_ref()], 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 diff_item = workspace.update(cx, |workspace, cx| {
workspace.active_item_as::<ProjectDiff>(cx).unwrap()
});
let diff_editor =
diff_item.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
assert_eq!(
diff_editor.read_with(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
editor
.diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot)
.map(|hunk| hunk.status.secondary)
.collect::<Vec<_>>()
}),
vec![
DiffHunkSecondaryStatus::HasSecondaryHunk,
DiffHunkSecondaryStatus::NoSecondaryHunk,
]
);
cx.focus(&workspace);
cx.update(|window, cx| {
window.dispatch_action(git_actions::ViewUnstagedChanges.boxed_clone(), cx);
});
cx.run_until_parked();
let unstaged_item = workspace.update(cx, |workspace, cx| {
workspace.active_item_as::<UnstagedDiff>(cx).unwrap()
});
assert_ne!(diff_item.entity_id(), unstaged_item.entity_id());
let unstaged_editor = workspace.update(cx, |workspace, cx| {
let active_item = workspace.active_item(cx).unwrap();
assert_eq!(active_item.tab_content_text(0, cx), "Unstaged Changes");
active_item
.act_as::<DiffMultibuffer>(cx)
.unwrap()
.read(cx)
.editor()
.read(cx)
.rhs_editor()
.clone()
});
assert_eq!(
unstaged_editor.read_with(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
editor
.diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot)
.map(|hunk| hunk.status.secondary)
.collect::<Vec<_>>()
}),
vec![DiffHunkSecondaryStatus::NoSecondaryHunk]
);
cx.focus(&workspace);
cx.update(|window, cx| {
window.dispatch_action(git_actions::ViewUncommittedChanges.boxed_clone(), cx);
});
cx.run_until_parked();
let uncommitted_item = workspace.update(cx, |workspace, cx| {
workspace.active_item_as::<ProjectDiff>(cx).unwrap()
});
assert_eq!(diff_item.entity_id(), uncommitted_item.entity_id());
assert_eq!(
uncommitted_item.read_with(cx, |diff, cx| diff.tab_content_text(0, cx)),
"Uncommitted Changes"
);
let uncommitted_editor = uncommitted_item
.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
assert_eq!(
uncommitted_editor.read_with(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
editor
.diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot)
.map(|hunk| hunk.status.secondary)
.collect::<Vec<_>>()
}),
vec![
DiffHunkSecondaryStatus::HasSecondaryHunk,
DiffHunkSecondaryStatus::NoSecondaryHunk,
]
);
cx.focus(&workspace);
cx.update(|window, cx| {
window.dispatch_action(git_actions::ViewStagedChanges.boxed_clone(), cx);
});
cx.run_until_parked();
let staged_editor = workspace.update(cx, |workspace, cx| {
workspace.active_item_as::<StagedDiff>(cx).unwrap();
let active_item = workspace.active_item(cx).unwrap();
assert_eq!(active_item.tab_content_text(0, cx), "Staged Changes");
active_item
.act_as::<DiffMultibuffer>(cx)
.unwrap()
.read(cx)
.editor()
.read(cx)
.rhs_editor()
.clone()
});
assert_eq!(
staged_editor.read_with(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
editor
.diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot)
.map(|hunk| hunk.status.secondary)
.collect::<Vec<_>>()
}),
vec![DiffHunkSecondaryStatus::NoSecondaryHunk]
);
}
#[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(cx).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_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(cx).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.diff.update(cx, |diff, cx| {
diff.move_to_path(
PathKey::with_sort_prefix(2, rel_path("foo").into_arc()),
window,
cx,
)
});
diff.editor(cx).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.diff.update(cx, |diff, cx| {
diff.move_to_path(
PathKey::with_sort_prefix(2, rel_path("bar").into_arc()),
window,
cx,
)
});
diff.editor(cx).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(cx).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(),
);
}
#[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(cx).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_sort_by_name_tie_breaks_on_path(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
let git_panel = settings.git_panel.get_or_insert_default();
git_panel.sort_by = Some(GitPanelSortBy::Name);
git_panel.group_by = Some(GitPanelGroupBy::None);
});
});
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"lib": { "foo.rs": "LIB FOO\n" },
"src": { "foo.rs": "SRC FOO\n" },
"m.rs": "M\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(),
&[
("lib/foo.rs", "lib foo\n".into()),
("src/foo.rs", "src foo\n".into()),
("m.rs", "m\n".into()),
],
);
cx.run_until_parked();
// Sorted by file name, the two `foo.rs` files come before `m.rs`, and the
// tie between them is broken by the full path (`lib/` before `src/`).
// A plain path sort would instead order them `lib/foo.rs`, `m.rs`,
// `src/foo.rs`.
let paths = diff.read_with(cx, |diff, cx| diff.excerpt_file_paths(cx));
assert_eq!(paths, vec!["lib/foo.rs", "src/foo.rs", "m.rs"]);
}
#[gpui::test]
async fn test_tree_view_orders_directories_before_files(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
let git_panel = settings.git_panel.get_or_insert_default();
git_panel.tree_view = Some(true);
git_panel.group_by = Some(GitPanelGroupBy::None);
});
});
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"src": {
"a.rs": "A\n",
"m.rs": "M\n",
"sub": { "b.rs": "B\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(),
&[
("src/a.rs", "a\n".into()),
("src/m.rs", "m\n".into()),
("src/sub/b.rs", "b\n".into()),
],
);
cx.run_until_parked();
// In tree view the `src/sub/` directory sorts before the files directly
// in `src/`. A plain path sort would interleave them as `src/a.rs`,
// `src/m.rs`, `src/sub/b.rs`.
let paths = diff.read_with(cx, |diff, cx| diff.excerpt_file_paths(cx));
assert_eq!(paths, vec!["src/sub/b.rs", "src/a.rs", "src/m.rs"]);
}
#[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.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
cx.run_until_parked();
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(cx).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}"));
}
}