use std::collections::HashSet; use std::ops::Deref; use std::sync::Arc; use galaxy_core::send_telemetry_from_app_ctx; use galaxy_util::path::LineAndColumnArg; use galaxyui::elements::{ Align, Border, ChildView, Clipped, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CornerRadius, Dismiss, DispatchEventResult, Empty, EventHandler, Fill, Flex, ParentElement, Radius, SavePosition, Shrinkable, }; use galaxyui::event::KeyState; use galaxyui::keymap::BindingId; use galaxyui::platform::keyboard::KeyCode; use galaxyui::units::{IntoPixels, Pixels}; use galaxyui::{ AppContext, Element, Entity, EntityId, FocusContext, ModelHandle, SingletonEntity, TypedActionView, ViewContext, ViewHandle, WindowId, }; use itertools::Itertools; use lazy_static::lazy_static; use super::super::palette_styles as styles; use super::CommandPaletteMixer; use crate::appearance::Appearance; use crate::drive::CloudObjectTypeAndId; use crate::features::FeatureFlag; use crate::palette::PaletteMode; use crate::root_view::OpenLaunchConfigArg; use crate::search::action::search_item::MatchedBinding; use crate::search::binding_source::{BindingFilterFn, BindingSource}; use crate::search::command_palette::data_sources::DataSourceStore; use crate::search::command_palette::mixer::CommandPaletteItemAction; use crate::search::command_palette::zero_state::{self, Event as ZeroStateEvent, ZeroState}; use crate::search::command_palette::SelectedItems; use crate::search::data_source::QueryResult; use crate::search::result_renderer::QueryResultRenderer; use crate::search::search_bar::{ SearchBar, SearchBarEvent, SearchBarState, SearchResultOrdering, SelectionUpdate, }; use crate::search::QueryFilter; use crate::server::ids::SyncId; use crate::server::telemetry::{LaunchConfigUiLocation, TelemetryEvent}; use crate::session_management::SessionSource; use crate::settings::CtrlTabBehavior; use crate::terminal::keys_settings::KeysSettings; use crate::themes::theme::GalaxyTheme; use crate::view_components::DismissibleToast; use crate::workspace::{active_terminal_in_window, ForkedConversationDestination, WorkspaceAction}; use crate::{send_telemetry_from_ctx, ToastStack}; lazy_static! { /// Set of hardcoded action names that we want to show in the command palette zero state. static ref SUGGESTED_ACTIONS: HashSet<&'static str> = HashSet::from_iter( [ if FeatureFlag::AgentMode.is_enabled() { "input:toggle_input_type" } else { "workspace:toggle_ai_assistant" }, "workspace:show_theme_chooser", "workspace:create_personal_workflow", ] ); } /// Position ID for the command palette list. const PALETTE_LIST_SAVE_POSITION_ID: &str = "command_palette:list"; /// Max number of results to be returned by the search mixer. We set this to an arbitrarily /// large size to minimize performances issues caused by rendering the elements of the palette /// using a [`ClippedScrollable`]. // TODO(alokedesai): Remove once we add a properly viewported element. const MAX_SEARCH_RESULTS: usize = 250; /// Number of recently selected items to show in the zero state. const NUM_RECENT_ITEMS_IN_ZERO_STATE: usize = 3; struct ViewState { clipped_scroll_state: ClippedScrollStateHandle, } #[derive(Debug)] pub enum Action { ResultClicked { action: CommandPaletteItemAction }, Close, CtrlPressed(bool), } #[derive(Debug)] pub enum Event { Close { accepted_action_type: Option<&'static str>, }, /// Execute the workflow identified by `id`. ExecuteWorkflow { id: SyncId }, /// Invoke the env vars identified by `id`. InvokeEnvironmentVariables { id: SyncId }, /// Open a notebook identified by `id`. OpenNotebook { id: SyncId }, /// View the relevant object in the Warp Drive sidebar. ViewInWarpDrive { id: CloudObjectTypeAndId }, /// Open a file at the given path. OpenFile { path: String, line_and_column_arg: Option, }, /// Open a directory at the given path. OpenDirectory { path: String }, } #[derive(Debug, Clone, Default)] pub enum NavigationMode { #[default] Normal, // Palette was entered via ctrl-tab for quick session switching. CtrlTab, } /// A view that renders the command palette and allows users to optionally apply a [`QueryFilter`] /// to filter results. pub struct View { pub search_bar: ViewHandle>, search_bar_state: ModelHandle>, state: ViewState, binding_source: ModelHandle, /// Model to lists the current active session. session_source: ModelHandle, zero_state_handle: ViewHandle, /// Placeholder element to render when no results are found. placeholder_query_renderer: QueryResultRenderer, /// List of [`BindingId`]s that should be shown in the zero state as "suggested" items. suggested_binding_ids: Vec, /// Store of all the data sources that should be used for the [`SearchMixer`]. pub data_source_store: ModelHandle, zero_state_items: ModelHandle, /// The current navigation mode. navigation_mode: NavigationMode, /// Whether the active session is a shared session viewer. /// This is set by the workspace when opening the palette. is_shared_session_viewer: bool, } impl Entity for View { type Event = Event; } impl TypedActionView for View { type Action = Action; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { Action::ResultClicked { action } => { self.handle_result_accepted(action.clone(), ctx); } Action::Close => self.close(ctx, None), Action::CtrlPressed(pressed) => { if !*pressed && matches!(self.navigation_mode, NavigationMode::CtrlTab) { // Accept the selected item and reset the navigation mode on release of Ctrl key. self.accept_selected_item(ctx); } } } } } impl galaxyui::View for View { fn ui_name() -> &'static str { "CommandPaletteView" } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let body = if self.search_bar.as_ref(app).should_show_zero_state(app) { ChildView::new(&self.zero_state_handle).finish() } else { self.render_palette_list(theme, app) }; let mut palette = Flex::column(); if matches!(self.navigation_mode, NavigationMode::Normal) { // Don't show the search bar when navigating with ctrl-tab. palette.add_child(self.render_search_bar()); } palette.add_child(Shrinkable::new(1., body).finish()); EventHandler::new( Align::new( Dismiss::new( Container::new( ConstrainedBox::new(palette.finish()) .with_width(styles::PALETTE_WIDTH) .with_max_height(styles::PALETTE_HEIGHT) .finish(), ) .with_background(theme.surface_2()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .with_border(Border::all(1.0).with_border_fill(theme.outline())) .with_margin_top(117.) .with_padding_bottom(10.) .with_drop_shadow(*styles::DROP_SHADOW) .finish(), ) .on_dismiss(|ctx, _app| ctx.dispatch_typed_action(Action::Close)) .prevent_interaction_with_other_elements() .finish(), ) .top_center() .finish(), ) .on_modifier_state_changed(|ctx, _, key_code, state| { if matches!(key_code, KeyCode::ControlLeft | KeyCode::ControlRight) { ctx.dispatch_typed_action(Action::CtrlPressed(matches!(state, KeyState::Pressed))); } DispatchEventResult::StopPropagation }) .finish() } fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { if focus_ctx.is_self_focused() { ctx.focus(&self.search_bar); } } } impl View { pub fn new(navigation_mode: NavigationMode, ctx: &mut ViewContext) -> Self { let search_bar_state = ctx.add_model(|_ctx| { SearchBarState::new(SearchResultOrdering::TopDown).with_max_results(MAX_SEARCH_RESULTS) }); ctx.subscribe_to_model(&search_bar_state, |me, _, event, ctx| { me.handle_search_bar_event(event, ctx); }); let binding_source = ctx.add_model(|_| BindingSource::None); let session_source = ctx.add_model(|_| SessionSource::None); let data_source_store = ctx.add_model(|ctx| { DataSourceStore::new(binding_source.clone(), session_source.clone(), ctx) }); ctx.observe(&binding_source, |me, _, ctx| { me.on_binding_source_changed(ctx) }); ctx.observe(&session_source, |me, _, ctx| { me.on_session_source_changed(ctx) }); let zero_state_items = ctx.add_model(|_| zero_state::Items::new()); let zero_state = ctx.add_typed_action_view(|ctx| ZeroState::new(zero_state_items.clone(), ctx)); ctx.subscribe_to_view(&zero_state, |me, _, event, ctx| { me.handle_zero_state_event(event, ctx); }); ctx.observe(&search_bar_state, |_, _, ctx| ctx.notify()); // Compute the list of binding IDs that we should show the suggested actions for based. Key // bindings are only registered once, so we only need to do this in the constructor. let suggested_binding_ids = SUGGESTED_ACTIONS .iter() .flat_map(|name| ctx.get_binding_by_name(name).map(|binding| binding.id)) .collect_vec(); let mixer = ctx.add_model(|_| CommandPaletteMixer::new()); data_source_store.update(ctx, |store, ctx| { store.reset_search_mixer(mixer.clone(), false, ctx); ctx.notify(); }); let ui_font_family = Appearance::as_ref(ctx).ui_font_family(); let search_bar = ctx.add_typed_action_view(|ctx| { SearchBar::new( mixer.clone(), search_bar_state.clone(), "Search for a command", Self::create_query_result_renderer, ctx, ) .with_font_family(ui_font_family, ctx) }); ctx.subscribe_to_view(&search_bar, |me, _, event, ctx| { me.handle_search_bar_event(event, ctx); }); let placeholder_element = QueryResultRenderer::new( MatchedBinding::placeholder("No results found".into()).into(), "command_palette:no_results".into(), |_, _, _| {}, *styles::QUERY_RESULT_RENDERER_STYLES, ); Self { navigation_mode, search_bar, search_bar_state, state: ViewState { clipped_scroll_state: Default::default(), }, binding_source, session_source, data_source_store, zero_state_handle: zero_state, placeholder_query_renderer: placeholder_element, suggested_binding_ids, zero_state_items, is_shared_session_viewer: false, } } #[cfg(feature = "integration_tests")] /// Returns the current search results within the command palette. Used within integration tests /// to verify the command palette returns the correct results when launch configurations or the /// current session changes. pub fn search_results<'a>( &'a self, app: &'a AppContext, ) -> impl Iterator> + 'a { let query_results = self.search_bar_state.as_ref(app).query_result_renderers(); query_results .into_iter() .flat_map(|results| results.iter()) .map(|item| &item.search_result) } /// Set the active query filter in the search bar to be `filter`. pub fn set_active_query_filter(&mut self, filter: QueryFilter, ctx: &mut ViewContext) { self.search_bar.update(ctx, |view, ctx| { view.set_query_filter(Some((filter, filter.filter_atom().primary_text)), ctx) }); ctx.notify(); } pub fn set_initial_selection_offset(&mut self, offset: isize, ctx: &mut ViewContext) { self.search_bar_state.update(ctx, move |state, _ctx| { state.offset_initial_selection_by(offset); }); } pub fn select_next_item(&mut self, ctx: &mut ViewContext) { self.search_bar.update(ctx, |search_bar, ctx| { search_bar.handle_selection_update(SelectionUpdate::Down, ctx); }); ctx.notify(); } pub fn select_prev_item(&mut self, ctx: &mut ViewContext) { self.search_bar.update(ctx, |search_bar, ctx| { search_bar.handle_selection_update(SelectionUpdate::Up, ctx); }); ctx.notify(); } fn accept_selected_item(&mut self, ctx: &mut ViewContext) { if let Some(result) = self.search_bar_state.as_ref(ctx).selected_result() { self.handle_result_accepted(result.accept_result().clone(), ctx); } } /// Returns the active query filters pub fn active_query_filter(&self, app: &AppContext) -> Option { self.search_bar_state.as_ref(app).active_query_filter() } pub fn is_mode_enabled(&self, mode: PaletteMode, app: &AppContext) -> bool { let Some(active_query_filter) = self.active_query_filter(app) else { return false; }; matches!( (mode, active_query_filter), (PaletteMode::Command, QueryFilter::Actions) | (PaletteMode::Navigation, QueryFilter::Sessions) | (PaletteMode::LaunchConfig, QueryFilter::LaunchConfigurations) | (PaletteMode::Files, QueryFilter::Files) | (PaletteMode::Conversations, QueryFilter::Conversations) | (PaletteMode::WarpDrive, QueryFilter::Drive) ) } /// Sets the new [`SessionSource`]. pub fn set_session_source( &mut self, session_source: SessionSource, ctx: &mut ViewContext, ) { self.session_source.update(ctx, |binding_source, ctx| { *binding_source = session_source; ctx.notify(); }); } /// Sets whether the active session is a shared session viewer. /// This should be called by the workspace before opening the palette. pub fn set_is_shared_session_viewer(&mut self, is_viewer: bool, ctx: &mut ViewContext) { self.is_shared_session_viewer = is_viewer; let mixer = self.search_bar.as_ref(ctx).mixer().clone(); self.data_source_store.update(ctx, |store, ctx| { store.reset_search_mixer(mixer.clone(), self.is_shared_session_viewer, ctx); ctx.notify(); }); } fn handle_zero_state_event(&mut self, event: &ZeroStateEvent, ctx: &mut ViewContext) { match event { ZeroStateEvent::FilterChipSelected { filter } => { self.set_active_query_filter(*filter, ctx); } } } fn create_query_result_renderer( index: usize, result: QueryResult, ) -> QueryResultRenderer { QueryResultRenderer::new( result, Self::query_result_save_position_id(index), |_result_index, action, event_ctx| { event_ctx.dispatch_typed_action(Action::ResultClicked { action }) }, *styles::QUERY_RESULT_RENDERER_STYLES, ) } /// Returns the position ID for a query result at `index`. fn query_result_save_position_id(index: usize) -> String { format!("command_palette:query_result:{index}") } /// Sets the set the binding source to produce the list of command bindings in the current /// context. pub fn set_binding_source( &mut self, window_id: WindowId, view_id: EntityId, ctx: &mut ViewContext, ) { let ctrl_tab_behavior = *KeysSettings::as_ref(ctx).ctrl_tab_behavior; let binding_filter_fn: BindingFilterFn = if matches!(ctrl_tab_behavior, CtrlTabBehavior::CycleMostRecentSession) { Some(Arc::new(|binding| { if let Some(action) = &binding.action { // Filter out the cycle next/prev session actions from the palette if ctrl-tab // behavior is set to cycle most/least recent session. Clicking on them or hitting enter // doesn't make sense because the action needs to be triggered from a ctrl-tab only (with // ctrl key held down). !matches!( action.as_any().downcast_ref::(), Some(WorkspaceAction::CycleNextSession) | Some(WorkspaceAction::CyclePrevSession) ) } else { true } })) } else { None }; self.binding_source.update(ctx, move |binding_source, ctx| { *binding_source = BindingSource::View { window_id, view_id, binding_filter_fn, }; ctx.notify(); }); } fn on_binding_source_changed(&mut self, ctx: &mut ViewContext) { let data_source_store = self.data_source_store.as_ref(ctx); // The binding source changed, recompute the bindings that could be suggested given the // current set of bindings that are focused. let suggested_query_renderers = self .suggested_binding_ids .iter() .filter_map(|binding_id| { data_source_store.query_result_for_binding_id(*binding_id, ctx) }) .enumerate() .map(|(idx, item)| Self::create_query_result_renderer(idx, item)) .collect_vec(); self.zero_state_items.update(ctx, |items, ctx| { items.set_suggested_items(suggested_query_renderers, ctx); }); self.compute_recent_items_for_zero_state(ctx); } fn on_session_source_changed(&mut self, ctx: &mut ViewContext) { self.compute_recent_items_for_zero_state(ctx); self.search_bar.update(ctx, |search_bar, ctx| { search_bar.run_query(ctx); }); } fn render_search_bar(&self) -> Box { Container::new( ConstrainedBox::new(Clipped::new(ChildView::new(&self.search_bar).finish()).finish()) .finish(), ) .with_vertical_padding(styles::SEARCH_BAR_PADDING_VERTICAL) .with_horizontal_padding(styles::RESULT_PADDING_HORIZONTAL) .finish() } /// Handles events emitted by the search bar. fn handle_search_bar_event( &mut self, event: &SearchBarEvent, ctx: &mut ViewContext, ) { match event { SearchBarEvent::Close => { self.close(ctx, None); } SearchBarEvent::BufferCleared { .. } => {} SearchBarEvent::ResultAccepted { action, .. } => { self.handle_result_accepted(action.clone(), ctx); } SearchBarEvent::ResultSelected { index } => { self.scroll_selected_index_into_view(*index, ctx); ctx.notify(); } // The QueryFilterChanged event is deferred (fires after the current // view update returns). When switching to the Files filter, // open_files_palette has already called reset_search_mixer and // run_query. Resetting the mixer here would abort the in-flight // async file search without re-running it, leaving the palette // empty. SearchBarEvent::QueryFilterChanged { .. } => {} SearchBarEvent::SelectionUpdateInZeroState { selection_update } => { self.zero_state_items.update(ctx, |items, ctx| { items.handle_selection_update(*selection_update, ctx); }) } SearchBarEvent::EnterInZeroState { modified_enter } => { if let Some(query_result) = self.zero_state_items.as_ref(ctx).selected_item() { let action = if *modified_enter { query_result.search_result.execute_result() } else { query_result.search_result.accept_result() }; self.handle_result_accepted(action, ctx); } } } } /// Scrolls the query result at `index` into view. fn scroll_selected_index_into_view(&self, index: usize, ctx: &mut ViewContext) { let list_bounds = ctx.element_position_by_id(PALETTE_LIST_SAVE_POSITION_ID); let item_bounds = ctx.element_position_by_id(Self::query_result_save_position_id(index).as_str()); let Some((viewport_bounds, position_size)) = list_bounds.zip(item_bounds) else { return; }; // If the selected index is contained within the viewport, there is no need to change the // scroll position. if viewport_bounds.contains_rect(position_size) { return; } let scroll_delta = if position_size.max_y() > viewport_bounds.max_y() { // The item is below the viewport. Update the scroll position by the number of pixels // the bottom of the item is below the viewport. position_size.max_y() - viewport_bounds.max_y() } else { // The item is above the viewport. Update the scroll position by the number of pixels // the top of the item is above the viewport. position_size.min_y() - viewport_bounds.min_y() }; let scroll_top = self.state.clipped_scroll_state.scroll_start(); self.state .clipped_scroll_state .scroll_to(scroll_top + scroll_delta.into_pixels()); ctx.notify(); } fn close(&mut self, ctx: &mut ViewContext, accepted_action_type: Option<&'static str>) { let buffer_length = self.search_bar.as_ref(ctx).query(ctx).len(); let filter = self.active_query_filter(ctx); let event = if let Some(result_type) = accepted_action_type { TelemetryEvent::PaletteSearchResultAccepted { result_type, filter, buffer_length, } } else { TelemetryEvent::PaletteSearchExited { filter, buffer_length, } }; send_telemetry_from_ctx!(event, ctx); self.state.clipped_scroll_state = Default::default(); self.reset(ctx); // Some of the actions that are dispatched before closing can close the Window (e.g. "Close // Tab" on the final tab of the window). Confirm that the Window still exists before trying // to update the view. if ctx.root_view_id(ctx.window_id()).is_some() { ctx.emit(Event::Close { accepted_action_type, }); } } pub fn reset(&mut self, ctx: &mut ViewContext) { self.state.clipped_scroll_state.scroll_to(Pixels::zero()); self.search_bar.update(ctx, |search_bar, ctx| { search_bar.reset( None, /* initial_query */ None, /* query_filter */ SearchResultOrdering::TopDown, ctx, ) }); ctx.notify(); } /// Recompute the items shown in the "recent" section for the zero state. We compute this after /// the [`BindingSource`] and [`SessionSource`] models have changed since both data sources /// require reading state about a current view from the UI framework. However, a quirk of the UI /// framework is that the framework _removes_ a view from its internal state before calling an /// action handler to get around Rust lifetime issues (it reinserts the view after the handler /// has been called). This creates a dependency where we would compute an incorrect set of /// recent results if we tried to call this function from a `Workspace` handler -- the app would /// not know about the `Workspace` and would return an incomplete set of bindings and sessions. /// /// As a workaround, we recompute these when the [`BindingSource`] and [`SessionSource`] models /// change. The model update handler is called after any view handlers, so it won't run into the /// same restrictions. fn compute_recent_items_for_zero_state(&mut self, ctx: &mut ViewContext) { let data_source_store = self.data_source_store.as_ref(ctx); let selected_items = SelectedItems::as_ref(ctx); let query_results = selected_items .iter() .filter_map(|summary| data_source_store.query_result_from_summary(summary, ctx)) .enumerate() .map(|(idx, item)| Self::create_query_result_renderer(idx, item)) .take(NUM_RECENT_ITEMS_IN_ZERO_STATE) .collect_vec(); self.zero_state_items.update(ctx, |items, ctx| { items.set_recent_items(query_results, ctx); }); } /// Inserts `query` into the search bar. pub fn insert_query_text(&mut self, query: &str, ctx: &mut ViewContext) { self.search_bar.update(ctx, |search_bar, ctx| { search_bar.insert_query_text(query, ctx); }) } fn render_palette_list(&self, theme: &GalaxyTheme, app: &AppContext) -> Box { match self.search_bar_state.as_ref(app).query_result_renderers() { None => Empty::new().finish(), Some(renderers) if renderers.is_empty() => { self.placeholder_query_renderer .render(0, true /* is_selected */, app) } Some(renderers) => { let selected_index = self.search_bar_state.as_ref(app).selected_index(); let list = Flex::column() .with_children(renderers.iter().enumerate().map(|(index, renderer)| { SavePosition::new( renderer.render(index, Some(index) == selected_index, app), renderer.position_id.as_str(), ) .finish() })) .finish(); SavePosition::new( ClippedScrollable::vertical( self.state.clipped_scroll_state.clone(), list, styles::SCROLLBAR_WIDTH, theme.nonactive_ui_detail().into(), theme.active_ui_detail().into(), // Leave the scrollbar gutter background transparent. Fill::None, ) .with_overlayed_scrollbar() .finish(), PALETTE_LIST_SAVE_POSITION_ID, ) .finish() } } } /// Handles the `CommandPaletteItemAction` action and closes the search panel. fn handle_result_accepted( &mut self, result_action: CommandPaletteItemAction, ctx: &mut ViewContext, ) { // Tab navigations don't appear in the main command palette to avoid confusion with session // navigations, so they can't evict real recent items from SelectedItems. if !matches!( result_action, CommandPaletteItemAction::NavigateToTab { .. } ) { let selected_items_handle = SelectedItems::handle(ctx); selected_items_handle.update(ctx, |selected_items, _ctx| { selected_items.enqueue(result_action.to_summary()) }); } if let CommandPaletteItemAction::AcceptBinding { binding } = &result_action { if let Some(action) = &binding.action { match action.as_any().downcast_ref::() { Some(WorkspaceAction::TogglePalette { mode: PaletteMode::LaunchConfig, source: _, }) => { self.reset(ctx); self.set_active_query_filter(QueryFilter::LaunchConfigurations, ctx); return; } Some(WorkspaceAction::TogglePalette { mode: PaletteMode::Navigation, source: _, }) => { self.reset(ctx); self.set_active_query_filter(QueryFilter::Sessions, ctx); return; } Some(WorkspaceAction::TogglePalette { mode: PaletteMode::Files, source: _, }) => { self.reset(ctx); self.set_active_query_filter(QueryFilter::Files, ctx); return; } Some(WorkspaceAction::TogglePalette { mode: PaletteMode::Conversations, source: _, }) => { self.reset(ctx); self.set_active_query_filter(QueryFilter::Conversations, ctx); return; } Some(WorkspaceAction::TogglePalette { mode: PaletteMode::Command, source: _, }) => { self.close(ctx, Some(result_action.result_type())); return; } _ => {} } } } match result_action.clone() { CommandPaletteItemAction::AcceptBinding { binding } => { if let Some(action) = binding.action.as_deref() { self.dispatch_typed_action_on_view(action, ctx); }; } CommandPaletteItemAction::NavigateToSession { pane_view_locator, window_id, } => { if let Some(root_view_id) = ctx.root_view_id(window_id) { ctx.dispatch_action_for_view( window_id, root_view_id, "root_view:handle_pane_navigation_event", &pane_view_locator, ); } send_telemetry_from_ctx!(TelemetryEvent::SelectNavigationPaletteItem, ctx); } CommandPaletteItemAction::NavigateToTab { pane_group_id, window_id, } => { if let Some(root_view_id) = ctx.root_view_id(window_id) { ctx.dispatch_action_for_view( window_id, root_view_id, "root_view:activate_tab_by_pane_group_id", &pane_group_id, ); } send_telemetry_from_ctx!(TelemetryEvent::SelectNavigationPaletteItem, ctx); } CommandPaletteItemAction::NavigateToConversation { pane_view_locator, window_id, conversation_id, terminal_view_id, } => { let should_block = { window_id .and_then(|window_id| { active_terminal_in_window(window_id, ctx, |terminal_view, ctx| { !terminal_view .ai_context_model() .as_ref(ctx) .can_start_new_conversation() }) }) .unwrap_or(false) }; if should_block { if let Some(window_id) = window_id { ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { toast_stack.add_ephemeral_toast( DismissibleToast::error( "Cannot switch conversations while agent is monitoring a command." .to_string(), ), window_id, ctx, ); }); } return; } ctx.dispatch_typed_action(&WorkspaceAction::RestoreOrNavigateToConversation { pane_view_locator, window_id, conversation_id, terminal_view_id, restore_layout: None, }); send_telemetry_from_app_ctx!(TelemetryEvent::SelectNavigationPaletteItem, ctx); } CommandPaletteItemAction::ForkConversation { conversation_id } => { ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation { conversation_id, fork_from_exchange: None, summarize_after_fork: false, summarization_prompt: None, initial_prompt: None, initial_attachments: vec![], destination: ForkedConversationDestination::SplitPane, }); } CommandPaletteItemAction::OpenLaunchConfiguration { open_in_active_window, config, } => { ctx.dispatch_global_action( "root_view:open_launch_config", OpenLaunchConfigArg { open_in_active_window, launch_config: config.deref().clone(), ui_location: LaunchConfigUiLocation::CommandPalette, }, ); } CommandPaletteItemAction::ExecuteWorkflow { id } => { ctx.emit(Event::ExecuteWorkflow { id }) } CommandPaletteItemAction::InvokeEnvironmentVariables { id } => { ctx.emit(Event::InvokeEnvironmentVariables { id }) } CommandPaletteItemAction::OpenNotebook { id } => ctx.emit(Event::OpenNotebook { id }), CommandPaletteItemAction::ViewInWarpDrive { id } => { ctx.emit(Event::ViewInWarpDrive { id }) } CommandPaletteItemAction::NewSession { source } => { self.dispatch_typed_action_on_view(source.action().deref(), ctx); } CommandPaletteItemAction::OpenFile { path, project_directory, line_and_column_arg, } => { let absolute_path = std::path::Path::new(&project_directory) .join(&path) .to_string_lossy() .to_string(); ctx.emit(Event::OpenFile { path: absolute_path, line_and_column_arg, }); } CommandPaletteItemAction::OpenDirectory { path, project_directory, } => { let absolute_path = std::path::Path::new(&project_directory) .join(&path) .to_string_lossy() .to_string(); ctx.emit(Event::OpenDirectory { path: absolute_path, }); } CommandPaletteItemAction::CreateFile { file_name, current_directory, } => { let file_path = std::path::Path::new(¤t_directory).join(&file_name); if let Err(e) = std::fs::File::create_new(&file_path) { if e.kind() != std::io::ErrorKind::AlreadyExists { log::warn!("Failed to create file {}: {e}", file_path.display()); return; } } ctx.emit(Event::OpenFile { path: file_path.to_string_lossy().to_string(), line_and_column_arg: None, }); } CommandPaletteItemAction::NewConversationInProject { path: _, project_name, } => { // AcceptProject is handled by the welcome palette, not the regular command palette. // This case should not normally be reached in the command palette context, but we // include it for completeness. If this somehow gets executed, we'll just log it. log::warn!( "OpenProjectConvo action unexpectedly handled in command palette for project: {project_name}" ); } CommandPaletteItemAction::NewConversation => { let window_id = match self.binding_source.as_ref(ctx) { BindingSource::View { window_id, .. } => *window_id, BindingSource::None => return, }; let (terminal_view_id, can_start_new_conversation) = { let terminal_view_id = active_terminal_in_window(window_id, ctx, |terminal_view, _| { terminal_view.id() }); let should_block = active_terminal_in_window(window_id, ctx, |terminal_view, ctx| { !terminal_view .ai_context_model() .as_ref(ctx) .can_start_new_conversation() }) .unwrap_or(false); (terminal_view_id, should_block) }; if can_start_new_conversation { ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { toast_stack.add_ephemeral_toast( DismissibleToast::error( "Cannot start a new conversation while agent is monitoring a command.".to_string(), ), window_id, ctx, ); }); return; } if let Some(terminal_view_id) = terminal_view_id { ctx.dispatch_typed_action(&WorkspaceAction::StartNewConversation { terminal_view_id, }); } } CommandPaletteItemAction::NoOp => { // No-op action (used for non-interactable separator items that don't do anything on click). } } self.close(ctx, Some(result_action.result_type())); } /// Dispatches `action` to the correct window and [`galaxyui::View`] by using the current state of /// the [`BindingSource`] model. fn dispatch_typed_action_on_view( &self, action: &dyn galaxyui::Action, ctx: &mut ViewContext, ) { send_telemetry_from_ctx!( TelemetryEvent::SelectCommandPaletteOption(format!("{action:?}")), ctx ); let (window_id, view_id) = match self.binding_source.as_ref(ctx) { BindingSource::View { window_id, view_id, .. } => (*window_id, *view_id), BindingSource::None => return, }; ctx.dispatch_typed_action_for_view(window_id, view_id, action); } }