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>
2184 lines
72 KiB
Rust
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}"));
|
|
}
|
|
}
|