1026 lines
40 KiB
Rust
1026 lines
40 KiB
Rust
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<LineAndColumnArg>,
|
|
},
|
|
/// 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<SearchBar<CommandPaletteItemAction>>,
|
|
search_bar_state: ModelHandle<SearchBarState<CommandPaletteItemAction>>,
|
|
state: ViewState,
|
|
binding_source: ModelHandle<BindingSource>,
|
|
/// Model to lists the current active session.
|
|
session_source: ModelHandle<SessionSource>,
|
|
zero_state_handle: ViewHandle<ZeroState>,
|
|
/// Placeholder element to render when no results are found.
|
|
placeholder_query_renderer: QueryResultRenderer<CommandPaletteItemAction>,
|
|
/// List of [`BindingId`]s that should be shown in the zero state as "suggested" items.
|
|
suggested_binding_ids: Vec<BindingId>,
|
|
/// Store of all the data sources that should be used for the [`SearchMixer`].
|
|
pub data_source_store: ModelHandle<DataSourceStore>,
|
|
zero_state_items: ModelHandle<zero_state::Items>,
|
|
|
|
/// 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<Self>) {
|
|
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<dyn Element> {
|
|
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<Self>) {
|
|
if focus_ctx.is_self_focused() {
|
|
ctx.focus(&self.search_bar);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl View {
|
|
pub fn new(navigation_mode: NavigationMode, ctx: &mut ViewContext<Self>) -> 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<Item = &'a QueryResult<CommandPaletteItemAction>> + '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>) {
|
|
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>) {
|
|
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>) {
|
|
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>) {
|
|
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<Self>) {
|
|
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<QueryFilter> {
|
|
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>,
|
|
) {
|
|
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>) {
|
|
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<Self>) {
|
|
match event {
|
|
ZeroStateEvent::FilterChipSelected { filter } => {
|
|
self.set_active_query_filter(*filter, ctx);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn create_query_result_renderer(
|
|
index: usize,
|
|
result: QueryResult<CommandPaletteItemAction>,
|
|
) -> QueryResultRenderer<CommandPaletteItemAction> {
|
|
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<Self>,
|
|
) {
|
|
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::<WorkspaceAction>(),
|
|
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<Self>) {
|
|
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>) {
|
|
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<dyn Element> {
|
|
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<CommandPaletteItemAction>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
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<Self>) {
|
|
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<Self>, 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>) {
|
|
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<View>) {
|
|
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>) {
|
|
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<dyn Element> {
|
|
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<Self>,
|
|
) {
|
|
// 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::<WorkspaceAction>() {
|
|
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<Self>,
|
|
) {
|
|
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);
|
|
}
|
|
}
|