ZIm/crates/git_ui/src/project_diff.rs
Piotr Osiewicz 6825715503
Another batch of lint fixes (#36521)
- **Enable a bunch of extra lints**
- **First batch of fixes**
- **More fixes**

Release Notes:

- N/A
2025-08-19 20:33:44 +00:00

1879 lines
62 KiB
Rust

use crate::{
conflict_view::ConflictAddon,
git_panel::{GitPanel, GitPanelAddon, GitStatusEntry},
git_panel_settings::GitPanelSettings,
remote_button::{render_publish_button, render_push_button},
};
use anyhow::Result;
use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
use collections::HashSet;
use editor::{
Editor, EditorEvent, SelectionEffects,
actions::{GoToHunk, GoToPreviousHunk},
scroll::Autoscroll,
};
use futures::StreamExt;
use git::{
Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext,
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
status::FileStatus,
};
use gpui::{
Action, AnyElement, AnyView, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter,
FocusHandle, Focusable, Render, Subscription, Task, WeakEntity, actions,
};
use language::{Anchor, Buffer, Capability, OffsetRangeExt};
use multi_buffer::{MultiBuffer, PathKey};
use project::{
Project, ProjectPath,
git_store::{GitStore, GitStoreEvent, RepositoryEvent},
};
use settings::{Settings, SettingsStore};
use std::any::{Any, TypeId};
use std::ops::Range;
use theme::ActiveTheme;
use ui::{KeyBinding, Tooltip, prelude::*, vertical_divider};
use util::ResultExt as _;
use workspace::{
CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
ToolbarItemView, Workspace,
item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
searchable::SearchableItemHandle,
};
actions!(
git,
[
/// Shows the diff between the working directory and the index.
Diff,
/// Adds files to the git staging area.
Add
]
);
pub struct ProjectDiff {
project: Entity<Project>,
multibuffer: Entity<MultiBuffer>,
editor: Entity<Editor>,
git_store: Entity<GitStore>,
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
update_needed: postage::watch::Sender<()>,
pending_scroll: Option<PathKey>,
_task: Task<Result<()>>,
_subscription: Subscription,
}
#[derive(Debug)]
struct DiffBuffer {
path_key: PathKey,
buffer: Entity<Buffer>,
diff: Entity<BufferDiff>,
file_status: FileStatus,
}
const CONFLICT_NAMESPACE: u32 = 1;
const TRACKED_NAMESPACE: u32 = 2;
const NEW_NAMESPACE: u32 = 3;
impl ProjectDiff {
pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
workspace.register_action(Self::deploy);
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)
}
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 project_diff = if let Some(existing) = workspace.item_of_type::<Self>(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(entry) = entry {
project_diff.update(cx, |project_diff, cx| {
project_diff.move_to_entry(entry, window, cx);
})
}
}
pub fn autoscroll(&self, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| {
editor.request_autoscroll(Autoscroll::fit(), cx);
})
}
fn new(
project: Entity<Project>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
let editor = cx.new(|cx| {
let mut diff_display_editor =
Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
diff_display_editor.disable_diagnostics(cx);
diff_display_editor.set_expand_all_diff_hunks(cx);
diff_display_editor.register_addon(GitPanelAddon {
workspace: workspace.downgrade(),
});
diff_display_editor
});
window.defer(cx, {
let workspace = workspace.clone();
let editor = editor.clone();
move |window, cx| {
workspace.update(cx, |workspace, cx| {
editor.update(cx, |editor, cx| {
editor.added_to_workspace(workspace, window, cx);
})
});
}
});
cx.subscribe_in(&editor, window, Self::handle_editor_event)
.detach();
let git_store = project.read(cx).git_store().clone();
let git_store_subscription = cx.subscribe_in(
&git_store,
window,
move |this, _git_store, event, _window, _cx| match event {
GitStoreEvent::ActiveRepositoryChanged(_)
| GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::Updated { .. }, true)
| GitStoreEvent::ConflictsUpdated => {
*this.update_needed.borrow_mut() = ();
}
_ => {}
},
);
let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
let mut was_collapse_untracked_diff =
GitPanelSettings::get_global(cx).collapse_untracked_diff;
cx.observe_global::<SettingsStore>(move |this, cx| {
let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
let is_collapse_untracked_diff =
GitPanelSettings::get_global(cx).collapse_untracked_diff;
if is_sort_by_path != was_sort_by_path
|| is_collapse_untracked_diff != was_collapse_untracked_diff
{
*this.update_needed.borrow_mut() = ();
}
was_sort_by_path = is_sort_by_path;
was_collapse_untracked_diff = is_collapse_untracked_diff;
})
.detach();
let (mut send, recv) = postage::watch::channel::<()>();
let worker = window.spawn(cx, {
let this = cx.weak_entity();
async |cx| Self::handle_status_updates(this, recv, cx).await
});
// Kick off a refresh immediately
*send.borrow_mut() = ();
Self {
project,
git_store: git_store.clone(),
workspace: workspace.downgrade(),
focus_handle,
editor,
multibuffer,
pending_scroll: None,
update_needed: send,
_task: worker,
_subscription: git_store_subscription,
}
}
pub fn move_to_entry(
&mut self,
entry: GitStatusEntry,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(git_repo) = self.git_store.read(cx).active_repository() else {
return;
};
let repo = git_repo.read(cx);
let namespace = if repo.had_conflict_on_last_merge_head_change(&entry.repo_path) {
CONFLICT_NAMESPACE
} else if entry.status.is_created() {
NEW_NAMESPACE
} else {
TRACKED_NAMESPACE
};
let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone());
self.move_to_path(path_key, window, cx)
}
pub fn active_path(&self, cx: &App) -> Option<ProjectPath> {
let editor = self.editor.read(cx);
let position = editor.selections.newest_anchor().head();
let multi_buffer = editor.buffer().read(cx);
let (_, buffer, _) = multi_buffer.excerpt_containing(position, cx)?;
let file = buffer.read(cx).file()?;
Some(ProjectPath {
worktree_id: file.worktree_id(cx),
path: file.path().clone(),
})
}
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.change_selections(
SelectionEffects::scroll(Autoscroll::focused()),
window,
cx,
|s| {
s.select_ranges([position..position]);
},
)
});
} else {
self.pending_scroll = Some(path_key);
}
}
fn button_states(&self, cx: &App) -> ButtonStates {
let editor = self.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;
if let Some((excerpt_id, buffer, range)) = self.editor.read(cx).active_excerpt(cx) {
ranges = vec![multi_buffer::Anchor::range_in_buffer(
excerpt_id,
buffer.read(cx).remote_id(),
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.secondary_status {
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<Editor>,
event: &EditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let EditorEvent::SelectionsChanged { local: true } = event {
let Some(project_path) = self.active_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();
}
if editor.focus_handle(cx).contains_focused(window, cx)
&& self.multibuffer.read(cx).is_empty()
{
self.focus_handle.focus(window)
}
}
fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<Task<Result<DiffBuffer>>> {
let Some(repo) = self.git_store.read(cx).active_repository() else {
self.multibuffer.update(cx, |multibuffer, cx| {
multibuffer.clear(cx);
});
return vec![];
};
let mut previous_paths = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
let mut result = vec![];
repo.update(cx, |repo, cx| {
for entry in repo.cached_status() {
if !entry.status.has_changes() {
continue;
}
let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path, cx)
else {
continue;
};
let namespace = if GitPanelSettings::get_global(cx).sort_by_path {
TRACKED_NAMESPACE
} else if repo.had_conflict_on_last_merge_head_change(&entry.repo_path) {
CONFLICT_NAMESPACE
} else if entry.status.is_created() {
NEW_NAMESPACE
} else {
TRACKED_NAMESPACE
};
let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone());
previous_paths.remove(&path_key);
let load_buffer = self
.project
.update(cx, |project, cx| project.open_buffer(project_path, cx));
let project = self.project.clone();
result.push(cx.spawn(async move |_, cx| {
let buffer = load_buffer.await?;
let changes = project
.update(cx, |project, cx| {
project.open_uncommitted_diff(buffer.clone(), cx)
})?
.await?;
Ok(DiffBuffer {
path_key,
buffer,
diff: changes,
file_status: entry.status,
})
}));
}
});
self.multibuffer.update(cx, |multibuffer, cx| {
for path in previous_paths {
multibuffer.remove_excerpts_for_path(path, cx);
}
});
result
}
fn register_buffer(
&mut self,
diff_buffer: DiffBuffer,
window: &mut Window,
cx: &mut Context<Self>,
) {
let path_key = diff_buffer.path_key;
let buffer = diff_buffer.buffer;
let diff = diff_buffer.diff;
let conflict_addon = self
.editor
.read(cx)
.addon::<ConflictAddon>()
.expect("project diff editor should have a conflict addon");
let snapshot = buffer.read(cx).snapshot();
let diff = diff.read(cx);
let diff_hunk_ranges = diff
.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
.map(|diff_hunk| diff_hunk.buffer_range.clone());
let conflicts = conflict_addon
.conflict_set(snapshot.remote_id())
.map(|conflict_set| conflict_set.read(cx).snapshot().conflicts.clone())
.unwrap_or_default();
let conflicts = conflicts.iter().map(|conflict| conflict.range.clone());
let excerpt_ranges = merge_anchor_ranges(diff_hunk_ranges, conflicts, &snapshot)
.map(|range| range.to_point(&snapshot))
.collect::<Vec<_>>();
let (was_empty, is_excerpt_newly_added) = self.multibuffer.update(cx, |multibuffer, cx| {
let was_empty = multibuffer.is_empty();
let (_, is_newly_added) = multibuffer.set_excerpts_for_path(
path_key.clone(),
buffer,
excerpt_ranges,
editor::DEFAULT_MULTIBUFFER_CONTEXT,
cx,
);
(was_empty, is_newly_added)
});
self.editor.update(cx, |editor, cx| {
if was_empty {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
// TODO select the very beginning (possibly inside a deletion)
selections.select_ranges([0..0])
});
}
if is_excerpt_newly_added
&& (diff_buffer.file_status.is_deleted()
|| (diff_buffer.file_status.is_untracked()
&& GitPanelSettings::get_global(cx).collapse_untracked_diff))
{
editor.fold_buffer(snapshot.text.remote_id(), cx)
}
});
if self.multibuffer.read(cx).is_empty()
&& self
.editor
.read(cx)
.focus_handle(cx)
.contains_focused(window, cx)
{
self.focus_handle.focus(window);
} 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);
});
}
if self.pending_scroll.as_ref() == Some(&path_key) {
self.move_to_path(path_key, window, cx);
}
}
pub async fn handle_status_updates(
this: WeakEntity<Self>,
mut recv: postage::watch::Receiver<()>,
cx: &mut AsyncWindowContext,
) -> Result<()> {
while (recv.next().await).is_some() {
let buffers_to_load = this.update(cx, |this, cx| this.load_buffers(cx))?;
for buffer_to_load in buffers_to_load {
if let Some(buffer) = buffer_to_load.await.log_err() {
cx.update(|window, cx| {
this.update(cx, |this, cx| this.register_buffer(buffer, window, cx))
.ok();
})?;
}
}
this.update(cx, |this, cx| {
this.pending_scroll.take();
cx.notify();
})?;
}
Ok(())
}
#[cfg(any(test, feature = "test-support"))]
pub fn excerpt_paths(&self, cx: &App) -> Vec<String> {
self.multibuffer
.read(cx)
.excerpt_paths()
.map(|key| key.path().to_string_lossy().to_string())
.collect()
}
}
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: impl 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.deactivated(window, cx));
}
fn navigate(
&mut self,
data: Box<dyn Any>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
self.editor
.update(cx, |editor, cx| editor.navigate(data, window, cx))
}
fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
Some("Project Diff".into())
}
fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
Label::new("Uncommitted Changes")
.color(if params.selected {
Color::Default
} else {
Color::Muted
})
.into_any_element()
}
fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
"Uncommitted Changes".into()
}
fn telemetry_event_text(&self) -> Option<&'static str> {
Some("Project Diff Opened")
}
fn as_searchable(&self, _: &Entity<Self>) -> 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.for_each_project_item(cx, f)
}
fn is_singleton(&self, _: &App) -> bool {
false
}
fn set_nav_history(
&mut self,
nav_history: ItemNavHistory,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, _| {
editor.set_nav_history(Some(nav_history));
});
}
fn clone_on_split(
&self,
_workspace_id: Option<workspace::WorkspaceId>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Entity<Self>>
where
Self: Sized,
{
let workspace = self.workspace.upgrade()?;
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.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.reload(project, window, cx)
}
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
) -> Option<AnyView> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.to_any())
} else if type_id == TypeId::of::<Editor>() {
Some(self.editor.to_any())
} else {
None
}
}
fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
ToolbarItemLocation::PrimaryLeft
}
fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
self.editor.breadcrumbs(theme, cx)
}
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();
div()
.track_focus(&self.focus_handle)
.key_context(if is_empty { "EmptyPane" } else { "GitDiff" })
.bg(cx.theme().colors().editor_background)
.flex()
.items_center()
.justify_center()
.size_full()
.when(is_empty, |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).clone();
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,
window,
cx,
))
.on_click(move |_, window, cx| {
window.focus(&keybinding_focus_handle);
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>>> {
window.spawn(cx, async move |cx| {
workspace.update_in(cx, |workspace, window, cx| {
let workspace_handle = cx.entity();
cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx))
})
})
}
fn serialize(
&mut self,
_workspace: &mut Workspace,
_item_id: workspace::ItemId,
_closing: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Task<Result<()>>> {
None
}
fn should_serialize(&self, _: &Self::Event) -> bool {
false
}
}
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);
}
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>,
) {
}
}
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);
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,
))
.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,
))
.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);
})),
),
)
}
}
#[derive(IntoElement, RegisterComponent)]
pub struct ProjectDiffEmptyState {
pub no_repo: bool,
pub can_push_and_pull: bool,
pub focus_handle: Option<FocusHandle>,
pub current_branch: Option<Branch>,
// has_pending_commits: bool,
// ahead_of_remote: bool,
// no_git_repository: bool,
}
impl RenderOnce for ProjectDiffEmptyState {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let status_against_remote = |ahead_by: usize, behind_by: usize| -> bool {
match self.current_branch {
Some(Branch {
upstream:
Some(Upstream {
tracking:
UpstreamTracking::Tracked(UpstreamTrackingStatus {
ahead, behind, ..
}),
..
}),
..
}) if (ahead > 0) == (ahead_by > 0) && (behind > 0) == (behind_by > 0) => true,
_ => false,
}
};
let change_count = |current_branch: &Branch| -> (usize, usize) {
match current_branch {
Branch {
upstream:
Some(Upstream {
tracking:
UpstreamTracking::Tracked(UpstreamTrackingStatus {
ahead, behind, ..
}),
..
}),
..
} => (*ahead as usize, *behind as usize),
_ => (0, 0),
}
};
let not_ahead_or_behind = status_against_remote(0, 0);
let ahead_of_remote = status_against_remote(1, 0);
let branch_not_on_remote = if let Some(branch) = self.current_branch.as_ref() {
branch.upstream.is_none()
} else {
false
};
let has_branch_container = |branch: &Branch| {
h_flex()
.max_w(px(420.))
.bg(cx.theme().colors().text.opacity(0.05))
.border_1()
.border_color(cx.theme().colors().border)
.rounded_sm()
.gap_8()
.px_6()
.py_4()
.map(|this| {
if ahead_of_remote {
let ahead_count = change_count(branch).0;
let ahead_string = format!("{} Commits Ahead", ahead_count);
this.child(
v_flex()
.child(Headline::new(ahead_string).size(HeadlineSize::Small))
.child(
Label::new(format!("Push your changes to {}", branch.name()))
.color(Color::Muted),
),
)
.child(div().child(render_push_button(
self.focus_handle,
"push".into(),
ahead_count as u32,
)))
} else if branch_not_on_remote {
this.child(
v_flex()
.child(Headline::new("Publish Branch").size(HeadlineSize::Small))
.child(
Label::new(format!("Create {} on remote", branch.name()))
.color(Color::Muted),
),
)
.child(
div().child(render_publish_button(self.focus_handle, "publish".into())),
)
} else {
this.child(Label::new("Remote status unknown").color(Color::Muted))
}
})
};
v_flex().size_full().items_center().justify_center().child(
v_flex()
.gap_1()
.when(self.no_repo, |this| {
// TODO: add git init
this.text_center()
.child(Label::new("No Repository").color(Color::Muted))
})
.map(|this| {
if not_ahead_or_behind && self.current_branch.is_some() {
this.text_center()
.child(Label::new("No Changes").color(Color::Muted))
} else {
this.when_some(self.current_branch.as_ref(), |this, branch| {
this.child(has_branch_container(branch))
})
}
}),
)
}
}
mod preview {
use git::repository::{
Branch, CommitSummary, Upstream, UpstreamTracking, UpstreamTrackingStatus,
};
use ui::prelude::*;
use super::ProjectDiffEmptyState;
// View this component preview using `workspace: open component-preview`
impl Component for ProjectDiffEmptyState {
fn scope() -> ComponentScope {
ComponentScope::VersionControl
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
let unknown_upstream: Option<UpstreamTracking> = None;
let ahead_of_upstream: Option<UpstreamTracking> = Some(
UpstreamTrackingStatus {
ahead: 2,
behind: 0,
}
.into(),
);
let not_ahead_or_behind_upstream: Option<UpstreamTracking> = Some(
UpstreamTrackingStatus {
ahead: 0,
behind: 0,
}
.into(),
);
fn branch(upstream: Option<UpstreamTracking>) -> Branch {
Branch {
is_head: true,
ref_name: "some-branch".into(),
upstream: upstream.map(|tracking| Upstream {
ref_name: "origin/some-branch".into(),
tracking,
}),
most_recent_commit: Some(CommitSummary {
sha: "abc123".into(),
subject: "Modify stuff".into(),
commit_timestamp: 1710932954,
has_parent: true,
}),
}
}
let no_repo_state = ProjectDiffEmptyState {
no_repo: true,
can_push_and_pull: false,
focus_handle: None,
current_branch: None,
};
let no_changes_state = ProjectDiffEmptyState {
no_repo: false,
can_push_and_pull: true,
focus_handle: None,
current_branch: Some(branch(not_ahead_or_behind_upstream)),
};
let ahead_of_upstream_state = ProjectDiffEmptyState {
no_repo: false,
can_push_and_pull: true,
focus_handle: None,
current_branch: Some(branch(ahead_of_upstream)),
};
let unknown_upstream_state = ProjectDiffEmptyState {
no_repo: false,
can_push_and_pull: true,
focus_handle: None,
current_branch: Some(branch(unknown_upstream)),
};
let (width, height) = (px(480.), px(320.));
Some(
v_flex()
.gap_6()
.children(vec![
example_group(vec![
single_example(
"No Repo",
div()
.w(width)
.h(height)
.child(no_repo_state)
.into_any_element(),
),
single_example(
"No Changes",
div()
.w(width)
.h(height)
.child(no_changes_state)
.into_any_element(),
),
single_example(
"Unknown Upstream",
div()
.w(width)
.h(height)
.child(unknown_upstream_state)
.into_any_element(),
),
single_example(
"Ahead of Remote",
div()
.w(width)
.h(height)
.child(ahead_of_upstream_state)
.into_any_element(),
),
])
.vertical(),
])
.into_any_element(),
)
}
}
}
fn merge_anchor_ranges<'a>(
left: impl 'a + Iterator<Item = Range<Anchor>>,
right: impl 'a + Iterator<Item = Range<Anchor>>,
snapshot: &'a language::BufferSnapshot,
) -> impl 'a + Iterator<Item = Range<Anchor>> {
let mut left = left.fuse().peekable();
let mut right = right.fuse().peekable();
std::iter::from_fn(move || {
let Some(left_range) = left.peek() else {
return right.next();
};
let Some(right_range) = right.peek() else {
return left.next();
};
let mut next_range = if left_range.start.cmp(&right_range.start, snapshot).is_lt() {
left.next().unwrap()
} else {
right.next().unwrap()
};
// Extend the basic range while there's overlap with a range from either stream.
loop {
if let Some(left_range) = left
.peek()
.filter(|range| range.start.cmp(&next_range.end, snapshot).is_le())
.cloned()
{
left.next();
next_range.end = left_range.end;
} else if let Some(right_range) = right
.peek()
.filter(|range| range.start.cmp(&next_range.end, snapshot).is_le())
.cloned()
{
right.next();
next_range.end = right_range.end;
} else {
break;
}
}
Some(next_range)
})
}
#[cfg(not(target_os = "windows"))]
#[cfg(test)]
mod tests {
use db::indoc;
use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
use git::status::{UnmergedStatus, UnmergedStatusCode};
use gpui::TestAppContext;
use project::FakeFs;
use serde_json::json;
use settings::SettingsStore;
use std::path::Path;
use unindent::Unindent as _;
use util::path;
use super::*;
#[ctor::ctor]
fn init_logger() {
zlog::init_test();
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let store = SettingsStore::test(cx);
cx.set_global(store);
theme::init(theme::LoadThemes::JustBase, cx);
language::init(cx);
Project::init_settings(cx);
workspace::init_settings(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;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let diff = cx.new_window_entity(|window, cx| {
ProjectDiff::new(project.clone(), workspace, window, cx)
});
cx.run_until_parked();
fs.set_head_for_repo(
path!("/project/.git").as_ref(),
&[("foo.txt".into(), "foo\n".into())],
"deadbeef",
);
fs.set_index_for_repo(
path!("/project/.git").as_ref(),
&[("foo.txt".into(), "foo\n".into())],
);
cx.run_until_parked();
let editor = diff.read_with(cx, |diff, _| diff.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);
});
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 (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
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".into(), "bar\n".into()),
("foo".into(), "foo\n".into()),
],
);
cx.run_until_parked();
let editor = cx.update_window_entity(&diff, |diff, window, cx| {
diff.move_to_path(
PathKey::namespaced(TRACKED_NAMESPACE, Path::new("foo").into()),
window,
cx,
);
diff.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::namespaced(TRACKED_NAMESPACE, Path::new("bar").into()),
window,
cx,
);
diff.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 (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
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();
fs.set_head_for_repo(
path!("/project/.git").as_ref(),
&[("foo".into(), "original\n".into())],
"deadbeef",
);
cx.run_until_parked();
let diff_editor = diff.read_with(cx, |diff, _| diff.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,
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::{
conflict_view::resolve_conflict,
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(
"/a",
json!({
".git": {},
"a.txt": "created\n",
"b.txt": "really changed\n",
"c.txt": "unchanged\n"
}),
)
.await;
fs.set_git_content_for_repo(
Path::new("/a/.git"),
&[
("b.txt".into(), "before\n".to_string(), None),
("c.txt".into(), "unchanged\n".to_string(), None),
("d.txt".into(), "deleted\n".to_string(), None),
],
);
let project = Project::test(fs, [Path::new("/a")], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
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, _| item.editor.clone());
let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
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
"
));
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(
"/a",
json!({
".git": {},
"main.rs": buffer_contents,
}),
)
.await;
fs.set_git_content_for_repo(
Path::new("/a/.git"),
&[("main.rs".into(), git_contents.to_owned(), None)],
);
let project = Project::test(fs, [Path::new("/a")], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
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, _| item.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]
async fn test_saving_resolved_conflicts(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"foo": "<<<<<<< x\nours\n=======\ntheirs\n>>>>>>> y\n",
}),
)
.await;
fs.set_status_for_repo(
Path::new(path!("/project/.git")),
&[(
Path::new("foo"),
UnmergedStatus {
first_head: UnmergedStatusCode::Updated,
second_head: UnmergedStatusCode::Updated,
}
.into(),
)],
);
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let diff = cx.new_window_entity(|window, cx| {
ProjectDiff::new(project.clone(), workspace, window, cx)
});
cx.run_until_parked();
cx.update(|window, cx| {
let editor = diff.read(cx).editor.clone();
let excerpt_ids = editor.read(cx).buffer().read(cx).excerpt_ids();
assert_eq!(excerpt_ids.len(), 1);
let excerpt_id = excerpt_ids[0];
let buffer = editor
.read(cx)
.buffer()
.read(cx)
.all_buffers()
.into_iter()
.next()
.unwrap();
let buffer_id = buffer.read(cx).remote_id();
let conflict_set = diff
.read(cx)
.editor
.read(cx)
.addon::<ConflictAddon>()
.unwrap()
.conflict_set(buffer_id)
.unwrap();
assert!(conflict_set.read(cx).has_conflict);
let snapshot = conflict_set.read(cx).snapshot();
assert_eq!(snapshot.conflicts.len(), 1);
let ours_range = snapshot.conflicts[0].ours.clone();
resolve_conflict(
editor.downgrade(),
excerpt_id,
snapshot.conflicts[0].clone(),
vec![ours_range],
window,
cx,
)
})
.await;
let contents = fs.read_file_sync(path!("/project/foo")).unwrap();
let contents = String::from_utf8(contents).unwrap();
assert_eq!(contents, "ours\n");
}
}