Detailed changes
@@ -0,0 +1,7 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 4.32848H10.4477C10.7723 4.32848 11.0835 4.45742 11.3131 4.68693C11.5426 4.91644 11.6715 5.22773 11.6715 5.55232V9.83575" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.32849 8V13.5073" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.16426 2.49272L2.49274 6.16424" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.16426 6.16424L2.49274 2.49272" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.6715 13.5073C12.6854 13.5073 13.5073 12.6854 13.5073 11.6715C13.5073 10.6577 12.6854 9.83575 11.6715 9.83575C10.6577 9.83575 9.83575 10.6577 9.83575 11.6715C9.83575 12.6854 10.6577 13.5073 11.6715 13.5073Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -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
+ }
}
}
}
@@ -219,6 +219,7 @@ impl UserMessage {
"<rules>\nThe user has specified the following rules that should be applied:\n";
const OPEN_DIAGNOSTICS_TAG: &str = "<diagnostics>";
const OPEN_DIFFS_TAG: &str = "<diffs>";
+ const MERGE_CONFLICT_TAG: &str = "<merge_conflicts>";
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("</merge_conflicts>\n");
+ message
+ .content
+ .push(language_model::MessageContent::Text(merge_conflict_context));
+ }
+
if message.content.len() > len_before_context {
message.content.insert(
len_before_context,
@@ -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::<AgentPanel>(cx) else {
+ return;
+ };
+
+ let content_blocks = build_conflict_resolution_prompt(&action.conflicts);
+
+ workspace.focus_panel::<AgentPanel>(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::<AgentPanel>(cx) else {
+ return;
+ };
+
+ let content_blocks =
+ build_conflicted_files_resolution_prompt(&action.conflicted_file_paths);
+
+ workspace.focus_panel::<AgentPanel>(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::<AgentPanel>(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<acp::ContentBlock> {
+ 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<acp::ContentBlock> {
+ 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<AgentPanel>, VisualTestContext) {
init_test(cx);
cx.update(|cx| {
@@ -7996,6 +7996,7 @@ pub(crate) fn open_link(
MentionUri::Diagnostics { .. } => {}
MentionUri::TerminalSelection { .. } => {}
MentionUri::GitDiff { .. } => {}
+ MentionUri::MergeConflict { .. } => {}
})
} else {
cx.open_url(&url);
@@ -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()))
@@ -187,7 +187,8 @@ fn open_mention_uri(
| MentionUri::Selection { abs_path: None, .. }
| MentionUri::Diagnostics { .. }
| MentionUri::TerminalSelection { .. }
- | MentionUri::GitDiff { .. } => {}
+ | MentionUri::GitDiff { .. }
+ | MentionUri::MergeConflict { .. } => {}
});
}
@@ -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<BufferId, BufferConflicts>,
@@ -368,11 +380,12 @@ fn render_conflict_buttons(
editor: WeakEntity<Editor>,
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::<String>();
+ 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::<MergeConflictNotification>()
+}
+
+fn collect_conflicted_file_paths(workspace: &Workspace, cx: &App) -> Vec<String> {
+ 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<Workspace>,
+) {
+ 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<Editor>,
excerpt_id: ExcerptId,
@@ -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) {
@@ -147,6 +147,7 @@ pub enum IconName {
GitBranchPlus,
GitCommit,
GitGraph,
+ GitMergeConflict,
Github,
Hash,
HistoryRerun,
@@ -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<ConflictContent>,
+ }
+
+ /// 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<String>,
+ }
}
pub mod assistant {