Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
use crate::ai::agent::conversation::{AIConversation, AIConversationId};
|
||||
use crate::ai::conversation_navigation::ConversationNavigationData;
|
||||
use crate::search::command_palette::conversations::search::{
|
||||
ConversationMatchResult, ConversationSearcher, FuzzyConversationSearcher, MatchedConversation,
|
||||
};
|
||||
use crate::search::command_palette::conversations::search_item::{
|
||||
ConversationAction, ConversationSearchItem,
|
||||
};
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::separator_search_item::SeparatorSearchItem;
|
||||
use crate::search::data_source::{DataSourceSearchError, Query, QueryResult};
|
||||
use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
use crate::search::SyncDataSource;
|
||||
use crate::workspace::Workspace;
|
||||
use itertools::Itertools;
|
||||
use std::collections::HashMap;
|
||||
use warpui::{AppContext, Entity};
|
||||
|
||||
/// Sections for grouping conversations in the command palette.
|
||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||
enum ConversationSection {
|
||||
ActivePane,
|
||||
OtherActive,
|
||||
Past,
|
||||
}
|
||||
|
||||
impl ConversationSection {
|
||||
fn title(&self) -> &'static str {
|
||||
match self {
|
||||
ConversationSection::ActivePane => "Active pane conversations",
|
||||
ConversationSection::OtherActive => "Other active conversations",
|
||||
ConversationSection::Past => "Past conversations",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the ordering of the sections for display in the command palette
|
||||
/// (the command palette renders items in reverse order).
|
||||
fn reverse_order() -> [ConversationSection; 3] {
|
||||
[
|
||||
ConversationSection::Past,
|
||||
ConversationSection::OtherActive,
|
||||
ConversationSection::ActivePane,
|
||||
]
|
||||
}
|
||||
|
||||
fn for_conversation(conversation: &ConversationNavigationData) -> Self {
|
||||
if conversation.is_historical() {
|
||||
ConversationSection::Past
|
||||
} else if conversation.is_in_active_pane {
|
||||
ConversationSection::ActivePane
|
||||
} else {
|
||||
ConversationSection::OtherActive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data source that produces conversations for a user to navigate to.
|
||||
pub struct DataSource {
|
||||
searcher: FuzzyConversationSearcher,
|
||||
/// Whether to include extra conversation actions (i.e. new conversation & fork conversation)
|
||||
add_conversation_actions: bool,
|
||||
}
|
||||
|
||||
impl Default for DataSource {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DataSource {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
searcher: FuzzyConversationSearcher::new(),
|
||||
add_conversation_actions: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn historical() -> Self {
|
||||
Self {
|
||||
searcher: FuzzyConversationSearcher::historical(),
|
||||
add_conversation_actions: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a [`QueryResult`] for a conversation identified by `conversation_id`. `None` if no result was
|
||||
/// found with the given ID.
|
||||
pub fn query_result(
|
||||
conversation_id: &AIConversationId,
|
||||
app: &AppContext,
|
||||
) -> Option<QueryResult<CommandPaletteItemAction>> {
|
||||
let all_conversations = ConversationNavigationData::all_conversations(app);
|
||||
|
||||
all_conversations
|
||||
.into_iter()
|
||||
.find(|conversation| &conversation.id == conversation_id)
|
||||
.map(|conversation| {
|
||||
let search_item = ConversationSearchItem::new(ConversationAction::Resume(
|
||||
Box::new(MatchedConversation {
|
||||
conversation,
|
||||
match_result: ConversationMatchResult::no_match(),
|
||||
}),
|
||||
));
|
||||
QueryResult::from(search_item)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn top_n(
|
||||
&self,
|
||||
limit: usize,
|
||||
app: &AppContext,
|
||||
) -> impl Iterator<Item = QueryResult<<Self as SyncDataSource>::Action>> {
|
||||
self.searcher
|
||||
.searchable_conversations(app)
|
||||
.into_iter()
|
||||
.k_largest_by_key(limit, |conversation| conversation.last_updated)
|
||||
.map(|conversation| {
|
||||
QueryResult::from(ConversationSearchItem::new(ConversationAction::Resume(
|
||||
Box::new(MatchedConversation {
|
||||
conversation,
|
||||
match_result: ConversationMatchResult::no_match(),
|
||||
}),
|
||||
)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the selected conversation in the focused pane.
|
||||
fn selected_conversation_in_focused_pane(app: &AppContext) -> Option<&AIConversation> {
|
||||
app.windows().active_window().and_then(|window_id| {
|
||||
app.views_of_type::<Workspace>(window_id)
|
||||
.and_then(|views| views.first().cloned())
|
||||
.and_then(|workspace| {
|
||||
workspace.read(app, |workspace, workspace_ctx| {
|
||||
workspace.active_tab_pane_group().read(
|
||||
workspace_ctx,
|
||||
|pane_group, pane_group_ctx| {
|
||||
pane_group.focused_session_view(pane_group_ctx).and_then(
|
||||
|terminal_view| {
|
||||
terminal_view
|
||||
.as_ref(pane_group_ctx)
|
||||
.ai_context_model()
|
||||
.as_ref(pane_group_ctx)
|
||||
.selected_conversation(app)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
impl SyncDataSource for DataSource {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
// When the query is empty, we want to insert special separator items between historical conversations,
|
||||
// open conversations, conversations in the active pane, and the conversation action items (i.e. new conversation & fork conversation).
|
||||
let result = if query.text.trim().is_empty() {
|
||||
let conversations = self.searcher.searchable_conversations(app);
|
||||
let mut results = Vec::new();
|
||||
|
||||
// Group conversations by section.
|
||||
let mut grouped: HashMap<ConversationSection, Vec<ConversationNavigationData>> =
|
||||
HashMap::new();
|
||||
for conversation in conversations {
|
||||
let section = ConversationSection::for_conversation(&conversation);
|
||||
grouped.entry(section).or_default().push(conversation);
|
||||
}
|
||||
grouped.values_mut().for_each(|group| group.sort());
|
||||
|
||||
// The command palette renders items in reverse order, so we need to add the sections in reverse order
|
||||
// and add each separator item after all of the items in the section.
|
||||
for section in ConversationSection::reverse_order() {
|
||||
if let Some(conversations) = grouped.get(§ion) {
|
||||
if !conversations.is_empty() {
|
||||
for conversation in conversations {
|
||||
let matched_conversation = MatchedConversation {
|
||||
conversation: conversation.clone(),
|
||||
match_result: ConversationMatchResult::no_match(),
|
||||
};
|
||||
results.push(
|
||||
ConversationSearchItem::new(ConversationAction::Resume(Box::new(
|
||||
matched_conversation,
|
||||
)))
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
results.push(SeparatorSearchItem::new(section.title().to_string()).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
} else {
|
||||
self.searcher
|
||||
.search(&query.text.trim().to_lowercase(), app)
|
||||
.map_err(|err| {
|
||||
let search_error = DataSourceSearchError {
|
||||
message: err.to_string(),
|
||||
};
|
||||
Box::new(search_error) as DataSourceRunErrorWrapper
|
||||
})
|
||||
};
|
||||
|
||||
// When the query is empty, we want to add the "new conversation" and "fork conversation" items.
|
||||
if self.add_conversation_actions && query.text.trim().is_empty() {
|
||||
result.map(|mut results| {
|
||||
if !cfg!(target_family = "wasm") {
|
||||
if let Some(conversation) = selected_conversation_in_focused_pane(app) {
|
||||
// Only surface the fork option if the selected conversation is done.
|
||||
if conversation.status().is_done() {
|
||||
results.push(
|
||||
ConversationSearchItem::new(ConversationAction::Fork {
|
||||
conversation_id: conversation.id(),
|
||||
title: conversation.title().unwrap_or_default().to_string(),
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
results.push(ConversationSearchItem::new(ConversationAction::New).into());
|
||||
results
|
||||
})
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod data_source;
|
||||
mod search;
|
||||
mod search_item;
|
||||
|
||||
#[cfg(test)]
|
||||
mod search_test;
|
||||
|
||||
pub use crate::ai::conversation_navigation::ConversationNavigationData;
|
||||
pub use data_source::DataSource;
|
||||
@@ -0,0 +1,230 @@
|
||||
use crate::ai::conversation_navigation::ConversationNavigationData;
|
||||
use crate::search::command_palette::conversations::search_item::ConversationAction;
|
||||
use crate::search::command_palette::conversations::search_item::ConversationSearchItem;
|
||||
use crate::search::command_palette::conversations::DataSource;
|
||||
use crate::search::data_source::QueryResult;
|
||||
use crate::search::SyncDataSource;
|
||||
use fuzzy_match::match_indices_case_insensitive;
|
||||
use warpui::AppContext;
|
||||
|
||||
/// A conversation that was fuzzy matched against a search term.
|
||||
#[derive(Debug)]
|
||||
pub struct MatchedConversation {
|
||||
pub conversation: ConversationNavigationData,
|
||||
pub match_result: ConversationMatchResult,
|
||||
}
|
||||
|
||||
impl MatchedConversation {
|
||||
/// Returns the score for the [`MatchedConversation`]. If there was no match result, a score of `0`
|
||||
/// is returned.
|
||||
pub fn score(&self) -> i64 {
|
||||
self.match_result.score
|
||||
}
|
||||
|
||||
/// Returns the [`ConversationHighlightIndices`] belonging to the matched conversation.
|
||||
pub fn highlight_indices(&self) -> &ConversationHighlightIndices {
|
||||
&self.match_result.highlight_indices
|
||||
}
|
||||
}
|
||||
|
||||
/// Result from matching a conversation.
|
||||
#[derive(Debug)]
|
||||
pub struct ConversationMatchResult {
|
||||
score: i64,
|
||||
highlight_indices: ConversationHighlightIndices,
|
||||
}
|
||||
|
||||
impl ConversationMatchResult {
|
||||
/// Returns a dummy match result when there is no match.
|
||||
pub fn no_match() -> Self {
|
||||
ConversationMatchResult {
|
||||
score: 0,
|
||||
highlight_indices: ConversationHighlightIndices {
|
||||
title_indices: vec![],
|
||||
initial_query_indices: vec![],
|
||||
working_directory_indices: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn score(&self) -> i64 {
|
||||
self.score
|
||||
}
|
||||
}
|
||||
|
||||
/// Matching indices for a matched conversation.
|
||||
#[derive(Debug)]
|
||||
pub struct ConversationHighlightIndices {
|
||||
pub(super) title_indices: Vec<usize>,
|
||||
pub(super) initial_query_indices: Vec<usize>,
|
||||
pub(super) working_directory_indices: Vec<usize>,
|
||||
}
|
||||
|
||||
impl ConversationHighlightIndices {
|
||||
fn new(
|
||||
title_indices: Vec<usize>,
|
||||
initial_query_indices: Vec<usize>,
|
||||
working_directory_indices: Vec<usize>,
|
||||
) -> ConversationHighlightIndices {
|
||||
ConversationHighlightIndices {
|
||||
title_indices,
|
||||
initial_query_indices,
|
||||
working_directory_indices,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the highlight indices for the conversation title.
|
||||
pub fn title_indices(&self) -> &Vec<usize> {
|
||||
&self.title_indices
|
||||
}
|
||||
|
||||
/// Returns the highlight indices for the initial query.
|
||||
pub fn initial_query_indices(&self) -> &Vec<usize> {
|
||||
&self.initial_query_indices
|
||||
}
|
||||
|
||||
/// Returns the highlight indices for the working directory.
|
||||
pub fn working_directory_indices(&self) -> &Vec<usize> {
|
||||
&self.working_directory_indices
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator of conversations that match `search_term`.
|
||||
pub fn filter_conversations<'a, 'b, I>(
|
||||
conversations_iter: I,
|
||||
search_term: &'b str,
|
||||
) -> impl Iterator<Item = MatchedConversation> + use<'a, 'b, I>
|
||||
where
|
||||
I: IntoIterator<Item = &'a ConversationNavigationData>,
|
||||
{
|
||||
conversations_iter
|
||||
.into_iter()
|
||||
.filter_map(move |conversation| {
|
||||
if search_term.is_empty() {
|
||||
Some((ConversationMatchResult::no_match(), conversation.clone()))
|
||||
} else {
|
||||
// Match against title, initial_query, and initial_working_directory
|
||||
let title_match = match_indices_case_insensitive(&conversation.title, search_term);
|
||||
let initial_query_match =
|
||||
conversation
|
||||
.initial_query
|
||||
.as_deref()
|
||||
.and_then(|initial_query| {
|
||||
match_indices_case_insensitive(initial_query, search_term)
|
||||
});
|
||||
let working_directory_match = conversation
|
||||
.initial_working_directory
|
||||
.as_deref()
|
||||
.and_then(|initial_working_directory| {
|
||||
match_indices_case_insensitive(initial_working_directory, search_term)
|
||||
});
|
||||
|
||||
// If none of the fields match, filter this conversation out
|
||||
if title_match.is_none()
|
||||
&& initial_query_match.is_none()
|
||||
&& working_directory_match.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Determine the best score among all matches
|
||||
let best_score = [
|
||||
title_match.as_ref(),
|
||||
initial_query_match.as_ref(),
|
||||
working_directory_match.as_ref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|r| r.score)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
let title_indices = title_match.map(|r| r.matched_indices).unwrap_or_default();
|
||||
let initial_query_indices = initial_query_match
|
||||
.map(|r| r.matched_indices)
|
||||
.unwrap_or_default();
|
||||
let working_directory_indices = working_directory_match
|
||||
.map(|r| r.matched_indices)
|
||||
.unwrap_or_default();
|
||||
|
||||
let highlight_indices = ConversationHighlightIndices::new(
|
||||
title_indices,
|
||||
initial_query_indices,
|
||||
working_directory_indices,
|
||||
);
|
||||
|
||||
Some((
|
||||
ConversationMatchResult {
|
||||
score: best_score,
|
||||
highlight_indices,
|
||||
},
|
||||
conversation.clone(),
|
||||
))
|
||||
}
|
||||
})
|
||||
.map(|(match_result, conversation)| MatchedConversation {
|
||||
conversation,
|
||||
match_result,
|
||||
})
|
||||
}
|
||||
|
||||
type SearcherAction = <DataSource as SyncDataSource>::Action;
|
||||
|
||||
pub trait ConversationSearcher {
|
||||
fn search(
|
||||
&self,
|
||||
_search_term: &str,
|
||||
_app: &AppContext,
|
||||
) -> anyhow::Result<Vec<QueryResult<SearcherAction>>>;
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum ConversationType {
|
||||
All,
|
||||
Historical,
|
||||
}
|
||||
|
||||
pub struct FuzzyConversationSearcher {
|
||||
filter: ConversationType,
|
||||
}
|
||||
|
||||
impl FuzzyConversationSearcher {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
filter: ConversationType::All,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn historical() -> Self {
|
||||
Self {
|
||||
filter: ConversationType::Historical,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn searchable_conversations(&self, app: &AppContext) -> Vec<ConversationNavigationData> {
|
||||
match self.filter {
|
||||
ConversationType::Historical => {
|
||||
ConversationNavigationData::historical_conversations(app)
|
||||
}
|
||||
ConversationType::All => ConversationNavigationData::all_conversations(app),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConversationSearcher for FuzzyConversationSearcher {
|
||||
fn search(
|
||||
&self,
|
||||
search_term: &str,
|
||||
app: &AppContext,
|
||||
) -> anyhow::Result<Vec<QueryResult<SearcherAction>>> {
|
||||
let conversations = self.searchable_conversations(app);
|
||||
Ok(filter_conversations(conversations.as_slice(), search_term)
|
||||
.map(|matched_conversation| {
|
||||
ConversationSearchItem::new(ConversationAction::Resume(Box::new(
|
||||
matched_conversation,
|
||||
)))
|
||||
.into()
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::command_palette::conversations::search::MatchedConversation;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::render_util::render_search_item_icon;
|
||||
use crate::search::command_palette::view::Action;
|
||||
use crate::search::item::IconLocation;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::search::SearchItem;
|
||||
use crate::ui_components::buttons::icon_button;
|
||||
use crate::util::time_format::format_approx_duration_from_now;
|
||||
use ordered_float::OrderedFloat;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::color::{blend::Blend, coloru_with_opacity};
|
||||
use warp_core::ui::icons::Icon;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warpui::elements::{
|
||||
AnchorPair, Container, CrossAxisAlignment, Expanded, Fill, Flex, Highlight, MainAxisSize,
|
||||
MouseStateHandle, OffsetPositioning, OffsetType, ParentElement, ParentOffsetBounds,
|
||||
PositioningAxis, Stack, Text, XAxisAnchor, YAxisAnchor,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::ui_components::button::ButtonTooltipPosition;
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, Gradient, SingletonEntity};
|
||||
|
||||
/// Information about which action to take once the conversation item is accepted.
|
||||
#[derive(Debug)]
|
||||
pub enum ConversationAction {
|
||||
/// Start a new conversation in the current view.
|
||||
New,
|
||||
/// Fork the current active conversation into a new view.
|
||||
Fork {
|
||||
conversation_id: AIConversationId,
|
||||
title: String,
|
||||
},
|
||||
/// Resume the matched conversation in its associated view.
|
||||
Resume(Box<MatchedConversation>),
|
||||
}
|
||||
|
||||
/// Search item to render a conversation within the command palette.
|
||||
/// When matched_conversation is None, we render this as a new conversation item.
|
||||
#[derive(Debug)]
|
||||
pub struct ConversationSearchItem {
|
||||
action_info: ConversationAction,
|
||||
action_button_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl ConversationSearchItem {
|
||||
pub fn new(action_info: ConversationAction) -> Self {
|
||||
Self {
|
||||
action_info,
|
||||
action_button_mouse_state: MouseStateHandle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the new conversation item for the command palette.
|
||||
pub fn render_new_conversation_action_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Text::new_inline(
|
||||
"New conversation",
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_fork_conversation_action_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
conversation_title: &str,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let action_title = Text::new_inline(
|
||||
"Fork current conversation",
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.with_style(Properties::default().weight(Weight::Bold));
|
||||
|
||||
let conversation_title = Text::new_inline(
|
||||
conversation_title.to_owned(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
Flex::column()
|
||||
.with_child(action_title.finish())
|
||||
.with_child(conversation_title.finish())
|
||||
.with_spacing(4.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_matched_conversation_item(
|
||||
&self,
|
||||
matched_conversation: &MatchedConversation,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let conversation = matched_conversation.conversation.clone();
|
||||
let sub_text_font_size = appearance.monospace_font_size() - 2.;
|
||||
|
||||
let mut conversation_title_element = Text::new_inline(
|
||||
conversation.title().to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.with_style(Properties::default().weight(Weight::Bold));
|
||||
|
||||
let mut working_directory_element = Text::new_inline(
|
||||
conversation
|
||||
.initial_working_directory
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
appearance.ui_font_family(),
|
||||
sub_text_font_size,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
// When the search query is empty, we only show the conversation's title and working directory.
|
||||
// Otherwise, we show the conversation's title, initial user query, and working directory.
|
||||
// We also highlight the indices in those elements that match the search query.
|
||||
let mut left_container = Flex::column().with_spacing(4.);
|
||||
if !self.query_is_empty() {
|
||||
// The first user query that was submitted for this conversation.
|
||||
let mut initial_query_element = Text::new_inline(
|
||||
conversation.initial_query.clone().unwrap_or_default(),
|
||||
appearance.ui_font_family(),
|
||||
sub_text_font_size,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
// Apply highlights for the search query's matching indices.
|
||||
let highlight = Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid());
|
||||
let highlight_indices = matched_conversation.highlight_indices();
|
||||
if !highlight_indices.title_indices().is_empty() {
|
||||
conversation_title_element = conversation_title_element
|
||||
.with_single_highlight(highlight, highlight_indices.title_indices().clone());
|
||||
}
|
||||
if !highlight_indices.initial_query_indices().is_empty() {
|
||||
initial_query_element = initial_query_element.with_single_highlight(
|
||||
highlight,
|
||||
highlight_indices.initial_query_indices().clone(),
|
||||
);
|
||||
}
|
||||
if !highlight_indices.working_directory_indices().is_empty() {
|
||||
working_directory_element = working_directory_element.with_single_highlight(
|
||||
highlight,
|
||||
highlight_indices.working_directory_indices().clone(),
|
||||
);
|
||||
}
|
||||
|
||||
// Add the conversation title and initial user query to the left container.
|
||||
left_container = left_container
|
||||
.with_child(conversation_title_element.finish())
|
||||
.with_child(initial_query_element.finish());
|
||||
} else {
|
||||
// When the search query is empty, we only show the conversation's title and working directory.
|
||||
left_container = left_container.with_child(conversation_title_element.finish());
|
||||
}
|
||||
// In all cases, we show the conversation's working directory last.
|
||||
left_container = left_container.with_child(working_directory_element.finish());
|
||||
|
||||
let last_updated = format_approx_duration_from_now(conversation.last_updated());
|
||||
let last_updated_element = Container::new(
|
||||
Text::new_inline(
|
||||
last_updated,
|
||||
appearance.ui_font_family(),
|
||||
sub_text_font_size,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(8.)
|
||||
.finish();
|
||||
|
||||
let search_item_content = Flex::row()
|
||||
.with_child(Expanded::new(1.0, left_container.finish()).finish())
|
||||
.with_child(last_updated_element)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.finish();
|
||||
|
||||
// We only want to show the fork button if the conversation is completed
|
||||
// (i.e. the agent has finished responding and there are no blocked commands).
|
||||
let conversation_is_done = BlocklistAIHistoryModel::as_ref(app)
|
||||
.conversation(&conversation.id())
|
||||
.map(|c| c.status().is_done())
|
||||
.unwrap_or(true);
|
||||
|
||||
if highlight_state.is_hovered() && conversation_is_done && !cfg!(target_family = "wasm") {
|
||||
// Base row content (unchanged layout for existing children)
|
||||
let base_row = Flex::row()
|
||||
.with_child(Expanded::new(1.0, search_item_content).finish())
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.finish();
|
||||
|
||||
// Overlay fork button on the right, positioned absolutely so it doesn't affect the layout.
|
||||
let fork_button_positioning = OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_parent(
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Right, XAxisAnchor::Right),
|
||||
),
|
||||
PositioningAxis::relative_to_parent(
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Top),
|
||||
),
|
||||
);
|
||||
|
||||
// We create a gradient background that is semi-transparent on the left and the item background color on the right.
|
||||
// The end color is the highlight_bg_color over surface_2 at the given highlight state's opacity.
|
||||
// The start color is fully transparent.
|
||||
let base_bg = appearance.theme().surface_2().into_solid();
|
||||
let end_color = base_bg.blend(&coloru_with_opacity(
|
||||
Fill::from(appearance.theme().accent()).start_color(),
|
||||
highlight_state.container_background_opacity(),
|
||||
));
|
||||
let start_color = ColorU::new(end_color.r, end_color.g, end_color.b, 0);
|
||||
|
||||
let fork_button_tool_tip = appearance
|
||||
.ui_builder()
|
||||
.tool_tip("Fork conversation".to_string())
|
||||
.build();
|
||||
|
||||
let fork_button_inner = icon_button(
|
||||
appearance,
|
||||
Icon::ArrowSplit,
|
||||
false,
|
||||
self.action_button_mouse_state.clone(),
|
||||
)
|
||||
.with_hovered_styles(
|
||||
UiComponentStyles::default()
|
||||
.set_background(internal_colors::fg_overlay_3(appearance.theme()).into()),
|
||||
)
|
||||
.with_clicked_styles(
|
||||
UiComponentStyles::default()
|
||||
.set_background(internal_colors::fg_overlay_5(appearance.theme()).into()),
|
||||
)
|
||||
.with_tooltip(|| fork_button_tool_tip.finish())
|
||||
.with_tooltip_position(ButtonTooltipPosition::AboveRight)
|
||||
.build()
|
||||
.on_click(move |ctx, _app, _pos| {
|
||||
ctx.dispatch_typed_action(Action::ResultClicked {
|
||||
action: CommandPaletteItemAction::ForkConversation {
|
||||
conversation_id: conversation.id(),
|
||||
},
|
||||
});
|
||||
})
|
||||
.finish();
|
||||
|
||||
// When the fork button itself is hovered, we use a solid background equal to the
|
||||
// gradient's end color. Otherwise, we use the original gradient.
|
||||
let is_hovered = self
|
||||
.action_button_mouse_state
|
||||
.lock()
|
||||
.map(|s| s.is_hovered())
|
||||
.unwrap_or(false);
|
||||
let fork_button = if is_hovered {
|
||||
Container::new(fork_button_inner)
|
||||
.with_background_color(end_color)
|
||||
.finish()
|
||||
} else {
|
||||
Container::new(fork_button_inner)
|
||||
.with_background_gradient(
|
||||
vec2f(0.0, 0.0),
|
||||
vec2f(0.2, 0.0),
|
||||
Gradient {
|
||||
start: start_color,
|
||||
end: end_color,
|
||||
},
|
||||
)
|
||||
.finish()
|
||||
};
|
||||
|
||||
let mut stack = Stack::new().with_child(base_row);
|
||||
stack.add_positioned_child(fork_button, fork_button_positioning);
|
||||
stack.finish()
|
||||
} else {
|
||||
search_item_content
|
||||
}
|
||||
}
|
||||
|
||||
fn query_is_empty(&self) -> bool {
|
||||
match &self.action_info {
|
||||
ConversationAction::Resume(matched_conversation) => {
|
||||
// If the score is empty, the query must be empty (otherwise, we would not be showing this item)
|
||||
matched_conversation.as_ref().match_result.score() == 0
|
||||
}
|
||||
ConversationAction::Fork { .. } | ConversationAction::New => {
|
||||
// We only show these items when the search query is empty.
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for ConversationSearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn is_multiline(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let (color, icon) = match &self.action_info {
|
||||
ConversationAction::Resume(..) => (
|
||||
appearance.theme().foreground().into_solid(),
|
||||
Icon::Conversation,
|
||||
),
|
||||
ConversationAction::New => (appearance.theme().foreground().into_solid(), Icon::Plus),
|
||||
ConversationAction::Fork { .. } => (
|
||||
appearance.theme().foreground().into_solid(),
|
||||
Icon::ArrowSplit,
|
||||
),
|
||||
};
|
||||
|
||||
render_search_item_icon(appearance, icon, color, highlight_state)
|
||||
}
|
||||
|
||||
fn icon_location(&self, appearance: &Appearance) -> IconLocation {
|
||||
if matches!(self.action_info, ConversationAction::New) {
|
||||
IconLocation::Centered
|
||||
} else {
|
||||
// The icon has the size of the monospace font, whereas the text has a height of
|
||||
// `line_height_ratio * font_size`. Offset the icon by this difference so it is rendered
|
||||
// centered with the text.
|
||||
let margin_top = (appearance.line_height_ratio() * appearance.monospace_font_size())
|
||||
- appearance.monospace_font_size();
|
||||
IconLocation::Top { margin_top }
|
||||
}
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
match &self.action_info {
|
||||
ConversationAction::Resume(matched_conversation) => self
|
||||
.render_matched_conversation_item(
|
||||
matched_conversation.as_ref(),
|
||||
highlight_state,
|
||||
app,
|
||||
),
|
||||
ConversationAction::New => {
|
||||
self.render_new_conversation_action_item(highlight_state, app)
|
||||
}
|
||||
ConversationAction::Fork { title, .. } => {
|
||||
self.render_fork_conversation_action_item(highlight_state, title, app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
let score = match &self.action_info {
|
||||
ConversationAction::Resume(matched_conversation) => matched_conversation.score() as f64,
|
||||
ConversationAction::Fork { .. } => f64::NAN,
|
||||
ConversationAction::New => f64::NAN,
|
||||
};
|
||||
OrderedFloat::from(score)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
match &self.action_info {
|
||||
ConversationAction::Resume(matched_conversation) => {
|
||||
let conversation = &matched_conversation.as_ref().conversation;
|
||||
CommandPaletteItemAction::NavigateToConversation {
|
||||
pane_view_locator: conversation.pane_view_locator(),
|
||||
window_id: conversation.window_id(),
|
||||
conversation_id: conversation.id(),
|
||||
terminal_view_id: conversation.terminal_view_id,
|
||||
}
|
||||
}
|
||||
ConversationAction::Fork {
|
||||
conversation_id, ..
|
||||
} => CommandPaletteItemAction::ForkConversation {
|
||||
conversation_id: *conversation_id,
|
||||
},
|
||||
ConversationAction::New => CommandPaletteItemAction::NewConversation,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
match &self.action_info {
|
||||
ConversationAction::Resume(matched_conversation) => {
|
||||
format!(
|
||||
"Conversation: {}",
|
||||
matched_conversation.as_ref().conversation.title()
|
||||
)
|
||||
}
|
||||
ConversationAction::Fork { title, .. } => {
|
||||
format!("Fork current conversation ({title})")
|
||||
}
|
||||
ConversationAction::New => "New conversation".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn accessibility_help_message(&self) -> Option<String> {
|
||||
match &self.action_info {
|
||||
ConversationAction::Resume(matched_conversation) => Some(format!(
|
||||
"Press enter to navigate to conversation \"{}\".",
|
||||
matched_conversation.as_ref().conversation.title()
|
||||
)),
|
||||
ConversationAction::Fork { .. } => {
|
||||
Some("Press enter to fork the current conversation into a new conversation.".into())
|
||||
}
|
||||
ConversationAction::New => Some("Press enter to create a new conversation.".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use crate::ai::{
|
||||
agent::conversation::AIConversationId, conversation_navigation::ConversationNavigationData,
|
||||
};
|
||||
use warpui::{EntityId, WindowId};
|
||||
|
||||
#[test]
|
||||
fn test_conversation_navigation_data_ordering() {
|
||||
// Create test data with different active states and timestamps
|
||||
let now = chrono::Local::now();
|
||||
let one_hour_ago = now - chrono::Duration::hours(1);
|
||||
let two_hours_ago = now - chrono::Duration::hours(2);
|
||||
|
||||
let active_recent = ConversationNavigationData {
|
||||
id: AIConversationId::new(),
|
||||
title: "Active Recent".to_string(),
|
||||
initial_query: None,
|
||||
last_updated: now,
|
||||
terminal_view_id: Some(EntityId::new()),
|
||||
window_id: Some(WindowId::new()),
|
||||
pane_view_locator: None,
|
||||
initial_working_directory: None,
|
||||
latest_working_directory: None,
|
||||
is_selected: true,
|
||||
is_closed: false,
|
||||
server_conversation_token: None,
|
||||
is_in_active_pane: true,
|
||||
};
|
||||
|
||||
let active_old = ConversationNavigationData {
|
||||
id: AIConversationId::new(),
|
||||
title: "Active Old".to_string(),
|
||||
initial_query: None,
|
||||
last_updated: two_hours_ago,
|
||||
terminal_view_id: Some(EntityId::new()),
|
||||
window_id: Some(WindowId::new()),
|
||||
pane_view_locator: None,
|
||||
initial_working_directory: None,
|
||||
latest_working_directory: None,
|
||||
is_selected: true,
|
||||
is_closed: false,
|
||||
server_conversation_token: None,
|
||||
is_in_active_pane: true,
|
||||
};
|
||||
|
||||
let inactive_recent = ConversationNavigationData {
|
||||
id: AIConversationId::new(),
|
||||
title: "Inactive Recent".to_string(),
|
||||
initial_query: None,
|
||||
last_updated: now,
|
||||
terminal_view_id: Some(EntityId::new()),
|
||||
window_id: Some(WindowId::new()),
|
||||
pane_view_locator: None,
|
||||
initial_working_directory: None,
|
||||
latest_working_directory: None,
|
||||
is_selected: false,
|
||||
is_closed: false,
|
||||
server_conversation_token: None,
|
||||
is_in_active_pane: false,
|
||||
};
|
||||
|
||||
let inactive_old = ConversationNavigationData {
|
||||
id: AIConversationId::new(),
|
||||
title: "Inactive Old".to_string(),
|
||||
initial_query: None,
|
||||
last_updated: one_hour_ago,
|
||||
terminal_view_id: Some(EntityId::new()),
|
||||
window_id: Some(WindowId::new()),
|
||||
pane_view_locator: None,
|
||||
initial_working_directory: None,
|
||||
latest_working_directory: None,
|
||||
is_selected: false,
|
||||
is_closed: false,
|
||||
server_conversation_token: None,
|
||||
is_in_active_pane: false,
|
||||
};
|
||||
|
||||
let historical_recent = ConversationNavigationData {
|
||||
id: AIConversationId::new(),
|
||||
title: "Historical Recent".to_string(),
|
||||
initial_query: None,
|
||||
last_updated: now,
|
||||
terminal_view_id: None,
|
||||
window_id: None,
|
||||
pane_view_locator: None,
|
||||
initial_working_directory: None,
|
||||
latest_working_directory: None,
|
||||
is_selected: false,
|
||||
is_closed: false,
|
||||
server_conversation_token: None,
|
||||
is_in_active_pane: false,
|
||||
};
|
||||
|
||||
let historical_old = ConversationNavigationData {
|
||||
id: AIConversationId::new(),
|
||||
title: "Historical Old".to_string(),
|
||||
initial_query: None,
|
||||
last_updated: one_hour_ago,
|
||||
terminal_view_id: None,
|
||||
window_id: None,
|
||||
pane_view_locator: None,
|
||||
initial_working_directory: None,
|
||||
latest_working_directory: None,
|
||||
is_selected: false,
|
||||
is_closed: false,
|
||||
server_conversation_token: None,
|
||||
is_in_active_pane: false,
|
||||
};
|
||||
|
||||
// Test sorting a vector
|
||||
let mut conversations = [
|
||||
inactive_old.clone(),
|
||||
active_old.clone(),
|
||||
inactive_recent.clone(),
|
||||
active_recent.clone(),
|
||||
historical_old.clone(),
|
||||
historical_recent.clone(),
|
||||
];
|
||||
|
||||
conversations.sort();
|
||||
|
||||
assert_eq!(conversations[0].title, "Historical Old");
|
||||
assert_eq!(conversations[1].title, "Historical Recent");
|
||||
assert_eq!(conversations[2].title, "Inactive Old");
|
||||
assert_eq!(conversations[3].title, "Inactive Recent");
|
||||
assert_eq!(conversations[4].title, "Active Old");
|
||||
assert_eq!(conversations[5].title, "Active Recent");
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::drive::settings::WarpDriveSettings;
|
||||
use crate::search::action::CommandBindingDataSource;
|
||||
use crate::search::binding_source::BindingSource;
|
||||
use crate::search::command_palette::files;
|
||||
use crate::search::command_palette::launch_config;
|
||||
use crate::search::command_palette::mixer::{CommandPaletteItemAction, ItemSummary};
|
||||
use crate::search::command_palette::new_session::NewSessionDataSource;
|
||||
use crate::search::command_palette::repos::RepoDataSource;
|
||||
use crate::search::command_palette::{navigation, CommandPaletteMixer};
|
||||
use crate::search::data_source::QueryResult;
|
||||
use crate::search::files::model::FileSearchModel;
|
||||
use crate::search::mixer::AddAsyncSourceOptions;
|
||||
use crate::search::QueryFilter;
|
||||
use crate::session_management::SessionSource;
|
||||
use crate::settings::AISettings;
|
||||
use warp_core::context_flag::ContextFlag;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::keymap::BindingId;
|
||||
use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::conversations;
|
||||
use super::warp_drive;
|
||||
|
||||
/// Store of all of the [`crate::search::DataSource`]s for the command palette.
|
||||
pub struct DataSourceStore {
|
||||
actions_data_source: ModelHandle<CommandBindingDataSource>,
|
||||
sessions_data_source: ModelHandle<navigation::DataSource>,
|
||||
warp_drive_data_source: ModelHandle<warp_drive::DataSource>,
|
||||
launch_config_data_source: ModelHandle<launch_config::DataSource>,
|
||||
new_session_data_source: Option<ModelHandle<NewSessionDataSource>>,
|
||||
historical_conversation_data_source: ModelHandle<conversations::DataSource>,
|
||||
all_conversation_data_source: ModelHandle<conversations::DataSource>,
|
||||
repo_data_source: ModelHandle<RepoDataSource>,
|
||||
}
|
||||
|
||||
impl DataSourceStore {
|
||||
pub fn new(
|
||||
binding_source: ModelHandle<BindingSource>,
|
||||
active_session_handle: ModelHandle<SessionSource>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let actions_data_source =
|
||||
ctx.add_model(|ctx| CommandBindingDataSource::new(binding_source.clone(), ctx));
|
||||
|
||||
let sessions_data_source =
|
||||
ctx.add_model(|_| navigation::DataSource::new(active_session_handle));
|
||||
|
||||
let warp_drive_data_source = ctx.add_model(warp_drive::DataSource::new);
|
||||
|
||||
let launch_config_data_source = ctx.add_model(launch_config::DataSource::new);
|
||||
|
||||
let new_session_data_source = (FeatureFlag::ShellSelector.is_enabled()
|
||||
&& cfg!(feature = "local_tty"))
|
||||
.then_some(ctx.add_model(|ctx| NewSessionDataSource::new(binding_source, ctx)));
|
||||
|
||||
let historical_conversation_data_source: ModelHandle<conversations::DataSource> =
|
||||
ctx.add_model(|_| conversations::DataSource::historical());
|
||||
|
||||
let all_conversation_data_source: ModelHandle<conversations::DataSource> =
|
||||
ctx.add_model(|_| conversations::DataSource::new());
|
||||
|
||||
let repo_data_source = ctx.add_model(|_| RepoDataSource::new());
|
||||
|
||||
Self {
|
||||
actions_data_source,
|
||||
sessions_data_source,
|
||||
warp_drive_data_source,
|
||||
launch_config_data_source,
|
||||
new_session_data_source,
|
||||
historical_conversation_data_source,
|
||||
all_conversation_data_source,
|
||||
repo_data_source,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the [`CommandPaletteMixer`] to the set of data sources that are relevant for the command palette.
|
||||
pub fn reset_search_mixer(
|
||||
&mut self,
|
||||
mixer: ModelHandle<CommandPaletteMixer>,
|
||||
is_shared_session_viewer: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.reset(ctx);
|
||||
|
||||
if ContextFlag::LaunchConfigurations.is_enabled() {
|
||||
mixer.add_sync_source(
|
||||
self.launch_config_data_source.clone(),
|
||||
HashSet::from([QueryFilter::LaunchConfigurations]),
|
||||
);
|
||||
}
|
||||
|
||||
mixer.add_sync_source(
|
||||
self.sessions_data_source.clone(),
|
||||
HashSet::from([QueryFilter::Sessions]),
|
||||
);
|
||||
|
||||
if WarpDriveSettings::is_warp_drive_enabled(ctx) {
|
||||
let mut warp_drive_filters = HashSet::from([
|
||||
QueryFilter::Notebooks,
|
||||
QueryFilter::Plans,
|
||||
QueryFilter::Drive,
|
||||
QueryFilter::Workflows,
|
||||
]);
|
||||
|
||||
warp_drive_filters.insert(QueryFilter::EnvironmentVariables);
|
||||
|
||||
if AISettings::as_ref(ctx).is_any_ai_enabled(ctx) {
|
||||
warp_drive_filters.insert(QueryFilter::AgentModeWorkflows);
|
||||
}
|
||||
mixer.add_sync_source(self.warp_drive_data_source.clone(), warp_drive_filters);
|
||||
}
|
||||
|
||||
mixer.add_sync_source(
|
||||
self.actions_data_source.clone(),
|
||||
HashSet::from([QueryFilter::Actions]),
|
||||
);
|
||||
|
||||
if let Some(new_session_data_source) = &self.new_session_data_source {
|
||||
mixer.add_sync_source(
|
||||
new_session_data_source.clone(),
|
||||
HashSet::from([QueryFilter::Actions]),
|
||||
);
|
||||
}
|
||||
|
||||
if FeatureFlag::CommandPaletteFileSearch.is_enabled() && !is_shared_session_viewer {
|
||||
let file_search_model = FileSearchModel::as_ref(ctx);
|
||||
let repo_root = file_search_model.repo_root(ctx);
|
||||
let is_in_git_repo = repo_root.is_some();
|
||||
|
||||
let files_data_source = if is_in_git_repo {
|
||||
ctx.add_model(|_| files::data_source::FileDataSource::new())
|
||||
} else {
|
||||
ctx.add_model(|ctx| files::data_source::FileDataSource::new_current_folder(ctx))
|
||||
};
|
||||
mixer.add_async_source(
|
||||
files_data_source,
|
||||
HashSet::from([QueryFilter::Files]),
|
||||
AddAsyncSourceOptions {
|
||||
debounce_interval: None,
|
||||
run_in_zero_state: true,
|
||||
run_when_unfiltered: true,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
// Add conversation search if AI is enabled
|
||||
if AISettings::as_ref(ctx).is_any_ai_enabled(ctx) {
|
||||
mixer.add_sync_source(
|
||||
self.all_conversation_data_source.clone(),
|
||||
HashSet::from([QueryFilter::Conversations]),
|
||||
);
|
||||
|
||||
mixer.add_sync_source(
|
||||
self.historical_conversation_data_source.clone(),
|
||||
HashSet::from([QueryFilter::HistoricalConversations]),
|
||||
);
|
||||
}
|
||||
|
||||
mixer.add_sync_source(
|
||||
self.repo_data_source.clone(),
|
||||
HashSet::from([QueryFilter::Repos]),
|
||||
);
|
||||
|
||||
ctx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns a [`QueryResult`] from the data sources identified by the `summary`. `None` if none
|
||||
/// of the data sources contained an item with given summary.
|
||||
pub fn query_result_from_summary(
|
||||
&self,
|
||||
summary: &ItemSummary,
|
||||
app: &AppContext,
|
||||
) -> Option<QueryResult<CommandPaletteItemAction>> {
|
||||
match summary {
|
||||
ItemSummary::Action { binding_id } => self
|
||||
.actions_data_source
|
||||
.as_ref(app)
|
||||
.query_result(*binding_id),
|
||||
ItemSummary::Workflow { id } => self
|
||||
.warp_drive_data_source
|
||||
.as_ref(app)
|
||||
.query_result(id, app),
|
||||
ItemSummary::EnvVarCollection { id } => self
|
||||
.warp_drive_data_source
|
||||
.as_ref(app)
|
||||
.query_result(id, app),
|
||||
ItemSummary::Notebook { id } => self
|
||||
.warp_drive_data_source
|
||||
.as_ref(app)
|
||||
.query_result(id, app),
|
||||
ItemSummary::Session { pane_view_locator } => self
|
||||
.sessions_data_source
|
||||
.as_ref(app)
|
||||
.query_result(*pane_view_locator, app),
|
||||
ItemSummary::LaunchConfiguration => {
|
||||
// TODO(CLD-205): Launch configurations are not supported in the recent section of the
|
||||
// zero state yet.
|
||||
None
|
||||
}
|
||||
ItemSummary::CloudObject => {
|
||||
// We don't yet support all cloud objects in the command palette but
|
||||
// we have a `ViewInWarpDrive` action that supports all of them, so
|
||||
// this is necessary to make the compiler happy.
|
||||
None
|
||||
}
|
||||
ItemSummary::NewSession { id } => self
|
||||
.new_session_data_source
|
||||
.as_ref()
|
||||
.and_then(|source| source.as_ref(app).query_result(id)),
|
||||
ItemSummary::File {
|
||||
path,
|
||||
project_directory,
|
||||
line_and_column_arg,
|
||||
} => {
|
||||
// Create a file search item from the summary
|
||||
use crate::search::command_palette::files::search_item::FileSearchItem;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
|
||||
let search_item = FileSearchItem {
|
||||
path: PathBuf::from(path),
|
||||
project_directory: project_directory.clone(),
|
||||
match_result: FuzzyMatchResult::no_match(),
|
||||
line_and_column_arg: *line_and_column_arg,
|
||||
is_directory: false,
|
||||
};
|
||||
Some(QueryResult::from(search_item))
|
||||
}
|
||||
ItemSummary::Directory {
|
||||
path,
|
||||
project_directory,
|
||||
} => {
|
||||
// Create a directory search item from the summary
|
||||
use crate::search::command_palette::files::search_item::FileSearchItem;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
|
||||
let search_item = FileSearchItem {
|
||||
path: PathBuf::from(path),
|
||||
project_directory: project_directory.clone(),
|
||||
match_result: FuzzyMatchResult::no_match(),
|
||||
line_and_column_arg: None,
|
||||
is_directory: true,
|
||||
};
|
||||
Some(QueryResult::from(search_item))
|
||||
}
|
||||
ItemSummary::Project { path: _ } => {
|
||||
// For project summaries, we would need a project data source to reconstruct the item,
|
||||
// but this is typically handled by the welcome palette, not the command palette.
|
||||
// For now, return None as projects aren't expected in the regular command palette.
|
||||
None
|
||||
}
|
||||
ItemSummary::Conversation { id } => conversations::DataSource::query_result(id, app),
|
||||
|
||||
ItemSummary::NewConversation => {
|
||||
// The new conversation item should not show up in the recent command list,
|
||||
// as its use is specific to the conversation filter.
|
||||
None
|
||||
}
|
||||
|
||||
ItemSummary::ForkConversation => {
|
||||
// The forked conversation item should not show up in the recent command list,
|
||||
// as its use is specific to the conversation filter.
|
||||
None
|
||||
}
|
||||
|
||||
ItemSummary::NoOp => {
|
||||
// No-op action (used for non-interactable separator items that don't do anything on click).
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a [`QueryResult`] for a binding with `binding_id`. `None` if no result was found
|
||||
/// with the given ID.
|
||||
pub fn query_result_for_binding_id(
|
||||
&self,
|
||||
binding_id: BindingId,
|
||||
app: &AppContext,
|
||||
) -> Option<QueryResult<CommandPaletteItemAction>> {
|
||||
self.query_result_from_summary(&ItemSummary::Action { binding_id }, app)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DataSourceStore {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "data_sources_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,308 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use settings::manager::SettingsManager;
|
||||
use warpui::{App, SingletonEntity};
|
||||
|
||||
use super::*;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::Owner;
|
||||
use crate::notebooks::manager::NotebookManager;
|
||||
use crate::notebooks::CloudNotebookModel;
|
||||
use crate::server::ids::ServerId;
|
||||
use crate::server::ids::SyncId::{self};
|
||||
use crate::settings::AISettings;
|
||||
use crate::workflows::workflow::Workflow;
|
||||
use crate::workflows::CloudWorkflowModel;
|
||||
use crate::{
|
||||
cloud_object::{
|
||||
model::{persistence::CloudModel, view::CloudViewModel},
|
||||
Revision, ServerMetadata, ServerNotebook, ServerPermissions, ServerWorkflow,
|
||||
},
|
||||
network::NetworkStatus,
|
||||
notebooks::NotebookId,
|
||||
search::data_source::Query,
|
||||
server::{
|
||||
cloud_objects::update_manager::UpdateManager, server_api::ServerApiProvider,
|
||||
sync_queue::SyncQueue,
|
||||
},
|
||||
system::SystemStats,
|
||||
workflows::WorkflowId,
|
||||
workspaces::{
|
||||
team_tester::TeamTesterStatus, user_profiles::UserProfiles, user_workspaces::UserWorkspaces,
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::server::server_api::object::MockObjectClient;
|
||||
#[cfg(test)]
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
#[cfg(test)]
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
|
||||
fn mock_server_metadata() -> ServerMetadata {
|
||||
ServerMetadata {
|
||||
uid: ServerId::default(),
|
||||
revision: Revision::now(),
|
||||
metadata_last_updated_ts: Utc::now().into(),
|
||||
trashed_ts: None,
|
||||
folder_id: None,
|
||||
is_welcome_object: false,
|
||||
creator_uid: None,
|
||||
last_editor_uid: None,
|
||||
current_editor_uid: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_server_permissions(owner: Owner) -> ServerPermissions {
|
||||
ServerPermissions {
|
||||
space: owner,
|
||||
guests: Vec::new(),
|
||||
anyone_link_sharing: None,
|
||||
permissions_last_updated_ts: Utc::now().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_server_workflow(id: WorkflowId, owner: Owner) -> ServerWorkflow {
|
||||
ServerWorkflow {
|
||||
id: SyncId::ServerId(id.into()),
|
||||
metadata: mock_server_metadata(),
|
||||
permissions: mock_server_permissions(owner),
|
||||
model: CloudWorkflowModel::new(Workflow::new(format!("foo{id}"), format!("bar{id}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_server_notebook(id: NotebookId, owner: Owner) -> ServerNotebook {
|
||||
ServerNotebook {
|
||||
id: SyncId::ServerId(id.into()),
|
||||
metadata: mock_server_metadata(),
|
||||
permissions: mock_server_permissions(owner),
|
||||
model: CloudNotebookModel {
|
||||
title: format!("foo{id}"),
|
||||
data: format!("bar{id}"),
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_app(app: &mut App) {
|
||||
// Add the necessary singleton models to the App
|
||||
app.add_singleton_model(|_| NetworkStatus::new());
|
||||
app.add_singleton_model(|_| SystemStats::new());
|
||||
let mock_team_client = Arc::new(MockTeamClient::new());
|
||||
let mock_workspace_client = Arc::new(MockWorkspaceClient::new());
|
||||
app.add_singleton_model(|ctx| {
|
||||
UserWorkspaces::mock(
|
||||
mock_team_client.clone(),
|
||||
mock_workspace_client.clone(),
|
||||
vec![],
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
app.add_singleton_model(TeamTesterStatus::new);
|
||||
app.add_singleton_model(SyncQueue::mock);
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|ctx| UpdateManager::new(None, Arc::new(MockObjectClient::new()), ctx));
|
||||
app.add_singleton_model(|_| UserProfiles::new(Vec::new()));
|
||||
app.add_singleton_model(CloudViewModel::new);
|
||||
app.add_singleton_model(NotebookManager::mock);
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
app.add_singleton_model(|_| SettingsManager::default());
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.update(crate::settings::init_and_register_user_preferences);
|
||||
app.update(AISettings::register_and_subscribe_to_events);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drive_data_source_correctly_filters_drive_filter() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
// Initialize CloudModel
|
||||
CloudModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.upsert_from_server_notebook(
|
||||
mock_server_notebook(1.into(), Owner::mock_current_user()),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_workflow(
|
||||
mock_server_workflow(2.into(), Owner::mock_current_user()),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
let mixer = app.add_model(|_| CommandPaletteMixer::new());
|
||||
let data_source_handle = app.add_model(warp_drive::DataSource::new);
|
||||
mixer.update(&mut app, |mixer, ctx| {
|
||||
// Add the drive data source with the relevant filters
|
||||
mixer.add_sync_source(
|
||||
data_source_handle,
|
||||
[
|
||||
QueryFilter::Drive,
|
||||
QueryFilter::Notebooks,
|
||||
QueryFilter::Workflows,
|
||||
],
|
||||
);
|
||||
|
||||
// Run the query with the drive filter
|
||||
mixer.run_query(
|
||||
Query {
|
||||
filters: HashSet::from([QueryFilter::Drive]),
|
||||
text: "foo".into(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
app.read(|app| {
|
||||
let results = mixer.as_ref(app).results();
|
||||
|
||||
// Expect both of the results to be included
|
||||
assert_eq!(results.len(), 2);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drive_data_source_correctly_filters_no_filter() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
// Initialize CloudModel
|
||||
CloudModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.upsert_from_server_notebook(
|
||||
mock_server_notebook(1.into(), Owner::mock_current_user()),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_workflow(
|
||||
mock_server_workflow(2.into(), Owner::mock_current_user()),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let mixer = app.add_model(|_| CommandPaletteMixer::new());
|
||||
let data_source_handle = app.add_model(warp_drive::DataSource::new);
|
||||
mixer.update(&mut app, |mixer, ctx| {
|
||||
// Add the drive data source with the relevant filters
|
||||
mixer.add_sync_source(
|
||||
data_source_handle,
|
||||
[
|
||||
QueryFilter::Drive,
|
||||
QueryFilter::Notebooks,
|
||||
QueryFilter::Workflows,
|
||||
],
|
||||
);
|
||||
|
||||
// Run the query with no filter
|
||||
mixer.run_query(
|
||||
Query {
|
||||
filters: HashSet::new(),
|
||||
text: "foo".into(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
app.read(|app| {
|
||||
let results = mixer.as_ref(app).results();
|
||||
|
||||
// Expect both of the results to be included
|
||||
assert_eq!(results.len(), 2);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drive_data_source_correctly_filters_workflow_filter() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
// Initialize CloudModel
|
||||
CloudModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.upsert_from_server_notebook(
|
||||
mock_server_notebook(1.into(), Owner::mock_current_user()),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_workflow(
|
||||
mock_server_workflow(2.into(), Owner::mock_current_user()),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let mixer = app.add_model(|_| CommandPaletteMixer::new());
|
||||
let data_source_handle = app.add_model(warp_drive::DataSource::new);
|
||||
mixer.update(&mut app, |mixer, ctx| {
|
||||
// Add the drive data source with the relevant filters
|
||||
mixer.add_sync_source(
|
||||
data_source_handle,
|
||||
[
|
||||
QueryFilter::Drive,
|
||||
QueryFilter::Notebooks,
|
||||
QueryFilter::Workflows,
|
||||
],
|
||||
);
|
||||
|
||||
// Run the query with no filter
|
||||
mixer.run_query(
|
||||
Query {
|
||||
filters: HashSet::from([QueryFilter::Workflows]),
|
||||
text: "foo".into(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
app.read(|app| {
|
||||
let results = mixer.as_ref(app).results();
|
||||
|
||||
// Expect only the workflow result to be included
|
||||
assert_eq!(results.len(), 1);
|
||||
|
||||
assert!(results[0].accessibility_label().starts_with("Workflow:"));
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drive_data_source_correctly_filters_notebook_filter() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
// Initialize CloudModel
|
||||
CloudModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.upsert_from_server_notebook(
|
||||
mock_server_notebook(1.into(), Owner::mock_current_user()),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_workflow(
|
||||
mock_server_workflow(2.into(), Owner::mock_current_user()),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let mixer = app.add_model(|_| CommandPaletteMixer::new());
|
||||
let data_source_handle = app.add_model(warp_drive::DataSource::new);
|
||||
mixer.update(&mut app, |mixer, ctx| {
|
||||
// Add the drive data source with the relevant filters
|
||||
mixer.add_sync_source(
|
||||
data_source_handle,
|
||||
[
|
||||
QueryFilter::Drive,
|
||||
QueryFilter::Notebooks,
|
||||
QueryFilter::Workflows,
|
||||
],
|
||||
);
|
||||
|
||||
// Run the query with no filter
|
||||
mixer.run_query(
|
||||
Query {
|
||||
filters: HashSet::from([QueryFilter::Notebooks]),
|
||||
text: "foo".into(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
app.read(|app| {
|
||||
let results = mixer.as_ref(app).results();
|
||||
|
||||
// Expect only the workflow result to be included
|
||||
assert_eq!(results.len(), 1);
|
||||
|
||||
assert!(results[0].accessibility_label().starts_with("Notebook:"));
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
#![cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
use super::search_item::{CreateFileSearchItem, FileSearchItem};
|
||||
use crate::code::opened_files::OpenedFilesModel;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::files::model::FileSearchModel;
|
||||
use crate::search::files::search_item::FileSearchResult;
|
||||
use crate::search::mixer::{AsyncDataSource, BoxFuture, DataSourceRunErrorWrapper};
|
||||
use futures_lite::FutureExt;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use instant::Instant;
|
||||
use itertools::Itertools;
|
||||
use std::collections::HashSet;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use warp_util::path::CleanPathResult;
|
||||
use warpui::{AppContext, Entity, SingletonEntity};
|
||||
|
||||
const MAX_RESULTS: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum FileRanking {
|
||||
None,
|
||||
ChangedInGit,
|
||||
OpenedInWarp { timestamp: Instant },
|
||||
}
|
||||
|
||||
pub struct FileDataSource {
|
||||
mode: FileDataSourceMode,
|
||||
}
|
||||
|
||||
enum FileDataSourceMode {
|
||||
/// Search across the repository (existing behavior)
|
||||
Repo,
|
||||
/// Search within the current folder only, using cached contents computed at creation time
|
||||
CurrentFolder {
|
||||
cached_contents: Vec<FileSearchResult>,
|
||||
},
|
||||
}
|
||||
|
||||
impl FileDataSource {
|
||||
pub fn new() -> Self {
|
||||
// Default to repo search to preserve existing call sites
|
||||
Self {
|
||||
mode: FileDataSourceMode::Repo,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a data source that searches only within the current folder.
|
||||
/// This will read folder contents once at creation and reuse them for subsequent queries.
|
||||
pub fn new_current_folder(app: &AppContext) -> Self {
|
||||
let file_search_model = FileSearchModel::as_ref(app);
|
||||
let contents = file_search_model.get_folder_contents(app);
|
||||
Self {
|
||||
mode: FileDataSourceMode::CurrentFolder {
|
||||
cached_contents: contents,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncDataSource for FileDataSource {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> BoxFuture<'static, Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper>> {
|
||||
// Get the search query text
|
||||
let query_text = &query.text;
|
||||
|
||||
// Early exit for very broad wildcard patterns that would match everything
|
||||
if FileSearchModel::should_skip_overly_broad_query(query_text) {
|
||||
return futures::future::ready(Ok(vec![])).boxed();
|
||||
}
|
||||
|
||||
// Zero state: fetch git-changed files and prioritize them
|
||||
if query_text.is_empty() {
|
||||
self.run_zero_state_query(app)
|
||||
} else {
|
||||
// Non-empty query: use fuzzy matching
|
||||
self.run_fuzzy_search_query(app, query_text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileDataSource {
|
||||
fn contents_with_git_changes(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
) -> (Arc<Vec<FileSearchResult>>, HashSet<String>) {
|
||||
match &self.mode {
|
||||
FileDataSourceMode::Repo => {
|
||||
let file_search_model = FileSearchModel::as_ref(app);
|
||||
file_search_model.get_repo_contents_with_git_status(app)
|
||||
}
|
||||
FileDataSourceMode::CurrentFolder { cached_contents } => {
|
||||
(Arc::new(cached_contents.clone()), HashSet::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn contents(&self, app: &AppContext) -> Arc<Vec<FileSearchResult>> {
|
||||
match &self.mode {
|
||||
FileDataSourceMode::Repo => {
|
||||
let file_search_model = FileSearchModel::as_ref(app);
|
||||
file_search_model.get_repo_contents(app)
|
||||
}
|
||||
FileDataSourceMode::CurrentFolder { cached_contents } => {
|
||||
Arc::new(cached_contents.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle zero state query - prioritize git-changed files without fuzzy matching
|
||||
fn run_zero_state_query(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
) -> BoxFuture<
|
||||
'static,
|
||||
Result<Vec<QueryResult<CommandPaletteItemAction>>, DataSourceRunErrorWrapper>,
|
||||
> {
|
||||
let file_search_model = FileSearchModel::as_ref(app);
|
||||
|
||||
let (contents, git_changed_files) = self.contents_with_git_changes(app);
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
let opened_files = OpenedFilesModel::as_ref(app);
|
||||
|
||||
let repo_root = file_search_model.repo_root(app);
|
||||
let opened_files =
|
||||
repo_root.and_then(|repo_root| opened_files.opened_files_for_repo(&repo_root));
|
||||
|
||||
for item in contents.iter() {
|
||||
let mut file_ranking = if git_changed_files.contains(&item.path) {
|
||||
FileRanking::ChangedInGit
|
||||
} else {
|
||||
FileRanking::None
|
||||
};
|
||||
|
||||
if let Some(last_opened_timestamp) =
|
||||
opened_files.and_then(|opened_files| opened_files.get(&PathBuf::from(&item.path)))
|
||||
{
|
||||
file_ranking = FileRanking::OpenedInWarp {
|
||||
timestamp: *last_opened_timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
let match_result = FuzzyMatchResult {
|
||||
score: 0,
|
||||
matched_indices: vec![], // No highlighting needed for zero state
|
||||
};
|
||||
|
||||
let search_item = FileSearchItem {
|
||||
path: PathBuf::from(&item.path),
|
||||
project_directory: item.project_directory.clone(),
|
||||
match_result,
|
||||
line_and_column_arg: None,
|
||||
is_directory: item.is_directory,
|
||||
};
|
||||
results.push((file_ranking, QueryResult::from(search_item)));
|
||||
}
|
||||
|
||||
results.sort_by_key(|(ranking, _)| *ranking);
|
||||
|
||||
Box::pin(async move { Ok(results.into_iter().map(|(_, ranking)| ranking).collect()) })
|
||||
}
|
||||
|
||||
/// Handle non-empty query with fuzzy matching (no git status needed)
|
||||
fn run_fuzzy_search_query(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
query_text: &str,
|
||||
) -> BoxFuture<
|
||||
'static,
|
||||
Result<Vec<QueryResult<CommandPaletteItemAction>>, DataSourceRunErrorWrapper>,
|
||||
> {
|
||||
let file_search_model = FileSearchModel::as_ref(app);
|
||||
|
||||
let contents = self.contents(app);
|
||||
|
||||
// Strip any trailing : in case user is in the middle of typing a line / column arg.
|
||||
let query_text = query_text.strip_suffix(':').unwrap_or(query_text);
|
||||
|
||||
let text = CleanPathResult::with_line_and_column_number(query_text);
|
||||
let query_file_content = text.path;
|
||||
|
||||
let opened_files = OpenedFilesModel::as_ref(app);
|
||||
|
||||
let repo_root = file_search_model.repo_root(app);
|
||||
|
||||
// For the "Create file" fallback, use the expanded (but not repo-root-stripped)
|
||||
// path so that absolute paths work correctly with Path::join.
|
||||
let query_file_name = shellexpand::tilde(&query_file_content).into_owned();
|
||||
|
||||
// Get the current directory for the "Create file" option and for path stripping.
|
||||
#[cfg(feature = "local_fs")]
|
||||
let current_directory = {
|
||||
use crate::workspace::ActiveSession;
|
||||
let active_window_id = app.windows().state().active_window;
|
||||
active_window_id
|
||||
.and_then(|window_id| ActiveSession::as_ref(app).path_if_local(window_id))
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
};
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
let current_directory: Option<String> = None;
|
||||
|
||||
// If the query looks like an absolute path, strip the common prefix with the
|
||||
// repo root (first) or working directory (second) so it can match against the
|
||||
// relative paths stored in the file index. This allows users to paste absolute
|
||||
// paths — e.g. copied via "Copy file path" in the Code Review pane — directly
|
||||
// into the Command-Palette file picker. We pass the tilde-expanded
|
||||
// `query_file_name` so that `~/...` paths are also handled.
|
||||
#[cfg(feature = "local_fs")]
|
||||
let query_file_content = FileSearchModel::strip_absolute_path_prefix(
|
||||
&query_file_name,
|
||||
repo_root.as_deref(),
|
||||
current_directory.as_deref().map(Path::new),
|
||||
)
|
||||
.unwrap_or(query_file_content);
|
||||
|
||||
let opened_files = repo_root
|
||||
.and_then(|repo_root| opened_files.opened_files_for_repo(&repo_root))
|
||||
.cloned();
|
||||
|
||||
const CHUNK_SIZE: usize = 50;
|
||||
|
||||
Box::pin(async move {
|
||||
let mut results = Vec::with_capacity(contents.len());
|
||||
|
||||
// Iterate in chunks of 50, yielding at the end of each chunk to
|
||||
// allow the main thread to abort the search if needed.
|
||||
for chunk in contents.chunks(CHUNK_SIZE) {
|
||||
for item in chunk {
|
||||
let Some(mut match_result) =
|
||||
FileSearchModel::fuzzy_match_path(&item.path, &query_file_content)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Never show directories -- there's no way to open them currently.
|
||||
if item.is_directory {
|
||||
continue;
|
||||
}
|
||||
|
||||
if opened_files
|
||||
.as_ref()
|
||||
.and_then(|opened_files| opened_files.get(&PathBuf::from(&item.path)))
|
||||
.is_some()
|
||||
{
|
||||
// Apply a boost to opened files to rank them above non-opened files.
|
||||
match_result.score += 100;
|
||||
};
|
||||
|
||||
let search_item = FileSearchItem {
|
||||
path: PathBuf::from(&item.path),
|
||||
project_directory: item.project_directory.clone(),
|
||||
line_and_column_arg: text.line_and_column_num,
|
||||
match_result,
|
||||
is_directory: item.is_directory,
|
||||
};
|
||||
results.push(search_item);
|
||||
}
|
||||
futures_lite::future::yield_now().await;
|
||||
}
|
||||
|
||||
let mut results: Vec<QueryResult<CommandPaletteItemAction>> = results
|
||||
.into_iter()
|
||||
.k_largest_relaxed_by_key(MAX_RESULTS, |item| item.match_result.score)
|
||||
.map(QueryResult::from)
|
||||
.collect();
|
||||
|
||||
// If no files matched and we have a valid query and current directory,
|
||||
// add a "Create <filename>..." option
|
||||
if results.is_empty() && !query_file_name.trim().is_empty() {
|
||||
if let Some(current_dir) = current_directory {
|
||||
let create_item = CreateFileSearchItem {
|
||||
file_name: query_file_name,
|
||||
current_directory: current_dir,
|
||||
};
|
||||
results.push(QueryResult::from(create_item));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for FileDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod data_source;
|
||||
pub mod search_item;
|
||||
@@ -0,0 +1,210 @@
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use std::fmt::Debug;
|
||||
use std::path::PathBuf;
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::styles;
|
||||
use crate::search::item::{IconLocation, SearchItem};
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use warpui::elements::{Align, ConstrainedBox, Container, Flex, Icon, ParentElement, Text};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
use crate::search::files::icon::icon_from_file_path;
|
||||
use crate::ui_components::render_file_search_row::{render_file_search_row, FileSearchRowOptions};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FileSearchItem {
|
||||
pub path: PathBuf,
|
||||
pub project_directory: String,
|
||||
pub match_result: FuzzyMatchResult,
|
||||
pub line_and_column_arg: Option<LineAndColumnArg>,
|
||||
pub is_directory: bool,
|
||||
}
|
||||
|
||||
impl SearchItem for FileSearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(if self.is_directory {
|
||||
Icon::new(
|
||||
"bundled/svg/completion-folder.svg",
|
||||
highlight_state.icon_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish()
|
||||
} else {
|
||||
icon_from_file_path(&self.path.to_string_lossy(), appearance, highlight_state)
|
||||
})
|
||||
.with_width(styles::SEARCH_ITEM_TEXT_PADDING * 4.0)
|
||||
.with_height(styles::SEARCH_ITEM_TEXT_PADDING * 4.0)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(styles::SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn icon_location(&self, _appearance: &Appearance) -> IconLocation {
|
||||
IconLocation::Centered
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
render_file_search_row(
|
||||
&self.path,
|
||||
FileSearchRowOptions {
|
||||
match_result: Some(&self.match_result),
|
||||
highlight_state,
|
||||
..Default::default()
|
||||
},
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
if self.is_directory {
|
||||
CommandPaletteItemAction::OpenDirectory {
|
||||
path: self.path.to_string_lossy().to_string(),
|
||||
project_directory: self.project_directory.clone(),
|
||||
}
|
||||
} else {
|
||||
CommandPaletteItemAction::OpenFile {
|
||||
path: self.path.to_string_lossy().to_string(),
|
||||
project_directory: self.project_directory.clone(),
|
||||
line_and_column_arg: self.line_and_column_arg,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
if self.is_directory {
|
||||
format!("Directory: {}", self.path.display())
|
||||
} else {
|
||||
format!("File: {}", self.path.display())
|
||||
}
|
||||
}
|
||||
|
||||
fn accessibility_help_message(&self) -> Option<String> {
|
||||
Some(if self.is_directory {
|
||||
"Press Enter to navigate to this directory".to_string()
|
||||
} else {
|
||||
"Press Enter to open this file".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn render_details(&self, _ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A search item for creating a new file with the specified name
|
||||
#[derive(Debug)]
|
||||
pub struct CreateFileSearchItem {
|
||||
pub file_name: String,
|
||||
pub current_directory: String,
|
||||
}
|
||||
|
||||
impl SearchItem for CreateFileSearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
"bundled/svg/plus-circle.svg",
|
||||
highlight_state.icon_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(styles::SEARCH_ITEM_TEXT_PADDING * 4.0)
|
||||
.with_height(styles::SEARCH_ITEM_TEXT_PADDING * 4.0)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(styles::SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn icon_location(&self, _appearance: &Appearance) -> IconLocation {
|
||||
IconLocation::Centered
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let text_color = highlight_state.sub_text_fill(appearance).into_solid();
|
||||
|
||||
let label = Text::new_inline(
|
||||
format!("Create {}…", &self.file_name),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(text_color)
|
||||
.with_style(Properties::default().weight(Weight::Normal))
|
||||
.finish();
|
||||
|
||||
ConstrainedBox::new(
|
||||
Align::new(Flex::row().with_child(label).finish())
|
||||
.left()
|
||||
.finish(),
|
||||
)
|
||||
.with_height(40.0)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
// Give it a very low score so it appears at the bottom
|
||||
OrderedFloat(-100000.0)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::CreateFile {
|
||||
file_name: self.file_name.clone(),
|
||||
current_directory: self.current_directory.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Create file: {}", self.file_name)
|
||||
}
|
||||
|
||||
fn accessibility_help_message(&self) -> Option<String> {
|
||||
Some(format!(
|
||||
"Press Enter to create {} in the current directory",
|
||||
self.file_name
|
||||
))
|
||||
}
|
||||
|
||||
fn render_details(&self, _ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::drive::cloud_object_styling::warp_drive_icon_color;
|
||||
use crate::drive::DriveObjectType;
|
||||
use crate::search::FilterChipRenderer as CommonFilterChipRenderer;
|
||||
use crate::search::QueryFilter;
|
||||
use crate::util::color::{ContrastingColor, MinimumAllowedContrast};
|
||||
use pathfinder_color::ColorU;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Hoverable, Icon,
|
||||
MouseStateHandle, ParentElement, Radius, Text,
|
||||
};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::{Element, EventContext};
|
||||
|
||||
/// Trait to render filter chips for the command palette.
|
||||
pub trait FilterChipRenderer: crate::search::FilterChipRenderer {
|
||||
/// Renders the filter chip. When the filter chip is clicked, `on_click_fn` is called.
|
||||
fn render_filter_chip(
|
||||
&self,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
on_click_fn: fn(&mut EventContext, Self),
|
||||
) -> Box<dyn Element>;
|
||||
|
||||
/// Returns the color of the icon for the filter chip.
|
||||
fn icon_color(&self, appearance: &Appearance) -> ColorU;
|
||||
}
|
||||
|
||||
impl FilterChipRenderer for QueryFilter {
|
||||
fn render_filter_chip(
|
||||
&self,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
on_click_fn: fn(&mut EventContext, Self),
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let self_copy: QueryFilter = *self;
|
||||
Hoverable::new(mouse_state_handle, |mouse_state| {
|
||||
let font_size = appearance.monospace_font_size() - 2.;
|
||||
Container::new({
|
||||
let flex_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Text::new_inline(
|
||||
self.display_name(),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().surface_2())
|
||||
.into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
match self.icon_svg_path() {
|
||||
None => flex_row.finish(),
|
||||
Some(icon_name) => {
|
||||
let icon_size = font_size + self.icon_size_offset();
|
||||
|
||||
let icon = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
icon_name,
|
||||
self.icon_color(appearance).on_background(
|
||||
appearance.theme().surface_2().into_solid(),
|
||||
MinimumAllowedContrast::NonText,
|
||||
),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(self.icon_margin_top());
|
||||
flex_row
|
||||
.with_child(icon.with_margin_left(8.).finish())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
})
|
||||
.with_vertical_padding(styles::vertical_padding(mouse_state))
|
||||
.with_horizontal_padding(styles::horizontal_padding(mouse_state))
|
||||
.with_background(styles::background_fill(mouse_state, theme))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.with_border(styles::border(mouse_state, theme))
|
||||
.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |event_ctx, _, _| on_click_fn(event_ctx, self_copy))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn icon_color(&self, appearance: &Appearance) -> ColorU {
|
||||
match self {
|
||||
QueryFilter::History
|
||||
| QueryFilter::NaturalLanguage
|
||||
| QueryFilter::Actions
|
||||
| QueryFilter::Sessions
|
||||
| QueryFilter::Drive
|
||||
| QueryFilter::LaunchConfigurations
|
||||
| QueryFilter::PromptHistory
|
||||
| QueryFilter::Files
|
||||
| QueryFilter::Commands
|
||||
| QueryFilter::Blocks
|
||||
| QueryFilter::Code
|
||||
| QueryFilter::Rules
|
||||
| QueryFilter::Repos
|
||||
| QueryFilter::DiffSets
|
||||
| QueryFilter::StaticSlashCommands
|
||||
| QueryFilter::Skills
|
||||
| QueryFilter::BaseModels
|
||||
| QueryFilter::FullTerminalUseModels
|
||||
| QueryFilter::CurrentDirectoryConversations => appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().surface_2())
|
||||
.into_solid(),
|
||||
QueryFilter::Conversations | QueryFilter::HistoricalConversations => appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().surface_2())
|
||||
.into_solid(),
|
||||
QueryFilter::Workflows => warp_drive_icon_color(appearance, DriveObjectType::Workflow),
|
||||
QueryFilter::Notebooks => warp_drive_icon_color(
|
||||
appearance,
|
||||
DriveObjectType::Notebook {
|
||||
is_ai_document: false,
|
||||
},
|
||||
),
|
||||
QueryFilter::Plans => warp_drive_icon_color(
|
||||
appearance,
|
||||
DriveObjectType::Notebook {
|
||||
is_ai_document: true,
|
||||
},
|
||||
),
|
||||
QueryFilter::EnvironmentVariables => {
|
||||
warp_drive_icon_color(appearance, DriveObjectType::EnvVarCollection)
|
||||
}
|
||||
QueryFilter::AgentModeWorkflows => {
|
||||
warp_drive_icon_color(appearance, DriveObjectType::AgentModeWorkflow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod styles {
|
||||
use crate::themes::theme::{Blend, Fill, WarpTheme};
|
||||
use warpui::elements::{Border, MouseState};
|
||||
|
||||
/// Size of the border when the query filter is hovered.
|
||||
const HOVERED_BORDER_SIZE: f32 = 2.;
|
||||
/// Size of the border when the query filter is _not_ hovered.
|
||||
const BORDER_SIZE: f32 = 1.;
|
||||
|
||||
/// Vertical padding when the query filter is _not_ hovered.
|
||||
const VERTICAL_PADDING: f32 = 8.;
|
||||
|
||||
/// Horizontal padding when the query filter is _not_ hovered.
|
||||
const HORIZONTAL_PADDING: f32 = 16.;
|
||||
|
||||
/// Returns the amount of vertical padding that should be applied to the query filter while also
|
||||
/// ensuring the query filter doesn't "jump" when it is hovered.
|
||||
pub fn vertical_padding(mouse_state: &MouseState) -> f32 {
|
||||
if mouse_state.is_hovered() {
|
||||
VERTICAL_PADDING - (HOVERED_BORDER_SIZE - BORDER_SIZE)
|
||||
} else {
|
||||
VERTICAL_PADDING
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the amount of horizontal padding that should be applied to the query filter while also
|
||||
/// ensuring the query filter doesn't "jump" when it is hovered.
|
||||
pub fn horizontal_padding(mouse_state: &MouseState) -> f32 {
|
||||
if mouse_state.is_hovered() {
|
||||
HORIZONTAL_PADDING - (HOVERED_BORDER_SIZE - BORDER_SIZE)
|
||||
} else {
|
||||
HORIZONTAL_PADDING
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the border that should be applied to the query filter.
|
||||
pub fn border(mouse_state: &MouseState, theme: &WarpTheme) -> Border {
|
||||
if mouse_state.is_hovered() {
|
||||
Border::all(HOVERED_BORDER_SIZE).with_border_fill(theme.accent())
|
||||
} else {
|
||||
Border::all(BORDER_SIZE).with_border_fill(theme.sub_text_color(theme.surface_2()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the background [`Fill`] that should be applied to the query filter.
|
||||
pub fn background_fill(mouse_state: &MouseState, theme: &WarpTheme) -> Fill {
|
||||
if mouse_state.is_hovered() {
|
||||
theme
|
||||
.surface_2()
|
||||
.blend(&theme.dark_overlay().with_opacity(25))
|
||||
} else {
|
||||
theme.surface_2()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
use crate::launch_configs::launch_config::LaunchConfig;
|
||||
use crate::search::command_palette::launch_config::search_item::SearchItem;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::data_source::{DataSourceSearchError, Query, QueryResult};
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
use crate::user_config::{WarpConfig, WarpConfigUpdateEvent};
|
||||
use fuzzy_match::match_indices_case_insensitive;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
/// Datasource that searches against `LaunchConfig`s.
|
||||
pub struct DataSource {
|
||||
searcher: Box<dyn LaunchConfigSearcher>,
|
||||
}
|
||||
|
||||
impl DataSource {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
if warp_core::features::FeatureFlag::UseTantivySearch.is_enabled() {
|
||||
Self::new_full_text(ctx)
|
||||
} else {
|
||||
Self::new_fuzzy(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self::new_fuzzy(ctx)
|
||||
}
|
||||
|
||||
fn new_fuzzy(ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(&WarpConfig::handle(ctx), Self::handle_config_event);
|
||||
let mut searcher = Box::new(FuzzyLaunchConfigSearcher::default());
|
||||
searcher.refresh_search_index(ctx);
|
||||
Self { searcher }
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn new_full_text(ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(&WarpConfig::handle(ctx), Self::handle_config_event);
|
||||
let mut searcher = Box::new(full_text_searcher::FullTextLaunchConfigSearcher::new(
|
||||
ctx.background_executor(),
|
||||
));
|
||||
searcher.refresh_search_index(ctx);
|
||||
Self { searcher }
|
||||
}
|
||||
|
||||
fn handle_config_event(&mut self, event: &WarpConfigUpdateEvent, ctx: &mut ModelContext<Self>) {
|
||||
if matches!(event, WarpConfigUpdateEvent::LaunchConfigs) {
|
||||
self.searcher.refresh_search_index(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SyncDataSource for DataSource {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
_app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
Ok(self
|
||||
.searcher
|
||||
.search(&query.text.trim().to_lowercase())
|
||||
.map_err(|err| {
|
||||
Box::new(DataSourceSearchError {
|
||||
message: err.to_string(),
|
||||
}) as DataSourceRunErrorWrapper
|
||||
})?
|
||||
.into_iter()
|
||||
.map(QueryResult::from)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DataSource {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
trait LaunchConfigSearcher {
|
||||
fn search(&self, search_term: &str) -> anyhow::Result<Vec<SearchItem>>;
|
||||
|
||||
fn refresh_search_index(&mut self, app: &AppContext);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FuzzyLaunchConfigSearcher {
|
||||
configs: HashMap<String, LaunchConfig>,
|
||||
}
|
||||
|
||||
impl LaunchConfigSearcher for FuzzyLaunchConfigSearcher {
|
||||
fn search(&self, search_term: &str) -> anyhow::Result<Vec<SearchItem>> {
|
||||
Ok(self
|
||||
.configs
|
||||
.values()
|
||||
.filter_map(|launch_config| {
|
||||
let match_result =
|
||||
match_indices_case_insensitive(&launch_config.name, search_term)?;
|
||||
|
||||
Some(SearchItem::new(
|
||||
Arc::new(launch_config.clone()),
|
||||
match_result,
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn refresh_search_index(&mut self, app: &AppContext) {
|
||||
self.configs = WarpConfig::as_ref(app)
|
||||
.launch_configs()
|
||||
.iter()
|
||||
.map(|config| (config.name.to_lowercase(), config.clone()))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod full_text_searcher {
|
||||
use crate::define_search_schema;
|
||||
use crate::launch_configs::launch_config::LaunchConfig;
|
||||
use crate::search::command_palette::launch_config::data_source::LaunchConfigSearcher;
|
||||
use crate::search::command_palette::launch_config::search_item::SearchItem;
|
||||
use crate::search::searcher::{AsyncSearcher, DEFAULT_MEMORY_BUDGET, SCORE_CONVERSION_FACTOR};
|
||||
use crate::user_config::WarpConfig;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use warpui::r#async::executor::Background;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
// The name of the launch configs are duplicated to ensure that the searcher
|
||||
// hashes the name to uniquely identify the launch config.
|
||||
// Also, it makes sense from a schema POV: the name is the identifying key.
|
||||
// TODO: Add a proper Launch Config ID
|
||||
define_search_schema!(
|
||||
schema_name: LAUNCH_CONFIG_SCHEMA,
|
||||
config_name: ConfigSearcherConfig,
|
||||
search_doc: LaunchConfigDocument,
|
||||
identifying_doc: LaunchConfigIdDocument,
|
||||
search_fields: [name: 1.0],
|
||||
id_fields: [name_id: String]
|
||||
);
|
||||
|
||||
pub(crate) struct FullTextLaunchConfigSearcher {
|
||||
background_executor: Arc<Background>,
|
||||
searcher: AsyncSearcher<ConfigSearcherConfig>,
|
||||
configs: HashMap<String, LaunchConfig>,
|
||||
}
|
||||
|
||||
impl LaunchConfigSearcher for FullTextLaunchConfigSearcher {
|
||||
fn search(&self, search_term: &str) -> anyhow::Result<Vec<SearchItem>> {
|
||||
if search_term.is_empty() {
|
||||
return Ok(self
|
||||
.configs
|
||||
.values()
|
||||
.map(|config| {
|
||||
SearchItem::new(Arc::new(config.clone()), FuzzyMatchResult::no_match())
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
|
||||
Ok(self
|
||||
.searcher
|
||||
.search_id(search_term)?
|
||||
.into_iter()
|
||||
.filter_map(|match_result| {
|
||||
let launch_config = self.configs.get(&match_result.values.name_id)?;
|
||||
let match_result = FuzzyMatchResult {
|
||||
score: (match_result.score * SCORE_CONVERSION_FACTOR) as i64,
|
||||
matched_indices: match_result.highlights.name,
|
||||
};
|
||||
|
||||
Some(SearchItem::new(
|
||||
Arc::new(launch_config.clone()),
|
||||
match_result,
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn refresh_search_index(&mut self, app: &AppContext) {
|
||||
self.configs = WarpConfig::as_ref(app)
|
||||
.launch_configs()
|
||||
.iter()
|
||||
.map(|config| (config.name.to_lowercase(), config.clone()))
|
||||
.collect();
|
||||
if self.rebuild_search_index().is_err() {
|
||||
log::error!("Failed to create search index writer for launch configs");
|
||||
self.clear_search_index();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FullTextLaunchConfigSearcher {
|
||||
pub(crate) fn new(background_executor: Arc<Background>) -> Self {
|
||||
Self {
|
||||
background_executor: background_executor.clone(),
|
||||
searcher: LAUNCH_CONFIG_SCHEMA
|
||||
.create_async_searcher(DEFAULT_MEMORY_BUDGET, background_executor),
|
||||
configs: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild_search_index(&mut self) -> Result<(), anyhow::Error> {
|
||||
self.clear_search_index();
|
||||
let documents = self.configs.keys().map(|name| LaunchConfigDocument {
|
||||
name: name.clone(),
|
||||
name_id: name.clone(),
|
||||
});
|
||||
self.searcher.build_index_async(documents)
|
||||
}
|
||||
|
||||
fn clear_search_index(&mut self) {
|
||||
if self.searcher.clear_search_index_async().is_err() {
|
||||
// As a workaround, we can create a new index and replace the old one.
|
||||
self.searcher = LAUNCH_CONFIG_SCHEMA
|
||||
.create_async_searcher(DEFAULT_MEMORY_BUDGET, self.background_executor.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod data_source;
|
||||
mod renderer;
|
||||
mod search_item;
|
||||
|
||||
pub use data_source::DataSource;
|
||||
@@ -0,0 +1,158 @@
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, Border, ConstrainedBox, Container, CornerRadius, Flex, Highlight, ParentElement,
|
||||
Radius, Shrinkable, Text,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
ui_components::{
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
text::Span,
|
||||
},
|
||||
Element,
|
||||
};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::launch_configs::launch_config::LaunchConfig;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::themes::theme::Fill;
|
||||
|
||||
impl LaunchConfig {
|
||||
/// Renders a [`LaunchConfig`] using a [`StylesProvider`]. Any character indices of the launch
|
||||
/// config title contained within `highlighted_indices` are highlighted in bold.
|
||||
pub(super) fn render(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
item_highlight_state: ItemHighlightState,
|
||||
highlight_indices: Vec<usize>,
|
||||
) -> Box<dyn Element> {
|
||||
let bg_color = background_fill(item_highlight_state, appearance);
|
||||
|
||||
let text_color = appearance.theme().main_text_color(bg_color).into_solid();
|
||||
|
||||
let highlight = Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(text_color);
|
||||
|
||||
let label = self
|
||||
.render_launch_config_name(appearance, item_highlight_state)
|
||||
.with_single_highlight(highlight, highlight_indices)
|
||||
.finish();
|
||||
|
||||
let mut configuration = Flex::row();
|
||||
configuration.add_child(Shrinkable::new(1., Align::new(label).left().finish()).finish());
|
||||
|
||||
configuration.add_child(
|
||||
Container::new(self.render_config_description(appearance))
|
||||
.with_margin_right(14.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
ConstrainedBox::new(configuration.finish())
|
||||
.with_height(40.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn default_pill_styles(appearance: &Appearance) -> UiComponentStyles {
|
||||
UiComponentStyles {
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_size: Some(appearance.monospace_font_size()),
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.hint_text_color(appearance.theme().background())
|
||||
.into_solid(),
|
||||
),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
|
||||
background: Some(appearance.theme().background().into()),
|
||||
height: Some(24.),
|
||||
padding: Some(Coords::default().left(6.).right(6.)),
|
||||
margin: Some(Coords::default().left(3.)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_string_with_pill_styling(
|
||||
str: impl Into<String>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let style = Self::default_pill_styles(appearance);
|
||||
let mut container =
|
||||
Container::new(Align::new(Span::new(str.into(), style).build().finish()).finish());
|
||||
let mut border = Border::all(style.border_width.unwrap_or_default());
|
||||
if let Some(border_color) = style.border_color {
|
||||
border = border.with_border_fill(border_color);
|
||||
}
|
||||
container = container.with_border(border);
|
||||
if let Some(padding) = style.padding {
|
||||
container = container
|
||||
.with_padding_top(padding.top)
|
||||
.with_padding_right(padding.right)
|
||||
.with_padding_bottom(padding.bottom)
|
||||
.with_padding_left(padding.left);
|
||||
}
|
||||
if let Some(radius) = style.border_radius {
|
||||
container = container.with_corner_radius(radius);
|
||||
}
|
||||
if let Some(background_color) = style.background {
|
||||
container = container.with_background(background_color);
|
||||
}
|
||||
let mut sized_container = ConstrainedBox::new(container.finish());
|
||||
if let Some(width) = style.width {
|
||||
sized_container = sized_container.with_width(width);
|
||||
}
|
||||
if let Some(height) = style.height {
|
||||
sized_container = sized_container.with_height(height);
|
||||
}
|
||||
let mut container = Container::new(Align::new(sized_container.finish()).finish());
|
||||
if let Some(margin) = style.margin {
|
||||
container = container
|
||||
.with_margin_top(margin.top)
|
||||
.with_margin_right(margin.right)
|
||||
.with_margin_bottom(margin.bottom)
|
||||
.with_margin_left(margin.left);
|
||||
}
|
||||
container.finish()
|
||||
}
|
||||
|
||||
fn render_config_description(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let num_windows = self.windows.len();
|
||||
let num_tabs: usize = self.windows.iter().map(|window| window.tabs.len()).sum();
|
||||
let mut windows_str = num_windows.to_string();
|
||||
match num_windows {
|
||||
1 => windows_str.push_str(" window "),
|
||||
_ => windows_str.push_str(" windows"),
|
||||
}
|
||||
let mut tabs_str = num_tabs.to_string();
|
||||
match num_tabs {
|
||||
1 => tabs_str.push_str(" tab "),
|
||||
_ => tabs_str.push_str(" tabs"),
|
||||
}
|
||||
Flex::row()
|
||||
.with_children(vec![
|
||||
Self::render_string_with_pill_styling(windows_str, appearance),
|
||||
Self::render_string_with_pill_styling(tabs_str, appearance),
|
||||
])
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_launch_config_name(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
item_highlight_state: ItemHighlightState,
|
||||
) -> Text {
|
||||
let text = Text::new_inline(
|
||||
self.name.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
);
|
||||
|
||||
let bg_color = background_fill(item_highlight_state, appearance);
|
||||
text.with_color(appearance.theme().sub_text_color(bg_color).into_solid())
|
||||
}
|
||||
}
|
||||
|
||||
fn background_fill(item_highlight_state: ItemHighlightState, appearance: &Appearance) -> Fill {
|
||||
item_highlight_state
|
||||
.container_background_fill(appearance)
|
||||
.unwrap_or_else(|| appearance.theme().surface_2())
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use crate::launch_configs::launch_config::LaunchConfig;
|
||||
use crate::{appearance::Appearance, ui_components::icons::Icon};
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::render_util::render_search_item_icon;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
|
||||
use ordered_float::OrderedFloat;
|
||||
use std::sync::Arc;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
/// SearchItem for a matching [`LaunchConfig`].
|
||||
#[derive(Debug)]
|
||||
pub struct SearchItem {
|
||||
match_result: FuzzyMatchResult,
|
||||
launch_config: Arc<LaunchConfig>,
|
||||
}
|
||||
|
||||
impl SearchItem {
|
||||
pub fn new(launch_config: Arc<LaunchConfig>, match_result: FuzzyMatchResult) -> Self {
|
||||
Self {
|
||||
match_result,
|
||||
launch_config,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::search::item::SearchItem for SearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let color = appearance.theme().foreground().into_solid();
|
||||
render_search_item_icon(appearance, Icon::Navigation, color, highlight_state)
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
self.launch_config.render(
|
||||
appearance,
|
||||
highlight_state,
|
||||
self.match_result.matched_indices.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat::from(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::OpenLaunchConfiguration {
|
||||
config: self.launch_config.clone(),
|
||||
open_in_active_window: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::OpenLaunchConfiguration {
|
||||
config: self.launch_config.clone(),
|
||||
open_in_active_window: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Selected {}.", self.launch_config.name)
|
||||
}
|
||||
|
||||
fn accessibility_help_message(&self) -> Option<String> {
|
||||
Some("Press enter to use this launch configuration.".into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::launch_configs::launch_config::LaunchConfig;
|
||||
use crate::search::command_palette::new_session::{NewSessionOption, NewSessionOptionId};
|
||||
use crate::search::mixer::SearchMixer;
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::util::bindings::CommandBinding;
|
||||
use crate::workspace::PaneViewLocator;
|
||||
use std::sync::Arc;
|
||||
use strum_macros::IntoStaticStr;
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
use warpui::keymap::BindingId;
|
||||
use warpui::{EntityId, WindowId};
|
||||
|
||||
pub type CommandPaletteMixer = SearchMixer<CommandPaletteItemAction>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CommandPaletteItemAction {
|
||||
/// A binding result was clicked.
|
||||
AcceptBinding {
|
||||
binding: Arc<CommandBinding>,
|
||||
},
|
||||
ExecuteWorkflow {
|
||||
id: SyncId,
|
||||
},
|
||||
OpenNotebook {
|
||||
id: SyncId,
|
||||
},
|
||||
ViewInWarpDrive {
|
||||
id: CloudObjectTypeAndId,
|
||||
},
|
||||
InvokeEnvironmentVariables {
|
||||
id: SyncId,
|
||||
},
|
||||
/// Navigate to the session identified by `pane_view`.
|
||||
NavigateToSession {
|
||||
pane_view_locator: PaneViewLocator,
|
||||
window_id: WindowId,
|
||||
},
|
||||
/// Navigate to a specific conversation.
|
||||
NavigateToConversation {
|
||||
pane_view_locator: Option<PaneViewLocator>,
|
||||
window_id: Option<WindowId>,
|
||||
conversation_id: AIConversationId,
|
||||
terminal_view_id: Option<EntityId>,
|
||||
},
|
||||
ForkConversation {
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
OpenLaunchConfiguration {
|
||||
config: Arc<LaunchConfig>,
|
||||
/// See [`OpenLaunchConfigArg::open_in_active_window`].
|
||||
open_in_active_window: bool,
|
||||
},
|
||||
NewSession {
|
||||
source: Arc<NewSessionOption>,
|
||||
},
|
||||
OpenFile {
|
||||
path: String,
|
||||
project_directory: String,
|
||||
line_and_column_arg: Option<LineAndColumnArg>,
|
||||
},
|
||||
OpenDirectory {
|
||||
path: String,
|
||||
project_directory: String,
|
||||
},
|
||||
CreateFile {
|
||||
file_name: String,
|
||||
current_directory: String,
|
||||
},
|
||||
NewConversationInProject {
|
||||
path: String,
|
||||
project_name: String,
|
||||
},
|
||||
/// Start a new AI conversation
|
||||
NewConversation,
|
||||
/// No-op action (used for non-interactable separator items that don't do anything on click).
|
||||
NoOp,
|
||||
}
|
||||
|
||||
impl CommandPaletteItemAction {
|
||||
pub fn to_summary(&self) -> ItemSummary {
|
||||
match self {
|
||||
CommandPaletteItemAction::AcceptBinding { binding } => ItemSummary::Action {
|
||||
binding_id: binding.id,
|
||||
},
|
||||
CommandPaletteItemAction::OpenNotebook { id } => ItemSummary::Notebook { id: *id },
|
||||
CommandPaletteItemAction::ExecuteWorkflow { id } => ItemSummary::Workflow { id: *id },
|
||||
CommandPaletteItemAction::InvokeEnvironmentVariables { id } => {
|
||||
ItemSummary::EnvVarCollection { id: *id }
|
||||
}
|
||||
CommandPaletteItemAction::NavigateToSession {
|
||||
pane_view_locator, ..
|
||||
} => ItemSummary::Session {
|
||||
pane_view_locator: *pane_view_locator,
|
||||
},
|
||||
CommandPaletteItemAction::NavigateToConversation {
|
||||
conversation_id, ..
|
||||
} => ItemSummary::Conversation {
|
||||
id: *conversation_id,
|
||||
},
|
||||
CommandPaletteItemAction::ForkConversation { .. } => ItemSummary::ForkConversation,
|
||||
CommandPaletteItemAction::NewSession { source } => ItemSummary::NewSession {
|
||||
id: source.id().clone(),
|
||||
},
|
||||
CommandPaletteItemAction::OpenLaunchConfiguration { .. } => {
|
||||
ItemSummary::LaunchConfiguration
|
||||
}
|
||||
CommandPaletteItemAction::ViewInWarpDrive { id } => match id {
|
||||
CloudObjectTypeAndId::Notebook(_)
|
||||
| CloudObjectTypeAndId::Folder(_)
|
||||
| CloudObjectTypeAndId::GenericStringObject { .. } => ItemSummary::CloudObject,
|
||||
CloudObjectTypeAndId::Workflow(id) => ItemSummary::Workflow { id: *id },
|
||||
},
|
||||
CommandPaletteItemAction::OpenFile {
|
||||
path,
|
||||
project_directory,
|
||||
line_and_column_arg,
|
||||
} => ItemSummary::File {
|
||||
path: path.clone(),
|
||||
project_directory: project_directory.clone(),
|
||||
line_and_column_arg: *line_and_column_arg,
|
||||
},
|
||||
CommandPaletteItemAction::OpenDirectory {
|
||||
path,
|
||||
project_directory,
|
||||
} => ItemSummary::Directory {
|
||||
path: path.clone(),
|
||||
project_directory: project_directory.clone(),
|
||||
},
|
||||
CommandPaletteItemAction::CreateFile { .. } => {
|
||||
// CreateFile actions should not show up in recent items
|
||||
ItemSummary::NoOp
|
||||
}
|
||||
CommandPaletteItemAction::NewConversationInProject { path, .. } => {
|
||||
ItemSummary::Project { path: path.clone() }
|
||||
}
|
||||
CommandPaletteItemAction::NewConversation => ItemSummary::NewConversation,
|
||||
CommandPaletteItemAction::NoOp => ItemSummary::NoOp,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn result_type(&self) -> &'static str {
|
||||
self.to_summary().into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary of items that were selected via the command palette. This is needed so that we have a
|
||||
/// unique way to identify a selected item so we can show it in the "recent" section of the
|
||||
/// palette. We choose to not use the entire [`CommandPaletteItemAction`] since we only need a
|
||||
/// unique identifier to store. Additionally, parts of the `CommandPaletteItemAction` could change
|
||||
/// in between invocations of the command palette (such as the content or title of a workflow or the
|
||||
/// trigger for a keybinding) that should not be factored in when determining whether to show it in
|
||||
/// the recent section of the palette.
|
||||
#[derive(Clone, Debug, PartialEq, IntoStaticStr)]
|
||||
pub enum ItemSummary {
|
||||
Action {
|
||||
binding_id: BindingId,
|
||||
},
|
||||
Workflow {
|
||||
id: SyncId,
|
||||
},
|
||||
EnvVarCollection {
|
||||
id: SyncId,
|
||||
},
|
||||
Notebook {
|
||||
id: SyncId,
|
||||
},
|
||||
Session {
|
||||
pane_view_locator: PaneViewLocator,
|
||||
},
|
||||
NewSession {
|
||||
id: NewSessionOptionId,
|
||||
},
|
||||
/// Dummy enum variant for launch configurations until we support showing them in recent section
|
||||
/// of the zero state
|
||||
LaunchConfiguration,
|
||||
/// Dummy enum variant for cloud objects that aren't supported yet in command palette
|
||||
CloudObject,
|
||||
File {
|
||||
path: String,
|
||||
project_directory: String,
|
||||
line_and_column_arg: Option<LineAndColumnArg>,
|
||||
},
|
||||
Directory {
|
||||
path: String,
|
||||
project_directory: String,
|
||||
},
|
||||
Project {
|
||||
path: String,
|
||||
},
|
||||
Conversation {
|
||||
id: AIConversationId,
|
||||
},
|
||||
ForkConversation,
|
||||
NewConversation,
|
||||
/// No-op action (used for non-interactable separator items that don't do anything on click).
|
||||
NoOp,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
pub mod conversations;
|
||||
mod data_sources;
|
||||
mod files;
|
||||
mod filter_chip_renderer;
|
||||
pub mod launch_config;
|
||||
pub mod mixer;
|
||||
pub mod navigation;
|
||||
#[cfg_attr(not(feature = "local_tty"), allow(dead_code))]
|
||||
pub mod new_session;
|
||||
pub mod render_util;
|
||||
pub mod repos;
|
||||
mod selected_items;
|
||||
pub mod separator_search_item;
|
||||
pub mod view;
|
||||
pub mod warp_drive;
|
||||
mod zero_state;
|
||||
|
||||
use filter_chip_renderer::FilterChipRenderer;
|
||||
pub use mixer::{CommandPaletteMixer, ItemSummary};
|
||||
pub use selected_items::SelectedItems;
|
||||
pub use view::View;
|
||||
|
||||
pub mod styles {
|
||||
pub const SEARCH_ITEM_TEXT_PADDING: f32 = 4.;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::navigation::search::{
|
||||
FuzzySessionSearcher, MatchedSession, SessionMatchResult, SessionSearcher,
|
||||
};
|
||||
use crate::search::command_palette::navigation::search_item::SearchItem;
|
||||
use crate::search::data_source::{DataSourceSearchError, Query, QueryResult};
|
||||
use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
use crate::search::SyncDataSource;
|
||||
use crate::session_management::{SessionNavigationData, SessionSource};
|
||||
use crate::workspace::PaneViewLocator;
|
||||
use warpui::{AppContext, Entity, ModelHandle};
|
||||
|
||||
/// Data source that produces possible running sessions a user could navigate to.
|
||||
pub struct DataSource {
|
||||
searcher: Box<dyn SessionSearcher>,
|
||||
}
|
||||
|
||||
impl DataSource {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn new(active_session_handle: ModelHandle<SessionSource>) -> Self {
|
||||
if warp_core::features::FeatureFlag::UseTantivySearch.is_enabled() {
|
||||
Self::new_full_text(active_session_handle)
|
||||
} else {
|
||||
Self::new_fuzzy(active_session_handle)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn new(active_session_handle: ModelHandle<SessionSource>) -> Self {
|
||||
Self::new_fuzzy(active_session_handle)
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn new_full_text(active_session_handle: ModelHandle<SessionSource>) -> Self {
|
||||
use crate::search::command_palette::navigation::search::FullTextSessionSearcher;
|
||||
let searcher = Box::new(FullTextSessionSearcher::new(active_session_handle));
|
||||
Self { searcher }
|
||||
}
|
||||
|
||||
fn new_fuzzy(active_session_handle: ModelHandle<SessionSource>) -> Self {
|
||||
let searcher = Box::new(FuzzySessionSearcher {
|
||||
session_source_handle: active_session_handle,
|
||||
});
|
||||
Self { searcher }
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for DataSource {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
self.searcher
|
||||
.search(&query.text.trim().to_lowercase(), app)
|
||||
.map_err(|err| {
|
||||
let search_error = DataSourceSearchError {
|
||||
message: err.to_string(),
|
||||
};
|
||||
Box::new(search_error) as DataSourceRunErrorWrapper
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl DataSource {
|
||||
/// Returns a [`QueryResult`] for a workflow identified by `sync_id`. `None` if no result was
|
||||
/// found with the given ID.
|
||||
pub fn query_result(
|
||||
&self,
|
||||
pane_view_locator: PaneViewLocator,
|
||||
app: &AppContext,
|
||||
) -> Option<QueryResult<CommandPaletteItemAction>> {
|
||||
let session = SessionNavigationData::all_sessions(app)
|
||||
.find(|session| session.pane_view_locator() == pane_view_locator)?;
|
||||
|
||||
let matched_session = MatchedSession {
|
||||
session,
|
||||
match_result: SessionMatchResult::no_match(),
|
||||
};
|
||||
|
||||
let active_session_id = self.searcher.active_session_id(app);
|
||||
|
||||
Some(SearchItem::new(matched_session, active_session_id).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod data_source;
|
||||
pub mod render;
|
||||
pub mod search;
|
||||
mod search_item;
|
||||
|
||||
pub use data_source::DataSource;
|
||||
@@ -0,0 +1,394 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::context_chips::display_chip::{
|
||||
chip_container, render_git_diff_stats_content, render_udi_chip, udi_font_size, GitLineChanges,
|
||||
UdiChipConfig,
|
||||
};
|
||||
use crate::context_chips::prompt_snapshot::PromptSnapshot;
|
||||
use crate::context_chips::{ChipValue, ContextChipKind};
|
||||
use crate::search::command_palette::navigation::search::SessionHighlightIndices;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::session_management::{CommandContext, SessionNavigationData};
|
||||
use crate::settings::FontSettings;
|
||||
use crate::terminal::blockgrid_element::BlockGridElement;
|
||||
use crate::terminal::grid_size_util::grid_cell_dimensions;
|
||||
use crate::terminal::ligature_settings::should_use_ligature_rendering;
|
||||
use crate::terminal::model::blockgrid::BlockGrid;
|
||||
use crate::terminal::model::grid::Dimensions;
|
||||
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
|
||||
use crate::terminal::SizeInfo;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warpui::elements::{
|
||||
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Highlight,
|
||||
ParentElement, Radius, Shrinkable, Wrap,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::units::IntoPixels;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
/// Renders a navigation session.
|
||||
pub fn render_navigation_session(
|
||||
session: &SessionNavigationData,
|
||||
appearance: &Appearance,
|
||||
item_highlight_state: ItemHighlightState,
|
||||
is_active_session: bool,
|
||||
highlight_indices: &SessionHighlightIndices,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
render_navigation_session_internal(
|
||||
render_session_label(
|
||||
session,
|
||||
appearance,
|
||||
item_highlight_state,
|
||||
is_active_session,
|
||||
highlight_indices,
|
||||
app,
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_navigation_session_internal(label: Box<dyn Element>) -> Box<dyn Element> {
|
||||
ConstrainedBox::new(label)
|
||||
.with_height(styles::NAVIGATION_PALETTE_ITEM_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_session_label(
|
||||
session: &SessionNavigationData,
|
||||
appearance: &Appearance,
|
||||
item_highlight_state: ItemHighlightState,
|
||||
is_active_session: bool,
|
||||
highlight_indices: &SessionHighlightIndices,
|
||||
app: &AppContext,
|
||||
) -> Flex {
|
||||
let mut navigation_palette_item = Flex::column();
|
||||
|
||||
let prompt = if let Some(ps1_grid) = &session.prompt_elements().ps1_prompt_grid {
|
||||
render_prompt_ps1(ps1_grid, appearance, app)
|
||||
} else if let Some(snapshot) = &session.prompt_elements().prompt_chip_snapshot {
|
||||
render_prompt_udi(snapshot, appearance)
|
||||
} else {
|
||||
// Fallback: empty container if neither is available (e.g. very early startup).
|
||||
Container::new(Flex::row().finish()).finish()
|
||||
};
|
||||
|
||||
let command_info = render_command_context(
|
||||
session,
|
||||
item_highlight_state,
|
||||
is_active_session,
|
||||
highlight_indices.command_indices.clone(),
|
||||
highlight_indices.hint_text_indices.clone(),
|
||||
appearance,
|
||||
);
|
||||
|
||||
navigation_palette_item.add_child(
|
||||
Container::new(prompt)
|
||||
.with_margin_right(styles::NAVIGATION_PALETTE_ROW_HORIZONTAL_SPACING)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
navigation_palette_item.add_child(
|
||||
Container::new(command_info)
|
||||
.with_margin_top(styles::NAVIGATION_PALETTE_ROW_VERTICAL_SPACING)
|
||||
.with_margin_right(styles::NAVIGATION_PALETTE_ROW_HORIZONTAL_SPACING)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
navigation_palette_item
|
||||
}
|
||||
|
||||
fn render_current_session_pill(
|
||||
command_context: CommandContext,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let current_session_pill = appearance
|
||||
.ui_builder()
|
||||
.span("Current".to_string())
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.monospace_font_family()),
|
||||
// The font size is scaled down to make sure the pill fits in the row with its padding.
|
||||
font_size: Some(appearance.monospace_font_size() * 0.85),
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().background())
|
||||
.into_solid(),
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_padding_left(5.)
|
||||
.with_padding_right(5.)
|
||||
.with_margin_left(10.)
|
||||
.with_margin_right(8.)
|
||||
.with_background_color(appearance.theme().background().into_solid())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.finish();
|
||||
|
||||
Shrinkable::new(
|
||||
// We need different flex values when different hint texts are present, otherwise the actual command won't take up enough room.
|
||||
match command_context {
|
||||
CommandContext::LastRunCommand { .. } | CommandContext::LastRunAIBlock { .. } => 0.5,
|
||||
CommandContext::RunningCommand { .. } | CommandContext::RunningAIBlock { .. } => 0.35,
|
||||
CommandContext::None => 1.,
|
||||
},
|
||||
Align::new(
|
||||
ConstrainedBox::new(current_session_pill)
|
||||
.with_max_width(135.)
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders the prompt as UDI-style context chips from a [`PromptSnapshot`].
|
||||
fn render_prompt_udi(snapshot: &PromptSnapshot, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let mut chip_row = Wrap::row().with_spacing(4.);
|
||||
|
||||
for chip_result in snapshot.chips() {
|
||||
let Some(value) = chip_result.value() else {
|
||||
continue;
|
||||
};
|
||||
// GitDiffStats are rendered differently than other chips, so we handle them separately.
|
||||
// This ensures that the rendered chip matches the live input chip.
|
||||
if matches!(chip_result.kind(), ContextChipKind::GitDiffStats) {
|
||||
let line_changes = match value {
|
||||
ChipValue::GitDiffStats(g) => g.clone(),
|
||||
ChipValue::Text(raw) => {
|
||||
let Some(parsed) = GitLineChanges::parse_from_git_output(raw) else {
|
||||
continue;
|
||||
};
|
||||
parsed
|
||||
}
|
||||
};
|
||||
let font_size = udi_font_size(appearance);
|
||||
let content = render_git_diff_stats_content(
|
||||
&line_changes,
|
||||
font_size,
|
||||
appearance.monospace_font_family(),
|
||||
font_size,
|
||||
appearance,
|
||||
);
|
||||
chip_row.add_child(chip_container(content, Some(Border::all(0.)), appearance).finish());
|
||||
continue;
|
||||
}
|
||||
|
||||
let color = chip_result
|
||||
.kind()
|
||||
.default_styles(appearance, false)
|
||||
.value_color;
|
||||
let value_text = value.to_string();
|
||||
let config = if let Some(icon) = chip_result.kind().udi_icon() {
|
||||
UdiChipConfig::new_with_icon(icon, color, value_text)
|
||||
} else {
|
||||
UdiChipConfig::new(color, value_text)
|
||||
}
|
||||
.with_border_override(Border::all(0.));
|
||||
chip_row.add_child(render_udi_chip(config, appearance));
|
||||
}
|
||||
|
||||
let prompt_section = Container::new(chip_row.finish())
|
||||
.with_margin_right(styles::NAVIGATION_PALETTE_COMMAND_HINT_MARGIN * 2.);
|
||||
|
||||
prompt_section.finish()
|
||||
}
|
||||
|
||||
/// Renders the prompt from the raw PS1 terminal grid, preserving full
|
||||
/// fidelity of the user's custom prompt (colors, glyphs, etc.).
|
||||
fn render_prompt_ps1(
|
||||
prompt_grid: &BlockGrid,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let cell_dimensions = grid_cell_dimensions(
|
||||
app.font_cache(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
appearance.line_height_ratio(),
|
||||
);
|
||||
// Derive the SizeInfo width from the grid's own column count so the
|
||||
// element renders at its natural size. The parent flex layout will
|
||||
// constrain it to the available palette width.
|
||||
let grid_width_px = prompt_grid.grid_handler().columns() as f32 * cell_dimensions.x();
|
||||
let size_info = SizeInfo::new(
|
||||
vec2f(grid_width_px, cell_dimensions.y()),
|
||||
cell_dimensions.x().into_pixels(),
|
||||
cell_dimensions.y().into_pixels(),
|
||||
0.0.into_pixels(),
|
||||
0.0.into_pixels(),
|
||||
);
|
||||
let enforce_minimum_contrast = *FontSettings::as_ref(app).enforce_minimum_contrast;
|
||||
let obfuscate_secrets = get_secret_obfuscation_mode(app);
|
||||
let mut block_grid_element = BlockGridElement::new(
|
||||
prompt_grid,
|
||||
appearance,
|
||||
enforce_minimum_contrast,
|
||||
obfuscate_secrets,
|
||||
size_info,
|
||||
);
|
||||
if should_use_ligature_rendering(app) {
|
||||
block_grid_element = block_grid_element.with_ligature_rendering();
|
||||
}
|
||||
|
||||
let prompt_section = Container::new(block_grid_element.finish())
|
||||
.with_margin_right(styles::NAVIGATION_PALETTE_COMMAND_HINT_MARGIN * 2.);
|
||||
|
||||
prompt_section.finish()
|
||||
}
|
||||
|
||||
fn render_command_context(
|
||||
session: &SessionNavigationData,
|
||||
item_highlight_state: ItemHighlightState,
|
||||
is_active_session: bool,
|
||||
command_indices: Option<Vec<usize>>,
|
||||
hint_text_indices: Vec<usize>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let command_render_info = CommandRenderInfo::from_context(session.command_context());
|
||||
|
||||
let mut command_row = Flex::row();
|
||||
let command_row_font_size = appearance.monospace_font_size() - 2.;
|
||||
|
||||
if let Some(command_text) = command_render_info.command_text {
|
||||
if !command_text.is_empty() {
|
||||
let running_command_text_color =
|
||||
item_highlight_state.main_text_fill(appearance).into_solid();
|
||||
|
||||
let mut running_command_text =
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span(command_text)
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.monospace_font_family()),
|
||||
font_size: Some(command_row_font_size),
|
||||
font_color: Some(running_command_text_color),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
if let Some(command_indices) = command_indices {
|
||||
let highlight = Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(running_command_text_color);
|
||||
running_command_text =
|
||||
running_command_text.with_highlights(command_indices, highlight);
|
||||
}
|
||||
|
||||
command_row.add_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Container::new(running_command_text.build().finish())
|
||||
.with_margin_right(command_render_info.row_spacing)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let hint_font_color = item_highlight_state.sub_text_fill(appearance).into_solid();
|
||||
|
||||
let mut hint_text = appearance
|
||||
.ui_builder()
|
||||
.span(command_render_info.hint_text)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(hint_font_color),
|
||||
font_family_id: Some(appearance.monospace_font_family()),
|
||||
font_size: Some(command_row_font_size),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let highlight = Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(hint_font_color);
|
||||
hint_text = hint_text.with_highlights(hint_text_indices, highlight);
|
||||
|
||||
command_row.add_child(
|
||||
Container::new(hint_text.build().finish())
|
||||
.with_margin_left(command_render_info.hint_margin)
|
||||
.with_margin_right(command_render_info.hint_margin)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if is_active_session {
|
||||
command_row.add_child(render_current_session_pill(
|
||||
session.command_context(),
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
|
||||
command_row = command_row.with_cross_axis_alignment(CrossAxisAlignment::End);
|
||||
|
||||
command_row.finish()
|
||||
}
|
||||
|
||||
pub(super) struct CommandRenderInfo {
|
||||
pub command_text: Option<String>,
|
||||
pub hint_text: String,
|
||||
row_spacing: f32,
|
||||
hint_margin: f32,
|
||||
}
|
||||
|
||||
impl CommandRenderInfo {
|
||||
pub fn from_context(command_context: CommandContext) -> CommandRenderInfo {
|
||||
match command_context {
|
||||
CommandContext::RunningCommand { running_command } => CommandRenderInfo {
|
||||
command_text: Some(running_command),
|
||||
hint_text: "Running...".to_string(),
|
||||
row_spacing: styles::NAVIGATION_PALETTE_COMMAND_ROW_SPACING,
|
||||
hint_margin: styles::NAVIGATION_PALETTE_COMMAND_HINT_MARGIN,
|
||||
},
|
||||
CommandContext::LastRunCommand {
|
||||
last_run_command,
|
||||
mins_since_completion,
|
||||
} => CommandRenderInfo {
|
||||
row_spacing: match last_run_command.is_empty() {
|
||||
true => 0., // Don't include any spacing if the command is empty.
|
||||
false => styles::NAVIGATION_PALETTE_COMMAND_ROW_SPACING,
|
||||
},
|
||||
hint_margin: match last_run_command.is_empty() {
|
||||
true => 0., // Don't include any margin if the command is empty.
|
||||
false => styles::NAVIGATION_PALETTE_COMMAND_HINT_MARGIN,
|
||||
},
|
||||
command_text: Some(last_run_command),
|
||||
hint_text: match mins_since_completion {
|
||||
Some(mins) if mins >= 60 => "Completed over 1 hour ago".to_string(),
|
||||
Some(mins) if mins == 1 => format!("Completed {mins} minute ago"),
|
||||
Some(mins) => format!("Completed {mins} minutes ago"),
|
||||
None => "No timestamp found".to_string(),
|
||||
},
|
||||
},
|
||||
CommandContext::RunningAIBlock { prompt } => CommandRenderInfo {
|
||||
command_text: Some(prompt),
|
||||
hint_text: "Running...".to_string(),
|
||||
row_spacing: styles::NAVIGATION_PALETTE_COMMAND_ROW_SPACING,
|
||||
hint_margin: styles::NAVIGATION_PALETTE_COMMAND_HINT_MARGIN,
|
||||
},
|
||||
CommandContext::LastRunAIBlock { prompt } => CommandRenderInfo {
|
||||
command_text: Some(prompt),
|
||||
hint_text: "Completed".to_string(),
|
||||
row_spacing: styles::NAVIGATION_PALETTE_COMMAND_ROW_SPACING,
|
||||
hint_margin: styles::NAVIGATION_PALETTE_COMMAND_HINT_MARGIN,
|
||||
},
|
||||
CommandContext::None => CommandRenderInfo {
|
||||
command_text: Some(String::new()),
|
||||
hint_text: "Empty Session".to_string(),
|
||||
row_spacing: 0.,
|
||||
hint_margin: 0.,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod styles {
|
||||
pub const NAVIGATION_PALETTE_ITEM_HEIGHT: f32 = 70.;
|
||||
|
||||
pub const NAVIGATION_PALETTE_ROW_VERTICAL_SPACING: f32 = 4.;
|
||||
|
||||
pub const NAVIGATION_PALETTE_ROW_HORIZONTAL_SPACING: f32 = 5.;
|
||||
|
||||
pub const NAVIGATION_PALETTE_COMMAND_ROW_SPACING: f32 = 10.;
|
||||
pub const NAVIGATION_PALETTE_COMMAND_HINT_MARGIN: f32 = 5.;
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
use crate::pane_group::PaneId;
|
||||
use crate::search::command_palette::navigation::render::CommandRenderInfo;
|
||||
use crate::search::command_palette::navigation::search_item::SearchItem;
|
||||
use crate::search::command_palette::navigation::DataSource;
|
||||
use crate::search::data_source::QueryResult;
|
||||
use crate::search::SyncDataSource;
|
||||
use crate::session_management::{CommandContext, SessionNavigationData, SessionSource};
|
||||
use fuzzy_match::match_indices_case_insensitive;
|
||||
use itertools::Itertools;
|
||||
use std::ops::Range;
|
||||
use warpui::{AppContext, ModelHandle};
|
||||
|
||||
/// A session that was fuzzy matched against a search term.
|
||||
pub struct MatchedSession {
|
||||
pub session: SessionNavigationData,
|
||||
pub match_result: SessionMatchResult,
|
||||
}
|
||||
|
||||
impl MatchedSession {
|
||||
/// Returns the score for the [`MatchedSession`]. If there was no match result, a score of `0`
|
||||
/// is returned.
|
||||
pub fn score(&self) -> i64 {
|
||||
self.match_result.score
|
||||
}
|
||||
|
||||
/// Returns the [`SessionHighlightIndices`] belonging to the matched session.
|
||||
pub fn highlight_indices(&self) -> &SessionHighlightIndices {
|
||||
&self.match_result.highlight_indices
|
||||
}
|
||||
}
|
||||
|
||||
/// Result from matching a session.
|
||||
#[derive(Debug)]
|
||||
pub struct SessionMatchResult {
|
||||
score: i64,
|
||||
highlight_indices: SessionHighlightIndices,
|
||||
}
|
||||
|
||||
impl SessionMatchResult {
|
||||
/// Returns a dummy match result when there is no match.
|
||||
pub fn no_match() -> Self {
|
||||
SessionMatchResult {
|
||||
score: 0,
|
||||
highlight_indices: SessionHighlightIndices {
|
||||
command_indices: None,
|
||||
hint_text_indices: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Matching indices for a matched session.
|
||||
#[derive(Debug)]
|
||||
pub struct SessionHighlightIndices {
|
||||
pub(super) command_indices: Option<Vec<usize>>,
|
||||
pub(super) hint_text_indices: Vec<usize>,
|
||||
}
|
||||
|
||||
impl SessionHighlightIndices {
|
||||
fn new(
|
||||
matched_indices: Vec<usize>,
|
||||
session_highlights: SearchableSessionStringRanges,
|
||||
) -> SessionHighlightIndices {
|
||||
// Allow lazy evaluations here. Using `then_some` will eagerly compute these
|
||||
// values, which can lead to underflow.
|
||||
#[allow(clippy::unnecessary_lazy_evaluations)]
|
||||
let command_indices = session_highlights.command_range.map(|command_range| {
|
||||
matched_indices
|
||||
.iter()
|
||||
.filter(|&idx| command_range.contains(idx))
|
||||
.map(|idx| *idx - command_range.start)
|
||||
.collect::<Vec<usize>>()
|
||||
});
|
||||
|
||||
#[allow(clippy::unnecessary_lazy_evaluations)]
|
||||
let hint_text_indices = matched_indices
|
||||
.iter()
|
||||
.filter(|&idx| session_highlights.hint_text_range.contains(idx))
|
||||
.map(|idx| *idx - session_highlights.hint_text_range.start)
|
||||
.collect::<Vec<usize>>();
|
||||
|
||||
SessionHighlightIndices {
|
||||
command_indices,
|
||||
hint_text_indices,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator of sessions that match `search_term`.
|
||||
pub fn filter_sessions<'a, 'b, I>(
|
||||
sessions_iter: I,
|
||||
search_term: &'b str,
|
||||
) -> impl Iterator<Item = MatchedSession> + use<'a, 'b, I>
|
||||
where
|
||||
I: IntoIterator<Item = &'a SessionNavigationData>,
|
||||
{
|
||||
sessions_iter
|
||||
.into_iter()
|
||||
.filter_map(move |session| {
|
||||
if search_term.is_empty() {
|
||||
Some((SessionMatchResult::no_match(), session.clone()))
|
||||
} else {
|
||||
let (searchable_string, session_highlights) =
|
||||
searchable_session_string_and_ranges(session);
|
||||
|
||||
match_indices_case_insensitive(&searchable_string, search_term).map(|result| {
|
||||
let highlight_indices =
|
||||
SessionHighlightIndices::new(result.matched_indices, session_highlights);
|
||||
(
|
||||
SessionMatchResult {
|
||||
score: result.score,
|
||||
highlight_indices,
|
||||
},
|
||||
session.clone(),
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
.map(|(match_result, session)| MatchedSession {
|
||||
session,
|
||||
match_result,
|
||||
})
|
||||
}
|
||||
|
||||
/// The searchable string format is: [prompt] [command] [hint text],
|
||||
/// where [command] may or may not be present.
|
||||
fn searchable_session_string_and_ranges(
|
||||
session: &SessionNavigationData,
|
||||
) -> (String, SearchableSessionStringRanges) {
|
||||
let mut searchable_string = session.prompt().to_string();
|
||||
let prompt_end = session.prompt().chars().count();
|
||||
|
||||
let command_range = match session.command_context() {
|
||||
CommandContext::LastRunCommand {
|
||||
last_run_command,
|
||||
mins_since_completion: _,
|
||||
} => {
|
||||
// Fuzzy search gives different weights to characters in the same word vs different words.
|
||||
searchable_string.push(' ');
|
||||
searchable_string.push_str(last_run_command.as_str());
|
||||
|
||||
let start = prompt_end + 1;
|
||||
let end = start + last_run_command.chars().count();
|
||||
Some(start..end)
|
||||
}
|
||||
CommandContext::RunningCommand { running_command } => {
|
||||
// Fuzzy search gives different weights to characters in the same word vs different words.
|
||||
searchable_string.push(' ');
|
||||
searchable_string.push_str(running_command.as_str());
|
||||
|
||||
let start = prompt_end + 1;
|
||||
let end = start + running_command.chars().count();
|
||||
Some(start..end)
|
||||
}
|
||||
CommandContext::LastRunAIBlock { prompt } | CommandContext::RunningAIBlock { prompt } => {
|
||||
// Fuzzy search gives different weights to characters in the same word vs different words.
|
||||
searchable_string.push(' ');
|
||||
searchable_string.push_str(prompt.as_str());
|
||||
|
||||
let start = prompt_end + 1;
|
||||
let end = start + prompt.chars().count();
|
||||
Some(start..end)
|
||||
}
|
||||
CommandContext::None => None,
|
||||
};
|
||||
|
||||
let command_info = CommandRenderInfo::from_context(session.command_context());
|
||||
searchable_string.push(' ');
|
||||
searchable_string.push_str(command_info.hint_text.as_str());
|
||||
let hint_text_range = match &command_range {
|
||||
Some(command_range) => {
|
||||
let start = command_range.end + 1;
|
||||
let end = start + command_info.hint_text.chars().count();
|
||||
start..end
|
||||
}
|
||||
None => {
|
||||
let start = prompt_end + 1;
|
||||
let end = start + command_info.hint_text.chars().count();
|
||||
start..end
|
||||
}
|
||||
};
|
||||
|
||||
(
|
||||
searchable_string,
|
||||
SearchableSessionStringRanges {
|
||||
command_range,
|
||||
hint_text_range,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
struct SearchableSessionStringRanges {
|
||||
command_range: Option<Range<usize>>,
|
||||
hint_text_range: Range<usize>,
|
||||
}
|
||||
|
||||
type SearcherAction = <DataSource as SyncDataSource>::Action;
|
||||
|
||||
pub trait SessionSearcher {
|
||||
fn search(
|
||||
&self,
|
||||
_search_term: &str,
|
||||
_app: &AppContext,
|
||||
) -> anyhow::Result<Vec<QueryResult<SearcherAction>>>;
|
||||
|
||||
fn active_session_id(&self, app: &AppContext) -> Option<PaneId>;
|
||||
}
|
||||
|
||||
pub struct FuzzySessionSearcher {
|
||||
pub(crate) session_source_handle: ModelHandle<SessionSource>,
|
||||
}
|
||||
|
||||
impl SessionSearcher for FuzzySessionSearcher {
|
||||
fn search(
|
||||
&self,
|
||||
search_term: &str,
|
||||
app: &AppContext,
|
||||
) -> anyhow::Result<Vec<QueryResult<SearcherAction>>> {
|
||||
let active_session_id = match self.session_source_handle.as_ref(app) {
|
||||
SessionSource::None => None,
|
||||
SessionSource::Set { active_pane_id, .. } => Some(*active_pane_id),
|
||||
};
|
||||
|
||||
// Sort sessions by last focus timestamp so sessions that were focused first are shown first.
|
||||
let all_sessions =
|
||||
SessionNavigationData::all_sessions(app).sorted_by_key(|x| x.last_focus_ts());
|
||||
|
||||
Ok(filter_sessions(all_sessions.as_slice(), search_term)
|
||||
.map(|matched_session| SearchItem::new(matched_session, active_session_id).into())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn active_session_id(&self, app: &AppContext) -> Option<PaneId> {
|
||||
match self.session_source_handle.as_ref(app) {
|
||||
SessionSource::None => None,
|
||||
SessionSource::Set { active_pane_id, .. } => Some(*active_pane_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub use full_text_searcher::FullTextSessionSearcher;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod full_text_searcher {
|
||||
use crate::define_search_schema;
|
||||
use crate::pane_group::PaneId;
|
||||
use crate::search::command_palette::navigation::search::{
|
||||
searchable_session_string_and_ranges, MatchedSession, SearcherAction,
|
||||
SessionHighlightIndices, SessionMatchResult, SessionSearcher,
|
||||
};
|
||||
use crate::search::command_palette::navigation::search_item::SearchItem;
|
||||
use crate::search::data_source::QueryResult;
|
||||
use crate::search::searcher::{DEFAULT_MEMORY_BUDGET, SCORE_CONVERSION_FACTOR};
|
||||
use crate::session_management::{SessionNavigationData, SessionSource};
|
||||
use itertools::Itertools;
|
||||
use std::collections::HashMap;
|
||||
use warpui::{AppContext, ModelHandle};
|
||||
|
||||
define_search_schema!(
|
||||
schema_name: SESSION_SEARCH_SCHEMA,
|
||||
config_name: SessionSearchConfig,
|
||||
search_doc: SessionSearchDocument,
|
||||
identifying_doc: SessionIdDocument,
|
||||
search_fields: [session: 1.0],
|
||||
id_fields: [search_id: u64],
|
||||
);
|
||||
|
||||
pub struct FullTextSessionSearcher {
|
||||
pub(crate) session_source_handle: ModelHandle<SessionSource>,
|
||||
}
|
||||
|
||||
impl SessionSearcher for FullTextSessionSearcher {
|
||||
fn search(
|
||||
&self,
|
||||
search_term: &str,
|
||||
app: &AppContext,
|
||||
) -> anyhow::Result<Vec<QueryResult<SearcherAction>>> {
|
||||
let searcher = SESSION_SEARCH_SCHEMA.create_searcher(DEFAULT_MEMORY_BUDGET);
|
||||
|
||||
let mut sessions = HashMap::new();
|
||||
let documents =
|
||||
SessionNavigationData::all_sessions(app)
|
||||
.enumerate()
|
||||
.map(|(idx, session)| {
|
||||
let (search_string, highlight) =
|
||||
searchable_session_string_and_ranges(&session);
|
||||
let search_id = SessionSearchId(idx);
|
||||
|
||||
sessions.insert(search_id, (session, highlight, search_string.clone()));
|
||||
SessionSearchDocument {
|
||||
session: search_string,
|
||||
search_id: search_id.0 as u64,
|
||||
}
|
||||
});
|
||||
|
||||
searcher.build_index(documents)?;
|
||||
|
||||
let active_session_id = match self.session_source_handle.as_ref(app) {
|
||||
SessionSource::None => None,
|
||||
SessionSource::Set { active_pane_id, .. } => Some(*active_pane_id),
|
||||
};
|
||||
|
||||
if search_term.is_empty() {
|
||||
return Ok(sessions
|
||||
.into_iter()
|
||||
.sorted_by_key(|(_, (session, ..))| session.last_focus_ts())
|
||||
.map(|(_, (session, ..))| {
|
||||
let matched_session = MatchedSession {
|
||||
session,
|
||||
match_result: SessionMatchResult::no_match(),
|
||||
};
|
||||
SearchItem::new(matched_session, active_session_id).into()
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
|
||||
let matched_sessions = searcher.search_id(search_term)?;
|
||||
Ok(matched_sessions
|
||||
.into_iter()
|
||||
.filter_map(|search_match| {
|
||||
let (session, highlight, search_string) = sessions
|
||||
.remove(&SessionSearchId(search_match.values.search_id as usize))?;
|
||||
|
||||
let char_indices = byte_indices_to_char_indices(
|
||||
&search_string,
|
||||
search_match.highlights.session,
|
||||
);
|
||||
let highlight_indices = SessionHighlightIndices::new(char_indices, highlight);
|
||||
let match_result = SessionMatchResult {
|
||||
score: (search_match.score * SCORE_CONVERSION_FACTOR) as i64,
|
||||
highlight_indices,
|
||||
};
|
||||
let matched_session = MatchedSession {
|
||||
session,
|
||||
match_result,
|
||||
};
|
||||
Some(SearchItem::new(matched_session, active_session_id).into())
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn active_session_id(&self, app: &AppContext) -> Option<PaneId> {
|
||||
match self.session_source_handle.as_ref(app) {
|
||||
SessionSource::None => None,
|
||||
SessionSource::Set { active_pane_id, .. } => Some(*active_pane_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FullTextSessionSearcher {
|
||||
pub fn new(session_source_handle: ModelHandle<SessionSource>) -> Self {
|
||||
Self {
|
||||
session_source_handle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts byte-based indices (from Tantivy snippet highlighting) into
|
||||
/// char-based indices that align with the char-based ranges used by
|
||||
/// [`SessionHighlightIndices`].
|
||||
pub(super) fn byte_indices_to_char_indices(text: &str, byte_indices: Vec<usize>) -> Vec<usize> {
|
||||
let byte_to_char: HashMap<usize, usize> = text
|
||||
.char_indices()
|
||||
.enumerate()
|
||||
.map(|(char_idx, (byte_idx, _))| (byte_idx, char_idx))
|
||||
.collect();
|
||||
|
||||
byte_indices
|
||||
.into_iter()
|
||||
.filter_map(|byte_idx| byte_to_char.get(&byte_idx).copied())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A unique identifier for a session.
|
||||
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct SessionSearchId(usize);
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_family = "wasm")))]
|
||||
#[path = "search_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,117 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::pane_group::PaneId;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::navigation::render::render_navigation_session;
|
||||
use crate::search::command_palette::navigation::search::MatchedSession;
|
||||
use crate::search::command_palette::render_util::render_search_item_icon;
|
||||
use crate::search::item::IconLocation;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::session_management::SessionNavigationData;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::elements::Container;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
/// Search item to render a session within the command palette.
|
||||
pub struct SearchItem {
|
||||
matched_session: MatchedSession,
|
||||
/// The current active session. `None` if there is no active session or we were
|
||||
/// unable to determine which session is currently active.
|
||||
active_session: Option<PaneId>,
|
||||
}
|
||||
|
||||
impl SearchItem {
|
||||
fn navigation_data(&self) -> &SessionNavigationData {
|
||||
&self.matched_session.session
|
||||
}
|
||||
|
||||
pub fn new(matched_session: MatchedSession, active_session: Option<PaneId>) -> Self {
|
||||
Self {
|
||||
matched_session,
|
||||
active_session,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::search::item::SearchItem for SearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn is_multiline(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let color = appearance.theme().foreground().into_solid();
|
||||
|
||||
render_search_item_icon(appearance, Icon::TerminalInput, color, highlight_state)
|
||||
}
|
||||
|
||||
fn icon_location(&self, appearance: &Appearance) -> IconLocation {
|
||||
// The icon is has the size of the monospace font, whereas the text have a height of
|
||||
// `line_height_ratio * font_size`. Offset the icon by this difference so it is rendered
|
||||
// centered with the text.
|
||||
let margin_top = (appearance.line_height_ratio() * appearance.monospace_font_size())
|
||||
- appearance.monospace_font_size();
|
||||
IconLocation::Top { margin_top }
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let is_active_session = self
|
||||
.active_session
|
||||
.is_some_and(|id| self.navigation_data().is_for_session(id));
|
||||
|
||||
let session_element = render_navigation_session(
|
||||
self.navigation_data(),
|
||||
appearance,
|
||||
highlight_state,
|
||||
is_active_session,
|
||||
self.matched_session.highlight_indices(),
|
||||
app,
|
||||
);
|
||||
Container::new(session_element).finish()
|
||||
}
|
||||
|
||||
fn render_details(&self, _: &AppContext) -> Option<Box<dyn Element>> {
|
||||
// Navigation search items don't support rendering a details panel.
|
||||
None
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat::from(self.matched_session.score() as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::NavigateToSession {
|
||||
pane_view_locator: self.navigation_data().pane_view_locator(),
|
||||
window_id: self.navigation_data().window_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!(
|
||||
"Selected {}. {}.",
|
||||
self.navigation_data().prompt(),
|
||||
self.navigation_data()
|
||||
.command_context()
|
||||
.a11y_description()
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
fn accessibility_help_message(&self) -> Option<String> {
|
||||
Some("Press enter to navigate to this session.".into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
use super::full_text_searcher::byte_indices_to_char_indices;
|
||||
use super::{SearchableSessionStringRanges, SessionHighlightIndices};
|
||||
|
||||
// ── byte_indices_to_char_indices ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn ascii_only_is_identity() {
|
||||
let text = "hello world";
|
||||
assert_eq!(
|
||||
byte_indices_to_char_indices(text, vec![0, 6, 10]),
|
||||
vec![0, 6, 10]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_byte_chars_shift_indices() {
|
||||
// '→' is 3 bytes. Layout:
|
||||
// byte 0..3 = '→' (char 0)
|
||||
// byte 3 = ' ' (char 1)
|
||||
// byte 4 = 'l' (char 2)
|
||||
// byte 5 = 's' (char 3)
|
||||
let text = "→ ls";
|
||||
assert_eq!(
|
||||
byte_indices_to_char_indices(text, vec![0, 3, 4, 5]),
|
||||
vec![0, 1, 2, 3]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_bytes_are_filtered_out() {
|
||||
// '→' occupies bytes 0, 1, 2. Only byte 0 is a char boundary.
|
||||
let text = "→ls";
|
||||
assert_eq!(
|
||||
byte_indices_to_char_indices(text, vec![0, 1, 2, 3, 4]),
|
||||
vec![0, 1, 2] // char 0='→', char 1='l', char 2='s'
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_inputs() {
|
||||
assert_eq!(
|
||||
byte_indices_to_char_indices("", vec![]),
|
||||
Vec::<usize>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
byte_indices_to_char_indices("abc", vec![]),
|
||||
Vec::<usize>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_width_characters() {
|
||||
// 'é' is 2 bytes, '→' is 3 bytes, 'a' is 1 byte.
|
||||
// Layout: é(0..2) →(2..5) a(5)
|
||||
let text = "é→a";
|
||||
assert_eq!(
|
||||
byte_indices_to_char_indices(text, vec![0, 2, 5]),
|
||||
vec![0, 1, 2] // char 0='é', char 1='→', char 2='a'
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_bounds_byte_indices_are_dropped() {
|
||||
let text = "ab";
|
||||
assert_eq!(
|
||||
byte_indices_to_char_indices(text, vec![0, 1, 99]),
|
||||
vec![0, 1]
|
||||
);
|
||||
}
|
||||
|
||||
// ── End-to-end: highlight pipeline with multi-byte prompt ────────────
|
||||
|
||||
/// Simulates the same range construction that `searchable_session_string_and_ranges`
|
||||
/// performs, then verifies that char-converted Tantivy byte indices produce
|
||||
/// correct per-element highlights.
|
||||
#[test]
|
||||
fn highlight_indices_correct_after_byte_to_char_conversion() {
|
||||
// Prompt with multi-byte chars: "→⇒≠" = 3 chars, 9 bytes.
|
||||
let prompt = "→⇒≠";
|
||||
let command = "ls";
|
||||
let hint = "Running...";
|
||||
|
||||
// Build the searchable string the same way the production code does.
|
||||
let mut searchable = prompt.to_string();
|
||||
let prompt_end = prompt.chars().count(); // 3
|
||||
|
||||
searchable.push(' ');
|
||||
searchable.push_str(command);
|
||||
let cmd_start = prompt_end + 1; // 4
|
||||
let cmd_end = cmd_start + command.chars().count(); // 6
|
||||
let command_range = Some(cmd_start..cmd_end);
|
||||
|
||||
searchable.push(' ');
|
||||
searchable.push_str(hint);
|
||||
let hint_start = cmd_end + 1; // 7
|
||||
let hint_end = hint_start + hint.chars().count(); // 17
|
||||
let hint_text_range = hint_start..hint_end;
|
||||
|
||||
// Simulate Tantivy returning byte offsets for "ls" in the searchable
|
||||
// string. "→⇒≠ ls Running..." — 'l' is at byte 10, 's' at byte 11.
|
||||
let byte_of_l = searchable.find('l').unwrap();
|
||||
let byte_of_s = byte_of_l + 1;
|
||||
assert_eq!(byte_of_l, 10, "precondition: 'l' should be at byte 10");
|
||||
|
||||
// Without conversion these byte offsets (10, 11) would NOT fall in the
|
||||
// char-based command_range (4..6), so highlights would be lost.
|
||||
let char_indices = byte_indices_to_char_indices(&searchable, vec![byte_of_l, byte_of_s]);
|
||||
|
||||
let ranges = SearchableSessionStringRanges {
|
||||
command_range,
|
||||
hint_text_range,
|
||||
};
|
||||
let highlights = SessionHighlightIndices::new(char_indices, ranges);
|
||||
|
||||
// 'l' and 's' should map to command-relative indices 0 and 1.
|
||||
assert_eq!(highlights.command_indices, Some(vec![0, 1]));
|
||||
assert!(highlights.hint_text_indices.is_empty());
|
||||
}
|
||||
|
||||
/// Same scenario but without the conversion — demonstrates the bug.
|
||||
#[test]
|
||||
fn raw_byte_indices_produce_wrong_highlights() {
|
||||
let prompt = "→⇒≠";
|
||||
let command = "ls";
|
||||
let hint = "Running...";
|
||||
|
||||
let mut searchable = prompt.to_string();
|
||||
let prompt_end = prompt.chars().count(); // 3
|
||||
|
||||
searchable.push(' ');
|
||||
searchable.push_str(command);
|
||||
let cmd_start = prompt_end + 1;
|
||||
let cmd_end = cmd_start + command.chars().count();
|
||||
let command_range = Some(cmd_start..cmd_end);
|
||||
|
||||
searchable.push(' ');
|
||||
searchable.push_str(hint);
|
||||
let hint_start = cmd_end + 1;
|
||||
let hint_end = hint_start + hint.chars().count();
|
||||
let hint_text_range = hint_start..hint_end;
|
||||
|
||||
// Feed raw byte offsets (10, 11) directly — the bug path.
|
||||
let byte_of_l = searchable.find('l').unwrap(); // 10
|
||||
let byte_of_s = byte_of_l + 1; // 11
|
||||
|
||||
let ranges = SearchableSessionStringRanges {
|
||||
command_range,
|
||||
hint_text_range,
|
||||
};
|
||||
let highlights = SessionHighlightIndices::new(vec![byte_of_l, byte_of_s], ranges);
|
||||
|
||||
// Byte 10 and 11 fall in the char-based hint_text_range (7..17), NOT the
|
||||
// command_range (4..6), so command highlights are lost and hint highlights
|
||||
// land on wrong characters.
|
||||
assert_eq!(highlights.command_indices, Some(vec![]));
|
||||
assert_eq!(highlights.hint_text_indices, vec![3, 4]); // wrong!
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
use super::new_session_option::{Direction, NewSessionConfig};
|
||||
use super::new_session_option::{NewSessionOption, NewSessionOptionId};
|
||||
use super::search_item::SearchItem;
|
||||
use crate::search::data_source::DataSourceSearchError;
|
||||
use crate::search::{
|
||||
binding_source::BindingSource,
|
||||
command_palette::mixer::CommandPaletteItemAction,
|
||||
data_source::{Query, QueryResult},
|
||||
mixer::{DataSourceRunErrorWrapper, SyncDataSource},
|
||||
};
|
||||
use crate::terminal::available_shells::AvailableShells;
|
||||
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
/// Controls which kinds of new sessions the data source should surface.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct AllowedSessionKinds {
|
||||
pub windows: bool,
|
||||
pub tabs: bool,
|
||||
pub panes: bool,
|
||||
}
|
||||
|
||||
impl Default for AllowedSessionKinds {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
windows: true,
|
||||
tabs: true,
|
||||
panes: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AllowedSessionKinds {
|
||||
pub fn tabs_only() -> Self {
|
||||
Self {
|
||||
windows: false,
|
||||
tabs: true,
|
||||
panes: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A data source that provides options for creating new terminal sessions
|
||||
/// Gathers this data by:
|
||||
/// - Listening for any binding source changes
|
||||
/// - Comparing the options in binding sources (open new tab, open new window, etc.)
|
||||
/// to the list of available shells, and creates an interesction of those items.
|
||||
pub struct NewSessionDataSource {
|
||||
searcher: Box<dyn NewSessionSearcher>,
|
||||
allowed: AllowedSessionKinds,
|
||||
}
|
||||
|
||||
impl NewSessionDataSource {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn new(binding_source: ModelHandle<BindingSource>, ctx: &mut ModelContext<Self>) -> Self {
|
||||
if FeatureFlag::UseTantivySearch.is_enabled() {
|
||||
Self::new_full_text(binding_source, ctx)
|
||||
} else {
|
||||
Self::new_fuzzy(binding_source, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn new(binding_source: ModelHandle<BindingSource>, ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self::new_fuzzy(binding_source, ctx)
|
||||
}
|
||||
|
||||
fn new_fuzzy(binding_source: ModelHandle<BindingSource>, ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.observe(&binding_source, Self::on_binding_source_changed);
|
||||
Self {
|
||||
searcher: Box::new(FuzzyNewSessionSearcher::default()),
|
||||
allowed: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn new_full_text(
|
||||
binding_source: ModelHandle<BindingSource>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
ctx.observe(&binding_source, Self::on_binding_source_changed);
|
||||
Self {
|
||||
searcher: Box::new(full_text_searcher::FullTextNewSessionSearcher::new(
|
||||
ctx.background_executor(),
|
||||
)),
|
||||
allowed: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_allowed_kinds(mut self, allowed: AllowedSessionKinds) -> Self {
|
||||
self.allowed = allowed;
|
||||
self
|
||||
}
|
||||
|
||||
fn on_binding_source_changed(
|
||||
&mut self,
|
||||
source: ModelHandle<BindingSource>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if !FeatureFlag::ShellSelector.is_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let (window_id, view_id) = match source.as_ref(ctx) {
|
||||
BindingSource::None => return,
|
||||
BindingSource::View {
|
||||
window_id, view_id, ..
|
||||
} => (*window_id, *view_id),
|
||||
};
|
||||
|
||||
let shell_id_to_options = self.searcher.bindings_mut();
|
||||
|
||||
let mut has_tabs = false;
|
||||
let mut has_panes = false;
|
||||
for lens in ctx.key_bindings_for_view(window_id, view_id) {
|
||||
match lens.name {
|
||||
"workspace:new_tab" => has_tabs = true,
|
||||
"pane_group:add_down" => has_panes = true,
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
shell_id_to_options.clear();
|
||||
|
||||
for shell in AvailableShells::as_ref(ctx).get_available_shells() {
|
||||
let Some(id_str) = shell.id() else { continue };
|
||||
|
||||
if self.allowed.windows {
|
||||
let id = NewSessionOptionId::new(format!("new_window:{id_str}"));
|
||||
let new_option = Arc::new(NewSessionOption::new(
|
||||
id.clone(),
|
||||
NewSessionConfig::NewWindow(shell.clone()),
|
||||
));
|
||||
shell_id_to_options.insert(id, new_option);
|
||||
}
|
||||
|
||||
if self.allowed.tabs && has_tabs {
|
||||
let id = NewSessionOptionId::new(format!("new_tab:{id_str}"));
|
||||
let new_option = Arc::new(NewSessionOption::new(
|
||||
id.clone(),
|
||||
NewSessionConfig::NewTab(shell.clone()),
|
||||
));
|
||||
shell_id_to_options.insert(id, new_option);
|
||||
}
|
||||
|
||||
if self.allowed.panes && has_panes {
|
||||
for (id_str, direction) in [
|
||||
(format!("split_down:{id_str}"), Direction::Down),
|
||||
(format!("split_right:{id_str}"), Direction::Right),
|
||||
(format!("split_up:{id_str}"), Direction::Up),
|
||||
(format!("split_left:{id_str}"), Direction::Left),
|
||||
] {
|
||||
let id = NewSessionOptionId::new(id_str);
|
||||
let new_option = Arc::new(NewSessionOption::new(
|
||||
id.clone(),
|
||||
NewSessionConfig::Split(direction, shell.clone()),
|
||||
));
|
||||
shell_id_to_options.insert(id, new_option);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.searcher.build_index();
|
||||
}
|
||||
|
||||
pub fn query_result(
|
||||
&self,
|
||||
id: &NewSessionOptionId,
|
||||
) -> Option<QueryResult<CommandPaletteItemAction>> {
|
||||
self.searcher
|
||||
.bindings()
|
||||
.get(id)
|
||||
.map(|option| SearchItem::new(option.clone(), FuzzyMatchResult::no_match()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for NewSessionDataSource {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
/// Does a fuzzy search on the descriptions of the new session options.
|
||||
/// Logic is mostly copied from actions/data_source.rs
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
_app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let search_term = query.text.as_str();
|
||||
self.searcher.search(search_term).map_err(|err| {
|
||||
let search_error = DataSourceSearchError {
|
||||
message: err.to_string(),
|
||||
};
|
||||
Box::new(search_error) as DataSourceRunErrorWrapper
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NewSessionDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
type SearcherAction = <NewSessionDataSource as SyncDataSource>::Action;
|
||||
|
||||
const SEARCHER_BASE_STRINGS: [&str; 6] = [
|
||||
"Create New Tab",
|
||||
"Create New Window",
|
||||
"Split Pane Down",
|
||||
"Split Pane Right",
|
||||
"Split Pane Up",
|
||||
"Split Pane Left",
|
||||
];
|
||||
|
||||
trait NewSessionSearcher {
|
||||
fn search(&self, _search_term: &str) -> anyhow::Result<Vec<QueryResult<SearcherAction>>>;
|
||||
|
||||
fn build_index(&mut self);
|
||||
|
||||
fn bindings(&self) -> &HashMap<NewSessionOptionId, Arc<NewSessionOption>>;
|
||||
fn bindings_mut(&mut self) -> &mut HashMap<NewSessionOptionId, Arc<NewSessionOption>>;
|
||||
|
||||
/// Computes the maximum match score for the given query string given
|
||||
/// the "base options". We want to make sure that the default command
|
||||
/// for any given variant is listed before the variant. Ex:
|
||||
/// "Create New Tab" should always be ranked higher than
|
||||
/// "Create New Tab: Zsh"
|
||||
/// This function computes the lowest possible ranking score
|
||||
/// for any base strings that match the query. All variant
|
||||
/// matches should have this value as a ceiling.
|
||||
fn compute_max_match(&self, query_str: &str) -> Option<f64>;
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct FuzzyNewSessionSearcher {
|
||||
shell_id_to_options: HashMap<NewSessionOptionId, Arc<NewSessionOption>>,
|
||||
}
|
||||
|
||||
impl NewSessionSearcher for FuzzyNewSessionSearcher {
|
||||
fn search(&self, search_term: &str) -> anyhow::Result<Vec<QueryResult<SearcherAction>>> {
|
||||
let max_match = self.compute_max_match(search_term);
|
||||
|
||||
Ok(self
|
||||
.shell_id_to_options
|
||||
.values()
|
||||
.filter_map(move |new_session_option| {
|
||||
// Binding descriptions are almost always upper case. If a user searches with
|
||||
// lowercase text, the fuzzy matcher will weight this match lower because the case
|
||||
// between the search term and the description differ. As a result, we lowercase
|
||||
// both the search term and the description to ensure that we are matching the two
|
||||
// with the same casing.
|
||||
match_indices_case_insensitive(
|
||||
new_session_option.description().to_lowercase().as_str(),
|
||||
search_term.to_lowercase().as_str(),
|
||||
)
|
||||
.map(|result| {
|
||||
// If for some reason the variant (ex: "Create New Tab: Powershell") ranks higher
|
||||
// than a match for a base string (ex: "Create New Tab"), we want to cap the score
|
||||
// to be one less than the base string.
|
||||
if let Some(max_match) = max_match {
|
||||
FuzzyMatchResult {
|
||||
score: std::cmp::min(result.score, max_match.round() as i64 - 1),
|
||||
matched_indices: result.matched_indices,
|
||||
}
|
||||
} else {
|
||||
result
|
||||
}
|
||||
})
|
||||
.map(|result| (result, new_session_option))
|
||||
})
|
||||
.map(|(match_result, new_session_config)| {
|
||||
SearchItem::new(new_session_config.clone(), match_result).into()
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// This method is a no-op for the fuzzy searcher since it does not maintain an index.
|
||||
fn build_index(&mut self) {}
|
||||
|
||||
fn bindings(&self) -> &HashMap<NewSessionOptionId, Arc<NewSessionOption>> {
|
||||
&self.shell_id_to_options
|
||||
}
|
||||
|
||||
fn bindings_mut(&mut self) -> &mut HashMap<NewSessionOptionId, Arc<NewSessionOption>> {
|
||||
&mut self.shell_id_to_options
|
||||
}
|
||||
|
||||
fn compute_max_match(&self, query_str: &str) -> Option<f64> {
|
||||
SEARCHER_BASE_STRINGS
|
||||
.iter()
|
||||
.filter_map(|base| {
|
||||
match_indices_case_insensitive(
|
||||
base.to_lowercase().as_str(),
|
||||
query_str.to_lowercase().as_str(),
|
||||
)
|
||||
.map(|result| result.score)
|
||||
})
|
||||
.min()
|
||||
.map(|score| score as f64)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod full_text_searcher {
|
||||
use crate::define_search_schema;
|
||||
use crate::search::command_palette::new_session::data_source::{
|
||||
NewSessionSearcher, SearcherAction, SEARCHER_BASE_STRINGS,
|
||||
};
|
||||
use crate::search::command_palette::new_session::search_item::SearchItem;
|
||||
use crate::search::command_palette::new_session::{NewSessionOption, NewSessionOptionId};
|
||||
use crate::search::data_source::QueryResult;
|
||||
use crate::search::searcher::{
|
||||
AsyncSearcher, DEFAULT_MEMORY_BUDGET, MIN_MEMORY_BUDGET, SCORE_CONVERSION_FACTOR,
|
||||
};
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use warpui::r#async::executor::Background;
|
||||
|
||||
define_search_schema!(
|
||||
schema_name: NEW_SESSION_SEARCH_SCHEMA,
|
||||
config_name: NewSessionConfig,
|
||||
search_doc: NewSessionDocument,
|
||||
identifying_doc: NewSessionIdDocument,
|
||||
search_fields: [new_session_option: 1.0],
|
||||
id_fields: [id: String]
|
||||
);
|
||||
define_search_schema!(
|
||||
schema_name: BASE_TEXT_SEARCH_SCHEMA,
|
||||
config_name: BaseTextConfig,
|
||||
search_doc: BaseTextDocument,
|
||||
identifying_doc: BaseTextIdDocument,
|
||||
search_fields: [base_text: 1.0],
|
||||
id_fields: []
|
||||
);
|
||||
|
||||
pub(crate) struct FullTextNewSessionSearcher {
|
||||
background_executor: Arc<Background>,
|
||||
searcher: AsyncSearcher<NewSessionConfig>,
|
||||
max_match_searcher: AsyncSearcher<BaseTextConfig>,
|
||||
shell_id_to_options: HashMap<NewSessionOptionId, Arc<NewSessionOption>>,
|
||||
}
|
||||
|
||||
impl NewSessionSearcher for FullTextNewSessionSearcher {
|
||||
fn search(&self, search_term: &str) -> anyhow::Result<Vec<QueryResult<SearcherAction>>> {
|
||||
let max_match = self.compute_max_match(search_term);
|
||||
let search_result = self.searcher.search_id(search_term)?;
|
||||
Ok(search_result
|
||||
.into_iter()
|
||||
.filter_map(|result| {
|
||||
let matched_indices = result.highlights.new_session_option;
|
||||
let new_session_option = self
|
||||
.shell_id_to_options
|
||||
.get(&NewSessionOptionId(result.values.id))?;
|
||||
|
||||
// If for some reason the variant (ex: "Create New Tab: Powershell") ranks higher
|
||||
// than a match for a base string (ex: "Create New Tab"), we want to cap the score
|
||||
// to be one less than the base string.
|
||||
let capped_score = Self::cap_score(result.score, max_match);
|
||||
|
||||
Some(
|
||||
SearchItem::new(
|
||||
new_session_option.clone(),
|
||||
FuzzyMatchResult {
|
||||
score: (capped_score * SCORE_CONVERSION_FACTOR) as i64,
|
||||
matched_indices,
|
||||
},
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn build_index(&mut self) {
|
||||
if self.rebuild_search_index().is_err() {
|
||||
log::error!("Failed to create search index writer for new session options");
|
||||
self.clear_search_index();
|
||||
}
|
||||
}
|
||||
|
||||
fn bindings(&self) -> &HashMap<NewSessionOptionId, Arc<NewSessionOption>> {
|
||||
&self.shell_id_to_options
|
||||
}
|
||||
|
||||
fn bindings_mut(&mut self) -> &mut HashMap<NewSessionOptionId, Arc<NewSessionOption>> {
|
||||
&mut self.shell_id_to_options
|
||||
}
|
||||
|
||||
fn compute_max_match(&self, query_str: &str) -> Option<f64> {
|
||||
self.max_match_searcher
|
||||
.search_id(query_str)
|
||||
.ok()?
|
||||
.iter()
|
||||
.map(|result| result.score)
|
||||
.reduce(|min, score| if score < min { score } else { min })
|
||||
}
|
||||
}
|
||||
|
||||
impl FullTextNewSessionSearcher {
|
||||
pub(crate) fn new(background_executor: Arc<Background>) -> Self {
|
||||
let searcher = NEW_SESSION_SEARCH_SCHEMA
|
||||
.create_async_searcher(DEFAULT_MEMORY_BUDGET, background_executor.clone());
|
||||
let mut max_match_searcher = BASE_TEXT_SEARCH_SCHEMA
|
||||
.create_async_searcher(MIN_MEMORY_BUDGET, background_executor.clone());
|
||||
let max_match_documents = SEARCHER_BASE_STRINGS.iter().map(|base| BaseTextDocument {
|
||||
base_text: base.to_string(),
|
||||
});
|
||||
if max_match_searcher
|
||||
.build_index_async(max_match_documents)
|
||||
.is_err()
|
||||
{
|
||||
log::error!("Failed to build search index for base text of new session search");
|
||||
if max_match_searcher.clear_search_index_async().is_err() {
|
||||
max_match_searcher = BASE_TEXT_SEARCH_SCHEMA
|
||||
.create_async_searcher(MIN_MEMORY_BUDGET, background_executor.clone())
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
background_executor,
|
||||
searcher,
|
||||
max_match_searcher,
|
||||
shell_id_to_options: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild_search_index(&mut self) -> Result<(), anyhow::Error> {
|
||||
self.clear_search_index();
|
||||
let documents = self.shell_id_to_options.iter().map(|(id, option)| {
|
||||
let binding_description = option.description().to_lowercase();
|
||||
|
||||
NewSessionDocument {
|
||||
new_session_option: binding_description.clone(),
|
||||
id: id.0.clone(),
|
||||
}
|
||||
});
|
||||
self.searcher.build_index_async(documents)
|
||||
}
|
||||
|
||||
fn clear_search_index(&mut self) {
|
||||
if self.searcher.clear_search_index_async().is_err() {
|
||||
// As a workaround, we can create a new index and replace the old one.
|
||||
self.searcher = NEW_SESSION_SEARCH_SCHEMA
|
||||
.create_async_searcher(DEFAULT_MEMORY_BUDGET, self.background_executor.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn cap_score(score: f64, max_match_score: Option<f64>) -> f64 {
|
||||
if let Some(max_match) = max_match_score {
|
||||
// The use of 0.02 comes from the fact that fuzzy search scores are reduced by 1 in this case,
|
||||
// and we boosted the Tantivy score by a factor of 50 to roughly match the fuzzy search scores.
|
||||
if score > max_match - 0.02 {
|
||||
max_match - 0.02
|
||||
} else {
|
||||
score
|
||||
}
|
||||
} else {
|
||||
score
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod new_session_option;
|
||||
|
||||
pub use new_session_option::{NewSessionOption, NewSessionOptionId};
|
||||
|
||||
mod data_source;
|
||||
mod renderer;
|
||||
mod search_item;
|
||||
|
||||
pub use data_source::{AllowedSessionKinds, NewSessionDataSource};
|
||||
@@ -0,0 +1,128 @@
|
||||
use crate::server::telemetry::AddTabWithShellSource;
|
||||
use crate::terminal::available_shells::AvailableShell;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::WorkspaceAction;
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
use warpui::Action;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct NewSessionOptionId(pub(crate) String);
|
||||
impl NewSessionOptionId {
|
||||
#[cfg_attr(not(feature = "local_tty"), allow(dead_code))]
|
||||
pub(super) fn new(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum Direction {
|
||||
Down,
|
||||
Right,
|
||||
Up,
|
||||
Left,
|
||||
}
|
||||
|
||||
impl fmt::Display for Direction {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
Direction::Down => "Down",
|
||||
Direction::Right => "Right",
|
||||
Direction::Up => "Up",
|
||||
Direction::Left => "Left",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum NewSessionConfig {
|
||||
NewTab(AvailableShell),
|
||||
NewWindow(AvailableShell),
|
||||
Split(Direction, AvailableShell),
|
||||
}
|
||||
|
||||
impl NewSessionConfig {
|
||||
fn shell(&self) -> &AvailableShell {
|
||||
match self {
|
||||
NewSessionConfig::NewTab(shell) => shell,
|
||||
NewSessionConfig::NewWindow(shell) => shell,
|
||||
NewSessionConfig::Split(_, shell) => shell,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// An option for creating a new terminal session
|
||||
///
|
||||
/// Contains configuration information like:
|
||||
/// - which shell to use
|
||||
/// - how to display the option in the command palette
|
||||
pub struct NewSessionOption {
|
||||
id: NewSessionOptionId,
|
||||
description: String,
|
||||
config: NewSessionConfig,
|
||||
}
|
||||
|
||||
impl NewSessionOption {
|
||||
pub fn id(&self) -> &NewSessionOptionId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Returns the description (a.k.a. the top line in the command palette entry)
|
||||
pub fn description(&self) -> &str {
|
||||
self.description.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl NewSessionOption {
|
||||
pub(super) fn new(id: NewSessionOptionId, config: NewSessionConfig) -> Self {
|
||||
let description = match &config {
|
||||
NewSessionConfig::NewTab(shell) => format!("Create New Tab: {}", shell.short_name()),
|
||||
NewSessionConfig::NewWindow(shell) => {
|
||||
format!("Create New Window: {}", shell.short_name())
|
||||
}
|
||||
NewSessionConfig::Split(direction, shell) => {
|
||||
format!("Split Pane {direction}: {}", shell.short_name())
|
||||
}
|
||||
};
|
||||
Self {
|
||||
id,
|
||||
description,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an action that should be triggered if this entry is accepted
|
||||
pub fn action(&self) -> Box<dyn Action> {
|
||||
match &self.config {
|
||||
NewSessionConfig::NewTab(shell) => Box::new(WorkspaceAction::AddTabWithShell {
|
||||
shell: shell.clone(),
|
||||
source: AddTabWithShellSource::CommandPalette,
|
||||
}),
|
||||
NewSessionConfig::NewWindow(shell) => Box::new(WorkspaceAction::AddWindowWithShell {
|
||||
shell: shell.clone(),
|
||||
}),
|
||||
NewSessionConfig::Split(Direction::Down, shell) => {
|
||||
Box::new(TerminalAction::SplitDown(Some(shell.clone())))
|
||||
}
|
||||
NewSessionConfig::Split(Direction::Up, shell) => {
|
||||
Box::new(TerminalAction::SplitUp(Some(shell.clone())))
|
||||
}
|
||||
NewSessionConfig::Split(Direction::Right, shell) => {
|
||||
Box::new(TerminalAction::SplitRight(Some(shell.clone())))
|
||||
}
|
||||
NewSessionConfig::Split(Direction::Left, shell) => {
|
||||
Box::new(TerminalAction::SplitLeft(Some(shell.clone())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the details (a.k.a. the second line in the command palette entry)
|
||||
pub fn details(&self) -> Cow<'_, str> {
|
||||
self.config.shell().details()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use super::new_session_option::NewSessionOption;
|
||||
|
||||
use crate::search::command_palette::styles::SEARCH_ITEM_TEXT_PADDING;
|
||||
use warpui::{
|
||||
elements::{Container, Flex, Highlight, ParentElement, Text},
|
||||
fonts::{Properties, Weight},
|
||||
Element,
|
||||
};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
|
||||
impl NewSessionOption {
|
||||
pub(super) fn render(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
highlight_state: ItemHighlightState,
|
||||
highlight_indices: Vec<usize>,
|
||||
) -> Box<dyn Element> {
|
||||
let highlight = Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid());
|
||||
|
||||
let display_text = Text::new_inline(
|
||||
self.description().to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.with_single_highlight(highlight, highlight_indices)
|
||||
.finish();
|
||||
|
||||
let details = Text::new_inline(
|
||||
self.details().to_string(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size() - 2.,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_child(Container::new(display_text).finish())
|
||||
.with_child(
|
||||
Container::new(details)
|
||||
.with_padding_top(SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use super::new_session_option::NewSessionOption;
|
||||
use crate::{
|
||||
appearance::Appearance, search::command_palette::render_util::render_search_item_icon,
|
||||
ui_components::icons::Icon,
|
||||
};
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
|
||||
use ordered_float::OrderedFloat;
|
||||
use std::sync::Arc;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SearchItem {
|
||||
match_result: FuzzyMatchResult,
|
||||
option: Arc<NewSessionOption>,
|
||||
}
|
||||
|
||||
impl SearchItem {
|
||||
pub fn new(option: Arc<NewSessionOption>, match_result: FuzzyMatchResult) -> Self {
|
||||
Self {
|
||||
match_result,
|
||||
option,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::search::item::SearchItem for SearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn is_multiline(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
render_search_item_icon(
|
||||
appearance,
|
||||
Icon::Terminal,
|
||||
appearance.theme().foreground().into_solid(),
|
||||
highlight_state,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
self.option.render(
|
||||
appearance,
|
||||
highlight_state,
|
||||
self.match_result.matched_indices.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat::from(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::NewSession {
|
||||
source: self.option.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Selected {}.", self.option.description())
|
||||
}
|
||||
|
||||
fn accessibility_help_message(&self) -> Option<String> {
|
||||
Some("Press enter to launch this session.".into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::themes::theme::Blend;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::util::color::{ContrastingColor, MinimumAllowedContrast};
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{Align, ConstrainedBox, Container, Empty};
|
||||
use warpui::Element;
|
||||
|
||||
/// Helper function to render an icon for any search item within the command palette with consistent
|
||||
/// styling.
|
||||
pub fn render_search_item_icon(
|
||||
appearance: &Appearance,
|
||||
icon: Icon,
|
||||
icon_color: ColorU,
|
||||
highlight_state: ItemHighlightState,
|
||||
) -> Box<dyn Element> {
|
||||
let base_background = appearance.theme().surface_2();
|
||||
let background_color = match highlight_state.container_background_fill(appearance) {
|
||||
None => base_background,
|
||||
Some(highlight) => base_background.blend(&highlight),
|
||||
};
|
||||
let icon_color = icon_color.on_background(
|
||||
background_color.into_solid(),
|
||||
MinimumAllowedContrast::NonText,
|
||||
);
|
||||
let icon_element = icon.to_warpui_icon(Fill::Solid(icon_color)).finish();
|
||||
render_search_item_icon_inner(appearance, icon_element)
|
||||
}
|
||||
|
||||
/// Helper function to render a placeholder element when a search item does not have an icon.
|
||||
pub fn render_search_item_icon_placeholder(appearance: &Appearance) -> Box<dyn Element> {
|
||||
render_search_item_icon_inner(appearance, Empty::new().finish())
|
||||
}
|
||||
|
||||
fn render_search_item_icon_inner(
|
||||
appearance: &Appearance,
|
||||
inner_element: Box<dyn Element>,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(Align::new(inner_element).finish())
|
||||
.with_width(appearance.monospace_font_size())
|
||||
.with_height(appearance.monospace_font_size())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(12.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub mod colors {
|
||||
pub const WARP_AI: u32 = 0xF3B911FF;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod repo_data_source;
|
||||
pub mod repo_search_item;
|
||||
|
||||
pub use repo_data_source::*;
|
||||
pub use repo_search_item::*;
|
||||
@@ -0,0 +1,72 @@
|
||||
use ai::workspace::WorkspaceMetadata;
|
||||
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
|
||||
use itertools::Itertools;
|
||||
use warpui::{AppContext, Entity, SingletonEntity};
|
||||
|
||||
use super::RepoSearchItem;
|
||||
use crate::ai::persisted_workspace::PersistedWorkspace;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
|
||||
const MAX_REPOS_CONSIDERED: usize = 50;
|
||||
|
||||
pub struct RepoDataSource {}
|
||||
|
||||
impl Default for RepoDataSource {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RepoDataSource {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
pub fn top_n(&self, limit: usize, app: &AppContext) -> impl Iterator<Item = RepoSearchItem> {
|
||||
PersistedWorkspace::as_ref(app)
|
||||
.workspaces()
|
||||
.filter(|cbm| cbm.path.is_dir())
|
||||
.sorted_by(WorkspaceMetadata::most_recently_navigated)
|
||||
.take(limit)
|
||||
.map(RepoSearchItem::new)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for RepoDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SyncDataSource for RepoDataSource {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let query_str = query.text.as_str();
|
||||
|
||||
let repos = self.top_n(MAX_REPOS_CONSIDERED, app);
|
||||
|
||||
let results = repos
|
||||
.filter_map(|mut repo| {
|
||||
let match_result = if query_str.is_empty() {
|
||||
Some(FuzzyMatchResult::no_match())
|
||||
} else {
|
||||
match_indices_case_insensitive(repo.display_name.as_str(), query_str)
|
||||
};
|
||||
|
||||
// Boost repo results so they compete fairly with other sources
|
||||
match_result.map(|mut match_result| {
|
||||
match_result.score *= 4;
|
||||
repo.match_result = match_result;
|
||||
repo.into()
|
||||
})
|
||||
})
|
||||
.collect_vec();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use ai::workspace::WorkspaceMetadata;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use std::path::Path;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::{
|
||||
elements::{Align, ConstrainedBox, Flex, Highlight, ParentElement, Shrinkable, Text},
|
||||
fonts::{Properties, Weight},
|
||||
AppContext, Element, SingletonEntity,
|
||||
};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::action::search_item::styles;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::render_util;
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::ui_components::icons::Icon as UiIcon;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RepoSearchItem {
|
||||
pub display_name: String,
|
||||
pub metadata: WorkspaceMetadata,
|
||||
pub match_result: FuzzyMatchResult,
|
||||
}
|
||||
|
||||
fn repo_display_name(repo_path: &Path) -> String {
|
||||
// Try to create a relative path from the user's home directory
|
||||
dirs::home_dir()
|
||||
.and_then(|home| repo_path.strip_prefix(&home).ok())
|
||||
.map(|relative_path| format!("~/{}", relative_path.display()))
|
||||
.unwrap_or_else(|| repo_path.display().to_string())
|
||||
}
|
||||
|
||||
impl RepoSearchItem {
|
||||
pub fn new(metadata: WorkspaceMetadata) -> Self {
|
||||
RepoSearchItem {
|
||||
display_name: repo_display_name(&metadata.path),
|
||||
metadata,
|
||||
match_result: FuzzyMatchResult::no_match(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
item_highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let label = self.render_label(item_highlight_state, appearance);
|
||||
let mut binding = Flex::row();
|
||||
|
||||
binding.add_child(Shrinkable::new(1., Align::new(label).left().finish()).finish());
|
||||
|
||||
ConstrainedBox::new(binding.finish())
|
||||
.with_height(styles::SEARCH_ITEM_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_label(
|
||||
&self,
|
||||
item_highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Text::new_inline(
|
||||
repo_display_name(&self.metadata.path),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(item_highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(
|
||||
item_highlight_state.main_text_fill(appearance).into_solid(),
|
||||
),
|
||||
self.match_result.matched_indices.clone(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for RepoSearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_color: Fill = appearance.theme().terminal_colors().normal.cyan.into();
|
||||
|
||||
render_util::render_search_item_icon(
|
||||
appearance,
|
||||
UiIcon::Folder,
|
||||
icon_color.into_solid(),
|
||||
highlight_state,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
self.render(highlight_state, appearance)
|
||||
}
|
||||
|
||||
fn render_details(&self, _ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> CommandPaletteItemAction {
|
||||
// Convert the absolute repo path into parent + basename for OpenDirectory
|
||||
let repo_path: &Path = &self.metadata.path;
|
||||
let parent = repo_path.parent().unwrap_or(Path::new("/"));
|
||||
let basename = repo_path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| repo_path.to_string_lossy().to_string());
|
||||
|
||||
CommandPaletteItemAction::OpenDirectory {
|
||||
path: basename,
|
||||
project_directory: parent.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> CommandPaletteItemAction {
|
||||
// For projects, execute and accept have the same behavior
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Repo: {}", self.metadata.path.display())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use crate::search::command_palette::mixer::ItemSummary;
|
||||
use bounded_vec_deque::BoundedVecDeque;
|
||||
use warpui::{Entity, SingletonEntity};
|
||||
|
||||
/// Maximum number of elements to store. Per the [`BoundedVecDeque`] docs, it is recommended that
|
||||
/// this is one less than the power of two to avoid unnecessary allocations.
|
||||
///
|
||||
/// Only a small set of selected items are stored (15). However, we store more items than we render
|
||||
/// in the command palette since it's not guaranteed that all of the items are available at a
|
||||
/// given time (available bindings are dependent on which view is focused, sessions could have been
|
||||
/// closed, workflows could have been deleted).
|
||||
const MAX_SIZE: usize = 15;
|
||||
|
||||
/// Store of all of recently selected items within the command palette. Only one item of any given
|
||||
/// [`ItemSummary`] type is stored.
|
||||
pub struct SelectedItems {
|
||||
items: BoundedVecDeque<ItemSummary>,
|
||||
}
|
||||
|
||||
impl Default for SelectedItems {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectedItems {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
items: BoundedVecDeque::new(MAX_SIZE),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueue a new `summary` into the list of [`SelectedItems`]. If the item is already in the
|
||||
/// list, it is removed and reinserted at the end.
|
||||
///
|
||||
/// Upon insertion, if the max number of items exceeds that of [`MAX_SIZE`], items from the
|
||||
/// beginning of the list are removed.
|
||||
pub fn enqueue(&mut self, summary: ItemSummary) {
|
||||
if let Some(index) = self.items.iter().position(|item| item == &summary) {
|
||||
self.items.remove(index);
|
||||
}
|
||||
|
||||
self.items.push_back(summary);
|
||||
}
|
||||
|
||||
/// Returns an iterator of the recently selected items in reverse order of when they were
|
||||
/// selected (newly selected items are returned first).
|
||||
pub fn iter(&self) -> impl Iterator<Item = &ItemSummary> {
|
||||
self.items.iter().rev()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SelectedItems {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for SelectedItems {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "selected_items_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,49 @@
|
||||
use super::*;
|
||||
use itertools::Itertools;
|
||||
use warpui::keymap::BindingId;
|
||||
|
||||
#[test]
|
||||
fn test_enqueue_new_item() {
|
||||
let mut selected_items = SelectedItems::new();
|
||||
|
||||
let summary_1 = ItemSummary::Action {
|
||||
binding_id: BindingId::new(),
|
||||
};
|
||||
let summary_2 = ItemSummary::Action {
|
||||
binding_id: BindingId::new(),
|
||||
};
|
||||
|
||||
// Enqueue two items.
|
||||
selected_items.enqueue(summary_1.clone());
|
||||
selected_items.enqueue(summary_2.clone());
|
||||
|
||||
// Items should be returned in reverse order of they were enqueued.
|
||||
assert_eq!(
|
||||
selected_items.iter().collect_vec(),
|
||||
vec![&summary_2, &summary_1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enqueue_existing_item() {
|
||||
let mut selected_items = SelectedItems::new();
|
||||
|
||||
let summary_1 = ItemSummary::Action {
|
||||
binding_id: BindingId::new(),
|
||||
};
|
||||
let summary_2 = ItemSummary::Action {
|
||||
binding_id: BindingId::new(),
|
||||
};
|
||||
|
||||
// Enqueue `summary_1` twice.
|
||||
selected_items.enqueue(summary_1.clone());
|
||||
selected_items.enqueue(summary_2.clone());
|
||||
selected_items.enqueue(summary_1.clone());
|
||||
|
||||
// Ensure `summary_1` is returned first since it was enqueued more recently and that it isn't
|
||||
// included in the selected items list twice.
|
||||
assert_eq!(
|
||||
selected_items.iter().collect_vec(),
|
||||
vec![&summary_1, &summary_2]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::{appearance::Appearance, search::command_palette::mixer::CommandPaletteItemAction};
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::{
|
||||
elements::{Empty, Text},
|
||||
AppContext, Element, SingletonEntity,
|
||||
};
|
||||
|
||||
/// A simple separator item that displays a title to visually separate sections in search results.
|
||||
#[derive(Debug)]
|
||||
pub struct SeparatorSearchItem {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
impl SeparatorSearchItem {
|
||||
pub fn new(title: String) -> Self {
|
||||
Self { title }
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for SeparatorSearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
_appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
Text::new_inline(
|
||||
self.title.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() * 0.85,
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.disabled_text_color(appearance.theme().surface_2())
|
||||
.into_solid(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
// Give separators a neutral score - they should be positioned explicitly
|
||||
OrderedFloat(0.0)
|
||||
}
|
||||
|
||||
/// Separators are non-interactable, so we should not do anything when they are accepted.
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::NoOp
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::NoOp
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Section: {}", self.title)
|
||||
}
|
||||
|
||||
fn is_static_separator(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,175 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::CloudObject;
|
||||
use crate::drive::cloud_object_styling::warp_drive_icon_color;
|
||||
use crate::drive::{CloudObjectTypeAndId, DriveObjectType};
|
||||
use crate::env_vars::CloudEnvVarCollection;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::render_util::render_search_item_icon;
|
||||
use crate::search::command_palette::styles::SEARCH_ITEM_TEXT_PADDING;
|
||||
use crate::search::env_var_collections::fuzzy_match::FuzzyMatchEnvVarCollectionResult;
|
||||
use crate::search::item::{IconLocation, SearchItem};
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use itertools::Itertools;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::elements::{Container, Flex, Highlight, ParentElement, Text};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
pub const ENV_VAR_NAME_SEPARATOR: &str = ", ";
|
||||
|
||||
/// Search item result for a cloud EnvVarCollection.
|
||||
#[derive(Debug)]
|
||||
pub struct EnvVarCollectionSearchItem {
|
||||
pub match_result: FuzzyMatchEnvVarCollectionResult,
|
||||
pub cloud_env_var_collection: CloudEnvVarCollection,
|
||||
}
|
||||
|
||||
impl SearchItem for EnvVarCollectionSearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn is_multiline(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let color = warp_drive_icon_color(appearance, DriveObjectType::EnvVarCollection);
|
||||
render_search_item_icon(appearance, Icon::EnvVarCollection, color, highlight_state)
|
||||
}
|
||||
|
||||
fn icon_location(&self, appearance: &Appearance) -> IconLocation {
|
||||
// The icon is has the size of the monospace font, whereas the text have a height of
|
||||
// `line_height_ratio * font_size`. Offset the icon by this difference so it is rendered
|
||||
// centered with the text.
|
||||
let margin_top = (appearance.line_height_ratio() * appearance.monospace_font_size())
|
||||
- appearance.monospace_font_size();
|
||||
IconLocation::Top { margin_top }
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let mut title_text = Text::new_inline(
|
||||
self.cloud_env_var_collection
|
||||
.model()
|
||||
.string_model
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or("Untitled".to_owned())
|
||||
.to_owned(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.with_style(Properties::default().weight(Weight::Bold));
|
||||
|
||||
if let Some(title_match_result) = &self.match_result.title_match_result {
|
||||
title_text = title_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
title_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let vars_text = self
|
||||
.cloud_env_var_collection
|
||||
.model()
|
||||
.string_model
|
||||
.vars
|
||||
.iter()
|
||||
.map(|var| var.name.clone())
|
||||
.collect_vec()
|
||||
.join(ENV_VAR_NAME_SEPARATOR);
|
||||
|
||||
let mut vars_element = Text::new_inline(
|
||||
vars_text.clone(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size() - 2.,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
if let Some(var_name_match_result) = &self.match_result.var_name_match_result {
|
||||
vars_element = vars_element.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
var_name_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut breadcrumbs_text: Text = Text::new_inline(
|
||||
self.cloud_env_var_collection.breadcrumbs(app),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
if let Some(breadcrumbs_match_result) = &self.match_result.breadcrumbs_match_result {
|
||||
breadcrumbs_text = breadcrumbs_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
breadcrumbs_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut item = Flex::column()
|
||||
.with_child(Container::new(title_text.finish()).finish())
|
||||
.with_child(
|
||||
Container::new(breadcrumbs_text.finish())
|
||||
.with_padding_top(SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
item.add_child(
|
||||
Container::new(vars_element.finish())
|
||||
.with_padding_top(SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
item.finish()
|
||||
}
|
||||
|
||||
fn render_details(&self, _: &AppContext) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.match_result.score()
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::InvokeEnvironmentVariables {
|
||||
id: self.cloud_env_var_collection.id,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::ViewInWarpDrive {
|
||||
id: CloudObjectTypeAndId::GenericStringObject {
|
||||
object_type: crate::cloud_object::GenericStringObjectFormat::Json(
|
||||
crate::cloud_object::JsonObjectType::EnvVarCollection,
|
||||
),
|
||||
id: self.cloud_env_var_collection.id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!(
|
||||
"Environment Variables: {}",
|
||||
self.cloud_env_var_collection
|
||||
.model()
|
||||
.string_model
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or("Untitled".to_owned())
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod data_source;
|
||||
mod env_var_collection_search_item;
|
||||
mod notebook_search_item;
|
||||
mod workflow_search_item;
|
||||
|
||||
pub use data_source::DataSource;
|
||||
pub use workflow_search_item::WorkflowSearchItem;
|
||||
@@ -0,0 +1,146 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::CloudObject;
|
||||
use crate::drive::cloud_object_styling::warp_drive_icon_color;
|
||||
use crate::drive::{CloudObjectTypeAndId, DriveObjectType};
|
||||
use crate::notebooks::CloudNotebook;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::render_util::render_search_item_icon;
|
||||
use crate::search::command_palette::styles::SEARCH_ITEM_TEXT_PADDING;
|
||||
use crate::search::item::{IconLocation, SearchItem};
|
||||
use crate::search::notebooks::fuzzy_match::{
|
||||
render_notebook_matched_content_with_highlight, FuzzyMatchNotebookResult,
|
||||
};
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::elements::{Container, Flex, Highlight, ParentElement, Text};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
/// Search item result for a cloud notebook.
|
||||
#[derive(Debug)]
|
||||
pub struct NotebookSearchItem {
|
||||
pub cloud_notebook: CloudNotebook,
|
||||
pub match_result: FuzzyMatchNotebookResult,
|
||||
}
|
||||
|
||||
impl SearchItem for NotebookSearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn is_multiline(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let color = warp_drive_icon_color(
|
||||
appearance,
|
||||
DriveObjectType::Notebook {
|
||||
is_ai_document: false,
|
||||
},
|
||||
);
|
||||
render_search_item_icon(appearance, Icon::Notebook, color, highlight_state)
|
||||
}
|
||||
|
||||
fn icon_location(&self, appearance: &Appearance) -> IconLocation {
|
||||
// The icon is has the size of the monospace font, whereas the text have a height of
|
||||
// `line_height_ratio * font_size`. Offset the icon by this difference so it is rendered
|
||||
// centered with the text.
|
||||
let margin_top = (appearance.line_height_ratio() * appearance.monospace_font_size())
|
||||
- appearance.monospace_font_size();
|
||||
IconLocation::Top { margin_top }
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let title = if self.cloud_notebook.model().title.is_empty() {
|
||||
"Untitled".to_string()
|
||||
} else {
|
||||
self.cloud_notebook.model().title.clone()
|
||||
};
|
||||
let mut name_text = Text::new_inline(
|
||||
title,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.with_style(Properties::default().weight(Weight::Bold));
|
||||
|
||||
if let Some(name_match_result) = &self.match_result.name_match_result {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
name_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut breadcrumbs_text: Text = Text::new_inline(
|
||||
self.cloud_notebook.breadcrumbs(app),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
if let Some(folder_match_result) = &self.match_result.folder_match_result {
|
||||
breadcrumbs_text = breadcrumbs_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
folder_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let notebook_content = render_notebook_matched_content_with_highlight(
|
||||
self.cloud_notebook.id,
|
||||
&self.cloud_notebook.model().data,
|
||||
&self.match_result.content_match_result,
|
||||
highlight_state,
|
||||
app,
|
||||
);
|
||||
|
||||
Flex::column()
|
||||
.with_child(Container::new(name_text.finish()).finish())
|
||||
.with_child(
|
||||
Container::new(breadcrumbs_text.finish())
|
||||
.with_padding_top(SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(notebook_content.finish())
|
||||
.with_padding_top(SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_details(&self, _: &AppContext) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.match_result.score()
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::OpenNotebook {
|
||||
id: self.cloud_notebook.id,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::ViewInWarpDrive {
|
||||
id: CloudObjectTypeAndId::Notebook(self.cloud_notebook.id),
|
||||
}
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Notebook: {}", self.cloud_notebook.model().title)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::CloudObject;
|
||||
use crate::drive::cloud_object_styling::warp_drive_icon_color;
|
||||
use crate::drive::{CloudObjectTypeAndId, DriveObjectType};
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::render_util::render_search_item_icon;
|
||||
use crate::search::command_palette::styles::SEARCH_ITEM_TEXT_PADDING;
|
||||
use crate::search::item::{IconLocation, SearchItem};
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::search::workflows::fuzzy_match::FuzzyMatchWorkflowResult;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::workflows::CloudWorkflow;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::elements::{Clipped, Container, Flex, Highlight, ParentElement, Shrinkable, Text};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
/// Search item result for a cloud workflow.
|
||||
#[derive(Debug)]
|
||||
pub struct WorkflowSearchItem {
|
||||
pub match_result: FuzzyMatchWorkflowResult,
|
||||
pub cloud_workflow: CloudWorkflow,
|
||||
}
|
||||
|
||||
impl SearchItem for WorkflowSearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn is_multiline(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let (icon, icon_color) = if self.cloud_workflow.model().data.is_agent_mode_workflow() {
|
||||
(
|
||||
Icon::Prompt,
|
||||
warp_drive_icon_color(appearance, DriveObjectType::AgentModeWorkflow),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Icon::Workflow,
|
||||
warp_drive_icon_color(appearance, DriveObjectType::Workflow),
|
||||
)
|
||||
};
|
||||
render_search_item_icon(appearance, icon, icon_color, highlight_state)
|
||||
}
|
||||
|
||||
fn icon_location(&self, appearance: &Appearance) -> IconLocation {
|
||||
// The icon is has the size of the monospace font, whereas the text have a height of
|
||||
// `line_height_ratio * font_size`. Offset the icon by this difference so it is rendered
|
||||
// centered with the text.
|
||||
let margin_top = (appearance.line_height_ratio() * appearance.monospace_font_size())
|
||||
- appearance.monospace_font_size();
|
||||
IconLocation::Top { margin_top }
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let mut name_text = Text::new_inline(
|
||||
self.cloud_workflow.model().data.name().to_owned(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.with_style(Properties::default().weight(Weight::Bold));
|
||||
|
||||
if let Some(name_match_result) = &self.match_result.name_match_result {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
name_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut breadcrumbs_text: Text = Text::new_inline(
|
||||
self.cloud_workflow.breadcrumbs(app),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
if let Some(folder_match_result) = &self.match_result.folder_match_result {
|
||||
breadcrumbs_text = breadcrumbs_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
folder_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut content_text = Text::new_inline(
|
||||
self.cloud_workflow.model().data.content().to_owned(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size() - 2.,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
if let Some(command_match_result) = &self.match_result.content_match_result {
|
||||
content_text = content_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
command_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let contents = Flex::column()
|
||||
.with_child(Container::new(name_text.finish()).finish())
|
||||
.with_child(
|
||||
Container::new(breadcrumbs_text.finish())
|
||||
.with_padding_top(SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(content_text.finish())
|
||||
.with_padding_top(SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Clipped::new(Shrinkable::new(1., contents).finish()).finish()
|
||||
}
|
||||
|
||||
fn render_details(&self, _: &AppContext) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.match_result.score()
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::ExecuteWorkflow {
|
||||
id: self.cloud_workflow.id,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::ViewInWarpDrive {
|
||||
id: CloudObjectTypeAndId::Workflow(self.cloud_workflow.id),
|
||||
}
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Workflow: {}", self.cloud_workflow.model().data.name())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
mod items;
|
||||
pub use items::Items;
|
||||
use warp_core::context_flag::ContextFlag;
|
||||
use warp_core::features::FeatureFlag;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::command_palette::FilterChipRenderer;
|
||||
|
||||
use crate::drive::settings::WarpDriveSettings;
|
||||
use crate::search::QueryFilter;
|
||||
use crate::settings::AISettings;
|
||||
use crate::workspace::Workspace;
|
||||
use std::collections::HashMap;
|
||||
use warpui::elements::{Container, Flex, MouseStateHandle, ParentElement, Shrinkable, Wrap};
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
WindowId,
|
||||
};
|
||||
|
||||
/// A zero-state view for the command palette.
|
||||
pub struct ZeroState {
|
||||
filter_chip_to_mouse_state_handle: HashMap<QueryFilter, MouseStateHandle>,
|
||||
items: ModelHandle<Items>,
|
||||
// Store the window this view belongs to so we don't rely on the global active window
|
||||
window_id: WindowId,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Action {
|
||||
FilterChipClicked { filter: QueryFilter },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Event {
|
||||
FilterChipSelected { filter: QueryFilter },
|
||||
}
|
||||
|
||||
impl ZeroState {
|
||||
pub fn new(results_model: ModelHandle<Items>, ctx: &mut ViewContext<Self>) -> Self {
|
||||
ctx.observe(&results_model, |_, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
Self {
|
||||
filter_chip_to_mouse_state_handle: QueryFilter::all()
|
||||
.map(|filter| (filter, MouseStateHandle::default()))
|
||||
.collect(),
|
||||
|
||||
items: results_model,
|
||||
window_id: ctx.window_id(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a clickable chip for each valid query filter. When a chip is
|
||||
/// clicked, the filter is emitted in a [`Event::FilterChipSelected`] event.
|
||||
fn render_filter_chips(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
valid_filters: impl IntoIterator<Item = QueryFilter>,
|
||||
) -> Box<dyn Element> {
|
||||
let wrap = Wrap::row()
|
||||
.with_run_spacing(styles::FILTER_CHIP_MARGIN)
|
||||
.with_children(valid_filters.into_iter().map(|filter| {
|
||||
Container::new(filter.render_filter_chip(
|
||||
self.filter_chip_to_mouse_state_handle[&filter].clone(),
|
||||
appearance,
|
||||
|event_ctx, filter| {
|
||||
event_ctx.dispatch_typed_action(Action::FilterChipClicked { filter })
|
||||
},
|
||||
))
|
||||
.with_margin_right(styles::FILTER_CHIP_MARGIN)
|
||||
.finish()
|
||||
}));
|
||||
|
||||
Container::new(wrap.finish())
|
||||
.with_margin_bottom(styles::FILTER_CHIPS_MARGIN_BOTTOM)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Returns the set of valid query filters for this zero state view.
|
||||
fn valid_query_filters(
|
||||
app: &AppContext,
|
||||
window_id: WindowId,
|
||||
) -> impl Iterator<Item = QueryFilter> {
|
||||
let show_warp_drive = WarpDriveSettings::is_warp_drive_enabled(app);
|
||||
|
||||
let mut valid_filters = vec![];
|
||||
if show_warp_drive {
|
||||
valid_filters.push(QueryFilter::Workflows);
|
||||
if FeatureFlag::AgentModeWorkflows.is_enabled()
|
||||
&& AISettings::as_ref(app).is_any_ai_enabled(app)
|
||||
{
|
||||
valid_filters.push(QueryFilter::AgentModeWorkflows);
|
||||
}
|
||||
valid_filters.push(QueryFilter::Notebooks);
|
||||
|
||||
valid_filters.push(QueryFilter::EnvironmentVariables);
|
||||
}
|
||||
|
||||
// Don't show Files filter if the user is a viewer of a shared session
|
||||
if FeatureFlag::CommandPaletteFileSearch.is_enabled() {
|
||||
let is_shared_session_viewer_focused = app
|
||||
.views_of_type::<Workspace>(window_id)
|
||||
.and_then(|workspaces| workspaces.first().cloned())
|
||||
.is_some_and(|workspace| {
|
||||
workspace.as_ref(app).is_shared_session_viewer_focused(app)
|
||||
});
|
||||
if !is_shared_session_viewer_focused {
|
||||
valid_filters.push(QueryFilter::Files);
|
||||
}
|
||||
}
|
||||
|
||||
if show_warp_drive {
|
||||
valid_filters.push(QueryFilter::Drive);
|
||||
}
|
||||
valid_filters.extend([QueryFilter::Actions, QueryFilter::Sessions]);
|
||||
|
||||
if ContextFlag::LaunchConfigurations.is_enabled() {
|
||||
valid_filters.push(QueryFilter::LaunchConfigurations);
|
||||
}
|
||||
|
||||
if AISettings::as_ref(app).is_any_ai_enabled(app) {
|
||||
valid_filters.push(QueryFilter::Conversations);
|
||||
}
|
||||
|
||||
valid_filters.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ZeroState {
|
||||
type Event = Event;
|
||||
}
|
||||
|
||||
impl View for ZeroState {
|
||||
fn ui_name() -> &'static str {
|
||||
"CommandPaletteZeroState"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let mut flex = Flex::column().with_child(
|
||||
self.render_filter_chips(appearance, Self::valid_query_filters(app, self.window_id)),
|
||||
);
|
||||
|
||||
let zero_state_items = self.items.as_ref(app).render(app);
|
||||
flex.add_child(Shrinkable::new(1., zero_state_items).finish());
|
||||
|
||||
Container::new(flex.finish())
|
||||
.with_vertical_padding(styles::PADDING_VERTICAL)
|
||||
.with_horizontal_padding(styles::PADDING_HORIZONTAL)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for ZeroState {
|
||||
type Action = Action;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
Action::FilterChipClicked { filter } => {
|
||||
ctx.emit(Event::FilterChipSelected { filter: *filter })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod styles {
|
||||
pub const FILTER_CHIP_MARGIN: f32 = 8.;
|
||||
pub const FILTER_CHIPS_MARGIN_BOTTOM: f32 = 16.;
|
||||
|
||||
/// Horizontal padding around all inner content within the view.
|
||||
pub const PADDING_HORIZONTAL: f32 = 24.;
|
||||
|
||||
/// Vertical padding around all inner content within the view.
|
||||
pub const PADDING_VERTICAL: f32 = 8.;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::result_renderer::QueryResultRenderer;
|
||||
use crate::search::search_bar::SelectionUpdate;
|
||||
|
||||
use warpui::elements::{Container, Flex, ParentElement};
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::ui_components::text::WrappableText;
|
||||
use warpui::{AppContext, Element, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
/// List of items shown within the zero state. "Recent" items are shown first followed by
|
||||
/// "Suggested" items.
|
||||
pub struct Items {
|
||||
recent: Vec<QueryResultRenderer<CommandPaletteItemAction>>,
|
||||
suggested: Vec<QueryResultRenderer<CommandPaletteItemAction>>,
|
||||
selected_index: Option<SelectedIndex>,
|
||||
}
|
||||
|
||||
/// Current selected index within the list of zero state items.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
enum SelectedIndex {
|
||||
Recent(usize),
|
||||
Suggested(usize),
|
||||
}
|
||||
|
||||
impl Items {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
recent: vec![],
|
||||
suggested: vec![],
|
||||
selected_index: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders title text for a section of the zero state.
|
||||
fn render_section_text(
|
||||
header_text: impl Into<String>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
WrappableText::build(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(header_text.into(), false)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(12.),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_vertical_padding(styles::ZERO_STATE_SECTION_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_query_result(
|
||||
query_result: &QueryResultRenderer<CommandPaletteItemAction>,
|
||||
index: usize,
|
||||
is_selected: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(query_result.render(index, is_selected, app))
|
||||
.with_horizontal_padding(-super::styles::PADDING_HORIZONTAL)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Sets the recent items in the zero state to that of `recent`.
|
||||
pub fn set_recent_items(
|
||||
&mut self,
|
||||
recent: Vec<QueryResultRenderer<CommandPaletteItemAction>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.recent = recent;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Sets the suggested items in the zero state to that of `suggested`.
|
||||
pub fn set_suggested_items(
|
||||
&mut self,
|
||||
suggested: Vec<QueryResultRenderer<CommandPaletteItemAction>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.suggested = suggested;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Returns the current selected item. `None` if no item is selected.
|
||||
pub fn selected_item(&self) -> Option<&QueryResultRenderer<CommandPaletteItemAction>> {
|
||||
let selected_item = self.selected_index?;
|
||||
match selected_item {
|
||||
SelectedIndex::Recent(index) => self.recent.get(index),
|
||||
SelectedIndex::Suggested(index) => self.suggested.get(index),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator of all of the [`SelectedIndex`]s in the order they would appear.
|
||||
fn all_indices(&self) -> impl Iterator<Item = SelectedIndex> + '_ {
|
||||
self.recent
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, _)| SelectedIndex::Recent(idx))
|
||||
.chain(
|
||||
self.suggested
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, _)| SelectedIndex::Suggested(idx)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the current [`SelectedIndex`] as a total index across both recent and suggested
|
||||
/// items.
|
||||
fn total_index(&self) -> Option<usize> {
|
||||
self.selected_index
|
||||
.map(|selected_index| match selected_index {
|
||||
SelectedIndex::Recent(index) => index,
|
||||
SelectedIndex::Suggested(index) => self.recent.len() + index,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the next [`SelectedIndex`]. `None` if the next selected index would exceed all of
|
||||
/// the items in the list.
|
||||
fn next_selected_index(&self) -> Option<SelectedIndex> {
|
||||
match self.total_index() {
|
||||
None => self.all_indices().next(),
|
||||
Some(index) => self.all_indices().nth(index + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the previous [`SelectedIndex`]. `None` if the selected item would exceed the first
|
||||
/// item in the list.
|
||||
fn prev_selected_index(&self) -> Option<SelectedIndex> {
|
||||
match self.total_index() {
|
||||
None => None,
|
||||
Some(0) => None,
|
||||
// We don't use `saturating_sub` because you don't wanna be stuck on 0.
|
||||
Some(index) => self.all_indices().nth(index - 1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_selection_update(
|
||||
&mut self,
|
||||
selection_update: SelectionUpdate,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
match selection_update {
|
||||
SelectionUpdate::Up => {
|
||||
self.selected_index = self.prev_selected_index();
|
||||
ctx.notify();
|
||||
}
|
||||
SelectionUpdate::Down => {
|
||||
// Only update the selected item if not `None` to prevent unsetting the selected
|
||||
// item if the user presses down when the last item is selected.
|
||||
if let Some(next_index) = self.next_selected_index() {
|
||||
self.selected_index = Some(next_index);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
SelectionUpdate::Clear => {
|
||||
self.selected_index = None;
|
||||
ctx.notify();
|
||||
}
|
||||
// We don't want an item selected by default in the zero state, so noop here.
|
||||
SelectionUpdate::Bottom | SelectionUpdate::Top => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let mut flex = Flex::column();
|
||||
|
||||
if !self.recent.is_empty() {
|
||||
flex.add_child(Self::render_section_text("Recent", appearance));
|
||||
|
||||
flex.add_children(self.recent.iter().enumerate().map(|(idx, result)| {
|
||||
Self::render_query_result(
|
||||
result,
|
||||
idx,
|
||||
Some(SelectedIndex::Recent(idx)) == self.selected_index,
|
||||
app,
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
if !self.suggested.is_empty() {
|
||||
flex.add_child(Self::render_section_text("Suggested", appearance));
|
||||
|
||||
flex.add_children(self.suggested.iter().enumerate().map(|(idx, result)| {
|
||||
Self::render_query_result(
|
||||
result,
|
||||
idx,
|
||||
Some(SelectedIndex::Suggested(idx)) == self.selected_index,
|
||||
app,
|
||||
)
|
||||
}));
|
||||
}
|
||||
flex.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Items {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
mod styles {
|
||||
pub const ZERO_STATE_SECTION_PADDING: f32 = 8.;
|
||||
}
|
||||
Reference in New Issue
Block a user