From b1395c5fdfc3aebeb4342b7fb80d1492c834afa8 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 29 Apr 2025 21:58:45 -0300 Subject: [PATCH] agent: Add new panel navigation dropdown (#29539) - [x] Ensure what appears in the dropdown is really what is accurate - [x] Ensure keyboard navigation works: - [x] Switching tabs with `enter` - [x] Closing items from the menu item - [x] Opening the dropdown - [x] Focus assistant panel on dismiss - [x] Add ability to close items from the dropdown menu - [x] Persistence - [x] Correct behavior when opening a text thread Release Notes: - agent: Added a navigation menu that shows the recently opened threads. The button to see the full history view has been changed inside this menu. --------- Co-authored-by: Bennet Bo Fenner Co-authored-by: Cole Miller Co-authored-by: Bennet Bo Fenner Co-authored-by: Cole Miller --- assets/icons/menu_alt.svg | 5 + assets/keymaps/default-linux.json | 6 + assets/keymaps/default-macos.json | 7 + crates/agent/src/active_thread.rs | 4 +- crates/agent/src/assistant.rs | 2 + crates/agent/src/assistant_panel.rs | 528 ++++++++++++------ crates/agent/src/context_picker.rs | 2 +- crates/agent/src/history_store.rs | 153 ++++- crates/agent/src/thread_history.rs | 9 +- crates/assistant/src/assistant_panel.rs | 5 +- .../assistant_context_editor/src/context.rs | 18 +- .../src/context/context_tests.rs | 4 +- .../src/context_editor.rs | 11 +- .../src/context_store.rs | 19 +- crates/icons/src/icons.rs | 1 + crates/menu/src/menu.rs | 3 +- crates/ui/src/components/context_menu.rs | 192 ++++++- 17 files changed, 740 insertions(+), 229 deletions(-) create mode 100644 assets/icons/menu_alt.svg diff --git a/assets/icons/menu_alt.svg b/assets/icons/menu_alt.svg new file mode 100644 index 0000000000..ae3581ba01 --- /dev/null +++ b/assets/icons/menu_alt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 00cc9805e3..90b509e9cf 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -250,6 +250,12 @@ "ctrl-alt-e": "agent::RemoveAllContext" } }, + { + "context": "AgentPanel > NavigationMenu", + "bindings": { + "ctrl-backspace": "agent::DeleteRecentlyOpenThread" + } + }, { "context": "AgentPanel > Markdown", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 7a606d91b6..e9711e09bf 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -290,11 +290,18 @@ "cmd-i": "agent::ToggleProfileSelector", "cmd-alt-/": "assistant::ToggleModelSelector", "cmd-shift-a": "agent::ToggleContextPicker", + "cmd-ctrl-a": "agent::ToggleNavigationMenu", "shift-escape": "agent::ExpandMessageEditor", "cmd-e": "agent::ChatMode", "cmd-alt-e": "agent::RemoveAllContext" } }, + { + "context": "AgentPanel > NavigationMenu", + "bindings": { + "ctrl-backspace": "agent::DeleteRecentlyOpenThread" + } + }, { "context": "AgentPanel > Markdown", "use_key_equivalents": true, diff --git a/crates/agent/src/active_thread.rs b/crates/agent/src/active_thread.rs index c465308a50..77acba1c6d 100644 --- a/crates/agent/src/active_thread.rs +++ b/crates/agent/src/active_thread.rs @@ -709,7 +709,7 @@ fn open_markdown_link( if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { panel - .open_thread(&thread_id, window, cx) + .open_thread_by_id(&thread_id, window, cx) .detach_and_log_err(cx) }); } @@ -3275,7 +3275,7 @@ pub(crate) fn open_context( panel.update(cx, |panel, cx| { let thread_id = thread_context.thread.read(cx).id().clone(); panel - .open_thread(&thread_id, window, cx) + .open_thread_by_id(&thread_id, window, cx) .detach_and_log_err(cx) }); } diff --git a/crates/agent/src/assistant.rs b/crates/agent/src/assistant.rs index 80c69665fb..f09bdfd34e 100644 --- a/crates/agent/src/assistant.rs +++ b/crates/agent/src/assistant.rs @@ -50,6 +50,8 @@ actions!( [ NewTextThread, ToggleContextPicker, + ToggleNavigationMenu, + DeleteRecentlyOpenThread, ToggleProfileSelector, RemoveAllContext, ExpandMessageEditor, diff --git a/crates/agent/src/assistant_panel.rs b/crates/agent/src/assistant_panel.rs index 0258bd6955..29c9fde5df 100644 --- a/crates/agent/src/assistant_panel.rs +++ b/crates/agent/src/assistant_panel.rs @@ -1,5 +1,5 @@ use std::ops::Range; -use std::path::PathBuf; +use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -18,8 +18,8 @@ use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; use fs::Fs; use gpui::{ Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem, - Corner, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext, Pixels, - Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between, + Corner, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext, + Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between, }; use language::LanguageRegistry; use language_model::{LanguageModelProviderTosView, LanguageModelRegistry}; @@ -41,15 +41,16 @@ use zed_actions::assistant::{OpenRulesLibrary, ToggleFocus}; use crate::active_thread::{ActiveThread, ActiveThreadEvent}; use crate::assistant_configuration::{AssistantConfiguration, AssistantConfigurationEvent}; -use crate::history_store::{HistoryEntry, HistoryStore}; +use crate::history_store::{HistoryEntry, HistoryStore, RecentEntry}; use crate::message_editor::{MessageEditor, MessageEditorEvent}; use crate::thread::{Thread, ThreadError, ThreadId, TokenUsageRatio}; use crate::thread_history::{PastContext, PastThread, ThreadHistory}; use crate::thread_store::ThreadStore; use crate::ui::UsageBanner; use crate::{ - AddContextServer, AgentDiff, ExpandMessageEditor, InlineAssistant, NewTextThread, NewThread, - OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ThreadEvent, ToggleContextPicker, + AddContextServer, AgentDiff, DeleteRecentlyOpenThread, ExpandMessageEditor, InlineAssistant, + NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ThreadEvent, + ToggleContextPicker, ToggleNavigationMenu, }; pub fn init(cx: &mut App) { @@ -104,6 +105,14 @@ pub fn init(cx: &mut App) { }); }); } + }) + .register_action(|workspace, _: &ToggleNavigationMenu, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + workspace.focus_panel::(window, cx); + panel.update(cx, |panel, cx| { + panel.toggle_navigation_menu(&ToggleNavigationMenu, window, cx); + }); + } }); }, ) @@ -113,6 +122,7 @@ pub fn init(cx: &mut App) { enum ActiveView { Thread { change_title_editor: Entity, + thread: WeakEntity, _subscriptions: Vec, }, PromptEditor { @@ -130,7 +140,7 @@ impl ActiveView { let editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_text(summary, window, cx); + editor.set_text(summary.clone(), window, cx); editor }); @@ -176,6 +186,7 @@ impl ActiveView { Self::Thread { change_title_editor: editor, + thread: thread.downgrade(), _subscriptions: subscriptions, } } @@ -279,6 +290,8 @@ pub struct AssistantPanel { history_store: Entity, history: Entity, assistant_dropdown_menu_handle: PopoverMenuHandle, + assistant_navigation_menu_handle: PopoverMenuHandle, + assistant_navigation_menu: Option>, width: Option, height: Option, } @@ -380,8 +393,14 @@ impl AssistantPanel { } }); - let history_store = - cx.new(|cx| HistoryStore::new(thread_store.clone(), context_store.clone(), cx)); + let history_store = cx.new(|cx| { + HistoryStore::new( + thread_store.clone(), + context_store.clone(), + [RecentEntry::Thread(thread.clone())], + cx, + ) + }); cx.observe(&history_store, |_, _, cx| cx.notify()).detach(); @@ -392,7 +411,7 @@ impl AssistantPanel { cx.notify(); } }); - let thread = cx.new(|cx| { + let active_thread = cx.new(|cx| { ActiveThread::new( thread.clone(), thread_store.clone(), @@ -403,10 +422,111 @@ impl AssistantPanel { ) }); - let active_thread_subscription = cx.subscribe(&thread, |_, _, event, cx| match &event { - ActiveThreadEvent::EditingMessageTokenCountChanged => { - cx.notify(); - } + let active_thread_subscription = + cx.subscribe(&active_thread, |_, _, event, cx| match &event { + ActiveThreadEvent::EditingMessageTokenCountChanged => { + cx.notify(); + } + }); + + let weak_panel = weak_self.clone(); + + window.defer(cx, move |window, cx| { + let panel = weak_panel.clone(); + let assistant_navigation_menu = + ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| { + let recently_opened = panel + .update(cx, |this, cx| { + this.history_store.update(cx, |history_store, cx| { + history_store.recently_opened_entries(cx) + }) + }) + .unwrap_or_default(); + + if !recently_opened.is_empty() { + menu = menu.header("Recently Opened"); + + for entry in recently_opened.iter() { + let summary = entry.summary(cx); + menu = menu.entry_with_end_slot( + summary, + None, + { + let panel = panel.clone(); + let entry = entry.clone(); + move |window, cx| { + panel + .update(cx, { + let entry = entry.clone(); + move |this, cx| match entry { + RecentEntry::Thread(thread) => { + this.open_thread(thread, window, cx) + } + RecentEntry::Context(context) => { + let Some(path) = context.read(cx).path() + else { + return; + }; + this.open_saved_prompt_editor( + path.clone(), + window, + cx, + ) + .detach_and_log_err(cx) + } + } + }) + .ok(); + } + }, + IconName::Close, + "Close Entry".into(), + { + let panel = panel.clone(); + let entry = entry.clone(); + move |_window, cx| { + panel + .update(cx, |this, cx| { + this.history_store.update( + cx, + |history_store, cx| { + history_store.remove_recently_opened_entry( + &entry, cx, + ); + }, + ); + }) + .ok(); + } + }, + ); + } + + menu = menu.separator(); + } + + menu.action("View All", Box::new(OpenHistory)) + .end_slot_action(DeleteRecentlyOpenThread.boxed_clone()) + .fixed_width(px(320.).into()) + .keep_open_on_confirm(false) + .key_context("NavigationMenu") + }); + weak_panel + .update(cx, |panel, cx| { + cx.subscribe_in( + &assistant_navigation_menu, + window, + |_, menu, _: &DismissEvent, window, cx| { + menu.update(cx, |menu, _| { + menu.clear_selected(); + }); + cx.focus_self(window); + }, + ) + .detach(); + panel.assistant_navigation_menu = Some(assistant_navigation_menu); + }) + .ok(); }); let _default_model_subscription = cx.subscribe( @@ -431,7 +551,7 @@ impl AssistantPanel { fs: fs.clone(), language_registry, thread_store: thread_store.clone(), - thread, + thread: active_thread, message_editor, _active_thread_subscriptions: vec![ thread_subscription, @@ -451,6 +571,8 @@ impl AssistantPanel { history_store: history_store.clone(), history: cx.new(|cx| ThreadHistory::new(weak_self, history_store, window, cx)), assistant_dropdown_menu_handle: PopoverMenuHandle::default(), + assistant_navigation_menu_handle: PopoverMenuHandle::default(), + assistant_navigation_menu: None, width: None, height: None, } @@ -645,13 +767,13 @@ impl AssistantPanel { pub(crate) fn open_saved_prompt_editor( &mut self, - path: PathBuf, + path: Arc, window: &mut Window, cx: &mut Context, ) -> Task> { let context = self .context_store - .update(cx, |store, cx| store.open_local_context(path.clone(), cx)); + .update(cx, |store, cx| store.open_local_context(path, cx)); let fs = self.fs.clone(); let project = self.project.clone(); let workspace = self.workspace.clone(); @@ -685,7 +807,7 @@ impl AssistantPanel { }) } - pub(crate) fn open_thread( + pub(crate) fn open_thread_by_id( &mut self, thread_id: &ThreadId, window: &mut Window, @@ -694,73 +816,83 @@ impl AssistantPanel { let open_thread_task = self .thread_store .update(cx, |this, cx| this.open_thread(thread_id, cx)); - cx.spawn_in(window, async move |this, cx| { let thread = open_thread_task.await?; this.update_in(cx, |this, window, cx| { - let thread_view = ActiveView::thread(thread.clone(), window, cx); - this.set_active_view(thread_view, window, cx); - let message_editor_context_store = cx.new(|_cx| { - crate::context_store::ContextStore::new( - this.project.downgrade(), - Some(this.thread_store.downgrade()), - ) - }); - let thread_subscription = cx.subscribe(&thread, |_, _, event, cx| { - if let ThreadEvent::MessageAdded(_) = &event { - // needed to leave empty state - cx.notify(); - } - }); - - this.thread = cx.new(|cx| { - ActiveThread::new( - thread.clone(), - this.thread_store.clone(), - this.language_registry.clone(), - this.workspace.clone(), - window, - cx, - ) - }); - - let active_thread_subscription = - cx.subscribe(&this.thread, |_, _, event, cx| match &event { - ActiveThreadEvent::EditingMessageTokenCountChanged => { - cx.notify(); - } - }); - - this.message_editor = cx.new(|cx| { - MessageEditor::new( - this.fs.clone(), - this.workspace.clone(), - message_editor_context_store, - this.prompt_store.clone(), - this.thread_store.downgrade(), - thread, - window, - cx, - ) - }); - this.message_editor.focus_handle(cx).focus(window); - - let message_editor_subscription = - cx.subscribe(&this.message_editor, |_, _, event, cx| match event { - MessageEditorEvent::Changed | MessageEditorEvent::EstimatedTokenCount => { - cx.notify(); - } - }); - - this._active_thread_subscriptions = vec![ - thread_subscription, - active_thread_subscription, - message_editor_subscription, - ]; - }) + this.open_thread(thread, window, cx); + anyhow::Ok(()) + })??; + Ok(()) }) } + pub(crate) fn open_thread( + &mut self, + thread: Entity, + window: &mut Window, + cx: &mut Context, + ) { + let thread_view = ActiveView::thread(thread.clone(), window, cx); + self.set_active_view(thread_view, window, cx); + let message_editor_context_store = cx.new(|_cx| { + crate::context_store::ContextStore::new( + self.project.downgrade(), + Some(self.thread_store.downgrade()), + ) + }); + let thread_subscription = cx.subscribe(&thread, |_, _, event, cx| { + if let ThreadEvent::MessageAdded(_) = &event { + // needed to leave empty state + cx.notify(); + } + }); + + self.thread = cx.new(|cx| { + ActiveThread::new( + thread.clone(), + self.thread_store.clone(), + self.language_registry.clone(), + self.workspace.clone(), + window, + cx, + ) + }); + + let active_thread_subscription = + cx.subscribe(&self.thread, |_, _, event, cx| match &event { + ActiveThreadEvent::EditingMessageTokenCountChanged => { + cx.notify(); + } + }); + + self.message_editor = cx.new(|cx| { + MessageEditor::new( + self.fs.clone(), + self.workspace.clone(), + message_editor_context_store, + self.prompt_store.clone(), + self.thread_store.downgrade(), + thread, + window, + cx, + ) + }); + self.message_editor.focus_handle(cx).focus(window); + + let message_editor_subscription = + cx.subscribe(&self.message_editor, |_, _, event, cx| match event { + MessageEditorEvent::Changed | MessageEditorEvent::EstimatedTokenCount => { + cx.notify(); + } + }); + + self._active_thread_subscriptions = vec![ + thread_subscription, + active_thread_subscription, + message_editor_subscription, + ]; + } + pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context) { match self.active_view { ActiveView::Configuration | ActiveView::History => { @@ -773,6 +905,15 @@ impl AssistantPanel { } } + pub fn toggle_navigation_menu( + &mut self, + _: &ToggleNavigationMenu, + window: &mut Window, + cx: &mut Context, + ) { + self.assistant_navigation_menu_handle.toggle(window, cx); + } + pub fn open_agent_diff( &mut self, _: &OpenAgentDiff, @@ -921,7 +1062,7 @@ impl AssistantPanel { pub(crate) fn delete_context( &mut self, - path: PathBuf, + path: Arc, cx: &mut Context, ) -> Task> { self.context_store @@ -937,6 +1078,32 @@ impl AssistantPanel { let current_is_history = matches!(self.active_view, ActiveView::History); let new_is_history = matches!(new_view, ActiveView::History); + match &self.active_view { + ActiveView::Thread { thread, .. } => self.history_store.update(cx, |store, cx| { + if let Some(thread) = thread.upgrade() { + if thread.read(cx).is_empty() { + store.remove_recently_opened_entry(&RecentEntry::Thread(thread), cx); + } + } + }), + _ => {} + } + + match &new_view { + ActiveView::Thread { thread, .. } => self.history_store.update(cx, |store, cx| { + if let Some(thread) = thread.upgrade() { + store.push_recently_opened_entry(RecentEntry::Thread(thread), cx); + } + }), + ActiveView::PromptEditor { context_editor, .. } => { + self.history_store.update(cx, |store, cx| { + let context = context_editor.read(cx).context().clone(); + store.push_recently_opened_entry(RecentEntry::Context(context), cx) + }) + } + _ => {} + } + if current_is_history && !new_is_history { self.active_view = new_view; } else if !current_is_history && new_is_history { @@ -1066,16 +1233,13 @@ impl AssistantPanel { if is_empty { Label::new(Thread::DEFAULT_SUMMARY.clone()) .truncate() - .ml_2() .into_any_element() } else if summary.is_none() { Label::new(LOADING_SUMMARY_PLACEHOLDER) - .ml_2() .truncate() .into_any_element() } else { div() - .ml_2() .w_full() .child(change_title_editor.clone()) .into_any_element() @@ -1092,18 +1256,15 @@ impl AssistantPanel { match summary { None => Label::new(AssistantContext::DEFAULT_SUMMARY.clone()) .truncate() - .ml_2() .into_any_element(), Some(summary) => { if summary.done { div() - .ml_2() .w_full() .child(title_editor.clone()) .into_any_element() } else { Label::new(LOADING_SUMMARY_PLACEHOLDER) - .ml_2() .truncate() .into_any_element() } @@ -1130,7 +1291,6 @@ impl AssistantPanel { let thread = active_thread.thread().read(cx); let thread_id = thread.id().clone(); let is_empty = active_thread.is_empty(); - let is_history = matches!(self.active_view, ActiveView::History); let show_token_count = match &self.active_view { ActiveView::Thread { .. } => !is_empty, @@ -1140,30 +1300,98 @@ impl AssistantPanel { let focus_handle = self.focus_handle(cx); - let go_back_button = match &self.active_view { - ActiveView::History | ActiveView::Configuration => Some( - div().pl_1().child( - IconButton::new("go-back", IconName::ArrowLeft) + let go_back_button = div().child( + IconButton::new("go-back", IconName::ArrowLeft) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.go_back(&workspace::GoBack, window, cx); + })) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |window, cx| { + Tooltip::for_action_in( + "Go Back", + &workspace::GoBack, + &focus_handle, + window, + cx, + ) + } + }), + ); + + let recent_entries_menu = div().child( + PopoverMenu::new("agent-nav-menu") + .trigger_with_tooltip( + IconButton::new("agent-nav-menu", IconName::MenuAlt) .icon_size(IconSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.go_back(&workspace::GoBack, window, cx); - })) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in( - "Go Back", - &workspace::GoBack, - &focus_handle, - window, - cx, - ) - } + .style(ui::ButtonStyle::Subtle), + { + let focus_handle = focus_handle.clone(); + move |window, cx| { + Tooltip::for_action_in( + "Toggle Panel Menu", + &ToggleNavigationMenu, + &focus_handle, + window, + cx, + ) + } + }, + ) + .anchor(Corner::TopLeft) + .with_handle(self.assistant_navigation_menu_handle.clone()) + .menu({ + let menu = self.assistant_navigation_menu.clone(); + move |window, cx| { + if let Some(menu) = menu.as_ref() { + menu.update(cx, |_, cx| { + cx.defer_in(window, |menu, window, cx| { + menu.rebuild(window, cx); + }); + }) + } + menu.clone() + } + }), + ); + + let agent_extra_menu = PopoverMenu::new("assistant-menu") + .trigger_with_tooltip( + IconButton::new("new", IconName::Ellipsis) + .icon_size(IconSize::Small) + .style(ButtonStyle::Subtle), + Tooltip::text("Toggle Agent Menu"), + ) + .anchor(Corner::TopRight) + .with_handle(self.assistant_dropdown_menu_handle.clone()) + .menu(move |window, cx| { + Some(ContextMenu::build(window, cx, |menu, _window, _cx| { + menu.when(!is_empty, |menu| { + menu.action( + "Start New From Summary", + Box::new(NewThread { + from_thread_id: Some(thread_id.clone()), + }), + ) + .separator() + }) + .action("New Text Thread", NewTextThread.boxed_clone()) + .action("Rules Library", Box::new(OpenRulesLibrary::default())) + .action("Settings", Box::new(OpenConfiguration)) + .separator() + .header("MCPs") + .action( + "View Server Extensions", + Box::new(zed_actions::Extensions { + category_filter: Some( + zed_actions::ExtensionCategoryFilter::ContextServers, + ), }), - ), - ), - _ => None, - }; + ) + .action("Add Custom Server", Box::new(AddContextServer)) + })) + }); h_flex() .id("assistant-toolbar") @@ -1177,18 +1405,22 @@ impl AssistantPanel { .border_color(cx.theme().colors().border) .child( h_flex() - .w_full() + .size_full() + .pl_1() .gap_1() - .children(go_back_button) + .child(match &self.active_view { + ActiveView::History | ActiveView::Configuration => go_back_button, + _ => recent_entries_menu, + }) .child(self.render_title_view(window, cx)), ) .child( h_flex() .h_full() .gap_2() - .when(show_token_count, |parent| + .when(show_token_count, |parent| { parent.children(self.render_token_count(&thread, cx)) - ) + }) .child( h_flex() .h_full() @@ -1216,72 +1448,7 @@ impl AssistantPanel { ); }), ) - .child( - IconButton::new("open-history", IconName::HistoryRerun) - .icon_size(IconSize::Small) - .toggle_state(is_history) - .selected_icon_color(Color::Accent) - .tooltip({ - let focus_handle = self.focus_handle(cx); - move |window, cx| { - Tooltip::for_action_in( - "History", - &OpenHistory, - &focus_handle, - window, - cx, - ) - } - }) - .on_click(move |_event, window, cx| { - window.dispatch_action(OpenHistory.boxed_clone(), cx); - }), - ) - .child( - PopoverMenu::new("assistant-menu") - .trigger_with_tooltip( - IconButton::new("new", IconName::Ellipsis) - .icon_size(IconSize::Small) - .style(ButtonStyle::Subtle), - Tooltip::text("Toggle Agent Menu"), - ) - .anchor(Corner::TopRight) - .with_handle(self.assistant_dropdown_menu_handle.clone()) - .menu(move |window, cx| { - Some(ContextMenu::build( - window, - cx, - |menu, _window, _cx| { - menu - .when(!is_empty, |menu| { - menu.action( - "Start New From Summary", - Box::new(NewThread { - from_thread_id: Some(thread_id.clone()), - }), - ).separator() - }) - .action( - "New Text Thread", - NewTextThread.boxed_clone(), - ) - .action("Rules Library", Box::new(OpenRulesLibrary::default())) - .action("Settings", Box::new(OpenConfiguration)) - .separator() - .header("MCPs") - .action( - "View Server Extensions", - Box::new(zed_actions::Extensions { - category_filter: Some( - zed_actions::ExtensionCategoryFilter::ContextServers, - ), - }), - ) - .action("Add Custom Server", Box::new(AddContextServer)) - }, - )) - }), - ), + .child(agent_extra_menu), ), ) } @@ -1982,6 +2149,7 @@ impl Render for AssistantPanel { .on_action(cx.listener(Self::deploy_rules_library)) .on_action(cx.listener(Self::open_agent_diff)) .on_action(cx.listener(Self::go_back)) + .on_action(cx.listener(Self::toggle_navigation_menu)) .child(self.render_toolbar(window, cx)) .map(|parent| match &self.active_view { ActiveView::Thread { .. } => parent @@ -2066,7 +2234,7 @@ impl AssistantPanelDelegate for ConcreteAssistantPanelDelegate { fn open_saved_context( &self, workspace: &mut Workspace, - path: std::path::PathBuf, + path: Arc, window: &mut Window, cx: &mut Context, ) -> Task> { diff --git a/crates/agent/src/context_picker.rs b/crates/agent/src/context_picker.rs index e8adb03ef3..98bd00b9bd 100644 --- a/crates/agent/src/context_picker.rs +++ b/crates/agent/src/context_picker.rs @@ -267,7 +267,7 @@ impl ContextPicker { context_picker.update(cx, |this, cx| this.select_entry(entry, window, cx)) }) })) - .keep_open_on_confirm() + .keep_open_on_confirm(true) }); cx.subscribe(&menu, move |_, _, _: &DismissEvent, cx| { diff --git a/crates/agent/src/history_store.rs b/crates/agent/src/history_store.rs index 029fe1b381..be1b621b59 100644 --- a/crates/agent/src/history_store.rs +++ b/crates/agent/src/history_store.rs @@ -1,10 +1,27 @@ -use assistant_context_editor::SavedContextMetadata; +use std::{collections::VecDeque, path::Path}; + +use anyhow::{Context as _, anyhow}; +use assistant_context_editor::{AssistantContext, SavedContextMetadata}; use chrono::{DateTime, Utc}; -use gpui::{Entity, prelude::*}; +use futures::future::{TryFutureExt as _, join_all}; +use gpui::{Entity, Task, prelude::*}; +use serde::{Deserialize, Serialize}; +use smol::future::FutureExt; +use std::time::Duration; +use ui::{App, SharedString}; +use util::ResultExt as _; -use crate::thread_store::{SerializedThreadMetadata, ThreadStore}; +use crate::{ + Thread, + thread::ThreadId, + thread_store::{SerializedThreadMetadata, ThreadStore}, +}; -#[derive(Debug)] +const MAX_RECENTLY_OPENED_ENTRIES: usize = 6; +const NAVIGATION_HISTORY_PATH: &str = "agent-navigation-history.json"; +const SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE: Duration = Duration::from_millis(50); + +#[derive(Clone, Debug)] pub enum HistoryEntry { Thread(SerializedThreadMetadata), Context(SavedContextMetadata), @@ -19,16 +36,40 @@ impl HistoryEntry { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum RecentEntry { + Thread(Entity), + Context(Entity), +} + +impl RecentEntry { + pub(crate) fn summary(&self, cx: &App) -> SharedString { + match self { + RecentEntry::Thread(thread) => thread.read(cx).summary_or_default(), + RecentEntry::Context(context) => context.read(cx).summary_or_default(), + } + } +} + +#[derive(Serialize, Deserialize)] +enum SerializedRecentEntry { + Thread(String), + Context(String), +} + pub struct HistoryStore { thread_store: Entity, context_store: Entity, + recently_opened_entries: VecDeque, _subscriptions: Vec, + _save_recently_opened_entries_task: Task<()>, } impl HistoryStore { pub fn new( thread_store: Entity, context_store: Entity, + initial_recent_entries: impl IntoIterator, cx: &mut Context, ) -> Self { let subscriptions = vec![ @@ -36,10 +77,61 @@ impl HistoryStore { cx.observe(&context_store, |_, _, cx| cx.notify()), ]; + cx.spawn({ + let thread_store = thread_store.downgrade(); + let context_store = context_store.downgrade(); + async move |this, cx| { + let path = paths::data_dir().join(NAVIGATION_HISTORY_PATH); + let contents = cx + .background_spawn(async move { std::fs::read_to_string(path) }) + .await + .context("reading persisted agent panel navigation history")?; + let entries = serde_json::from_str::>(&contents) + .context("deserializing persisted agent panel navigation history")? + .into_iter() + .take(MAX_RECENTLY_OPENED_ENTRIES) + .map(|serialized| match serialized { + SerializedRecentEntry::Thread(id) => thread_store + .update(cx, |thread_store, cx| { + thread_store + .open_thread(&ThreadId::from(id.as_str()), cx) + .map_ok(RecentEntry::Thread) + .boxed() + }) + .unwrap_or_else(|_| async { Err(anyhow!("no thread store")) }.boxed()), + SerializedRecentEntry::Context(id) => context_store + .update(cx, |context_store, cx| { + context_store + .open_local_context(Path::new(&id).into(), cx) + .map_ok(RecentEntry::Context) + .boxed() + }) + .unwrap_or_else(|_| async { Err(anyhow!("no context store")) }.boxed()), + }); + let entries = join_all(entries) + .await + .into_iter() + .filter_map(|result| result.log_err()) + .collect::>(); + + this.update(cx, |this, _| { + this.recently_opened_entries.extend(entries); + this.recently_opened_entries + .truncate(MAX_RECENTLY_OPENED_ENTRIES); + }) + .ok(); + + anyhow::Ok(()) + } + }) + .detach_and_log_err(cx); + Self { thread_store, context_store, + recently_opened_entries: initial_recent_entries.into_iter().collect(), _subscriptions: subscriptions, + _save_recently_opened_entries_task: Task::ready(()), } } @@ -69,4 +161,57 @@ impl HistoryStore { pub fn recent_entries(&self, limit: usize, cx: &mut Context) -> Vec { self.entries(cx).into_iter().take(limit).collect() } + + fn save_recently_opened_entries(&mut self, cx: &mut Context) { + let serialized_entries = self + .recently_opened_entries + .iter() + .filter_map(|entry| match entry { + RecentEntry::Context(context) => Some(SerializedRecentEntry::Context( + context.read(cx).path()?.to_str()?.to_owned(), + )), + RecentEntry::Thread(thread) => Some(SerializedRecentEntry::Thread( + thread.read(cx).id().to_string(), + )), + }) + .collect::>(); + + self._save_recently_opened_entries_task = cx.spawn(async move |_, cx| { + cx.background_executor() + .timer(SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE) + .await; + cx.background_spawn(async move { + let path = paths::data_dir().join(NAVIGATION_HISTORY_PATH); + let content = serde_json::to_string(&serialized_entries)?; + std::fs::write(path, content)?; + anyhow::Ok(()) + }) + .await + .log_err(); + }); + } + + pub fn push_recently_opened_entry(&mut self, entry: RecentEntry, cx: &mut Context) { + self.recently_opened_entries + .retain(|old_entry| old_entry != &entry); + self.recently_opened_entries.push_front(entry); + self.recently_opened_entries + .truncate(MAX_RECENTLY_OPENED_ENTRIES); + self.save_recently_opened_entries(cx); + } + + pub fn remove_recently_opened_entry(&mut self, entry: &RecentEntry, cx: &mut Context) { + self.recently_opened_entries + .retain(|old_entry| old_entry != entry); + self.save_recently_opened_entries(cx); + } + + pub fn recently_opened_entries(&self, _cx: &mut Context) -> VecDeque { + #[cfg(debug_assertions)] + if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() { + return VecDeque::new(); + } + + self.recently_opened_entries.clone() + } } diff --git a/crates/agent/src/thread_history.rs b/crates/agent/src/thread_history.rs index ecf5e958a7..cfd50a2dd5 100644 --- a/crates/agent/src/thread_history.rs +++ b/crates/agent/src/thread_history.rs @@ -270,9 +270,9 @@ impl ThreadHistory { fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { if let Some(entry) = self.get_match(self.selected_index) { let task_result = match entry { - HistoryEntry::Thread(thread) => self - .assistant_panel - .update(cx, move |this, cx| this.open_thread(&thread.id, window, cx)), + HistoryEntry::Thread(thread) => self.assistant_panel.update(cx, move |this, cx| { + this.open_thread_by_id(&thread.id, window, cx) + }), HistoryEntry::Context(context) => { self.assistant_panel.update(cx, move |this, cx| { this.open_saved_prompt_editor(context.path.clone(), window, cx) @@ -525,7 +525,8 @@ impl RenderOnce for PastThread { move |_event, window, cx| { assistant_panel .update(cx, |this, cx| { - this.open_thread(&id, window, cx).detach_and_log_err(cx); + this.open_thread_by_id(&id, window, cx) + .detach_and_log_err(cx); }) .ok(); } diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 7a8717b590..d19bb9f396 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -33,6 +33,7 @@ use settings::{Settings, update_settings_file}; use smol::stream::StreamExt; use std::ops::Range; +use std::path::Path; use std::{ops::ControlFlow, path::PathBuf, sync::Arc}; use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use ui::{ContextMenu, PopoverMenu, Tooltip, prelude::*}; @@ -1080,7 +1081,7 @@ impl AssistantPanel { pub fn open_saved_context( &mut self, - path: PathBuf, + path: Arc, window: &mut Window, cx: &mut Context, ) -> Task> { @@ -1391,7 +1392,7 @@ impl AssistantPanelDelegate for ConcreteAssistantPanelDelegate { fn open_saved_context( &self, workspace: &mut Workspace, - path: PathBuf, + path: Arc, window: &mut Window, cx: &mut Context, ) -> Task> { diff --git a/crates/assistant_context_editor/src/context.rs b/crates/assistant_context_editor/src/context.rs index 5f5a6e8ee8..f29bcbe753 100644 --- a/crates/assistant_context_editor/src/context.rs +++ b/crates/assistant_context_editor/src/context.rs @@ -35,7 +35,7 @@ use std::{ fmt::Debug, iter, mem, ops::Range, - path::{Path, PathBuf}, + path::Path, str::FromStr as _, sync::Arc, time::{Duration, Instant}, @@ -46,7 +46,7 @@ use ui::IconName; use util::{ResultExt, TryFutureExt, post_inc}; use uuid::Uuid; -#[derive(Clone, Eq, PartialEq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub struct ContextId(String); impl ContextId { @@ -648,7 +648,7 @@ pub struct AssistantContext { pending_token_count: Task>, pending_save: Task>, pending_cache_warming_task: Task>, - path: Option, + path: Option>, _subscriptions: Vec, telemetry: Option>, language_registry: Arc, @@ -839,7 +839,7 @@ impl AssistantContext { pub fn deserialize( saved_context: SavedContext, - path: PathBuf, + path: Arc, language_registry: Arc, prompt_builder: Arc, slash_commands: Arc, @@ -1147,8 +1147,8 @@ impl AssistantContext { self.prompt_builder.clone() } - pub fn path(&self) -> Option<&Path> { - self.path.as_deref() + pub fn path(&self) -> Option<&Arc> { + self.path.as_ref() } pub fn summary(&self) -> Option<&ContextSummary> { @@ -3181,7 +3181,7 @@ impl AssistantContext { fs.atomic_write(new_path.clone(), serde_json::to_string(&context).unwrap()) .await?; if let Some(old_path) = old_path { - if new_path != old_path { + if new_path.as_path() != old_path.as_ref() { fs.remove_file( &old_path, RemoveOptions { @@ -3193,7 +3193,7 @@ impl AssistantContext { } } - this.update(cx, |this, _| this.path = Some(new_path))?; + this.update(cx, |this, _| this.path = Some(new_path.into()))?; } Ok(()) @@ -3589,6 +3589,6 @@ impl SavedContextV0_1_0 { #[derive(Debug, Clone)] pub struct SavedContextMetadata { pub title: String, - pub path: PathBuf, + pub path: Arc, pub mtime: chrono::DateTime, } diff --git a/crates/assistant_context_editor/src/context/context_tests.rs b/crates/assistant_context_editor/src/context/context_tests.rs index 8293744831..12c8003f6b 100644 --- a/crates/assistant_context_editor/src/context/context_tests.rs +++ b/crates/assistant_context_editor/src/context/context_tests.rs @@ -959,7 +959,7 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) { let deserialized_context = cx.new(|cx| { AssistantContext::deserialize( serialized_context, - Default::default(), + Path::new("").into(), registry.clone(), prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), @@ -1120,7 +1120,7 @@ async fn test_serialization(cx: &mut TestAppContext) { let deserialized_context = cx.new(|cx| { AssistantContext::deserialize( serialized_context, - Default::default(), + Path::new("").into(), registry.clone(), prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), diff --git a/crates/assistant_context_editor/src/context_editor.rs b/crates/assistant_context_editor/src/context_editor.rs index 975789f01b..bcae8bf777 100644 --- a/crates/assistant_context_editor/src/context_editor.rs +++ b/crates/assistant_context_editor/src/context_editor.rs @@ -48,7 +48,14 @@ use project::{Project, Worktree}; use rope::Point; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore, update_settings_file}; -use std::{any::TypeId, cmp, ops::Range, path::PathBuf, sync::Arc, time::Duration}; +use std::{ + any::TypeId, + cmp, + ops::Range, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; use text::SelectionGoal; use ui::{ ButtonLike, Disclosure, ElevationIndex, KeyBinding, PopoverMenuHandle, TintColor, Tooltip, @@ -139,7 +146,7 @@ pub trait AssistantPanelDelegate { fn open_saved_context( &self, workspace: &mut Workspace, - path: PathBuf, + path: Arc, window: &mut Window, cx: &mut Context, ) -> Task>; diff --git a/crates/assistant_context_editor/src/context_store.rs b/crates/assistant_context_editor/src/context_store.rs index a7b33c279b..e1ea41e40a 100644 --- a/crates/assistant_context_editor/src/context_store.rs +++ b/crates/assistant_context_editor/src/context_store.rs @@ -20,14 +20,7 @@ use prompt_store::PromptBuilder; use regex::Regex; use rpc::AnyProtoClient; use std::sync::LazyLock; -use std::{ - cmp::Reverse, - ffi::OsStr, - mem, - path::{Path, PathBuf}, - sync::Arc, - time::Duration, -}; +use std::{cmp::Reverse, ffi::OsStr, mem, path::Path, sync::Arc, time::Duration}; use util::{ResultExt, TryFutureExt}; pub(crate) fn init(client: &AnyProtoClient) { @@ -430,7 +423,7 @@ impl ContextStore { pub fn open_local_context( &mut self, - path: PathBuf, + path: Arc, cx: &Context, ) -> Task>> { if let Some(existing_context) = self.loaded_context_for_path(&path, cx) { @@ -478,7 +471,7 @@ impl ContextStore { pub fn delete_local_context( &mut self, - path: PathBuf, + path: Arc, cx: &mut Context, ) -> Task> { let fs = self.fs.clone(); @@ -501,7 +494,7 @@ impl ContextStore { != Some(&path) }); this.contexts_metadata - .retain(|context| context.path != path); + .retain(|context| context.path.as_ref() != path.as_ref()); })?; Ok(()) @@ -511,7 +504,7 @@ impl ContextStore { fn loaded_context_for_path(&self, path: &Path, cx: &App) -> Option> { self.contexts.iter().find_map(|context| { let context = context.upgrade()?; - if context.read(cx).path() == Some(path) { + if context.read(cx).path().map(Arc::as_ref) == Some(path) { Some(context) } else { None @@ -794,7 +787,7 @@ impl ContextStore { { contexts.push(SavedContextMetadata { title: title.to_string(), - path, + path: path.into(), mtime: metadata.mtime.timestamp_for_user().into(), }); } diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 9a19109444..f7b15dbfcd 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -156,6 +156,7 @@ pub enum IconName { MailOpen, Maximize, Menu, + MenuAlt, MessageBubbles, Mic, MicMute, diff --git a/crates/menu/src/menu.rs b/crates/menu/src/menu.rs index 7c8cc7abb0..10eeeff8ca 100644 --- a/crates/menu/src/menu.rs +++ b/crates/menu/src/menu.rs @@ -19,6 +19,7 @@ actions!( SelectNext, SelectFirst, SelectLast, - Restart + Restart, + EndSlot, ] ); diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 5904a4d558..a076a08aef 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -1,6 +1,6 @@ use crate::{ - Icon, IconName, IconSize, KeyBinding, Label, List, ListItem, ListSeparator, ListSubHeader, - h_flex, prelude::*, utils::WithRemSize, v_flex, + Icon, IconButtonShape, IconName, IconSize, KeyBinding, Label, List, ListItem, ListSeparator, + ListSubHeader, h_flex, prelude::*, utils::WithRemSize, v_flex, }; use gpui::{ Action, AnyElement, App, AppContext as _, DismissEvent, Entity, EventEmitter, FocusHandle, @@ -11,6 +11,8 @@ use settings::Settings; use std::{rc::Rc, time::Duration}; use theme::ThemeSettings; +use super::Tooltip; + pub enum ContextMenuItem { Separator, Header(SharedString), @@ -47,6 +49,9 @@ pub struct ContextMenuEntry { action: Option>, disabled: bool, documentation_aside: Option AnyElement>>, + end_slot_icon: Option, + end_slot_title: Option, + end_slot_handler: Option, &mut Window, &mut App)>>, } impl ContextMenuEntry { @@ -62,6 +67,9 @@ impl ContextMenuEntry { action: None, disabled: false, documentation_aside: None, + end_slot_icon: None, + end_slot_title: None, + end_slot_handler: None, } } @@ -133,10 +141,13 @@ pub struct ContextMenu { selected_index: Option, delayed: bool, clicked: bool, + end_slot_action: Option>, + key_context: SharedString, _on_blur_subscription: Subscription, keep_open_on_confirm: bool, eager: bool, documentation_aside: Option<(usize, Rc AnyElement>)>, + fixed_width: Option, } impl Focusable for ContextMenu { @@ -172,10 +183,13 @@ impl ContextMenu { selected_index: None, delayed: false, clicked: false, + key_context: "menu".into(), _on_blur_subscription, keep_open_on_confirm: false, eager: false, documentation_aside: None, + fixed_width: None, + end_slot_action: None, }, window, cx, @@ -212,10 +226,13 @@ impl ContextMenu { selected_index: None, delayed: false, clicked: false, + key_context: "menu".into(), _on_blur_subscription, keep_open_on_confirm: true, eager: false, documentation_aside: None, + fixed_width: None, + end_slot_action: None, }, window, cx, @@ -245,10 +262,13 @@ impl ContextMenu { selected_index: None, delayed: false, clicked: false, + key_context: "menu".into(), _on_blur_subscription, keep_open_on_confirm: false, eager: true, documentation_aside: None, + fixed_width: None, + end_slot_action: None, }, window, cx, @@ -263,7 +283,7 @@ impl ContextMenu { /// /// This only works if the [`ContextMenu`] was constructed using [`ContextMenu::build_persistent`]. Otherwise it is /// a no-op. - fn rebuild(&mut self, window: &mut Window, cx: &mut Context) { + pub fn rebuild(&mut self, window: &mut Window, cx: &mut Context) { let Some(builder) = self.builder.clone() else { return; }; @@ -279,6 +299,7 @@ impl ContextMenu { selected_index: None, delayed: false, clicked: false, + key_context: "menu".into(), _on_blur_subscription: cx.on_blur( &focus_handle, window, @@ -287,6 +308,8 @@ impl ContextMenu { keep_open_on_confirm: false, eager: false, documentation_aside: None, + fixed_width: None, + end_slot_action: None, }, window, cx, @@ -339,6 +362,36 @@ impl ContextMenu { action, disabled: false, documentation_aside: None, + end_slot_icon: None, + end_slot_title: None, + end_slot_handler: None, + })); + self + } + + pub fn entry_with_end_slot( + mut self, + label: impl Into, + action: Option>, + handler: impl Fn(&mut Window, &mut App) + 'static, + end_slot_icon: IconName, + end_slot_title: SharedString, + end_slot_handler: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + self.items.push(ContextMenuItem::Entry(ContextMenuEntry { + toggle: None, + label: label.into(), + handler: Rc::new(move |_, window, cx| handler(window, cx)), + icon: None, + icon_position: IconPosition::End, + icon_size: IconSize::Small, + icon_color: None, + action, + disabled: false, + documentation_aside: None, + end_slot_icon: Some(end_slot_icon), + end_slot_title: Some(end_slot_title), + end_slot_handler: Some(Rc::new(move |_, window, cx| end_slot_handler(window, cx))), })); self } @@ -362,6 +415,9 @@ impl ContextMenu { action, disabled: false, documentation_aside: None, + end_slot_icon: None, + end_slot_title: None, + end_slot_handler: None, })); self } @@ -413,6 +469,9 @@ impl ContextMenu { icon_color: None, disabled: false, documentation_aside: None, + end_slot_icon: None, + end_slot_title: None, + end_slot_handler: None, })); self } @@ -438,6 +497,9 @@ impl ContextMenu { icon_color: None, disabled: true, documentation_aside: None, + end_slot_icon: None, + end_slot_title: None, + end_slot_handler: None, })); self } @@ -454,12 +516,43 @@ impl ContextMenu { icon_color: None, disabled: false, documentation_aside: None, + end_slot_icon: None, + end_slot_title: None, + end_slot_handler: None, })); self } - pub fn keep_open_on_confirm(mut self) -> Self { - self.keep_open_on_confirm = true; + pub fn keep_open_on_confirm(mut self, keep_open: bool) -> Self { + self.keep_open_on_confirm = keep_open; + self + } + + pub fn trigger_end_slot_handler(&mut self, window: &mut Window, cx: &mut Context) { + let Some(entry) = self.selected_index.and_then(|ix| self.items.get(ix)) else { + return; + }; + let ContextMenuItem::Entry(entry) = entry else { + return; + }; + let Some(handler) = entry.end_slot_handler.as_ref() else { + return; + }; + handler(None, window, cx); + } + + pub fn fixed_width(mut self, width: DefiniteLength) -> Self { + self.fixed_width = Some(width); + self + } + + pub fn end_slot_action(mut self, action: Box) -> Self { + self.end_slot_action = Some(action); + self + } + + pub fn key_context(mut self, context: impl Into) -> Self { + self.key_context = context.into(); self } @@ -492,6 +585,25 @@ impl ContextMenu { cx.emit(DismissEvent); } + pub fn end_slot(&mut self, _: &dyn Action, window: &mut Window, cx: &mut Context) { + let Some(item) = self.selected_index.and_then(|ix| self.items.get(ix)) else { + return; + }; + let ContextMenuItem::Entry(entry) = item else { + return; + }; + let Some(handler) = entry.end_slot_handler.as_ref() else { + return; + }; + handler(None, window, cx); + self.rebuild(window, cx); + cx.notify(); + } + + pub fn clear_selected(&mut self) { + self.selected_index = None; + } + fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context) { if let Some(ix) = self.items.iter().position(|item| item.is_selectable()) { self.select_index(ix, window, cx); @@ -707,7 +819,11 @@ impl ContextMenu { action, disabled, documentation_aside, + end_slot_icon, + end_slot_title, + end_slot_handler, } = entry; + let this = cx.weak_entity(); let handler = handler.clone(); let menu = cx.entity().downgrade(); @@ -733,7 +849,7 @@ impl ContextMenu { *icon_position == IconPosition::Start && toggle.is_none(), |flex| flex.child(Icon::new(*icon_name).size(*icon_size).color(icon_color)), ) - .child(Label::new(label.clone()).color(label_color)) + .child(Label::new(label.clone()).color(label_color).truncate()) .when(*icon_position == IconPosition::End, |flex| { flex.child(Icon::new(*icon_name).size(*icon_size).color(icon_color)) }) @@ -741,6 +857,7 @@ impl ContextMenu { } else { Label::new(label.clone()) .color(label_color) + .truncate() .into_any_element() }; @@ -818,6 +935,56 @@ impl ContextMenu { }, ), ) + .when_some( + end_slot_icon + .as_ref() + .zip(self.end_slot_action.as_ref()) + .zip(end_slot_title.as_ref()) + .zip(end_slot_handler.as_ref()), + |el, (((icon, action), title), handler)| { + el.end_slot( + IconButton::new("end-slot-icon", *icon) + .shape(IconButtonShape::Square) + .tooltip({ + let action_context = self.action_context.clone(); + let title = title.clone(); + let action = action.boxed_clone(); + move |window, cx| { + action_context + .as_ref() + .map(|focus| { + Tooltip::for_action_in( + title.clone(), + &*action, + focus, + window, + cx, + ) + }) + .unwrap_or_else(|| { + Tooltip::for_action( + title.clone(), + &*action, + window, + cx, + ) + }) + } + }) + .on_click({ + let handler = handler.clone(); + move |_, window, cx| { + handler(None, window, cx); + this.update(cx, |this, cx| { + this.rebuild(window, cx); + cx.notify(); + }) + .ok(); + } + }), + ) + }, + ) .on_click({ let context = self.action_context.clone(); let keep_open_on_confirm = self.keep_open_on_confirm; @@ -888,21 +1055,28 @@ impl Render for ContextMenu { .child( v_flex() .id("context-menu") - .min_w(px(200.)) .max_h(vh(0.75, window)) - .flex_1() + .when_some(self.fixed_width, |this, width| { + this.w(width).overflow_x_hidden() + }) + .when(self.fixed_width.is_none(), |this| { + this.min_w(px(200.)).flex_1() + }) .overflow_y_scroll() .track_focus(&self.focus_handle(cx)) .on_mouse_down_out(cx.listener(|this, _, window, cx| { this.cancel(&menu::Cancel, window, cx) })) - .key_context("menu") + .key_context(self.key_context.as_ref()) .on_action(cx.listener(ContextMenu::select_first)) .on_action(cx.listener(ContextMenu::handle_select_last)) .on_action(cx.listener(ContextMenu::select_next)) .on_action(cx.listener(ContextMenu::select_previous)) .on_action(cx.listener(ContextMenu::confirm)) .on_action(cx.listener(ContextMenu::cancel)) + .when_some(self.end_slot_action.as_ref(), |el, action| { + el.on_boxed_action(&**action, cx.listener(ContextMenu::end_slot)) + }) .when(!self.delayed, |mut el| { for item in self.items.iter() { if let ContextMenuItem::Entry(ContextMenuEntry {