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 {