diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 175f7e05f5ee824239fc179e12ca56aa9f2e1c74..02050a0e11d45dbb0c7c5cfeea3a200409a266ad 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -79,6 +79,7 @@ pub(crate) use model_selector_popover::ModelSelectorPopover; pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor}; pub(crate) use thread_history::ThreadHistory; pub(crate) use thread_history_view::*; +pub use thread_import::{AcpThreadImportOnboarding, ThreadImportModal}; use zed_actions; pub const DEFAULT_THREAD_TITLE: &str = "New Thread"; diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs index c01403d795d9f377fa1dce0a5171f37d214b9f33..9dd6b5efa0ae1cd3bc19dc6ae6a287218de8c668 100644 --- a/crates/agent_ui/src/thread_import.rs +++ b/crates/agent_ui/src/thread_import.rs @@ -3,6 +3,7 @@ use agent::ThreadStore; use agent_client_protocol as acp; use chrono::Utc; use collections::HashSet; +use db::kvp::Dismissable; use fs::Fs; use futures::FutureExt as _; use gpui::{ @@ -11,7 +12,10 @@ use gpui::{ }; use notifications::status_toast::{StatusToast, ToastIcon}; use project::{AgentId, AgentRegistryStore, AgentServerStore}; -use ui::{Checkbox, CommonAnimationExt as _, KeyBinding, ListItem, ListItemSpacing, prelude::*}; +use ui::{ + Checkbox, KeyBinding, ListItem, ListItemSpacing, Modal, ModalFooter, ModalHeader, Section, + prelude::*, +}; use util::ResultExt; use workspace::{ModalView, MultiWorkspace, Workspace}; @@ -21,6 +25,22 @@ use crate::{ thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}, }; +pub struct AcpThreadImportOnboarding; + +impl AcpThreadImportOnboarding { + pub fn dismissed(cx: &App) -> bool { + ::dismissed(cx) + } + + pub fn dismiss(cx: &mut App) { + ::set_dismissed(true, cx); + } +} + +impl Dismissable for AcpThreadImportOnboarding { + const KEY: &'static str = "dismissed-acp-thread-import"; +} + #[derive(Clone)] struct AgentEntry { agent_id: AgentId, @@ -34,6 +54,7 @@ pub struct ThreadImportModal { multi_workspace: WeakEntity, agent_entries: Vec, unchecked_agents: HashSet, + selected_index: Option, is_importing: bool, last_error: Option, } @@ -47,6 +68,8 @@ impl ThreadImportModal { _window: &mut Window, cx: &mut Context, ) -> Self { + AcpThreadImportOnboarding::dismiss(cx); + let agent_entries = agent_server_store .read(cx) .external_agents() @@ -85,6 +108,7 @@ impl ThreadImportModal { multi_workspace, agent_entries, unchecked_agents: HashSet::default(), + selected_index: None, is_importing: false, last_error: None, } @@ -118,11 +142,53 @@ impl ThreadImportModal { cx.notify(); } + fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context) { + if self.agent_entries.is_empty() { + return; + } + self.selected_index = Some(match self.selected_index { + Some(ix) if ix + 1 >= self.agent_entries.len() => 0, + Some(ix) => ix + 1, + None => 0, + }); + cx.notify(); + } + + fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + if self.agent_entries.is_empty() { + return; + } + self.selected_index = Some(match self.selected_index { + Some(0) => self.agent_entries.len() - 1, + Some(ix) => ix - 1, + None => self.agent_entries.len() - 1, + }); + cx.notify(); + } + + fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + if let Some(ix) = self.selected_index { + if let Some(entry) = self.agent_entries.get(ix) { + self.toggle_agent_checked(entry.agent_id.clone(), cx); + } + } + } + fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { cx.emit(DismissEvent); } - fn import_threads(&mut self, _: &menu::Confirm, _: &mut Window, cx: &mut Context) { + fn import_threads( + &mut self, + _: &menu::SecondaryConfirm, + _: &mut Window, + cx: &mut Context, + ) { if self.is_importing { return; } @@ -182,7 +248,8 @@ impl ThreadImportModal { fn show_imported_threads_toast(&self, imported_count: usize, cx: &mut App) { let status_toast = if imported_count == 0 { StatusToast::new("No threads found to import.", cx, |this, _cx| { - this.icon(ToastIcon::new(IconName::Info).color(Color::Info)) + this.icon(ToastIcon::new(IconName::Info).color(Color::Muted)) + .dismiss_button(true) }) } else { let message = if imported_count == 1 { @@ -192,6 +259,7 @@ impl ThreadImportModal { }; StatusToast::new(message, cx, |this, _cx| { this.icon(ToastIcon::new(IconName::Check).color(Color::Success)) + .dismiss_button(true) }) }; @@ -221,11 +289,29 @@ impl Render for ThreadImportModal { .enumerate() .map(|(ix, entry)| { let is_checked = !self.unchecked_agents.contains(&entry.agent_id); + let is_focused = self.selected_index == Some(ix); ListItem::new(("thread-import-agent", ix)) - .inset(true) + .rounded() .spacing(ListItemSpacing::Sparse) - .start_slot( + .focused(is_focused) + .child( + h_flex() + .w_full() + .gap_2() + .when(!is_checked, |this| this.opacity(0.6)) + .child(if let Some(icon_path) = entry.icon_path.clone() { + Icon::from_external_svg(icon_path) + .color(Color::Muted) + .size(IconSize::Small) + } else { + Icon::new(IconName::Sparkle) + .color(Color::Muted) + .size(IconSize::Small) + }) + .child(Label::new(entry.display_name.clone())), + ) + .end_slot( Checkbox::new( ("thread-import-agent-checkbox", ix), if is_checked { @@ -247,25 +333,13 @@ impl Render for ThreadImportModal { this.toggle_agent_checked(agent_id.clone(), cx); }) }) - .child( - h_flex() - .w_full() - .gap_2() - .child(if let Some(icon_path) = entry.icon_path.clone() { - Icon::from_external_svg(icon_path) - .color(Color::Muted) - .size(IconSize::Small) - } else { - Icon::new(IconName::Sparkle) - .color(Color::Muted) - .size(IconSize::Small) - }) - .child(Label::new(entry.display_name.clone())), - ) }) .collect::>(); let has_agents = !self.agent_entries.is_empty(); + let disabled_import_thread = self.is_importing + || !has_agents + || self.unchecked_agents.len() == self.agent_entries.len(); v_flex() .id("thread-import-modal") @@ -275,85 +349,62 @@ impl Render for ThreadImportModal { .overflow_hidden() .track_focus(&self.focus_handle) .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) .on_action(cx.listener(Self::import_threads)) .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { this.focus_handle.focus(window, cx); })) - // Header - .child( - v_flex() - .p_4() - .pb_2() - .gap_1() - .child(Headline::new("Import Threads").size(HeadlineSize::Small)) - .child( - Label::new( - "Select the agents whose threads you'd like to import. \ - Imported threads will appear in your thread archive.", - ) - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - // Agent list - .child( - v_flex() - .id("thread-import-agent-list") - .px_2() - .max_h(rems(20.)) - .overflow_y_scroll() - .when(has_agents, |this| this.children(agent_rows)) - .when(!has_agents, |this| { - this.child( - div().p_4().child( - Label::new("No ACP agents available.") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - }), - ) - // Footer .child( - h_flex() - .w_full() - .p_3() - .gap_2() - .items_center() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child(div().flex_1().min_w_0().when_some( - self.last_error.clone(), - |this, error| { - this.child( - Label::new(error) - .size(LabelSize::Small) - .color(Color::Error) - .truncate(), + Modal::new("import-threads", None) + .header( + ModalHeader::new() + .headline("Import ACP Threads") + .description( + "Import threads from your ACP agents — whether started in Zed or another client. \ + Choose which agents to include, and their threads will appear in your archive." ) - }, - )) - .child( - h_flex() - .gap_2() - .items_center() - .when(self.is_importing, |this| { - this.child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::Small) - .color(Color::Muted) - .with_rotate_animation(2), + .show_dismiss_button(true), + + ) + .section( + Section::new().child( + v_flex() + .id("thread-import-agent-list") + .max_h(rems_from_px(320.)) + .pb_2() + .overflow_y_scroll() + .when(has_agents, |this| this.children(agent_rows)) + .when(!has_agents, |this| { + this.child( + Label::new("No ACP agents available.") + .color(Color::Muted) + .size(LabelSize::Small), + ) + }), + ), + ) + .footer( + ModalFooter::new() + .when_some(self.last_error.clone(), |this, error| { + this.start_slot( + Label::new(error) + .size(LabelSize::Small) + .color(Color::Error) + .truncate(), ) }) - .child( + .end_slot( Button::new("import-threads", "Import Threads") - .disabled(self.is_importing || !has_agents) + .loading(self.is_importing) + .disabled(disabled_import_thread) .key_binding( - KeyBinding::for_action(&menu::Confirm, cx) + KeyBinding::for_action(&menu::SecondaryConfirm, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(cx.listener(|this, _, window, cx| { - this.import_threads(&menu::Confirm, window, cx); + this.import_threads(&menu::SecondaryConfirm, window, cx); })), ), ), diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index f96efe36ba4541304b855f7f67d8f9e5cd482c2f..cd786880c5d76d2a2b2ad9ff74b2e94aa4ac483d 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -1,5 +1,5 @@ use crate::agent_connection_store::AgentConnectionStore; -use crate::thread_import::ThreadImportModal; +use crate::thread_import::{AcpThreadImportOnboarding, ThreadImportModal}; use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; use crate::{Agent, RemoveSelectedThread}; @@ -108,6 +108,7 @@ pub struct ThreadsArchiveView { items: Vec, selection: Option, hovered_index: Option, + preserve_selection_on_next_update: bool, filter_editor: Entity, _subscriptions: Vec, _refresh_history_task: Task<()>, @@ -176,6 +177,7 @@ impl ThreadsArchiveView { items: Vec::new(), selection: None, hovered_index: None, + preserve_selection_on_next_update: false, filter_editor, _subscriptions: vec![ filter_editor_subscription, @@ -251,10 +253,36 @@ impl ThreadsArchiveView { }); } + let preserve = self.preserve_selection_on_next_update; + self.preserve_selection_on_next_update = false; + + let saved_scroll = if preserve { + Some(self.list_state.logical_scroll_top()) + } else { + None + }; + self.list_state.reset(items.len()); self.items = items; - self.selection = None; self.hovered_index = None; + + if let Some(scroll_top) = saved_scroll { + self.list_state.scroll_to(scroll_top); + + if let Some(ix) = self.selection { + let next = self.find_next_selectable(ix).or_else(|| { + ix.checked_sub(1) + .and_then(|i| self.find_previous_selectable(i)) + }); + self.selection = next; + if let Some(next) = next { + self.list_state.scroll_to_reveal_item(next); + } + } + } else { + self.selection = None; + } + cx.notify(); } @@ -435,66 +463,56 @@ impl ThreadsArchiveView { cx.notify(); })) .action_slot( - h_flex() - .gap_2() - .when(is_hovered || is_focused, |this| { - let focus_handle = self.focus_handle.clone(); - this.child( - Button::new("unarchive-thread", "Open") - .style(ButtonStyle::Filled) - .label_size(LabelSize::Small) - .when(is_focused, |this| { - this.key_binding( - KeyBinding::for_action_in( - &menu::Confirm, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - }) - .on_click({ - let thread = thread.clone(); - cx.listener(move |this, _, window, cx| { - this.unarchive_thread(thread.clone(), window, cx); - }) - }), - ) + IconButton::new("delete-thread", IconName::Trash) + .style(ButtonStyle::Filled) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + "Delete Thread", + &RemoveSelectedThread, + &focus_handle, + cx, + ) + } }) - .child( - IconButton::new("delete-thread", IconName::Trash) - .style(ButtonStyle::Filled) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip({ - move |_window, cx| { - Tooltip::for_action_in( - "Delete Thread", - &RemoveSelectedThread, - &focus_handle, - cx, - ) - } - }) - .on_click({ - let agent = thread.agent_id.clone(); - let session_id = thread.session_id.clone(); - cx.listener(move |this, _, _, cx| { - this.delete_thread( - session_id.clone(), - agent.clone(), - cx, - ); - cx.stop_propagation(); - }) - }), - ), + .on_click({ + let agent = thread.agent_id.clone(); + let session_id = thread.session_id.clone(); + cx.listener(move |this, _, _, cx| { + this.delete_thread(session_id.clone(), agent.clone(), cx); + cx.stop_propagation(); + }) + }), ) + .tooltip(move |_, cx| Tooltip::for_action("Restore Thread", &menu::Confirm, cx)) + .on_click({ + let thread = thread.clone(); + cx.listener(move |this, _, window, cx| { + this.unarchive_thread(thread.clone(), window, cx); + }) + }) .into_any_element() } } } + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.selection else { return }; + let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else { + return; + }; + + self.preserve_selection_on_next_update = true; + self.delete_thread(thread.session_id.clone(), thread.agent_id.clone(), cx); + } + fn delete_thread( &mut self, session_id: acp::SessionId, @@ -531,6 +549,16 @@ impl ThreadsArchiveView { .detach_and_log_err(cx); } + fn should_render_acp_import_onboarding(&self, cx: &App) -> bool { + let has_external_agents = self + .agent_server_store + .upgrade() + .map(|store| store.read(cx).has_external_agents()) + .unwrap_or(false); + + has_external_agents && !AcpThreadImportOnboarding::dismissed(cx) + } + fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context) { let Some(agent_server_store) = self.agent_server_store.upgrade() else { return; @@ -605,27 +633,16 @@ impl ThreadsArchiveView { .when(show_focus_keybinding, |this| { this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)) }) - .map(|this| { - if has_query { - this.child( - IconButton::new("clear-filter", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Clear Search")) - .on_click(cx.listener(|this, _, window, cx| { - this.reset_filter_editor_text(window, cx); - this.update_items(cx); - })), - ) - } else { - this.child( - IconButton::new("import-thread", IconName::Plus) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Import ACP Threads")) - .on_click(cx.listener(|this, _, window, cx| { - this.show_thread_import_modal(window, cx); - })), - ) - } + .when(has_query, |this| { + this.child( + IconButton::new("clear-filter", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Clear Search")) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_filter_editor_text(window, cx); + this.update_items(cx); + })), + ) }) } } @@ -707,8 +724,32 @@ impl Render for ThreadsArchiveView { .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::remove_selected_thread)) .size_full() .child(self.render_header(window, cx)) .child(content) + .when(!self.should_render_acp_import_onboarding(cx), |this| { + this.child( + div() + .w_full() + .p_1p5() + .border_t_1() + .border_color(cx.theme().colors().border) + .child( + Button::new("import-acp", "Import ACP Threads") + .full_width() + .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border)) + .label_size(LabelSize::Small) + .start_icon( + Icon::new(IconName::ArrowDown) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.show_thread_import_modal(window, cx); + })), + ), + ) + }) } } diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 218b1841d1178a5ebba29f4935e4699189567fde..a41c34826ca5c9db918f1e699fca126dd0c06b62 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -695,6 +695,10 @@ impl AgentServerStore { } } + pub fn has_external_agents(&self) -> bool { + !self.external_agents.is_empty() + } + pub fn external_agents(&self) -> impl Iterator { self.external_agents.keys() } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 6d7be34ab5314c963652f768b5f84ff1896c4a21..b6d6ca98cf8b61cb7cba95cd204f4a8a429cb538 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -8,6 +8,7 @@ use agent_ui::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; use agent_ui::threads_archive_view::{ ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp, }; +use agent_ui::{AcpThreadImportOnboarding, ThreadImportModal}; use agent_ui::{ Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread, }; @@ -16,7 +17,8 @@ use editor::Editor; use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; use gpui::{ Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, KeyContext, ListState, - Pixels, Render, SharedString, WeakEntity, Window, WindowHandle, list, prelude::*, px, + Pixels, Render, SharedString, WeakEntity, Window, WindowHandle, linear_color_stop, + linear_gradient, list, prelude::*, px, }; use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, @@ -3134,9 +3136,9 @@ impl Sidebar { h_flex() .w_1_2() .gap_2() - .child(Divider::horizontal()) + .child(Divider::horizontal().color(ui::DividerColor::Border)) .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted)) - .child(Divider::horizontal()), + .child(Divider::horizontal().color(ui::DividerColor::Border)), ) .child( Button::new("clone_repo", "Clone Repository") @@ -3325,6 +3327,112 @@ impl Sidebar { } } + fn active_workspace(&self, cx: &App) -> Option> { + self.multi_workspace.upgrade().and_then(|w| { + w.read(cx) + .workspaces() + .get(w.read(cx).active_workspace_index()) + .cloned() + }) + } + + fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context) { + let Some(active_workspace) = self.active_workspace(cx) else { + return; + }; + + let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else { + return; + }; + + let agent_server_store = active_workspace + .read(cx) + .project() + .read(cx) + .agent_server_store() + .clone(); + + let workspace_handle = active_workspace.downgrade(); + let multi_workspace = self.multi_workspace.clone(); + + active_workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + ThreadImportModal::new( + agent_server_store, + agent_registry_store, + workspace_handle.clone(), + multi_workspace.clone(), + window, + cx, + ) + }); + }); + } + + fn should_render_acp_import_onboarding(&self, cx: &App) -> bool { + let has_external_agents = self + .active_workspace(cx) + .map(|ws| { + ws.read(cx) + .project() + .read(cx) + .agent_server_store() + .read(cx) + .has_external_agents() + }) + .unwrap_or(false); + + has_external_agents && !AcpThreadImportOnboarding::dismissed(cx) + } + + fn render_acp_import_onboarding(&mut self, cx: &mut Context) -> impl IntoElement { + let description = + "Import threads from your ACP agents — whether started in Zed or another client."; + + let bg = cx.theme().colors().text_accent; + + v_flex() + .min_w_0() + .w_full() + .p_1p5() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(linear_gradient( + 360., + linear_color_stop(bg.opacity(0.06), 1.), + linear_color_stop(bg.opacity(0.), 0.), + )) + .child( + h_flex() + .min_w_0() + .w_full() + .gap_1() + .justify_between() + .child(Label::new("Looking for ACP threads?")) + .child( + IconButton::new("close-onboarding", IconName::Close) + .icon_size(IconSize::Small) + .on_click(|_, _window, cx| AcpThreadImportOnboarding::dismiss(cx)), + ), + ) + .child(Label::new(description).color(Color::Muted).mb_2()) + .child( + Button::new("import-acp", "Import ACP Threads") + .full_width() + .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border)) + .label_size(LabelSize::Small) + .start_icon( + Icon::new(IconName::ArrowDown) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.show_archive(window, cx); + this.show_thread_import_modal(window, cx); + })), + ) + } + fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context) { match &self.view { SidebarView::ThreadList => self.show_archive(window, cx), @@ -3569,6 +3677,9 @@ impl Render for Sidebar { }), SidebarView::Archive(archive_view) => this.child(archive_view.clone()), }) + .when(self.should_render_acp_import_onboarding(cx), |this| { + this.child(self.render_acp_import_onboarding(cx)) + }) .child(self.render_sidebar_bottom_bar(cx)) } } diff --git a/crates/ui/src/components/button/button.rs b/crates/ui/src/components/button/button.rs index 52ea9df14293e5aa25ab8de4487975019a6481ff..a3636285999eabe1491ca004241c58669321cf5a 100644 --- a/crates/ui/src/components/button/button.rs +++ b/crates/ui/src/components/button/button.rs @@ -2,6 +2,7 @@ use crate::component_prelude::*; use gpui::{AnyElement, AnyView, DefiniteLength}; use ui_macros::RegisterComponent; +use crate::traits::animation_ext::CommonAnimationExt; use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, Label}; use crate::{ Color, DynamicSpacing, ElevationIndex, KeyBinding, KeybindingPosition, TintColor, prelude::*, @@ -88,6 +89,7 @@ pub struct Button { key_binding_position: KeybindingPosition, alpha: Option, truncate: bool, + loading: bool, } impl Button { @@ -111,6 +113,7 @@ impl Button { key_binding_position: KeybindingPosition::default(), alpha: None, truncate: false, + loading: false, } } @@ -183,6 +186,14 @@ impl Button { self.truncate = truncate; self } + + /// Displays a rotating loading spinner in place of the `start_icon`. + /// + /// When `loading` is `true`, any `start_icon` is ignored. and a rotating + pub fn loading(mut self, loading: bool) -> Self { + self.loading = loading; + self + } } impl Toggleable for Button { @@ -378,11 +389,21 @@ impl RenderOnce for Button { h_flex() .when(self.truncate, |this| this.min_w_0().overflow_hidden()) .gap(DynamicSpacing::Base04.rems(cx)) - .when_some(self.start_icon, |this, icon| { - this.child(if is_disabled { - icon.color(Color::Disabled) - } else { - icon + .when(self.loading, |this| { + this.child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2), + ) + }) + .when(!self.loading, |this| { + this.when_some(self.start_icon, |this, icon| { + this.child(if is_disabled { + icon.color(Color::Disabled) + } else { + icon + }) }) }) .child( diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index 9cb7bd85ef1dbcdf16b821d61b7fe02800b8e182..2b0fa20683cce462cb998e59be95731f7f214cec 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -138,6 +138,9 @@ pub enum ButtonStyle { /// A more de-emphasized version of the outlined button. OutlinedGhost, + /// Like [`ButtonStyle::Outlined`], but with a caller-provided border color. + OutlinedCustom(Hsla), + /// The default button style, used for most buttons. Has a transparent background, /// but has a background color to indicate states like hover and active. #[default] @@ -230,6 +233,12 @@ impl ButtonStyle { label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), }, + ButtonStyle::OutlinedCustom(border_color) => ButtonLikeStyles { + background: transparent_black(), + border_color, + label_color: Color::Default.color(cx), + icon_color: Color::Default.color(cx), + }, ButtonStyle::Subtle => ButtonLikeStyles { background: cx.theme().colors().ghost_element_background, border_color: transparent_black(), @@ -280,6 +289,12 @@ impl ButtonStyle { label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), }, + ButtonStyle::OutlinedCustom(border_color) => ButtonLikeStyles { + background: cx.theme().colors().ghost_element_hover, + border_color, + label_color: Color::Default.color(cx), + icon_color: Color::Default.color(cx), + }, ButtonStyle::Subtle => ButtonLikeStyles { background: cx.theme().colors().ghost_element_hover, border_color: transparent_black(), @@ -324,6 +339,12 @@ impl ButtonStyle { label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), }, + ButtonStyle::OutlinedCustom(border_color) => ButtonLikeStyles { + background: cx.theme().colors().element_active, + border_color, + label_color: Color::Default.color(cx), + icon_color: Color::Default.color(cx), + }, ButtonStyle::Transparent => ButtonLikeStyles { background: transparent_black(), border_color: transparent_black(), @@ -363,6 +384,12 @@ impl ButtonStyle { label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), }, + ButtonStyle::OutlinedCustom(border_color) => ButtonLikeStyles { + background: cx.theme().colors().ghost_element_background, + border_color, + label_color: Color::Default.color(cx), + icon_color: Color::Default.color(cx), + }, ButtonStyle::Transparent => ButtonLikeStyles { background: transparent_black(), border_color: cx.theme().colors().border_focused, @@ -405,6 +432,12 @@ impl ButtonStyle { label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), }, + ButtonStyle::OutlinedCustom(_) => ButtonLikeStyles { + background: cx.theme().colors().element_disabled, + border_color: cx.theme().colors().border_disabled, + label_color: Color::Default.color(cx), + icon_color: Color::Default.color(cx), + }, ButtonStyle::Transparent => ButtonLikeStyles { background: transparent_black(), border_color: transparent_black(), @@ -640,7 +673,7 @@ impl RenderOnce for ButtonLike { let is_outlined = matches!( self.style, - ButtonStyle::Outlined | ButtonStyle::OutlinedGhost + ButtonStyle::Outlined | ButtonStyle::OutlinedGhost | ButtonStyle::OutlinedCustom(_) ); self.base diff --git a/crates/ui/src/components/modal.rs b/crates/ui/src/components/modal.rs index 53922875c8a883037c7f8a60df66a39aa6d488bf..fbd5f42989e20d0c8aa98693ca9e13eaa1077280 100644 --- a/crates/ui/src/components/modal.rs +++ b/crates/ui/src/components/modal.rs @@ -1,7 +1,5 @@ -use crate::{ - Clickable, Color, DynamicSpacing, Headline, HeadlineSize, Icon, IconButton, IconButtonShape, - IconName, Label, LabelCommon, LabelSize, h_flex, v_flex, -}; +use crate::{IconButtonShape, prelude::*}; + use gpui::{prelude::FluentBuilder, *}; use smallvec::SmallVec; use theme::ActiveTheme; @@ -169,6 +167,7 @@ impl RenderOnce for ModalHeader { } h_flex() + .min_w_0() .flex_none() .justify_between() .w_full() @@ -187,26 +186,33 @@ impl RenderOnce for ModalHeader { }) .child( v_flex() + .min_w_0() .flex_1() .child( h_flex() + .w_full() .gap_1() - .when_some(self.icon, |this, icon| this.child(icon)) - .children(children), + .justify_between() + .child( + h_flex() + .gap_1() + .when_some(self.icon, |this, icon| this.child(icon)) + .children(children), + ) + .when(self.show_dismiss_button, |this| { + this.child( + IconButton::new("dismiss", IconName::Close) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Cancel.boxed_clone(), cx); + }), + ) + }), ) .when_some(self.description, |this, description| { - this.child(Label::new(description).color(Color::Muted).mb_2()) + this.child(Label::new(description).color(Color::Muted).mb_2().flex_1()) }), ) - .when(self.show_dismiss_button, |this| { - this.child( - IconButton::new("dismiss", IconName::Close) - .shape(IconButtonShape::Square) - .on_click(|_, window, cx| { - window.dispatch_action(menu::Cancel.boxed_clone(), cx); - }), - ) - }) } }