diff --git a/assets/settings/default.json b/assets/settings/default.json index e9d21eb0dcc18ae939a41e3415b93eaeba1e4546..5e1eb0e68d2f8a17f89422597aa29b99516333e8 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1144,6 +1144,11 @@ // // Default: false "show_turn_stats": false, + // Whether to show the merge conflict indicator in the status bar + // that offers to resolve conflicts using the agent. + // + // Default: true + "show_merge_conflict_indicator": true, }, // Whether the screen sharing icon is shown in the os status bar. "show_call_status_icon": true, diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index c67942e5cd3769f814fad62f7311bf7967f3317a..58e779da59aef176464839ed6f2d6a5c16e4bc12 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -595,6 +595,7 @@ mod tests { message_editor_min_lines: 1, tool_permissions, show_turn_stats: false, + show_merge_conflict_indicator: true, new_thread_location: Default::default(), sidebar_side: Default::default(), thinking_display: Default::default(), diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index f0730d39eee17cbd544e5ba8574b30f03963c524..0c68d2f25d54f966d1cc0a93476457bbba79c959 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -176,6 +176,7 @@ pub struct AgentSettings { pub use_modifier_to_send: bool, pub message_editor_min_lines: usize, pub show_turn_stats: bool, + pub show_merge_conflict_indicator: bool, pub tool_permissions: ToolPermissions, pub new_thread_location: NewThreadLocation, } @@ -629,6 +630,7 @@ impl Settings for AgentSettings { use_modifier_to_send: agent.use_modifier_to_send.unwrap(), message_editor_min_lines: agent.message_editor_min_lines.unwrap(), show_turn_stats: agent.show_turn_stats.unwrap(), + show_merge_conflict_indicator: agent.show_merge_conflict_indicator.unwrap(), tool_permissions: compile_tool_permissions(agent.tool_permissions), new_thread_location: agent.new_thread_location.unwrap_or_default(), } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index e58c7eb3526cc1a53d7b8e6d449e968a5923425a..5cff5bfc38d4512d659d919c6e7c4ff02fcc0caf 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -734,6 +734,7 @@ mod tests { message_editor_min_lines: 1, tool_permissions: Default::default(), show_turn_stats: false, + show_merge_conflict_indicator: true, new_thread_location: Default::default(), sidebar_side: Default::default(), thinking_display: Default::default(), diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 886ac816c925067b6be6b4553361eb2425539ada..25af09832f3473aa690c7b205e1b56bab86e9709 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -816,13 +816,10 @@ impl ThreadView { } } })); - if self.parent_id.is_none() { - self.suppress_merge_conflict_notification(cx); - } generation } - pub fn stop_turn(&mut self, generation: usize, cx: &mut Context) { + pub fn stop_turn(&mut self, generation: usize, _cx: &mut Context) { if self.turn_fields.turn_generation != generation { return; } @@ -833,25 +830,6 @@ impl ThreadView { .map(|started| started.elapsed()); self.turn_fields.last_turn_tokens = self.turn_fields.turn_tokens.take(); self.turn_fields._turn_timer_task = None; - if self.parent_id.is_none() { - self.unsuppress_merge_conflict_notification(cx); - } - } - - fn suppress_merge_conflict_notification(&self, cx: &mut Context) { - self.workspace - .update(cx, |workspace, cx| { - workspace.suppress_notification(&workspace::merge_conflict_notification_id(), cx); - }) - .ok(); - } - - fn unsuppress_merge_conflict_notification(&self, cx: &mut Context) { - self.workspace - .update(cx, |workspace, _cx| { - workspace.unsuppress(workspace::merge_conflict_notification_id()); - }) - .ok(); } pub fn update_turn_tokens(&mut self, cx: &App) { diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 95d46676a80ebca3b2db1ba1d7c88edee32df9ea..25175dce48163778615c26a585cd8a6319c1735f 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -6,19 +6,19 @@ use editor::{ display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, }; use gpui::{ - App, Context, DismissEvent, Entity, InteractiveElement as _, ParentElement as _, Subscription, - Task, WeakEntity, + App, ClickEvent, Context, Empty, Entity, InteractiveElement as _, ParentElement as _, + Subscription, Task, WeakEntity, }; use language::{Anchor, Buffer, BufferId}; use project::{ ConflictRegion, ConflictSet, ConflictSetUpdate, Project, ProjectItem as _, - git_store::{GitStoreEvent, RepositoryEvent}, + git_store::{GitStore, GitStoreEvent, RepositoryEvent}, }; use settings::Settings; -use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc}; -use ui::{ActiveTheme, Divider, Element as _, Styled, Window, prelude::*}; +use std::{ops::Range, sync::Arc}; +use ui::{ButtonLike, Divider, Tooltip, prelude::*}; use util::{ResultExt as _, debug_panic, maybe}; -use workspace::{Workspace, notifications::simple_message_notification::MessageNotification}; +use workspace::{StatusItemView, Workspace, item::ItemHandle}; use zed_actions::agent::{ ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, }; @@ -433,74 +433,6 @@ fn collect_conflicted_file_paths(project: &Project, cx: &App) -> Vec { paths } -pub(crate) fn register_conflict_notification( - workspace: &mut Workspace, - cx: &mut Context, -) { - let git_store = workspace.project().read(cx).git_store().clone(); - - let last_shown_paths: Rc>> = Rc::new(RefCell::new(HashSet::default())); - - cx.subscribe(&git_store, move |workspace, _git_store, event, cx| { - let conflicts_changed = matches!( - event, - GitStoreEvent::ConflictsUpdated - | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _) - ); - if !AgentSettings::get_global(cx).enabled(cx) || !conflicts_changed { - return; - } - let project = workspace.project().read(cx); - if project.is_via_collab() { - return; - } - - if workspace.is_notification_suppressed(workspace::merge_conflict_notification_id()) { - return; - } - - let paths = collect_conflicted_file_paths(project, cx); - let notification_id = workspace::merge_conflict_notification_id(); - let current_paths_set: HashSet = paths.iter().cloned().collect(); - - if paths.is_empty() { - last_shown_paths.borrow_mut().clear(); - workspace.dismiss_notification(¬ification_id, cx); - } else if *last_shown_paths.borrow() != current_paths_set { - // Only show the notification if the set of conflicted paths has changed. - // This prevents re-showing after the user dismisses it while working on the same conflicts. - *last_shown_paths.borrow_mut() = current_paths_set; - let file_count = paths.len(); - workspace.show_notification(notification_id, cx, |cx| { - cx.new(|cx| { - let message = format!( - "{file_count} file{} have unresolved merge conflicts", - if file_count == 1 { "" } else { "s" } - ); - - MessageNotification::new(message, cx) - .primary_message("Resolve 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, resolved_conflict: ConflictRegion, @@ -573,3 +505,171 @@ pub(crate) fn resolve_conflict( } }) } + +pub struct MergeConflictIndicator { + project: Entity, + conflicted_paths: Vec, + last_shown_paths: HashSet, + dismissed: bool, + _subscription: Subscription, +} + +impl MergeConflictIndicator { + pub fn new(workspace: &Workspace, cx: &mut Context) -> Self { + let project = workspace.project().clone(); + let git_store = project.read(cx).git_store().clone(); + + let subscription = cx.subscribe(&git_store, Self::on_git_store_event); + + let conflicted_paths = collect_conflicted_file_paths(project.read(cx), cx); + let last_shown_paths: HashSet = conflicted_paths.iter().cloned().collect(); + + Self { + project, + conflicted_paths, + last_shown_paths, + dismissed: false, + _subscription: subscription, + } + } + + fn on_git_store_event( + &mut self, + _git_store: Entity, + event: &GitStoreEvent, + cx: &mut Context, + ) { + let conflicts_changed = matches!( + event, + GitStoreEvent::ConflictsUpdated + | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _) + ); + + let agent_settings = AgentSettings::get_global(cx); + if !agent_settings.enabled(cx) + || !agent_settings.show_merge_conflict_indicator + || !conflicts_changed + { + return; + } + + let project = self.project.read(cx); + if project.is_via_collab() { + return; + } + + let paths = collect_conflicted_file_paths(project, cx); + let current_paths_set: HashSet = paths.iter().cloned().collect(); + + if paths.is_empty() { + self.conflicted_paths.clear(); + self.last_shown_paths.clear(); + self.dismissed = false; + cx.notify(); + } else if self.last_shown_paths != current_paths_set { + self.last_shown_paths = current_paths_set; + self.conflicted_paths = paths; + self.dismissed = false; + cx.notify(); + } + } + + fn resolve_with_agent(&mut self, window: &mut Window, cx: &mut Context) { + window.dispatch_action( + Box::new(ResolveConflictedFilesWithAgent { + conflicted_file_paths: self.conflicted_paths.clone(), + }), + cx, + ); + self.dismissed = true; + cx.notify(); + } + + fn dismiss(&mut self, _: &ClickEvent, _window: &mut Window, cx: &mut Context) { + self.dismissed = true; + cx.notify(); + } +} + +impl Render for MergeConflictIndicator { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let agent_settings = AgentSettings::get_global(cx); + if !agent_settings.enabled(cx) + || !agent_settings.show_merge_conflict_indicator + || self.conflicted_paths.is_empty() + || self.dismissed + { + return Empty.into_any_element(); + } + + let file_count = self.conflicted_paths.len(); + + let message: SharedString = format!( + "Resolve Merge Conflict{} with Agent", + if file_count == 1 { "" } else { "s" } + ) + .into(); + + let tooltip_label: SharedString = format!( + "Found {} {} across the codebase", + file_count, + if file_count == 1 { + "conflict" + } else { + "conflicts" + } + ) + .into(); + + let border_color = cx.theme().colors().text_accent.opacity(0.2); + + h_flex() + .h(rems_from_px(22.)) + .rounded_sm() + .border_1() + .border_color(border_color) + .child( + ButtonLike::new("update-button") + .child( + h_flex() + .h_full() + .gap_1() + .child( + Icon::new(IconName::GitMergeConflict) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(Label::new(message).size(LabelSize::Small)), + ) + .tooltip(move |_, cx| { + Tooltip::with_meta( + tooltip_label.clone(), + None, + "Click to Resolve with Agent", + cx, + ) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.resolve_with_agent(window, cx); + })), + ) + .child( + div().border_l_1().border_color(border_color).child( + IconButton::new("dismiss-merge-conflicts", IconName::Close) + .icon_size(IconSize::XSmall) + .on_click(cx.listener(Self::dismiss)), + ), + ) + .into_any_element() + } +} + +impl StatusItemView for MergeConflictIndicator { + fn set_active_pane_item( + &mut self, + _: Option<&dyn ItemHandle>, + _window: &mut Window, + _: &mut Context, + ) { + } +} diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index e12e9142d081c5f083a1f9ba414d7099776f327d..7d73760e34d1b2923a247f71b04fc8b5218f380b 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -47,6 +47,8 @@ pub mod stash_picker; pub mod text_diff_view; pub mod worktree_picker; +pub use conflict_view::MergeConflictIndicator; + pub fn init(cx: &mut App) { editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx); commit_view::init(cx); @@ -62,7 +64,6 @@ 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/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 7ec6a6b5bbdee57cbe75c13d1abe5277ac4f1825..5b1b3c014f8c538cb0dff506e05d84a80dc863d1 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -209,6 +209,11 @@ pub struct AgentSettingsContent { /// /// Default: false pub show_turn_stats: Option, + /// Whether to show the merge conflict indicator in the status bar + /// that offers to resolve conflicts using the agent. + /// + /// Default: true + pub show_merge_conflict_indicator: Option, /// Per-tool permission rules for granular control over which tool actions /// require confirmation. /// diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 828a574115c4664b3ab2f37f32ad4087363b3978..bacfd227d83933d3ebd9b2d8836bbe19958acf2b 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -7516,6 +7516,24 @@ fn ai_page(cx: &App) -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Show Merge Conflict Indicator", + description: "Whether to show the merge conflict indicator in the status bar that offers to resolve conflicts using the agent.", + field: Box::new(SettingField { + json_path: Some("agent.show_merge_conflict_indicator"), + pick: |settings_content| { + settings_content.agent.as_ref()?.show_merge_conflict_indicator.as_ref() + }, + write: |settings_content, value| { + settings_content + .agent + .get_or_insert_default() + .show_merge_conflict_indicator = value; + }, + }), + metadata: None, + files: USER, + }), ]); items.into_boxed_slice() diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 10f8fa4e30178b5d9036ce4c59842944c3bcd501..6a5e9a3318e576054a9533c7ab92f86fc10e1a66 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -7693,11 +7693,6 @@ impl GlobalAnyActiveCall { } } -pub fn merge_conflict_notification_id() -> NotificationId { - struct MergeConflictNotification; - NotificationId::unique::() -} - /// Workspace-local view of a remote participant's location. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ParticipantLocation { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 9b81ccf0e1c183363bbb170d71b7b3a1a5526085..795fd12a6c73d9576095b6cd4a26cdd5577e6000 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -502,12 +502,15 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { cx.new(|_| go_to_line::cursor_position::CursorPosition::new(workspace)); let line_ending_indicator = cx.new(|_| line_ending_selector::LineEndingIndicator::default()); + let merge_conflict_indicator = + cx.new(|cx| git_ui::MergeConflictIndicator::new(workspace, cx)); workspace.status_bar().update(cx, |status_bar, cx| { status_bar.add_left_item(search_button, window, cx); status_bar.add_left_item(lsp_button, window, cx); status_bar.add_left_item(diagnostic_summary, window, cx); status_bar.add_left_item(active_file_name, window, cx); status_bar.add_left_item(activity_indicator, window, cx); + status_bar.add_left_item(merge_conflict_indicator, window, cx); status_bar.add_right_item(edit_prediction_ui, window, cx); status_bar.add_right_item(active_buffer_encoding, window, cx); status_bar.add_right_item(active_buffer_language, window, cx);