Detailed changes
@@ -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";
@@ -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 {
+ <Self as Dismissable>::dismissed(cx)
+ }
+
+ pub fn dismiss(cx: &mut App) {
+ <Self as Dismissable>::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<MultiWorkspace>,
agent_entries: Vec<AgentEntry>,
unchecked_agents: HashSet<AgentId>,
+ selected_index: Option<usize>,
is_importing: bool,
last_error: Option<SharedString>,
}
@@ -47,6 +68,8 @@ impl ThreadImportModal {
_window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Self>) {
+ 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<Self>,
+ ) {
+ 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<Self>) {
+ 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<Self>) {
cx.emit(DismissEvent);
}
- fn import_threads(&mut self, _: &menu::Confirm, _: &mut Window, cx: &mut Context<Self>) {
+ fn import_threads(
+ &mut self,
+ _: &menu::SecondaryConfirm,
+ _: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
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::<Vec<_>>();
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);
})),
),
),
@@ -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<ArchiveListItem>,
selection: Option<usize>,
hovered_index: Option<usize>,
+ preserve_selection_on_next_update: bool,
filter_editor: Entity<Editor>,
_subscriptions: Vec<gpui::Subscription>,
_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<Self>,
+ ) {
+ 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<Self>) {
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);
+ })),
+ ),
+ )
+ })
}
}
@@ -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<Item = &AgentId> {
self.external_agents.keys()
}
@@ -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<Entity<Workspace>> {
+ 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<Self>) {
+ 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<Self>) -> 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<Self>) {
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))
}
}
@@ -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<f32>,
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(
@@ -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
@@ -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);
- }),
- )
- })
}
}