Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -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(&section) {
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())
}
}
+199
View File
@@ -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,
}
+25
View File
@@ -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.;
}