From f18567c1f0a4d2db2dd121202d4a4d84a2d9d02d Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:19:26 -0300 Subject: [PATCH] git: Add the ability to resolve merge conflicts with the agent (#49807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a "Resolve with Agent" button in each merge conflict block, as well as "Resolve Conflicts with Agents" button on a notification for resolving conflicts across all the files that have any. When clicking on either of these buttons, the agent panel opens up with a template prompt auto-submitted. For the first case, the specific content of the merge block is already attached as context for the agent to act quickly, given it's a local and small context. For the second case (all conflicts across the codebase), the prompt just indicates to the agent which files have conflicts and then it's up for the agent to see them. This felt like a simpler way to go as opposed to extracting the content for all merge conflicts across all damaged files. Here's how the UI looks like: Screenshot 2026-02-21 at 11  04@2x --- Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Git: Added the ability to quickly resolve merge conflicts with the agent. --------- Co-authored-by: Bennet Bo Fenner Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> --- assets/icons/git_merge_conflict.svg | 7 + crates/acp_thread/src/mention.rs | 19 + crates/agent/src/thread.rs | 21 + crates/agent_ui/src/agent_panel.rs | 442 +++++++++++++++++- .../src/connection_view/thread_view.rs | 1 + crates/agent_ui/src/mention_set.rs | 7 +- crates/agent_ui/src/ui/mention_crease.rs | 3 +- crates/git_ui/src/conflict_view.rs | 154 +++++- crates/git_ui/src/git_ui.rs | 1 + crates/icons/src/icons.rs | 1 + crates/zed_actions/src/lib.rs | 27 ++ 11 files changed, 675 insertions(+), 8 deletions(-) create mode 100644 assets/icons/git_merge_conflict.svg diff --git a/assets/icons/git_merge_conflict.svg b/assets/icons/git_merge_conflict.svg new file mode 100644 index 0000000000000000000000000000000000000000..10bc2c04fc9877112723273b0d60351c3a4c56bc --- /dev/null +++ b/assets/icons/git_merge_conflict.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index b63eec154a40de8909d13de2a4e1bd3e9d1e06f3..43dfe7610e34a0399a27a1d28858b938acfc2e0f 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -60,6 +60,9 @@ pub enum MentionUri { GitDiff { base_ref: String, }, + MergeConflict { + file_path: String, + }, } impl MentionUri { @@ -215,6 +218,9 @@ impl MentionUri { let base_ref = single_query_param(&url, "base")?.unwrap_or_else(|| "main".to_string()); Ok(Self::GitDiff { base_ref }) + } else if path.starts_with("/agent/merge-conflict") { + let file_path = single_query_param(&url, "path")?.unwrap_or_default(); + Ok(Self::MergeConflict { file_path }) } else { bail!("invalid zed url: {:?}", input); } @@ -245,6 +251,13 @@ impl MentionUri { } } MentionUri::GitDiff { base_ref } => format!("Branch Diff ({})", base_ref), + MentionUri::MergeConflict { file_path } => { + let name = Path::new(file_path) + .file_name() + .unwrap_or_default() + .to_string_lossy(); + format!("Merge Conflict ({name})") + } MentionUri::Selection { abs_path: path, line_range, @@ -306,6 +319,7 @@ impl MentionUri { MentionUri::Selection { .. } => IconName::Reader.path().into(), MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(), MentionUri::GitDiff { .. } => IconName::GitBranch.path().into(), + MentionUri::MergeConflict { .. } => IconName::GitMergeConflict.path().into(), } } @@ -409,6 +423,11 @@ impl MentionUri { url.query_pairs_mut().append_pair("base", base_ref); url } + MentionUri::MergeConflict { file_path } => { + let mut url = Url::parse("zed:///agent/merge-conflict").unwrap(); + url.query_pairs_mut().append_pair("path", file_path); + url + } } } } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index e61a395e71f93d49d63d378355c89e44359db835..02ffac47f120ee3ec4694b3a3be085af053c5909 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -219,6 +219,7 @@ impl UserMessage { "\nThe user has specified the following rules that should be applied:\n"; const OPEN_DIAGNOSTICS_TAG: &str = ""; const OPEN_DIFFS_TAG: &str = ""; + const MERGE_CONFLICT_TAG: &str = ""; let mut file_context = OPEN_FILES_TAG.to_string(); let mut directory_context = OPEN_DIRECTORIES_TAG.to_string(); @@ -229,6 +230,7 @@ impl UserMessage { let mut rules_context = OPEN_RULES_TAG.to_string(); let mut diagnostics_context = OPEN_DIAGNOSTICS_TAG.to_string(); let mut diffs_context = OPEN_DIFFS_TAG.to_string(); + let mut merge_conflict_context = MERGE_CONFLICT_TAG.to_string(); for chunk in &self.content { let chunk = match chunk { @@ -336,6 +338,18 @@ impl UserMessage { ) .ok(); } + MentionUri::MergeConflict { file_path } => { + write!( + &mut merge_conflict_context, + "\nMerge conflict in {}:\n{}", + file_path, + MarkdownCodeBlock { + tag: "diff", + text: content + } + ) + .ok(); + } } language_model::MessageContent::Text(uri.as_link().to_string()) @@ -410,6 +424,13 @@ impl UserMessage { .push(language_model::MessageContent::Text(diagnostics_context)); } + if merge_conflict_context.len() > MERGE_CONFLICT_TAG.len() { + merge_conflict_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(merge_conflict_context)); + } + if message.content.len() > len_before_context { message.content.insert( len_before_context, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index c49b7f668ab12ad4d2b04e8ec48488f7afab3c1c..c63d41b6833db425fb28ac9b64b34aa27d6d2490 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -13,6 +13,7 @@ use acp_thread::{AcpThread, MentionUri, ThreadStatus}; use agent::{ContextServerRegistry, SharedThread, ThreadStore}; use agent_client_protocol as acp; use agent_servers::AgentServer; +use collections::HashSet; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use itertools::Itertools; use project::{ @@ -23,7 +24,10 @@ use serde::{Deserialize, Serialize}; use settings::{LanguageModelProviderSetting, LanguageModelSelection}; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; -use zed_actions::agent::{OpenClaudeAgentOnboardingModal, ReauthenticateAgent, ReviewBranchDiff}; +use zed_actions::agent::{ + ConflictContent, OpenClaudeAgentOnboardingModal, ReauthenticateAgent, + ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, ReviewBranchDiff, +}; use crate::ManageProfiles; use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal}; @@ -358,6 +362,55 @@ pub fn init(cx: &mut App) { ); }); }) + .register_action( + |workspace, action: &ResolveConflictsWithAgent, window, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + + let content_blocks = build_conflict_resolution_prompt(&action.conflicts); + + workspace.focus_panel::(window, cx); + + panel.update(cx, |panel, cx| { + panel.external_thread( + None, + None, + Some(AgentInitialContent::ContentBlock { + blocks: content_blocks, + auto_submit: true, + }), + window, + cx, + ); + }); + }, + ) + .register_action( + |workspace, action: &ResolveConflictedFilesWithAgent, window, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + + let content_blocks = + build_conflicted_files_resolution_prompt(&action.conflicted_file_paths); + + workspace.focus_panel::(window, cx); + + panel.update(cx, |panel, cx| { + panel.external_thread( + None, + None, + Some(AgentInitialContent::ContentBlock { + blocks: content_blocks, + auto_submit: true, + }), + window, + cx, + ); + }); + }, + ) .register_action(|workspace, action: &StartThreadIn, _window, cx| { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { @@ -370,6 +423,113 @@ pub fn init(cx: &mut App) { .detach(); } +fn conflict_resource_block(conflict: &ConflictContent) -> acp::ContentBlock { + let mention_uri = MentionUri::MergeConflict { + file_path: conflict.file_path.clone(), + }; + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents::new( + conflict.conflict_text.clone(), + mention_uri.to_uri().to_string(), + )), + )) +} + +fn build_conflict_resolution_prompt(conflicts: &[ConflictContent]) -> Vec { + if conflicts.is_empty() { + return Vec::new(); + } + + let mut blocks = Vec::new(); + + if conflicts.len() == 1 { + let conflict = &conflicts[0]; + + blocks.push(acp::ContentBlock::Text(acp::TextContent::new( + "Please resolve the following merge conflict in ", + ))); + let mention = MentionUri::File { + abs_path: PathBuf::from(conflict.file_path.clone()), + }; + blocks.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + mention.name(), + mention.to_uri(), + ))); + + blocks.push(acp::ContentBlock::Text(acp::TextContent::new( + indoc::formatdoc!( + "\nThe conflict is between branch `{ours}` (ours) and `{theirs}` (theirs). + + Analyze both versions carefully and resolve the conflict by editing \ + the file directly. Choose the resolution that best preserves the intent \ + of both changes, or combine them if appropriate. + + ", + ours = conflict.ours_branch_name, + theirs = conflict.theirs_branch_name, + ), + ))); + } else { + let n = conflicts.len(); + let unique_files: HashSet<&str> = conflicts.iter().map(|c| c.file_path.as_str()).collect(); + let ours = &conflicts[0].ours_branch_name; + let theirs = &conflicts[0].theirs_branch_name; + blocks.push(acp::ContentBlock::Text(acp::TextContent::new( + indoc::formatdoc!( + "Please resolve all {n} merge conflicts below. + + The conflicts are between branch `{ours}` (ours) and `{theirs}` (theirs). + + For each conflict, analyze both versions carefully and resolve them \ + by editing the file{suffix} directly. Choose resolutions that best preserve \ + the intent of both changes, or combine them if appropriate. + + ", + suffix = if unique_files.len() > 1 { "s" } else { "" }, + ), + ))); + } + + for conflict in conflicts { + blocks.push(conflict_resource_block(conflict)); + } + + blocks +} + +fn build_conflicted_files_resolution_prompt( + conflicted_file_paths: &[String], +) -> Vec { + if conflicted_file_paths.is_empty() { + return Vec::new(); + } + + let instruction = indoc::indoc!( + "The following files have unresolved merge conflicts. Please open each \ + file, find the conflict markers (`<<<<<<<` / `=======` / `>>>>>>>`), \ + and resolve every conflict by editing the files directly. + + Choose resolutions that best preserve the intent of both changes, \ + or combine them if appropriate. + + Files with conflicts: + ", + ); + + let mut content = vec![acp::ContentBlock::Text(acp::TextContent::new(instruction))]; + for path in conflicted_file_paths { + let mention = MentionUri::File { + abs_path: PathBuf::from(path), + }; + content.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + mention.name(), + mention.to_uri(), + ))); + content.push(acp::ContentBlock::Text(acp::TextContent::new("\n"))); + } + content +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum HistoryKind { AgentThreads, @@ -4920,6 +5080,286 @@ mod tests { cx.run_until_parked(); } + /// Extracts the text from a Text content block, panicking if it's not Text. + fn expect_text_block(block: &acp::ContentBlock) -> &str { + match block { + acp::ContentBlock::Text(t) => t.text.as_str(), + other => panic!("expected Text block, got {:?}", other), + } + } + + /// Extracts the (text_content, uri) from a Resource content block, panicking + /// if it's not a TextResourceContents resource. + fn expect_resource_block(block: &acp::ContentBlock) -> (&str, &str) { + match block { + acp::ContentBlock::Resource(r) => match &r.resource { + acp::EmbeddedResourceResource::TextResourceContents(t) => { + (t.text.as_str(), t.uri.as_str()) + } + other => panic!("expected TextResourceContents, got {:?}", other), + }, + other => panic!("expected Resource block, got {:?}", other), + } + } + + #[test] + fn test_build_conflict_resolution_prompt_single_conflict() { + let conflicts = vec![ConflictContent { + file_path: "src/main.rs".to_string(), + conflict_text: "<<<<<<< HEAD\nlet x = 1;\n=======\nlet x = 2;\n>>>>>>> feature" + .to_string(), + ours_branch_name: "HEAD".to_string(), + theirs_branch_name: "feature".to_string(), + }]; + + let blocks = build_conflict_resolution_prompt(&conflicts); + // 2 Text blocks + 1 ResourceLink + 1 Resource for the conflict + assert_eq!( + blocks.len(), + 4, + "expected 2 text + 1 resource link + 1 resource block" + ); + + let intro_text = expect_text_block(&blocks[0]); + assert!( + intro_text.contains("Please resolve the following merge conflict in"), + "prompt should include single-conflict intro text" + ); + + match &blocks[1] { + acp::ContentBlock::ResourceLink(link) => { + assert!( + link.uri.contains("file://"), + "resource link URI should use file scheme" + ); + assert!( + link.uri.contains("main.rs"), + "resource link URI should reference file path" + ); + } + other => panic!("expected ResourceLink block, got {:?}", other), + } + + let body_text = expect_text_block(&blocks[2]); + assert!( + body_text.contains("`HEAD` (ours)"), + "prompt should mention ours branch" + ); + assert!( + body_text.contains("`feature` (theirs)"), + "prompt should mention theirs branch" + ); + assert!( + body_text.contains("editing the file directly"), + "prompt should instruct the agent to edit the file" + ); + + let (resource_text, resource_uri) = expect_resource_block(&blocks[3]); + assert!( + resource_text.contains("<<<<<<< HEAD"), + "resource should contain the conflict text" + ); + assert!( + resource_uri.contains("merge-conflict"), + "resource URI should use the merge-conflict scheme" + ); + assert!( + resource_uri.contains("main.rs"), + "resource URI should reference the file path" + ); + } + + #[test] + fn test_build_conflict_resolution_prompt_multiple_conflicts_same_file() { + let conflicts = vec![ + ConflictContent { + file_path: "src/lib.rs".to_string(), + conflict_text: "<<<<<<< main\nfn a() {}\n=======\nfn a_v2() {}\n>>>>>>> dev" + .to_string(), + ours_branch_name: "main".to_string(), + theirs_branch_name: "dev".to_string(), + }, + ConflictContent { + file_path: "src/lib.rs".to_string(), + conflict_text: "<<<<<<< main\nfn b() {}\n=======\nfn b_v2() {}\n>>>>>>> dev" + .to_string(), + ours_branch_name: "main".to_string(), + theirs_branch_name: "dev".to_string(), + }, + ]; + + let blocks = build_conflict_resolution_prompt(&conflicts); + // 1 Text instruction + 2 Resource blocks + assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks"); + + let text = expect_text_block(&blocks[0]); + assert!( + text.contains("all 2 merge conflicts"), + "prompt should mention the total count" + ); + assert!( + text.contains("`main` (ours)"), + "prompt should mention ours branch" + ); + assert!( + text.contains("`dev` (theirs)"), + "prompt should mention theirs branch" + ); + // Single file, so "file" not "files" + assert!( + text.contains("file directly"), + "single file should use singular 'file'" + ); + + let (resource_a, _) = expect_resource_block(&blocks[1]); + let (resource_b, _) = expect_resource_block(&blocks[2]); + assert!( + resource_a.contains("fn a()"), + "first resource should contain first conflict" + ); + assert!( + resource_b.contains("fn b()"), + "second resource should contain second conflict" + ); + } + + #[test] + fn test_build_conflict_resolution_prompt_multiple_conflicts_different_files() { + let conflicts = vec![ + ConflictContent { + file_path: "src/a.rs".to_string(), + conflict_text: "<<<<<<< main\nA\n=======\nB\n>>>>>>> dev".to_string(), + ours_branch_name: "main".to_string(), + theirs_branch_name: "dev".to_string(), + }, + ConflictContent { + file_path: "src/b.rs".to_string(), + conflict_text: "<<<<<<< main\nC\n=======\nD\n>>>>>>> dev".to_string(), + ours_branch_name: "main".to_string(), + theirs_branch_name: "dev".to_string(), + }, + ]; + + let blocks = build_conflict_resolution_prompt(&conflicts); + // 1 Text instruction + 2 Resource blocks + assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks"); + + let text = expect_text_block(&blocks[0]); + assert!( + text.contains("files directly"), + "multiple files should use plural 'files'" + ); + + let (_, uri_a) = expect_resource_block(&blocks[1]); + let (_, uri_b) = expect_resource_block(&blocks[2]); + assert!( + uri_a.contains("a.rs"), + "first resource URI should reference a.rs" + ); + assert!( + uri_b.contains("b.rs"), + "second resource URI should reference b.rs" + ); + } + + #[test] + fn test_build_conflicted_files_resolution_prompt_file_paths_only() { + let file_paths = vec![ + "src/main.rs".to_string(), + "src/lib.rs".to_string(), + "tests/integration.rs".to_string(), + ]; + + let blocks = build_conflicted_files_resolution_prompt(&file_paths); + // 1 instruction Text block + (ResourceLink + newline Text) per file + assert_eq!( + blocks.len(), + 1 + (file_paths.len() * 2), + "expected instruction text plus resource links and separators" + ); + + let text = expect_text_block(&blocks[0]); + assert!( + text.contains("unresolved merge conflicts"), + "prompt should describe the task" + ); + assert!( + text.contains("conflict markers"), + "prompt should mention conflict markers" + ); + + for (index, path) in file_paths.iter().enumerate() { + let link_index = 1 + (index * 2); + let newline_index = link_index + 1; + + match &blocks[link_index] { + acp::ContentBlock::ResourceLink(link) => { + assert!( + link.uri.contains("file://"), + "resource link URI should use file scheme" + ); + assert!( + link.uri.contains(path), + "resource link URI should reference file path: {path}" + ); + } + other => panic!( + "expected ResourceLink block at index {}, got {:?}", + link_index, other + ), + } + + let separator = expect_text_block(&blocks[newline_index]); + assert_eq!( + separator, "\n", + "expected newline separator after each file" + ); + } + } + + #[test] + fn test_build_conflict_resolution_prompt_empty_conflicts() { + let blocks = build_conflict_resolution_prompt(&[]); + assert!( + blocks.is_empty(), + "empty conflicts should produce no blocks, got {} blocks", + blocks.len() + ); + } + + #[test] + fn test_build_conflicted_files_resolution_prompt_empty_paths() { + let blocks = build_conflicted_files_resolution_prompt(&[]); + assert!( + blocks.is_empty(), + "empty paths should produce no blocks, got {} blocks", + blocks.len() + ); + } + + #[test] + fn test_conflict_resource_block_structure() { + let conflict = ConflictContent { + file_path: "src/utils.rs".to_string(), + conflict_text: "<<<<<<< HEAD\nold code\n=======\nnew code\n>>>>>>> branch".to_string(), + ours_branch_name: "HEAD".to_string(), + theirs_branch_name: "branch".to_string(), + }; + + let block = conflict_resource_block(&conflict); + let (text, uri) = expect_resource_block(&block); + + assert_eq!( + text, conflict.conflict_text, + "resource text should be the raw conflict" + ); + assert!( + uri.starts_with("zed:///agent/merge-conflict"), + "URI should use the zed merge-conflict scheme, got: {uri}" + ); + assert!(uri.contains("utils.rs"), "URI should encode the file path"); + } + async fn setup_panel(cx: &mut TestAppContext) -> (Entity, VisualTestContext) { init_test(cx); cx.update(|cx| { diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index f5fc681a82b636ec401f3a8c6168bcb368931930..806b2c9c397de1c729164b5f859ceae4b7f6231f 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -7996,6 +7996,7 @@ pub(crate) fn open_link( MentionUri::Diagnostics { .. } => {} MentionUri::TerminalSelection { .. } => {} MentionUri::GitDiff { .. } => {} + MentionUri::MergeConflict { .. } => {} }) } else { cx.open_url(&url); diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 792bfc11a63471e02b22835823fa8c59cdfc9bcf..5a76e2b355c3373ee278b0f0de95ddcfcdd13101 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -150,7 +150,8 @@ impl MentionSet { MentionUri::PastedImage | MentionUri::Selection { .. } | MentionUri::TerminalSelection { .. } - | MentionUri::GitDiff { .. } => { + | MentionUri::GitDiff { .. } + | MentionUri::MergeConflict { .. } => { Task::ready(Err(anyhow!("Unsupported mention URI type for paste"))) } } @@ -301,6 +302,10 @@ impl MentionSet { debug_panic!("unexpected git diff URI"); Task::ready(Err(anyhow!("unexpected git diff URI"))) } + MentionUri::MergeConflict { .. } => { + debug_panic!("unexpected merge conflict URI"); + Task::ready(Err(anyhow!("unexpected merge conflict URI"))) + } }; let task = cx .spawn(async move |_, _| task.await.map_err(|e| e.to_string())) diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 8b813ef7e40c2afe91b98600b9d1146d4751d48b..0f0b8ecc1d7d66a6025bcfed772c7ead7061fe20 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -187,7 +187,8 @@ fn open_mention_uri( | MentionUri::Selection { abs_path: None, .. } | MentionUri::Diagnostics { .. } | MentionUri::TerminalSelection { .. } - | MentionUri::GitDiff { .. } => {} + | MentionUri::GitDiff { .. } + | MentionUri::MergeConflict { .. } => {} }); } diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 67b39618eaaaa2f7704e100d98621f53b725ff43..6c2c0b6f58696147da069b0aebdf55d396f7a388 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -1,3 +1,4 @@ +use agent_settings::AgentSettings; use collections::{HashMap, HashSet}; use editor::{ ConflictsOurs, ConflictsOursMarker, ConflictsOuter, ConflictsTheirs, ConflictsTheirsMarker, @@ -5,14 +6,25 @@ use editor::{ display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, }; use gpui::{ - App, Context, Entity, InteractiveElement as _, ParentElement as _, Subscription, Task, - WeakEntity, + App, Context, DismissEvent, Entity, InteractiveElement as _, ParentElement as _, Subscription, + Task, WeakEntity, }; use language::{Anchor, Buffer, BufferId}; -use project::{ConflictRegion, ConflictSet, ConflictSetUpdate, ProjectItem as _}; +use project::{ + ConflictRegion, ConflictSet, ConflictSetUpdate, ProjectItem as _, + git_store::{GitStoreEvent, RepositoryEvent}, +}; +use settings::Settings; use std::{ops::Range, sync::Arc}; -use ui::{ActiveTheme, Element as _, Styled, Window, prelude::*}; +use ui::{ActiveTheme, Divider, Element as _, Styled, Window, prelude::*}; use util::{ResultExt as _, debug_panic, maybe}; +use workspace::{ + Workspace, + notifications::{NotificationId, simple_message_notification::MessageNotification}, +}; +use zed_actions::agent::{ + ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, +}; pub(crate) struct ConflictAddon { buffers: HashMap, @@ -368,11 +380,12 @@ fn render_conflict_buttons( editor: WeakEntity, cx: &mut BlockContext, ) -> AnyElement { + let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx); + h_flex() .id(cx.block_id) .h(cx.line_height) .ml(cx.margins.gutter.width) - .items_end() .gap_1() .bg(cx.theme().colors().editor_background) .child( @@ -419,6 +432,7 @@ fn render_conflict_buttons( Button::new("both", "Use Both") .label_size(LabelSize::Small) .on_click({ + let editor = editor.clone(); let conflict = conflict.clone(); let ours = conflict.ours.clone(); let theirs = conflict.theirs.clone(); @@ -435,9 +449,139 @@ fn render_conflict_buttons( } }), ) + .when(is_ai_enabled, |this| { + this.child(Divider::vertical()).child( + Button::new("resolve-with-agent", "Resolve with Agent") + .label_size(LabelSize::Small) + .icon(IconName::ZedAssistant) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .on_click({ + let conflict = conflict.clone(); + move |_, window, cx| { + let content = editor + .update(cx, |editor, cx| { + let multibuffer = editor.buffer().read(cx); + let buffer_id = conflict.ours.end.buffer_id?; + let buffer = multibuffer.buffer(buffer_id)?; + let buffer_read = buffer.read(cx); + let snapshot = buffer_read.snapshot(); + let conflict_text = snapshot + .text_for_range(conflict.range.clone()) + .collect::(); + let file_path = buffer_read + .file() + .and_then(|file| file.as_local()) + .map(|f| f.abs_path(cx).to_string_lossy().to_string()) + .unwrap_or_default(); + Some(ConflictContent { + file_path, + conflict_text, + ours_branch_name: conflict.ours_branch_name.to_string(), + theirs_branch_name: conflict.theirs_branch_name.to_string(), + }) + }) + .ok() + .flatten(); + if let Some(content) = content { + window.dispatch_action( + Box::new(ResolveConflictsWithAgent { + conflicts: vec![content], + }), + cx, + ); + } + } + }), + ) + }) .into_any() } +struct MergeConflictNotification; + +fn merge_conflict_notification_id() -> NotificationId { + NotificationId::unique::() +} + +fn collect_conflicted_file_paths(workspace: &Workspace, cx: &App) -> Vec { + let project = workspace.project().read(cx); + let git_store = project.git_store().read(cx); + let mut paths = Vec::new(); + + for repo in git_store.repositories().values() { + let snapshot = repo.read(cx).snapshot(); + for (repo_path, _) in snapshot.merge.merge_heads_by_conflicted_path.iter() { + if let Some(project_path) = repo.read(cx).repo_path_to_project_path(repo_path, cx) { + paths.push( + project_path + .path + .as_std_path() + .to_string_lossy() + .to_string(), + ); + } + } + } + + paths +} + +pub(crate) fn register_conflict_notification( + workspace: &mut Workspace, + cx: &mut Context, +) { + let git_store = workspace.project().read(cx).git_store().clone(); + + cx.subscribe(&git_store, |workspace, _git_store, event, cx| { + let conflicts_changed = matches!( + event, + GitStoreEvent::ConflictsUpdated + | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _) + ); + if !AgentSettings::get_global(cx).enabled || !conflicts_changed { + return; + } + + let paths = collect_conflicted_file_paths(workspace, cx); + let notification_id = merge_conflict_notification_id(); + + if paths.is_empty() { + workspace.dismiss_notification(¬ification_id, cx); + } else { + let file_count = paths.len(); + workspace.show_notification(notification_id, cx, |cx| { + cx.new(|cx| { + let message = if file_count == 1 { + "1 file has unresolved merge conflicts".to_string() + } else { + format!("{file_count} files have unresolved merge conflicts") + }; + + MessageNotification::new(message, cx) + .primary_message("Resolve Conflicts with Agent") + .primary_icon(IconName::ZedAssistant) + .primary_icon_color(Color::Muted) + .primary_on_click({ + let paths = paths.clone(); + move |window, cx| { + window.dispatch_action( + Box::new(ResolveConflictedFilesWithAgent { + conflicted_file_paths: paths.clone(), + }), + cx, + ); + cx.emit(DismissEvent); + } + }) + }) + }); + } + }) + .detach(); +} + pub(crate) fn resolve_conflict( editor: WeakEntity, excerpt_id: ExcerptId, diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index e19eb8c21f9bf8a3eb88e4804d2a977ffb97e31c..1a9866fcc6e7ef420742620dab3faa2f38bfa5f5 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -62,6 +62,7 @@ pub fn init(cx: &mut App) { git_panel::register(workspace); repository_selector::register(workspace); git_picker::register(workspace); + conflict_view::register_conflict_notification(workspace, cx); let project = workspace.project().read(cx); if project.is_read_only(cx) { diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 3536e73a9db6247a798145f186ae20d2efe29da5..7c06eaef92ece60e8b4a9ad78976b68aee854226 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -147,6 +147,7 @@ pub enum IconName { GitBranchPlus, GitCommit, GitGraph, + GitMergeConflict, Github, Hash, HistoryRerun, diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index ae785bb4a0c792dd7f55d8850e8c05ce6327c108..854f71175e79c84f03261a3d58f89638b7259e54 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -469,6 +469,33 @@ pub mod agent { /// The base ref that the diff was computed against (e.g. "main"). pub base_ref: SharedString, } + + /// A single merge conflict region extracted from a file. + #[derive(Clone, Debug, PartialEq, Deserialize, JsonSchema)] + pub struct ConflictContent { + pub file_path: String, + pub conflict_text: String, + pub ours_branch_name: String, + pub theirs_branch_name: String, + } + + /// Opens a new agent thread to resolve specific merge conflicts. + #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] + #[action(namespace = agent)] + #[serde(deny_unknown_fields)] + pub struct ResolveConflictsWithAgent { + /// Individual conflicts with their full text. + pub conflicts: Vec, + } + + /// Opens a new agent thread to resolve merge conflicts in the given file paths. + #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] + #[action(namespace = agent)] + #[serde(deny_unknown_fields)] + pub struct ResolveConflictedFilesWithAgent { + /// File paths with unresolved conflicts (for project-wide resolution). + pub conflicted_file_paths: Vec, + } } pub mod assistant {