git: Save buffer when resolving a conflict from the project diff (#30762)

Cole Miller created

Closes #30555

Release Notes:

- Changed the project diff to autosave the targeted buffer after
resolving a merge conflict.

Change summary

crates/git_ui/src/conflict_view.rs | 153 ++++++++++++++++++++-----------
crates/git_ui/src/project_diff.rs  |  93 +++++++++++++++++++
2 files changed, 190 insertions(+), 56 deletions(-)

Detailed changes

crates/git_ui/src/conflict_view.rs šŸ”—

@@ -5,16 +5,17 @@ use editor::{
     display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
 };
 use gpui::{
-    App, Context, Entity, InteractiveElement as _, ParentElement as _, Subscription, WeakEntity,
+    App, Context, Entity, InteractiveElement as _, ParentElement as _, Subscription, Task,
+    WeakEntity,
 };
 use language::{Anchor, Buffer, BufferId};
-use project::{ConflictRegion, ConflictSet, ConflictSetUpdate};
+use project::{ConflictRegion, ConflictSet, ConflictSetUpdate, ProjectItem as _};
 use std::{ops::Range, sync::Arc};
 use ui::{
     ActiveTheme, AnyElement, Element as _, StatefulInteractiveElement, Styled,
-    StyledTypography as _, div, h_flex, rems,
+    StyledTypography as _, Window, div, h_flex, rems,
 };
-use util::{debug_panic, maybe};
+use util::{ResultExt as _, debug_panic, maybe};
 
 pub(crate) struct ConflictAddon {
     buffers: HashMap<BufferId, BufferConflicts>,
@@ -404,8 +405,16 @@ fn render_conflict_buttons(
                     let editor = editor.clone();
                     let conflict = conflict.clone();
                     let ours = conflict.ours.clone();
-                    move |_, _, cx| {
-                        resolve_conflict(editor.clone(), excerpt_id, &conflict, &[ours.clone()], cx)
+                    move |_, window, cx| {
+                        resolve_conflict(
+                            editor.clone(),
+                            excerpt_id,
+                            conflict.clone(),
+                            vec![ours.clone()],
+                            window,
+                            cx,
+                        )
+                        .detach()
                     }
                 }),
         )
@@ -422,14 +431,16 @@ fn render_conflict_buttons(
                     let editor = editor.clone();
                     let conflict = conflict.clone();
                     let theirs = conflict.theirs.clone();
-                    move |_, _, cx| {
+                    move |_, window, cx| {
                         resolve_conflict(
                             editor.clone(),
                             excerpt_id,
-                            &conflict,
-                            &[theirs.clone()],
+                            conflict.clone(),
+                            vec![theirs.clone()],
+                            window,
                             cx,
                         )
+                        .detach()
                     }
                 }),
         )
@@ -447,69 +458,101 @@ fn render_conflict_buttons(
                     let conflict = conflict.clone();
                     let ours = conflict.ours.clone();
                     let theirs = conflict.theirs.clone();
-                    move |_, _, cx| {
+                    move |_, window, cx| {
                         resolve_conflict(
                             editor.clone(),
                             excerpt_id,
-                            &conflict,
-                            &[ours.clone(), theirs.clone()],
+                            conflict.clone(),
+                            vec![ours.clone(), theirs.clone()],
+                            window,
                             cx,
                         )
+                        .detach()
                     }
                 }),
         )
         .into_any()
 }
 
-fn resolve_conflict(
+pub(crate) fn resolve_conflict(
     editor: WeakEntity<Editor>,
     excerpt_id: ExcerptId,
-    resolved_conflict: &ConflictRegion,
-    ranges: &[Range<Anchor>],
+    resolved_conflict: ConflictRegion,
+    ranges: Vec<Range<Anchor>>,
+    window: &mut Window,
     cx: &mut App,
-) {
-    let Some(editor) = editor.upgrade() else {
-        return;
-    };
-
-    let multibuffer = editor.read(cx).buffer().read(cx);
-    let snapshot = multibuffer.snapshot(cx);
-    let Some(buffer) = resolved_conflict
-        .ours
-        .end
-        .buffer_id
-        .and_then(|buffer_id| multibuffer.buffer(buffer_id))
-    else {
-        return;
-    };
-    let buffer_snapshot = buffer.read(cx).snapshot();
-
-    resolved_conflict.resolve(buffer, ranges, cx);
-
-    editor.update(cx, |editor, cx| {
-        let conflict_addon = editor.addon_mut::<ConflictAddon>().unwrap();
-        let Some(state) = conflict_addon.buffers.get_mut(&buffer_snapshot.remote_id()) else {
+) -> Task<()> {
+    window.spawn(cx, async move |cx| {
+        let Some((workspace, project, multibuffer, buffer)) = editor
+            .update(cx, |editor, cx| {
+                let workspace = editor.workspace()?;
+                let project = editor.project.clone()?;
+                let multibuffer = editor.buffer().clone();
+                let buffer_id = resolved_conflict.ours.end.buffer_id?;
+                let buffer = multibuffer.read(cx).buffer(buffer_id)?;
+                resolved_conflict.resolve(buffer.clone(), &ranges, cx);
+                let conflict_addon = editor.addon_mut::<ConflictAddon>().unwrap();
+                let snapshot = multibuffer.read(cx).snapshot(cx);
+                let buffer_snapshot = buffer.read(cx).snapshot();
+                let state = conflict_addon
+                    .buffers
+                    .get_mut(&buffer_snapshot.remote_id())?;
+                let ix = state
+                    .block_ids
+                    .binary_search_by(|(range, _)| {
+                        range
+                            .start
+                            .cmp(&resolved_conflict.range.start, &buffer_snapshot)
+                    })
+                    .ok()?;
+                let &(_, block_id) = &state.block_ids[ix];
+                let start = snapshot
+                    .anchor_in_excerpt(excerpt_id, resolved_conflict.range.start)
+                    .unwrap();
+                let end = snapshot
+                    .anchor_in_excerpt(excerpt_id, resolved_conflict.range.end)
+                    .unwrap();
+                editor.remove_highlighted_rows::<ConflictsOuter>(vec![start..end], cx);
+                editor.remove_highlighted_rows::<ConflictsOurs>(vec![start..end], cx);
+                editor.remove_highlighted_rows::<ConflictsTheirs>(vec![start..end], cx);
+                editor.remove_highlighted_rows::<ConflictsOursMarker>(vec![start..end], cx);
+                editor.remove_highlighted_rows::<ConflictsTheirsMarker>(vec![start..end], cx);
+                editor.remove_blocks(HashSet::from_iter([block_id]), None, cx);
+                Some((workspace, project, multibuffer, buffer))
+            })
+            .ok()
+            .flatten()
+        else {
             return;
         };
-        let Ok(ix) = state.block_ids.binary_search_by(|(range, _)| {
-            range
-                .start
-                .cmp(&resolved_conflict.range.start, &buffer_snapshot)
-        }) else {
+        let Some(save) = project
+            .update(cx, |project, cx| {
+                if multibuffer.read(cx).all_diff_hunks_expanded() {
+                    project.save_buffer(buffer.clone(), cx)
+                } else {
+                    Task::ready(Ok(()))
+                }
+            })
+            .ok()
+        else {
             return;
         };
-        let &(_, block_id) = &state.block_ids[ix];
-        let start = snapshot
-            .anchor_in_excerpt(excerpt_id, resolved_conflict.range.start)
-            .unwrap();
-        let end = snapshot
-            .anchor_in_excerpt(excerpt_id, resolved_conflict.range.end)
-            .unwrap();
-        editor.remove_highlighted_rows::<ConflictsOuter>(vec![start..end], cx);
-        editor.remove_highlighted_rows::<ConflictsOurs>(vec![start..end], cx);
-        editor.remove_highlighted_rows::<ConflictsTheirs>(vec![start..end], cx);
-        editor.remove_highlighted_rows::<ConflictsOursMarker>(vec![start..end], cx);
-        editor.remove_highlighted_rows::<ConflictsTheirsMarker>(vec![start..end], cx);
-        editor.remove_blocks(HashSet::from_iter([block_id]), None, cx);
+        if save.await.log_err().is_none() {
+            let open_path = maybe!({
+                let path = buffer
+                    .read_with(cx, |buffer, cx| buffer.project_path(cx))
+                    .ok()
+                    .flatten()?;
+                workspace
+                    .update_in(cx, |workspace, window, cx| {
+                        workspace.open_path_preview(path, None, false, false, false, window, cx)
+                    })
+                    .ok()
+            });
+
+            if let Some(open_path) = open_path {
+                open_path.await.log_err();
+            }
+        }
     })
 }

crates/git_ui/src/project_diff.rs šŸ”—

@@ -148,6 +148,17 @@ impl ProjectDiff {
             });
             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();
 
@@ -1323,6 +1334,7 @@ fn merge_anchor_ranges<'a>(
 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;
@@ -1583,7 +1595,10 @@ mod tests {
         );
     }
 
-    use crate::project_diff::{self, ProjectDiff};
+    use crate::{
+        conflict_view::resolve_conflict,
+        project_diff::{self, ProjectDiff},
+    };
 
     #[gpui::test]
     async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
@@ -1754,4 +1769,80 @@ mod tests {
 
         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");
+    }
 }