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