Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use warpui::{AppContext, Entity, ModelContext, ModelHandle};
|
||||
|
||||
use crate::search::action::search_item::MatchedBinding;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::data_source::{DataSourceSearchError, Query, QueryResult};
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
|
||||
use crate::util::bindings::CommandBinding;
|
||||
|
||||
use crate::search::binding_source::BindingSource;
|
||||
use warpui::keymap::{BindingId, DescriptionContext};
|
||||
|
||||
/// Data source for [`CommandBinding`]s. Produces a list of in-app actions a user can currently
|
||||
/// perform.
|
||||
pub struct CommandBindingDataSource {
|
||||
searcher: Box<dyn ActionSearcher>,
|
||||
}
|
||||
|
||||
impl CommandBindingDataSource {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn new(binding_source: ModelHandle<BindingSource>, ctx: &mut ModelContext<Self>) -> Self {
|
||||
if warp_core::features::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)
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
||||
let searcher = Box::new(full_text_searcher::FullTextActionSearcher::new());
|
||||
Self { searcher }
|
||||
}
|
||||
|
||||
fn new_fuzzy(binding_source: ModelHandle<BindingSource>, ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.observe(&binding_source, Self::on_binding_source_changed);
|
||||
|
||||
let searcher = Box::new(FuzzyActionSearcher {
|
||||
all_bindings: Default::default(),
|
||||
});
|
||||
Self { searcher }
|
||||
}
|
||||
|
||||
/// Returns a [`QueryResult`] for a binding with `binding_id`. `None` if no result was found
|
||||
/// with the given ID.
|
||||
pub fn query_result(
|
||||
&self,
|
||||
binding_id: BindingId,
|
||||
) -> Option<QueryResult<CommandPaletteItemAction>> {
|
||||
self.searcher.bindings().get(&binding_id).map(|binding| {
|
||||
MatchedBinding::new(FuzzyMatchResult::no_match(), binding.clone()).into()
|
||||
})
|
||||
}
|
||||
|
||||
fn on_binding_source_changed(
|
||||
&mut self,
|
||||
source: ModelHandle<BindingSource>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let (window_id, view_id, binding_filter_fn) = match source.as_ref(ctx) {
|
||||
BindingSource::None => return,
|
||||
BindingSource::View {
|
||||
window_id,
|
||||
view_id,
|
||||
binding_filter_fn,
|
||||
} => (*window_id, *view_id, binding_filter_fn.clone()),
|
||||
};
|
||||
|
||||
*self.searcher.bindings_mut() = ctx
|
||||
.key_bindings_for_view(window_id, view_id)
|
||||
.into_iter()
|
||||
.filter_map(|lens| CommandBinding::from_lens(lens, ctx))
|
||||
.filter(|binding| binding_filter_fn.as_ref().is_none_or(|f| f(binding)))
|
||||
.map(Arc::new)
|
||||
.map(|binding| (binding.id, binding))
|
||||
.collect();
|
||||
|
||||
self.searcher.build_index();
|
||||
ctx.emit(Event::IndexUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for CommandBindingDataSource {
|
||||
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())
|
||||
.map_err(|err| {
|
||||
let search_error = DataSourceSearchError {
|
||||
message: err.to_string(),
|
||||
};
|
||||
Box::new(search_error) as DataSourceRunErrorWrapper
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
IndexUpdated,
|
||||
}
|
||||
|
||||
impl Entity for CommandBindingDataSource {
|
||||
type Event = Event;
|
||||
}
|
||||
|
||||
type SearcherAction = <CommandBindingDataSource as SyncDataSource>::Action;
|
||||
|
||||
trait ActionSearcher {
|
||||
fn search(&self, _search_term: &str) -> anyhow::Result<Vec<QueryResult<SearcherAction>>>;
|
||||
|
||||
fn build_index(&mut self);
|
||||
|
||||
/// Set of cached bindings, keyed on [`BindingId`]. This is cached via the [`BindingSource`]
|
||||
/// model to ensure that we surface bindings to the user that were executable _before_ the
|
||||
/// command palette was opened.
|
||||
fn bindings(&self) -> &HashMap<BindingId, Arc<CommandBinding>>;
|
||||
|
||||
fn bindings_mut(&mut self) -> &mut HashMap<BindingId, Arc<CommandBinding>>;
|
||||
}
|
||||
|
||||
struct FuzzyActionSearcher {
|
||||
all_bindings: HashMap<BindingId, Arc<CommandBinding>>,
|
||||
}
|
||||
|
||||
impl ActionSearcher for FuzzyActionSearcher {
|
||||
fn search(&self, search_term: &str) -> anyhow::Result<Vec<QueryResult<SearcherAction>>> {
|
||||
Ok(self
|
||||
.all_bindings
|
||||
.values()
|
||||
.filter_map(move |binding| {
|
||||
if is_excluded_binding(binding) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 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(
|
||||
binding
|
||||
.description
|
||||
.in_context(DescriptionContext::Default)
|
||||
.to_lowercase()
|
||||
.as_str(),
|
||||
search_term.to_lowercase().as_str(),
|
||||
)
|
||||
.map(|result| (result, binding))
|
||||
})
|
||||
.map(|(match_result, binding)| {
|
||||
MatchedBinding::new(match_result, binding.clone()).into()
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn build_index(&mut self) {}
|
||||
|
||||
fn bindings(&self) -> &HashMap<BindingId, Arc<CommandBinding>> {
|
||||
&self.all_bindings
|
||||
}
|
||||
|
||||
fn bindings_mut(&mut self) -> &mut HashMap<BindingId, Arc<CommandBinding>> {
|
||||
&mut self.all_bindings
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod full_text_searcher {
|
||||
use crate::define_search_schema;
|
||||
use crate::search::action::{
|
||||
data_source::{is_excluded_binding, ActionSearcher, SearcherAction},
|
||||
search_item::MatchedBinding,
|
||||
};
|
||||
use crate::search::data_source::QueryResult;
|
||||
use crate::search::searcher::{
|
||||
SimpleFullTextSearcher, DEFAULT_MEMORY_BUDGET, SCORE_CONVERSION_FACTOR,
|
||||
};
|
||||
use crate::util::bindings::CommandBinding;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use warpui::keymap::{BindingId, DescriptionContext};
|
||||
|
||||
define_search_schema!(
|
||||
schema_name: ACTION_SEARCH_SCHEMA,
|
||||
config_name: ActionSearchConfig,
|
||||
search_doc: ActionDocument,
|
||||
identifying_doc: ActionIdDocument,
|
||||
search_fields: [action: 1.0],
|
||||
id_fields: [id: u64]
|
||||
);
|
||||
|
||||
pub(crate) struct FullTextActionSearcher {
|
||||
searcher: SimpleFullTextSearcher<ActionSearchConfig>,
|
||||
all_bindings: HashMap<BindingId, Arc<CommandBinding>>,
|
||||
}
|
||||
|
||||
impl ActionSearcher for FullTextActionSearcher {
|
||||
fn search(&self, search_term: &str) -> anyhow::Result<Vec<QueryResult<SearcherAction>>> {
|
||||
// If the search term is empty, return all bindings (except excluded ones)
|
||||
if search_term.is_empty() {
|
||||
return Ok(self
|
||||
.all_bindings
|
||||
.values()
|
||||
.filter_map(|binding| {
|
||||
if is_excluded_binding(binding) {
|
||||
return None;
|
||||
}
|
||||
let matched_binding =
|
||||
MatchedBinding::new(FuzzyMatchResult::no_match(), binding.clone());
|
||||
Some(QueryResult::from(matched_binding))
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
|
||||
// Execute the full-text search
|
||||
let matched_bindings = self.searcher.search_id(search_term)?;
|
||||
Ok(matched_bindings
|
||||
.into_iter()
|
||||
.filter_map(|match_result| {
|
||||
let binding = self
|
||||
.all_bindings
|
||||
.get(&BindingId(match_result.values.id as usize))?;
|
||||
|
||||
if is_excluded_binding(binding) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let matched_indices = match_result.highlights.action;
|
||||
Some(
|
||||
MatchedBinding::new(
|
||||
FuzzyMatchResult {
|
||||
score: (match_result.score * SCORE_CONVERSION_FACTOR) as i64,
|
||||
matched_indices,
|
||||
},
|
||||
binding.clone(),
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn build_index(&mut self) {
|
||||
if self.rebuild_search_index().is_err() {
|
||||
log::error!("Failed to create search index writer for actions");
|
||||
self.clear_search_index();
|
||||
}
|
||||
}
|
||||
|
||||
fn bindings(&self) -> &HashMap<BindingId, Arc<CommandBinding>> {
|
||||
&self.all_bindings
|
||||
}
|
||||
fn bindings_mut(&mut self) -> &mut HashMap<BindingId, Arc<CommandBinding>> {
|
||||
&mut self.all_bindings
|
||||
}
|
||||
}
|
||||
|
||||
impl FullTextActionSearcher {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
searcher: ACTION_SEARCH_SCHEMA.create_searcher(DEFAULT_MEMORY_BUDGET),
|
||||
all_bindings: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild_search_index(&mut self) -> Result<(), anyhow::Error> {
|
||||
self.clear_search_index();
|
||||
let documents = self.all_bindings.iter().map(|(id, binding)| {
|
||||
let binding_description = binding
|
||||
.description
|
||||
.in_context(DescriptionContext::Default)
|
||||
.to_lowercase();
|
||||
|
||||
ActionDocument {
|
||||
action: binding_description,
|
||||
id: id.0 as u64,
|
||||
}
|
||||
});
|
||||
self.searcher.build_index(documents)
|
||||
}
|
||||
|
||||
fn clear_search_index(&mut self) {
|
||||
if self.searcher.clear_search_index().is_err() {
|
||||
// As a workaround, we can create a new index and replace the old one.
|
||||
self.searcher = ACTION_SEARCH_SCHEMA.create_searcher(DEFAULT_MEMORY_BUDGET);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Context on why the search_drive action is excluded can be seen here: https://github.com/warpdotdev/warp-internal/pull/11705
|
||||
fn is_excluded_binding(binding: &CommandBinding) -> bool {
|
||||
binding.name == *"workspace:search_drive"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
mod data_source;
|
||||
pub mod search_item;
|
||||
|
||||
pub use data_source::{CommandBindingDataSource, Event};
|
||||
@@ -0,0 +1,237 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::drive::cloud_object_styling::warp_drive_icon_color;
|
||||
use crate::drive::DriveObjectType;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::render_util::{
|
||||
colors, render_search_item_icon, render_search_item_icon_placeholder,
|
||||
};
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::util::bindings::{BindingGroup, CommandBinding};
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use pathfinder_color::ColorU;
|
||||
use std::sync::Arc;
|
||||
use warpui::elements::{
|
||||
Align, ConstrainedBox, Container, Flex, Highlight, ParentElement, Shrinkable, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::keymap::{DescriptionContext, Keystroke};
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
/// A matched binding from a search query.
|
||||
#[derive(Debug)]
|
||||
pub struct MatchedBinding {
|
||||
fuzzy_match_result: FuzzyMatchResult,
|
||||
binding: Arc<CommandBinding>,
|
||||
}
|
||||
|
||||
impl MatchedBinding {
|
||||
pub fn new(fuzzy_match_result: FuzzyMatchResult, binding: Arc<CommandBinding>) -> Self {
|
||||
Self {
|
||||
fuzzy_match_result,
|
||||
binding,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new placeholder [`MatchedBinding`] using `name` as the [`CommandBinding`] name.
|
||||
pub fn placeholder(name: String) -> Self {
|
||||
Self::new(
|
||||
FuzzyMatchResult::no_match(),
|
||||
Arc::new(CommandBinding::placeholder(name)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let label = self.render_label(highlight_state, appearance);
|
||||
let mut binding = Flex::row();
|
||||
|
||||
binding.add_child(Shrinkable::new(1., Align::new(label).left().finish()).finish());
|
||||
|
||||
if let Some(trigger) = self.binding.trigger.clone() {
|
||||
let shortcut = appearance.ui_builder().keyboard_shortcut(&trigger).build();
|
||||
binding.add_child(
|
||||
Container::new(shortcut.finish())
|
||||
.with_margin_right(styles::KEYBINDING_MARGIN_RIGHT)
|
||||
.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(
|
||||
self.binding
|
||||
.description
|
||||
.in_context(DescriptionContext::Default)
|
||||
.to_owned(),
|
||||
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.fuzzy_match_result.matched_indices.clone(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for MatchedBinding {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
match self.binding.group {
|
||||
None => render_search_item_icon_placeholder(appearance),
|
||||
Some(group) => render_search_item_icon(
|
||||
appearance,
|
||||
group.icon(),
|
||||
group.icon_color(appearance),
|
||||
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, _: &AppContext) -> Option<Box<dyn Element>> {
|
||||
// Bindings do not support details panels.
|
||||
None
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.fuzzy_match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::AcceptBinding {
|
||||
binding: self.binding.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
let trigger = self.binding.trigger.as_ref();
|
||||
|
||||
format!(
|
||||
"Selected {}, {}.",
|
||||
&self
|
||||
.binding
|
||||
.description
|
||||
.in_context(DescriptionContext::Default),
|
||||
trigger.map(Keystroke::normalized).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
fn accessibility_help_message(&self) -> Option<String> {
|
||||
self.binding
|
||||
.trigger
|
||||
.as_ref()
|
||||
.map_or("Press enter to confirm.".into(), |trigger| {
|
||||
format!(
|
||||
"Press enter to confirm. Use {} binding to run this action in the future.",
|
||||
trigger.normalized()
|
||||
)
|
||||
})
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait to compute an icon for a search item.
|
||||
trait SearchItemIcon {
|
||||
fn icon(&self) -> Icon;
|
||||
|
||||
fn icon_color(&self, appearance: &Appearance) -> ColorU;
|
||||
}
|
||||
|
||||
impl SearchItemIcon for BindingGroup {
|
||||
fn icon(&self) -> Icon {
|
||||
match self {
|
||||
Self::Settings => Icon::Gear,
|
||||
Self::WarpAi => {
|
||||
if !FeatureFlag::AgentMode.is_enabled() {
|
||||
Icon::AiAssistant
|
||||
} else {
|
||||
Icon::Oz
|
||||
}
|
||||
}
|
||||
Self::Close => Icon::X,
|
||||
Self::Navigation => Icon::Navigation,
|
||||
Self::Workflow => Icon::Workflow,
|
||||
Self::Notebooks => Icon::Notebook,
|
||||
Self::Folders => Icon::Folder,
|
||||
Self::KeyboardShortcuts => Icon::Keyboard,
|
||||
Self::AutoUpdate => Icon::AutoUpdate,
|
||||
Self::Notifications => Icon::Bell,
|
||||
Self::EnvVarCollection => Icon::EnvVarCollection,
|
||||
Self::Terminal => Icon::Terminal,
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_color(&self, appearance: &Appearance) -> ColorU {
|
||||
match self {
|
||||
Self::Settings
|
||||
| Self::Navigation
|
||||
| Self::Close
|
||||
| Self::KeyboardShortcuts
|
||||
| Self::AutoUpdate
|
||||
| Self::Folders
|
||||
| Self::Terminal
|
||||
| Self::Notifications => appearance.theme().foreground().into_solid(),
|
||||
Self::WarpAi if !FeatureFlag::AgentMode.is_enabled() => {
|
||||
ColorU::from_u32(colors::WARP_AI)
|
||||
}
|
||||
Self::WarpAi => appearance.theme().foreground().into_solid(),
|
||||
Self::Workflow => warp_drive_icon_color(appearance, DriveObjectType::Workflow),
|
||||
Self::Notebooks => warp_drive_icon_color(
|
||||
appearance,
|
||||
DriveObjectType::Notebook {
|
||||
is_ai_document: false,
|
||||
},
|
||||
),
|
||||
Self::EnvVarCollection => {
|
||||
warp_drive_icon_color(appearance, DriveObjectType::EnvVarCollection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod styles {
|
||||
/// Total height of the search item.
|
||||
pub const SEARCH_ITEM_HEIGHT: f32 = 40.;
|
||||
|
||||
/// Margin between the right-side of the element and the end of the keybinding.
|
||||
pub const KEYBINDING_MARGIN_RIGHT: f32 = 14.;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
use super::search_item::BlockSearchItem;
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
use crate::terminal::model::block::Block;
|
||||
use crate::terminal::TerminalView;
|
||||
use crate::workspace::ActiveSession;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use itertools::Itertools;
|
||||
use warpui::{AppContext, Entity, SingletonEntity};
|
||||
|
||||
const MAX_RESULTS: usize = 20;
|
||||
const ZERO_STATE_BASE_SCORE: i64 = 1000;
|
||||
const RECENCY_SCALE: usize = 30;
|
||||
const ACTIVE_SESSION_BONUS: i64 = 5;
|
||||
|
||||
pub struct BlockDataSource;
|
||||
|
||||
impl BlockDataSource {
|
||||
#![cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Helper function to process all eligible blocks from terminal views.
|
||||
/// The processor closure receives the command text, the block, and whether
|
||||
/// the block belongs to the currently active terminal session.
|
||||
fn process_eligible_blocks<F, R>(&self, app: &AppContext, mut processor: F) -> Vec<R>
|
||||
where
|
||||
F: FnMut(&str, &Block, bool) -> Option<R>,
|
||||
{
|
||||
let mut results = Vec::new();
|
||||
let active_session = ActiveSession::as_ref(app);
|
||||
|
||||
// Iterate over all window IDs to search across all terminal views
|
||||
for window_id in app.window_ids() {
|
||||
let active_view_id = active_session.terminal_view_id(window_id);
|
||||
|
||||
// Try to get all terminal views for this window
|
||||
if let Some(terminal_views) = app.views_of_type::<TerminalView>(window_id) {
|
||||
for terminal_view_handle in terminal_views {
|
||||
let is_active =
|
||||
active_view_id.is_some_and(|id| id == terminal_view_handle.id());
|
||||
let terminal_view = terminal_view_handle.as_ref(app);
|
||||
let terminal_model = terminal_view.model.lock();
|
||||
let block_list = terminal_model.block_list();
|
||||
|
||||
// Process all eligible blocks
|
||||
for block in block_list.blocks().iter() {
|
||||
if !block.can_be_ai_context(block_list.agent_view_state()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let command = block.command_to_string();
|
||||
|
||||
// Skip empty commands
|
||||
if command.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(result) = processor(&command, block, is_active) {
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Create a BlockSearchItem from a command and block
|
||||
fn create_block_search_item(
|
||||
&self,
|
||||
command: String,
|
||||
block: &Block,
|
||||
match_result: FuzzyMatchResult,
|
||||
is_active_session: bool,
|
||||
) -> BlockSearchItem {
|
||||
// Get output lines (limit to last 3 lines for performance)
|
||||
let output = block.output_to_string();
|
||||
let output_lines: Vec<String> = output
|
||||
.lines()
|
||||
.rev()
|
||||
.take(3)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
BlockSearchItem {
|
||||
block_id: block.id().clone(),
|
||||
command,
|
||||
directory: block.pwd().cloned(),
|
||||
exit_code: block.exit_code(),
|
||||
output_lines,
|
||||
completed_ts: block.completed_ts().cloned(),
|
||||
match_result,
|
||||
is_active_session,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get terminal blocks from all sessions' block lists by searching command text
|
||||
fn get_matching_blocks(&self, query: &str, app: &AppContext) -> Vec<BlockSearchItem> {
|
||||
let results = self.process_eligible_blocks(app, |command, block, is_active| {
|
||||
self.fuzzy_match_command(command, query)
|
||||
.map(|mut match_result| {
|
||||
// Give active-session blocks a score bonus so they rank
|
||||
// above equally-matched blocks from other sessions without
|
||||
// being pinned to a separate priority tier.
|
||||
if is_active {
|
||||
match_result.score += ACTIVE_SESSION_BONUS;
|
||||
}
|
||||
self.create_block_search_item(
|
||||
command.to_string(),
|
||||
block,
|
||||
match_result,
|
||||
is_active,
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
results
|
||||
.into_iter()
|
||||
.k_largest_relaxed_by_key(MAX_RESULTS, |item| item.score())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Handle zero-state query.
|
||||
///
|
||||
/// Each block gets a composite score:
|
||||
/// ZERO_STATE_BASE_SCORE + recency (0..RECENCY_SCALE) + active-session bonus
|
||||
///
|
||||
/// Recency is position-based: sort all blocks by timestamp ascending,
|
||||
/// map position onto 0..RECENCY_SCALE. Active-session blocks get a
|
||||
/// flat ACTIVE_SESSION_BONUS on top. A very recent inactive block can
|
||||
/// outrank an old active block, but blocks of similar age will be
|
||||
/// boosted by the active-session bonus.
|
||||
///
|
||||
/// Results are sorted descending by score and truncated to MAX_RESULTS.
|
||||
/// The mixer sorts ascending by (priority_tier, score, source_order)
|
||||
/// and the search bar reverses with .rev(), so higher scores appear
|
||||
/// at the top.
|
||||
fn run_zero_state_query(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<AIContextMenuSearchableAction>>, DataSourceRunErrorWrapper> {
|
||||
let mut results = self.process_eligible_blocks(app, |command, block, is_active| {
|
||||
let match_result = FuzzyMatchResult {
|
||||
score: 0,
|
||||
matched_indices: vec![],
|
||||
};
|
||||
Some(self.create_block_search_item(command.to_string(), block, match_result, is_active))
|
||||
});
|
||||
|
||||
// Sort by timestamp ascending to assign position-based recency.
|
||||
results.sort_by(
|
||||
|a, b| match (a.completed_ts.as_ref(), b.completed_ts.as_ref()) {
|
||||
(Some(a_ts), Some(b_ts)) => a_ts.cmp(b_ts),
|
||||
(Some(_), None) => std::cmp::Ordering::Greater,
|
||||
(None, Some(_)) => std::cmp::Ordering::Less,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
},
|
||||
);
|
||||
|
||||
let total = results.len();
|
||||
for (index, item) in results.iter_mut().enumerate() {
|
||||
let recency = (RECENCY_SCALE * (index + 1) / total) as i64;
|
||||
let active_bonus = if item.is_active_session {
|
||||
ACTIVE_SESSION_BONUS
|
||||
} else {
|
||||
0
|
||||
};
|
||||
item.match_result.score = ZERO_STATE_BASE_SCORE + recency + active_bonus;
|
||||
}
|
||||
|
||||
let mut query_results: Vec<QueryResult<AIContextMenuSearchableAction>> =
|
||||
results.into_iter().map(QueryResult::from).collect();
|
||||
query_results.sort_by_key(|r| std::cmp::Reverse(r.score()));
|
||||
query_results.truncate(MAX_RESULTS);
|
||||
|
||||
Ok(query_results)
|
||||
}
|
||||
|
||||
/// Handle non-empty query with fuzzy matching
|
||||
fn run_fuzzy_search_query(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
query_text: &str,
|
||||
) -> Result<Vec<QueryResult<AIContextMenuSearchableAction>>, DataSourceRunErrorWrapper> {
|
||||
let matching_blocks = self.get_matching_blocks(query_text, app);
|
||||
let results: Vec<QueryResult<AIContextMenuSearchableAction>> =
|
||||
matching_blocks.into_iter().map(QueryResult::from).collect();
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn fuzzy_match_command(&self, command: &str, query: &str) -> Option<FuzzyMatchResult> {
|
||||
fuzzy_match::match_indices_case_insensitive_ignore_spaces(command, query).map(
|
||||
|mut match_result| {
|
||||
// Normalize command and query for comparison
|
||||
let normalized_command = command
|
||||
.split_whitespace()
|
||||
.collect::<Vec<&str>>()
|
||||
.join(" ")
|
||||
.to_lowercase();
|
||||
let normalized_query = query
|
||||
.split_whitespace()
|
||||
.collect::<Vec<&str>>()
|
||||
.join(" ")
|
||||
.to_lowercase();
|
||||
|
||||
let is_exact_match = normalized_command == normalized_query;
|
||||
|
||||
// Check if query matches the root command (first word)
|
||||
let command_root = command
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let query_normalized = normalized_query.clone();
|
||||
let is_root_command_match = !is_exact_match && command_root == query_normalized;
|
||||
|
||||
if is_exact_match {
|
||||
// Apply highest boost for exact matches to prioritize them over everything else
|
||||
match_result.score *= 10;
|
||||
} else if is_root_command_match {
|
||||
// Apply medium boost for root command matches (e.g., "tail" matches "tail -f file.log")
|
||||
// This should rank higher than partial matches from files but lower than exact matches
|
||||
match_result.score *= 6;
|
||||
} else {
|
||||
// Apply standard 3x weighted multiplier for other fuzzy matches
|
||||
match_result.score *= 3;
|
||||
}
|
||||
|
||||
match_result
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for BlockDataSource {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let query_text = &query.text;
|
||||
|
||||
if query_text.is_empty() {
|
||||
// Zero state: prioritize active-session blocks, then recency
|
||||
self.run_zero_state_query(app)
|
||||
} else {
|
||||
// Non-empty query: fuzzy match against command text
|
||||
self.run_fuzzy_search_query(app, query_text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for BlockDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "data_source_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,274 @@
|
||||
use chrono::{Duration, Local};
|
||||
|
||||
use crate::search::ai_context_menu::blocks::data_source::BlockDataSource;
|
||||
use crate::search::ai_context_menu::blocks::search_item::BlockSearchItem;
|
||||
use crate::search::data_source::Query;
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::mixer::SyncDataSource;
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::test_util::terminal::{
|
||||
add_window_with_id_and_terminal, add_window_with_terminal, initialize_app_for_terminal_view,
|
||||
};
|
||||
use crate::workspace::ActiveSession;
|
||||
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use warp_core::command::ExitCode;
|
||||
use warpui::{App, SingletonEntity};
|
||||
|
||||
/// Helper to create a `BlockSearchItem` with the given parameters.
|
||||
fn make_block_search_item(
|
||||
command: &str,
|
||||
completed_ts: Option<chrono::DateTime<Local>>,
|
||||
score: i64,
|
||||
is_active_session: bool,
|
||||
) -> BlockSearchItem {
|
||||
BlockSearchItem {
|
||||
block_id: BlockId::new(),
|
||||
command: command.to_string(),
|
||||
directory: None,
|
||||
exit_code: ExitCode::from(0),
|
||||
output_lines: vec![],
|
||||
completed_ts,
|
||||
match_result: FuzzyMatchResult {
|
||||
score,
|
||||
matched_indices: vec![],
|
||||
},
|
||||
is_active_session,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_state_scores_reflect_recency() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
let term = add_window_with_terminal(&mut app, None);
|
||||
|
||||
let now = Local::now();
|
||||
term.update(&mut app, |view, _ctx| {
|
||||
let mut model = view.model.lock();
|
||||
|
||||
model.simulate_block("oldest_cmd", "out1");
|
||||
model.simulate_block("middle_cmd", "out2");
|
||||
model.simulate_block("newest_cmd", "out3");
|
||||
|
||||
let blocks = model.block_list_mut().blocks_mut();
|
||||
for block in blocks.iter_mut() {
|
||||
let cmd = block.command_to_string();
|
||||
if cmd.contains("oldest_cmd") {
|
||||
block.override_completed_ts(now - Duration::minutes(3));
|
||||
} else if cmd.contains("middle_cmd") {
|
||||
block.override_completed_ts(now - Duration::minutes(2));
|
||||
} else if cmd.contains("newest_cmd") {
|
||||
block.override_completed_ts(now - Duration::minutes(1));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let data_source = BlockDataSource::new();
|
||||
let results = app.read(|app| data_source.run_query(&Query::from(""), app).unwrap());
|
||||
|
||||
assert!(
|
||||
results.len() >= 3,
|
||||
"Expected at least 3 results, got {}",
|
||||
results.len()
|
||||
);
|
||||
|
||||
// Newer blocks should receive strictly higher scores.
|
||||
let scores: Vec<_> = results.iter().map(|r| r.score()).collect();
|
||||
assert!(
|
||||
scores[0] > scores[1] && scores[1] > scores[2],
|
||||
"Expected scores in strictly descending order (newest first), got {scores:?}"
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_state_active_bonus_boosts_nearby_blocks() {
|
||||
// With enough blocks, adjacent positions have a small recency gap.
|
||||
// The ACTIVE_SESSION_BONUS should be enough to let an active block
|
||||
// that is one position older still outscore its inactive neighbour.
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
|
||||
let inactive_term = add_window_with_terminal(&mut app, None);
|
||||
let active_term = add_window_with_terminal(&mut app, None);
|
||||
|
||||
let now = Local::now();
|
||||
|
||||
// 10 inactive blocks spanning minutes 1..=10
|
||||
inactive_term.update(&mut app, |view, _ctx| {
|
||||
let mut model = view.model.lock();
|
||||
for i in 1..=10 {
|
||||
model.simulate_block(format!("inactive_{i}").as_str(), "out");
|
||||
let blocks = model.block_list_mut().blocks_mut();
|
||||
if let Some(block) = blocks.iter_mut().last() {
|
||||
block.override_completed_ts(now - Duration::minutes(i as i64));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 1 active block at 2 minutes ago — sits between inactive_1 and
|
||||
// inactive_2 in recency, so its position-based recency score is
|
||||
// similar to nearby inactive blocks.
|
||||
active_term.update(&mut app, |view, _ctx| {
|
||||
let mut model = view.model.lock();
|
||||
model.simulate_block("active_cmd", "out");
|
||||
let blocks = model.block_list_mut().blocks_mut();
|
||||
if let Some(block) = blocks.iter_mut().last() {
|
||||
block.override_completed_ts(now - Duration::minutes(2));
|
||||
}
|
||||
});
|
||||
|
||||
let data_source = BlockDataSource::new();
|
||||
let results = app.read(|app| data_source.run_query(&Query::from(""), app).unwrap());
|
||||
|
||||
// Find the active block's score and its immediate inactive
|
||||
// neighbour (inactive_1 at 1 min ago, which has higher recency).
|
||||
let scores: Vec<_> = results.iter().map(|r| r.score()).collect();
|
||||
|
||||
// Results are descending by score. The active block should appear
|
||||
// above inactive_2 (which has the same or lower recency) thanks
|
||||
// to the bonus.
|
||||
// More importantly: the active block shouldn't be dead last.
|
||||
let active_score = scores
|
||||
.iter()
|
||||
.zip(results.iter())
|
||||
.find(|(_, r)| {
|
||||
matches!(
|
||||
r.accept_result(),
|
||||
crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction::InsertText { ref text } if text.contains("active")
|
||||
) || {
|
||||
// Fall back: check if the block came from the active terminal
|
||||
// by verifying its score includes the bonus.
|
||||
false
|
||||
}
|
||||
})
|
||||
.map(|(s, _)| *s);
|
||||
|
||||
let inactive_2_score = scores
|
||||
.iter()
|
||||
.zip(results.iter())
|
||||
.find(|(_, r)| {
|
||||
matches!(
|
||||
r.accept_result(),
|
||||
crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction::InsertText { ref text } if text.contains("inactive_2")
|
||||
)
|
||||
})
|
||||
.map(|(s, _)| *s);
|
||||
|
||||
if let (Some(active), Some(inactive)) = (active_score, inactive_2_score) {
|
||||
assert!(
|
||||
active > inactive,
|
||||
"Expected active block (at -2min + bonus) to outscore inactive_2 (at -2min). \
|
||||
Active: {active:?}, Inactive_2: {inactive:?}"
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_state_very_recent_inactive_outranks_old_active() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
|
||||
let inactive_term = add_window_with_terminal(&mut app, None);
|
||||
let active_term = add_window_with_terminal(&mut app, None);
|
||||
|
||||
let now = Local::now();
|
||||
|
||||
// Very recent inactive block: 1 minute ago
|
||||
inactive_term.update(&mut app, |view, _ctx| {
|
||||
let mut model = view.model.lock();
|
||||
model.simulate_block("recent_inactive", "out");
|
||||
let blocks = model.block_list_mut().blocks_mut();
|
||||
if let Some(block) = blocks.iter_mut().last() {
|
||||
block.override_completed_ts(now - Duration::minutes(1));
|
||||
}
|
||||
});
|
||||
|
||||
// Very old active block: 100 minutes ago
|
||||
active_term.update(&mut app, |view, _ctx| {
|
||||
let mut model = view.model.lock();
|
||||
model.simulate_block("old_active", "out");
|
||||
let blocks = model.block_list_mut().blocks_mut();
|
||||
if let Some(block) = blocks.iter_mut().last() {
|
||||
block.override_completed_ts(now - Duration::minutes(100));
|
||||
}
|
||||
});
|
||||
|
||||
let data_source = BlockDataSource::new();
|
||||
let results = app.read(|app| data_source.run_query(&Query::from(""), app).unwrap());
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
|
||||
let scores: Vec<_> = results.iter().map(|r| r.score()).collect();
|
||||
// The very recent inactive block should outscore the very old
|
||||
// active block because recency (30) > active bonus (5).
|
||||
assert!(
|
||||
scores[0] > scores[1],
|
||||
"Expected very recent inactive to outscore very old active. Got: {scores:?}"
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_query_active_session_blocks_rank_above_other_sessions() {
|
||||
// Blocks from the active session receive a score bonus, so given equal
|
||||
// fuzzy match quality the active-session block should rank above others.
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
|
||||
// First window is inactive.
|
||||
let inactive_term = add_window_with_terminal(&mut app, None);
|
||||
// Second window is the active session — register it with ActiveSession.
|
||||
let (active_window_id, active_term) = add_window_with_id_and_terminal(&mut app, None);
|
||||
|
||||
let active_view_id = active_term.id();
|
||||
ActiveSession::handle(&app).update(&mut app, |active_session, ctx| {
|
||||
active_session.set_session_for_test(
|
||||
active_window_id,
|
||||
std::sync::Arc::new(crate::terminal::model::session::Session::test()),
|
||||
None::<std::path::PathBuf>,
|
||||
Some(active_view_id),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
// Add the identical command to both terminals.
|
||||
inactive_term.update(&mut app, |view, _ctx| {
|
||||
view.model.lock().simulate_block("cargo build", "out");
|
||||
});
|
||||
active_term.update(&mut app, |view, _ctx| {
|
||||
view.model.lock().simulate_block("cargo build", "out");
|
||||
});
|
||||
|
||||
let data_source = BlockDataSource::new();
|
||||
let results = app.read(|app| data_source.run_query(&Query::from("cargo"), app).unwrap());
|
||||
|
||||
assert_eq!(results.len(), 2, "Expected one result per terminal");
|
||||
// The data source returns results sorted descending by score.
|
||||
// The active-session block should be first due to its score bonus.
|
||||
let scores: Vec<_> = results.iter().map(|r| r.score()).collect();
|
||||
assert!(
|
||||
scores[0] > scores[1],
|
||||
"Expected active-session block to score higher. Got: {scores:?}"
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_query_within_same_session_higher_fuzzy_score_wins() {
|
||||
// Both blocks are active-session, but one has a much better fuzzy score
|
||||
let better_match = make_block_search_item("cargo test", None, 9000, true);
|
||||
let worse_match = make_block_search_item("cat README.md", None, 3000, true);
|
||||
|
||||
// Same tier, so score should determine ordering
|
||||
assert_eq!(better_match.priority_tier(), worse_match.priority_tier());
|
||||
assert!(
|
||||
better_match.score() > worse_match.score(),
|
||||
"Expected higher fuzzy score to win within same tier. \
|
||||
Better: {}, Worse: {}",
|
||||
better_match.score(),
|
||||
worse_match.score(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod data_source;
|
||||
pub mod search_item;
|
||||
@@ -0,0 +1,212 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::ai_context_menu::styles;
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::util::truncation::truncate_from_end;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::elements::Highlight;
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{
|
||||
elements::{ConstrainedBox, Container, CrossAxisAlignment, Flex, Icon, ParentElement, Text},
|
||||
AppContext, Element, SingletonEntity,
|
||||
};
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
use warp_core::command::ExitCode;
|
||||
|
||||
/// Calculate how long ago a timestamp was
|
||||
fn time_ago_string(timestamp: Option<&DateTime<Local>>) -> String {
|
||||
let Some(timestamp) = timestamp else {
|
||||
return "Just now".to_string();
|
||||
};
|
||||
|
||||
let now = Local::now();
|
||||
let duration = now.signed_duration_since(*timestamp);
|
||||
|
||||
if duration.num_seconds() < 60 {
|
||||
"Just now".to_string()
|
||||
} else if duration.num_minutes() < 60 {
|
||||
format!("{} minutes ago", duration.num_minutes())
|
||||
} else if duration.num_hours() < 24 {
|
||||
format!("{} hours ago", duration.num_hours())
|
||||
} else {
|
||||
format!("{} days ago", duration.num_days())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BlockSearchItem {
|
||||
pub block_id: BlockId,
|
||||
pub command: String,
|
||||
pub directory: Option<String>,
|
||||
pub exit_code: ExitCode,
|
||||
pub output_lines: Vec<String>,
|
||||
pub completed_ts: Option<DateTime<Local>>,
|
||||
pub match_result: FuzzyMatchResult,
|
||||
/// Whether this block belongs to the currently active terminal session.
|
||||
/// Used to give active-session blocks higher priority in search results.
|
||||
pub is_active_session: bool,
|
||||
}
|
||||
|
||||
impl SearchItem for BlockSearchItem {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
// Show error icon if the block failed, otherwise show the regular block icon
|
||||
let (icon_path, icon_color) = if !self.exit_code.was_successful() {
|
||||
(
|
||||
"bundled/svg/alert-triangle.svg",
|
||||
appearance.theme().ui_error_color(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"bundled/svg/terminal.svg",
|
||||
highlight_state.icon_fill(appearance).into_solid(),
|
||||
)
|
||||
};
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(Icon::new(icon_path, icon_color).finish())
|
||||
.with_width(styles::ICON_SIZE)
|
||||
.with_height(styles::ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(styles::MARGIN_RIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
// Create command text with highlighting
|
||||
let mut command_text = Text::new(
|
||||
self.command.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
)
|
||||
.with_color(highlight_state.main_text_fill(appearance).into_solid());
|
||||
|
||||
if !self.match_result.matched_indices.is_empty() {
|
||||
command_text = command_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
self.match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
// Create directory text with lighter color
|
||||
let directory_text = self.directory.as_ref().map(|directory| {
|
||||
Text::new(
|
||||
directory.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.0,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.finish()
|
||||
});
|
||||
|
||||
// Create row with command name and directory on the same line
|
||||
let mut row = Flex::row()
|
||||
.with_child(command_text.finish())
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
if let Some(directory) = directory_text {
|
||||
row.add_child(Container::new(directory).with_padding_left(8.0).finish());
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn render_details(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let theme = appearance.theme();
|
||||
|
||||
// Create main text: command (truncate for hover card too)
|
||||
let main_text = truncate_from_end(&self.command, 100);
|
||||
|
||||
// Create sub text: last 3 lines of output
|
||||
let sub_text = if self.output_lines.is_empty() {
|
||||
"No output".to_string()
|
||||
} else {
|
||||
let joined = self.output_lines.join("\n").trim().to_string();
|
||||
// Additional safety truncation for the hover card
|
||||
truncate_from_end(&joined, 400)
|
||||
};
|
||||
|
||||
// Create time ago text
|
||||
let time_ago_text = time_ago_string(self.completed_ts.as_ref());
|
||||
|
||||
// Create main text element - use monospace font for command
|
||||
let main_text_element = Text::new(
|
||||
main_text,
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.with_style(Properties::default().weight(Weight::Medium))
|
||||
.finish();
|
||||
|
||||
// Create sub text element - output lines
|
||||
let sub_text_element = Text::new(
|
||||
sub_text,
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size() - 3.0,
|
||||
)
|
||||
.with_color(theme.nonactive_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
// Create time ago element
|
||||
let time_ago_element = Text::new(
|
||||
time_ago_text,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 3.0,
|
||||
)
|
||||
.with_color(theme.nonactive_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
// Create modal content with reduced spacing
|
||||
let content = Flex::column()
|
||||
.with_child(main_text_element)
|
||||
.with_child(
|
||||
Container::new(sub_text_element)
|
||||
.with_padding_top(4.0)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(time_ago_element)
|
||||
.with_padding_top(4.0)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Some(content)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> AIContextMenuSearchableAction {
|
||||
AIContextMenuSearchableAction::InsertText {
|
||||
text: format!("<block:{}>", self.block_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> AIContextMenuSearchableAction {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Block: {}", self.command)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
#![cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use super::search_item::CodeSearchItem;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::search::files::model::FileSearchModel;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::search::mixer::{
|
||||
AsyncDataSource, BoxFuture, DataSourceRunError, DataSourceRunErrorWrapper,
|
||||
};
|
||||
use ai::index::Symbol;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use instant::Instant;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use itertools::Itertools;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::time::Duration;
|
||||
use warpui::AppContext;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warpui::ModelSpawner;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::outline::{OutlineStatus, RepoOutlines, RepoOutlinesEvent};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::workspace::ActiveSession;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::path::Path;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warpui::SingletonEntity;
|
||||
|
||||
const MAX_RESULTS: usize = 200;
|
||||
|
||||
/// Represents a single code symbol within a file
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CodeSymbol {
|
||||
pub file_path: PathBuf,
|
||||
pub symbol: Symbol,
|
||||
}
|
||||
|
||||
/// Symbol cache that stores all symbols in a simple vector
|
||||
pub struct SymbolCache {
|
||||
/// All symbols stored in a vector
|
||||
pub(crate) symbols: Vec<CodeSymbol>,
|
||||
}
|
||||
|
||||
impl SymbolCache {
|
||||
fn new(symbols: Vec<CodeSymbol>) -> Self {
|
||||
Self { symbols }
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity that owns a per-repo map of cached [`CodeSymbol`]s (the "symbol cache").
|
||||
/// Lives on `AIContextMenu` so the cache persists across mixer resets.
|
||||
///
|
||||
/// On construction subscribes to [`RepoOutlinesEvent::OutlinesUpdated`]; when an
|
||||
/// outline changes for a repo, the corresponding cache entry is evicted so the next
|
||||
/// query re-populates it from the fresh outline.
|
||||
pub struct CodeSymbolCache {
|
||||
symbol_cache: RefCell<HashMap<PathBuf, SymbolCache>>,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
spawner: ModelSpawner<Self>,
|
||||
}
|
||||
|
||||
impl warpui::Entity for CodeSymbolCache {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl CodeSymbolCache {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn new(ctx: &mut warpui::ModelContext<Self>) -> Self {
|
||||
let spawner = ctx.spawner();
|
||||
let cache = Self {
|
||||
symbol_cache: RefCell::new(HashMap::new()),
|
||||
spawner,
|
||||
};
|
||||
|
||||
ctx.subscribe_to_model(&RepoOutlines::handle(ctx), |me, event, ctx| match event {
|
||||
RepoOutlinesEvent::OutlinesUpdated(repo_path) => {
|
||||
me.symbol_cache.get_mut().remove(repo_path);
|
||||
ctx.emit(());
|
||||
}
|
||||
});
|
||||
|
||||
cache
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
symbol_cache: RefCell::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn spawner(&self) -> ModelSpawner<Self> {
|
||||
self.spawner.clone()
|
||||
}
|
||||
|
||||
/// Resolves the active git repo from the current window, looks up its outline,
|
||||
/// and lazily populates the symbol cache from that outline. Returns the repo
|
||||
/// path and total symbol count, or `None` when no repo or completed outline is
|
||||
/// available.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn ensure_symbols_cached(&mut self, app: &AppContext) -> Option<(PathBuf, usize)> {
|
||||
let git_repo_path = app
|
||||
.windows()
|
||||
.state()
|
||||
.active_window
|
||||
.and_then(|window_id| ActiveSession::as_ref(app).path_if_local(window_id))
|
||||
.and_then(|current_dir| {
|
||||
DetectedRepositories::as_ref(app).get_root_for_path(Path::new(current_dir))
|
||||
})?;
|
||||
|
||||
let (outline_status, _) = RepoOutlines::as_ref(app).get_outline(&git_repo_path)?;
|
||||
let outline = match outline_status {
|
||||
OutlineStatus::Complete(outline) => outline,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let cache = self.symbol_cache.get_mut();
|
||||
let cached = cache.entry(git_repo_path.clone()).or_insert_with(|| {
|
||||
let symbols = outline
|
||||
.to_symbols_by_file(None)
|
||||
.into_iter()
|
||||
.flat_map(|(file_path, file_outline)| {
|
||||
let prefix = git_repo_path.clone();
|
||||
file_outline
|
||||
.symbols()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(move |symbol| CodeSymbol {
|
||||
file_path: file_path
|
||||
.strip_prefix(&prefix)
|
||||
.unwrap_or(&file_path)
|
||||
.to_path_buf(),
|
||||
symbol: symbol.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
SymbolCache::new(symbols)
|
||||
});
|
||||
|
||||
let count = cached.symbols.len();
|
||||
Some((git_repo_path, count))
|
||||
}
|
||||
|
||||
/// Processes a chunk of symbols starting at `cursor`, fuzzy-matching each against `query`
|
||||
/// until `budget` is exceeded. Returns `(new_cursor, batch_results)`.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn search_symbols_chunk(
|
||||
&mut self,
|
||||
repo_path: &Path,
|
||||
cursor: usize,
|
||||
query: &str,
|
||||
budget: Duration,
|
||||
) -> (usize, Vec<CodeSearchItem>) {
|
||||
// If the cache was invalidated between chunks, signal the caller with usize::MAX.
|
||||
let Some(cached) = self.symbol_cache.get_mut().get(repo_path) else {
|
||||
return (usize::MAX, Vec::new());
|
||||
};
|
||||
|
||||
let symbols = &cached.symbols;
|
||||
if cursor >= symbols.len() {
|
||||
return (symbols.len(), Vec::new());
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
let mut batch = Vec::new();
|
||||
let mut i = cursor;
|
||||
while i < symbols.len() && start.elapsed() < budget {
|
||||
let symbol = &symbols[i];
|
||||
let match_result = fuzzy_match_symbol_with_type(symbol, query);
|
||||
batch.push(CodeSearchItem {
|
||||
code_symbol: symbol.clone(),
|
||||
match_result,
|
||||
});
|
||||
i += 1;
|
||||
}
|
||||
(i, batch)
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn get_git_changed_files(&self, app: &AppContext) -> HashSet<String> {
|
||||
let Some(git_repo_path) = app
|
||||
.windows()
|
||||
.state()
|
||||
.active_window
|
||||
.and_then(|window_id| ActiveSession::as_ref(app).path_if_local(window_id))
|
||||
.and_then(|current_dir| {
|
||||
DetectedRepositories::as_ref(app).get_root_for_path(Path::new(current_dir))
|
||||
})
|
||||
else {
|
||||
return HashSet::new();
|
||||
};
|
||||
|
||||
FileSearchModel::as_ref(app)
|
||||
.get_git_changed_files(&git_repo_path)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn get_git_changed_files(&self, _app: &AppContext) -> HashSet<String> {
|
||||
HashSet::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
#[derive(Debug)]
|
||||
struct CodeSearchError;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
impl DataSourceRunError for CodeSearchError {
|
||||
fn user_facing_error(&self) -> String {
|
||||
"Code search failed".to_string()
|
||||
}
|
||||
|
||||
fn telemetry_payload(&self) -> serde_json::Value {
|
||||
serde_json::json!({ "error": "model_dropped" })
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Data source that searches code symbols incrementally on the main thread
|
||||
/// using time-budgeted chunks, avoiding bulk-cloning the symbol list.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub struct CodeCursorDataSource {
|
||||
spawner: ModelSpawner<CodeSymbolCache>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
impl CodeCursorDataSource {
|
||||
pub fn new(spawner: ModelSpawner<CodeSymbolCache>) -> Self {
|
||||
Self { spawner }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
impl AsyncDataSource for CodeCursorDataSource {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
_app: &AppContext,
|
||||
) -> BoxFuture<'static, Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper>> {
|
||||
let spawner = self.spawner.clone();
|
||||
let query_text = query.text.clone();
|
||||
let is_zero_state = query_text.is_empty();
|
||||
|
||||
Box::pin(async move {
|
||||
let map_err = |_| -> DataSourceRunErrorWrapper { Box::new(CodeSearchError) };
|
||||
|
||||
// Populate cache, get repo path + count, and git-changed files if zero-state
|
||||
let init_query = query_text.clone();
|
||||
let init = spawner
|
||||
.spawn(move |cache, ctx| {
|
||||
let (repo_path, total) = cache.ensure_symbols_cached(ctx)?;
|
||||
let git_changed_files = if init_query.is_empty() {
|
||||
cache.get_git_changed_files(ctx)
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
Some((repo_path, total, git_changed_files))
|
||||
})
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
let Some((repo_path, total, git_changed_files)) = init else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
// We can't actually perform the search off of the main thread
|
||||
// (because we don't have access to the code data we need for searching).
|
||||
// Instead, we dispatch small search chunks to the main thread so it
|
||||
// can access the cache. We yield between chunks, letting
|
||||
// the main thread continue to perform render cycles while we're searching.
|
||||
let mut cursor = 0usize;
|
||||
let mut all_items: Vec<CodeSearchItem> = Vec::new();
|
||||
while cursor < total {
|
||||
let rp = repo_path.clone();
|
||||
let qt = query_text.clone();
|
||||
let (new_cursor, batch) = spawner
|
||||
.spawn(move |cache, _ctx| {
|
||||
cache.search_symbols_chunk(&rp, cursor, &qt, Duration::from_millis(5))
|
||||
})
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
all_items.extend(batch);
|
||||
|
||||
// Cache was invalidated or we reached the end
|
||||
if new_cursor == usize::MAX || new_cursor >= total {
|
||||
break;
|
||||
}
|
||||
cursor = new_cursor;
|
||||
}
|
||||
|
||||
// Finalize: sort/filter results (runs on background thread)
|
||||
if is_zero_state {
|
||||
Ok(finalize_zero_state(all_items, &git_changed_files))
|
||||
} else {
|
||||
Ok(finalize_query(all_items))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn code_data_source(cache: &CodeSymbolCache) -> CodeCursorDataSource {
|
||||
CodeCursorDataSource::new(cache.spawner())
|
||||
}
|
||||
|
||||
/// Zero-state finalisation: prioritize symbols from git-changed files.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn finalize_zero_state(
|
||||
items: Vec<CodeSearchItem>,
|
||||
git_changed_files: &HashSet<String>,
|
||||
) -> Vec<QueryResult<AIContextMenuSearchableAction>> {
|
||||
let mut results: Vec<QueryResult<AIContextMenuSearchableAction>> = Vec::new();
|
||||
|
||||
// First, add all symbols from git-changed files (they get priority)
|
||||
for item in &items {
|
||||
let file_path_str = item.code_symbol.file_path.to_string_lossy().to_string();
|
||||
if git_changed_files.contains(&file_path_str) {
|
||||
let search_item = CodeSearchItem {
|
||||
code_symbol: item.code_symbol.clone(),
|
||||
match_result: FuzzyMatchResult {
|
||||
score: 10000,
|
||||
matched_indices: vec![],
|
||||
},
|
||||
};
|
||||
results.push(QueryResult::from(search_item));
|
||||
}
|
||||
}
|
||||
|
||||
// Then add remaining symbols up to MAX_RESULTS total
|
||||
for item in &items {
|
||||
let file_path_str = item.code_symbol.file_path.to_string_lossy().to_string();
|
||||
if !git_changed_files.contains(&file_path_str) && results.len() < MAX_RESULTS {
|
||||
let search_item = CodeSearchItem {
|
||||
code_symbol: item.code_symbol.clone(),
|
||||
match_result: FuzzyMatchResult {
|
||||
score: 0,
|
||||
matched_indices: vec![],
|
||||
},
|
||||
};
|
||||
results.push(QueryResult::from(search_item));
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Query finalisation: take top-k by fuzzy score.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn finalize_query(items: Vec<CodeSearchItem>) -> Vec<QueryResult<AIContextMenuSearchableAction>> {
|
||||
items
|
||||
.into_iter()
|
||||
.k_largest_relaxed_by_key(MAX_RESULTS, |item| item.match_result.score)
|
||||
.map(QueryResult::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Matches a symbol name (including type prefix when present) and applies symbol-score weighting.
|
||||
fn fuzzy_match_symbol_with_type(code_symbol: &CodeSymbol, query: &str) -> FuzzyMatchResult {
|
||||
if query.is_empty() {
|
||||
return FuzzyMatchResult::no_match();
|
||||
}
|
||||
|
||||
let search_text = if let Some(type_prefix) = &code_symbol.symbol.type_prefix {
|
||||
format!("{}{}", type_prefix, code_symbol.symbol.name)
|
||||
} else {
|
||||
code_symbol.symbol.name.clone()
|
||||
};
|
||||
|
||||
if let Some(mut match_result) =
|
||||
fuzzy_match::match_indices_case_insensitive_ignore_spaces(&search_text, query)
|
||||
{
|
||||
// Apply 3x weighted multiplier to make symbol scores competitive with file scores
|
||||
match_result.score *= 3;
|
||||
match_result
|
||||
} else {
|
||||
FuzzyMatchResult::no_match()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "data_source_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,277 @@
|
||||
#[cfg(test)]
|
||||
use super::*;
|
||||
use ai::index::Symbol;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_symbol(name: &str, type_prefix: Option<&str>) -> CodeSymbol {
|
||||
CodeSymbol {
|
||||
file_path: PathBuf::from("test.rs"),
|
||||
symbol: Symbol {
|
||||
name: name.to_string(),
|
||||
type_prefix: type_prefix.map(|s| s.to_string()),
|
||||
comment: None,
|
||||
line_number: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_symbol_in_file(
|
||||
name: &str,
|
||||
type_prefix: Option<&str>,
|
||||
file_path: &str,
|
||||
) -> CodeSymbol {
|
||||
CodeSymbol {
|
||||
file_path: PathBuf::from(file_path),
|
||||
symbol: Symbol {
|
||||
name: name.to_string(),
|
||||
type_prefix: type_prefix.map(|s| s.to_string()),
|
||||
comment: None,
|
||||
line_number: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn search_code_symbols(symbols: &[CodeSymbol], query: &str) -> Vec<CodeSearchItem> {
|
||||
if query.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| {
|
||||
let match_result = fuzzy_match_symbol_with_type(symbol, query);
|
||||
CodeSearchItem {
|
||||
code_symbol: symbol.clone(),
|
||||
match_result,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_symbol_with_type_basic_functionality() {
|
||||
let symbol = create_test_symbol("my_function", Some("fn"));
|
||||
|
||||
let name_match = fuzzy_match_symbol_with_type(&symbol, "function");
|
||||
let type_match = fuzzy_match_symbol_with_type(&symbol, "fn");
|
||||
let combined_match = fuzzy_match_symbol_with_type(&symbol, "fn my_function");
|
||||
let no_match = fuzzy_match_symbol_with_type(&symbol, "xyz");
|
||||
|
||||
assert!(name_match.score > 0);
|
||||
assert!(type_match.score > 0);
|
||||
assert!(combined_match.score > 0);
|
||||
assert_eq!(no_match.score, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_symbol_with_type_no_type_handling() {
|
||||
let symbol = create_test_symbol("some_variable", None);
|
||||
|
||||
let name_match = fuzzy_match_symbol_with_type(&symbol, "variable");
|
||||
let no_match = fuzzy_match_symbol_with_type(&symbol, "xyz");
|
||||
|
||||
assert!(name_match.score > 0);
|
||||
assert_eq!(no_match.score, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_symbol_cache_creation() {
|
||||
let symbols = vec![
|
||||
create_test_symbol("my_function", Some("fn")),
|
||||
create_test_symbol("MyStruct", Some("struct")),
|
||||
create_test_symbol("global_var", None),
|
||||
create_test_symbol("another_function", Some("fn")),
|
||||
];
|
||||
|
||||
let cache = SymbolCache::new(symbols);
|
||||
|
||||
assert_eq!(cache.symbols.len(), 4);
|
||||
|
||||
let symbol_names: Vec<&str> = cache
|
||||
.symbols
|
||||
.iter()
|
||||
.map(|s| s.symbol.name.as_str())
|
||||
.collect();
|
||||
assert!(symbol_names.contains(&"my_function"));
|
||||
assert!(symbol_names.contains(&"MyStruct"));
|
||||
assert!(symbol_names.contains(&"global_var"));
|
||||
assert!(symbol_names.contains(&"another_function"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_code_symbols_basic_functionality() {
|
||||
let symbols = vec![
|
||||
create_test_symbol("my_function", Some("fn")),
|
||||
create_test_symbol("MyStruct", Some("struct")),
|
||||
create_test_symbol("global_var", None),
|
||||
];
|
||||
|
||||
let results = search_code_symbols(&symbols, "function");
|
||||
assert!(!results.is_empty());
|
||||
assert!(results
|
||||
.iter()
|
||||
.any(|r| r.code_symbol.symbol.name == "my_function"));
|
||||
|
||||
let results = search_code_symbols(&symbols, "fn");
|
||||
assert!(!results.is_empty());
|
||||
assert!(results
|
||||
.iter()
|
||||
.any(|r| r.code_symbol.symbol.name == "my_function"));
|
||||
|
||||
let results = search_code_symbols(&symbols, "fn function");
|
||||
assert!(!results.is_empty());
|
||||
assert!(results
|
||||
.iter()
|
||||
.any(|r| r.code_symbol.symbol.name == "my_function"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_code_symbols_all_symbols_searched() {
|
||||
let symbols = vec![
|
||||
create_test_symbol("process_data", Some("fn")),
|
||||
create_test_symbol("DataProcessor", Some("struct")),
|
||||
create_test_symbol("my_variable", None),
|
||||
];
|
||||
|
||||
let results = search_code_symbols(&symbols, "data");
|
||||
|
||||
assert!(results.len() >= 2);
|
||||
let found_names: Vec<&str> = results
|
||||
.iter()
|
||||
.map(|r| r.code_symbol.symbol.name.as_str())
|
||||
.collect();
|
||||
assert!(found_names.contains(&"process_data"));
|
||||
assert!(found_names.contains(&"DataProcessor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_code_symbols_empty_query() {
|
||||
let symbols = vec![
|
||||
create_test_symbol("my_function", Some("fn")),
|
||||
create_test_symbol("MyStruct", Some("struct")),
|
||||
];
|
||||
|
||||
let results = search_code_symbols(&symbols, "");
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_code_symbols_no_matches() {
|
||||
let symbols = vec![
|
||||
create_test_symbol("my_function", Some("fn")),
|
||||
create_test_symbol("MyStruct", Some("struct")),
|
||||
];
|
||||
|
||||
let results = search_code_symbols(&symbols, "nonexistent");
|
||||
assert_eq!(results.len(), 2);
|
||||
|
||||
for result in results {
|
||||
assert_eq!(result.match_result.score, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_code_symbols_untyped_symbols() {
|
||||
let symbols = vec![
|
||||
create_test_symbol("my_function", Some("fn")),
|
||||
create_test_symbol("my_variable", None),
|
||||
];
|
||||
|
||||
let results = search_code_symbols(&symbols, "variable");
|
||||
assert!(!results.is_empty());
|
||||
assert!(results
|
||||
.iter()
|
||||
.any(|r| r.code_symbol.symbol.name == "my_variable"));
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
#[test]
|
||||
fn test_finalize_zero_state_git_changed_first() {
|
||||
let items = vec![
|
||||
CodeSearchItem {
|
||||
code_symbol: create_test_symbol_in_file("unchanged_fn", Some("fn"), "src/lib.rs"),
|
||||
match_result: FuzzyMatchResult::no_match(),
|
||||
},
|
||||
CodeSearchItem {
|
||||
code_symbol: create_test_symbol_in_file("changed_fn", Some("fn"), "src/changed.rs"),
|
||||
match_result: FuzzyMatchResult::no_match(),
|
||||
},
|
||||
CodeSearchItem {
|
||||
code_symbol: create_test_symbol_in_file("another_fn", Some("fn"), "src/other.rs"),
|
||||
match_result: FuzzyMatchResult::no_match(),
|
||||
},
|
||||
];
|
||||
let git_changed_files = HashSet::from(["src/changed.rs".to_string()]);
|
||||
|
||||
let results = finalize_zero_state(items, &git_changed_files);
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
assert!(results[0].score() > results[1].score());
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
#[test]
|
||||
fn test_finalize_query_returns_top_results() {
|
||||
let items: Vec<CodeSearchItem> = vec![
|
||||
CodeSearchItem {
|
||||
code_symbol: create_test_symbol("my_function", Some("fn")),
|
||||
match_result: fuzzy_match_symbol_with_type(
|
||||
&create_test_symbol("my_function", Some("fn")),
|
||||
"function",
|
||||
),
|
||||
},
|
||||
CodeSearchItem {
|
||||
code_symbol: create_test_symbol("MyStruct", Some("struct")),
|
||||
match_result: fuzzy_match_symbol_with_type(
|
||||
&create_test_symbol("MyStruct", Some("struct")),
|
||||
"function",
|
||||
),
|
||||
},
|
||||
CodeSearchItem {
|
||||
code_symbol: create_test_symbol("unrelated_var", None),
|
||||
match_result: fuzzy_match_symbol_with_type(
|
||||
&create_test_symbol("unrelated_var", None),
|
||||
"function",
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
let results = finalize_query(items);
|
||||
|
||||
let best = results.iter().max_by_key(|r| r.score()).unwrap();
|
||||
assert_eq!(
|
||||
best.accept_result(),
|
||||
AIContextMenuSearchableAction::InsertText {
|
||||
text: "fn my_function in test.rs:1".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_code_symbols_3x_multiplier() {
|
||||
let symbol = create_test_symbol("my_function", Some("fn"));
|
||||
|
||||
let match_result = fuzzy_match_symbol_with_type(&symbol, "function");
|
||||
|
||||
// The score should be 3x the raw fuzzy match score.
|
||||
// We can verify the multiplier is applied by checking score > 0
|
||||
// and that it's divisible by 3 (since raw scores are integers).
|
||||
assert!(match_result.score > 0);
|
||||
assert_eq!(match_result.score % 3, 0);
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
#[test]
|
||||
fn test_finalize_zero_state_respects_max_results() {
|
||||
let items: Vec<CodeSearchItem> = (0..300)
|
||||
.map(|i| CodeSearchItem {
|
||||
code_symbol: create_test_symbol_in_file(&format!("sym_{i}"), Some("fn"), "src/main.rs"),
|
||||
match_result: FuzzyMatchResult::no_match(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results = finalize_zero_state(items, &HashSet::new());
|
||||
|
||||
assert_eq!(results.len(), 200);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
pub mod data_source;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod search_item;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::outline::{OutlineStatus, RepoOutlines};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::workspace::ActiveSession;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::path::Path;
|
||||
use warpui::AppContext;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warpui::SingletonEntity;
|
||||
|
||||
/// Checks if the code symbols (outline) are currently being indexed for the active directory.
|
||||
/// Returns true if the outline is in a pending state, false otherwise.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn is_code_symbols_indexing(app: &AppContext) -> bool {
|
||||
let active_window_id = app.windows().state().active_window;
|
||||
|
||||
let current_dir =
|
||||
active_window_id.and_then(|window_id| ActiveSession::as_ref(app).path_if_local(window_id));
|
||||
|
||||
if let Some(current_dir) = current_dir {
|
||||
let repo_outlines = RepoOutlines::handle(app);
|
||||
let repo_outlines_ref = repo_outlines.as_ref(app);
|
||||
|
||||
if let Some((status, _)) = repo_outlines_ref.get_outline(Path::new(current_dir)) {
|
||||
matches!(status, OutlineStatus::Pending)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// WASM stub for the indexing check function.
|
||||
#[cfg(target_family = "wasm")]
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub fn is_code_symbols_indexing(_app: &AppContext) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::ai_context_menu::styles;
|
||||
use crate::search::ai_context_menu::{mixer::AIContextMenuSearchableAction, safe_truncate};
|
||||
use crate::search::item::{IconLocation, SearchItem};
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, Icon, ParentElement, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
// Import CodeSymbol from the data_source module
|
||||
use super::data_source::CodeSymbol;
|
||||
|
||||
const MAX_COMBINED_LENGTH: usize = 55;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CodeSearchItem {
|
||||
pub code_symbol: CodeSymbol,
|
||||
pub match_result: FuzzyMatchResult,
|
||||
}
|
||||
|
||||
impl SearchItem for CodeSearchItem {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
"bundled/svg/code-01.svg",
|
||||
_highlight_state.icon_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(styles::ICON_SIZE)
|
||||
.with_height(styles::ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(styles::MARGIN_RIGHT)
|
||||
.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);
|
||||
|
||||
// Build the symbol name with type prefix
|
||||
let mut symbol_name = String::new();
|
||||
if let Some(symbol_type) = &self.code_symbol.symbol.type_prefix {
|
||||
symbol_name.push_str(&format!("{symbol_type} "));
|
||||
}
|
||||
symbol_name.push_str(&self.code_symbol.symbol.name);
|
||||
|
||||
// Get file path for display
|
||||
let file_path = self.code_symbol.file_path.to_string_lossy().to_string();
|
||||
let mut path_display = file_path.clone();
|
||||
|
||||
// Track truncation for highlight adjustment
|
||||
let mut symbol_truncated = false;
|
||||
|
||||
// Ensure combined length is less than MAX_COMBINED_LENGTH characters
|
||||
let combined_length = symbol_name.len() + path_display.len();
|
||||
|
||||
if combined_length > MAX_COMBINED_LENGTH {
|
||||
// If combined length is too long, prioritize showing the symbol name
|
||||
if symbol_name.len() >= MAX_COMBINED_LENGTH {
|
||||
// If symbol name itself is too long, truncate it and add ellipsis
|
||||
safe_truncate(&mut symbol_name, MAX_COMBINED_LENGTH - 3);
|
||||
symbol_name.push_str("...");
|
||||
symbol_truncated = true;
|
||||
path_display.clear();
|
||||
} else {
|
||||
// Symbol name fits, truncate path display
|
||||
let available_for_path = MAX_COMBINED_LENGTH - symbol_name.len();
|
||||
if path_display.len() > available_for_path {
|
||||
let new_path_len = available_for_path.saturating_sub(3);
|
||||
safe_truncate(&mut path_display, new_path_len);
|
||||
path_display.push_str("...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate highlight indices, adjusting for display format
|
||||
// The fuzzy matching is done on concatenated "typeprefix" + "symbolname" (no space)
|
||||
// But display shows "typeprefix " + "symbolname" (with space)
|
||||
// So we need to adjust indices to account for the added space in display
|
||||
let symbol_highlights: Vec<usize> = if !symbol_truncated {
|
||||
self.match_result
|
||||
.matched_indices
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
if let Some(symbol_type) = &self.code_symbol.symbol.type_prefix {
|
||||
// If we have a type prefix, adjust indices:
|
||||
// - Indices 0 to type_prefix.len()-1 map directly (type prefix part)
|
||||
// - Indices type_prefix.len() and beyond need +1 offset (for the added space)
|
||||
if i < symbol_type.len() {
|
||||
i // Direct mapping for type prefix
|
||||
} else {
|
||||
i + 1 // Add 1 for the space between type and name
|
||||
}
|
||||
} else {
|
||||
i // No type prefix, direct mapping
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
// Only include highlights that fall within the truncated range
|
||||
self.match_result
|
||||
.matched_indices
|
||||
.iter()
|
||||
.filter_map(|&i| {
|
||||
let adjusted_i = if let Some(symbol_type) = &self.code_symbol.symbol.type_prefix
|
||||
{
|
||||
if i < symbol_type.len() {
|
||||
i
|
||||
} else {
|
||||
i + 1
|
||||
}
|
||||
} else {
|
||||
i
|
||||
};
|
||||
|
||||
if adjusted_i < MAX_COMBINED_LENGTH - 3 {
|
||||
Some(adjusted_i)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Create symbol name text with highlighting
|
||||
let mut symbol_text = Text::new(
|
||||
symbol_name,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
)
|
||||
.with_color(highlight_state.main_text_fill(appearance).into_solid());
|
||||
|
||||
if !symbol_highlights.is_empty() {
|
||||
symbol_text = symbol_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
symbol_highlights,
|
||||
);
|
||||
}
|
||||
|
||||
// Create path text with lighter color
|
||||
let path_text = if !path_display.is_empty() {
|
||||
Some(
|
||||
Text::new(
|
||||
path_display,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.0,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.finish(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Create row with symbol name and path on the same line
|
||||
let mut row = Flex::row()
|
||||
.with_child(symbol_text.finish())
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
if let Some(path) = path_text {
|
||||
row.add_child(Container::new(path).with_padding_left(8.0).finish());
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn render_details(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let theme = appearance.theme();
|
||||
|
||||
// Create main text: symbol type + name (e.g., "fn initialize_logger")
|
||||
let mut main_text = String::new();
|
||||
if let Some(symbol_type) = &self.code_symbol.symbol.type_prefix {
|
||||
main_text.push_str(symbol_type);
|
||||
main_text.push(' ');
|
||||
}
|
||||
main_text.push_str(&self.code_symbol.symbol.name);
|
||||
|
||||
// Create sub text: path + line number (e.g., "core/logging.rs (44)")
|
||||
let sub_text = format!(
|
||||
"{} ({})",
|
||||
self.code_symbol.file_path.to_string_lossy(),
|
||||
self.code_symbol.symbol.line_number
|
||||
);
|
||||
|
||||
// Create main text element - use slightly smaller font that scales with user settings
|
||||
let main_text_element = Text::new(
|
||||
main_text,
|
||||
appearance.monospace_font_family(), // Use monospace font for consistency
|
||||
appearance.monospace_font_size() - 1.0, // Slightly smaller than normal
|
||||
)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.with_style(Properties::default().weight(Weight::Medium))
|
||||
.finish();
|
||||
|
||||
// Create sub text element - even smaller for sub information
|
||||
let sub_text_element = Text::new(
|
||||
sub_text,
|
||||
appearance.monospace_font_family(), // Use monospace font for consistency
|
||||
appearance.monospace_font_size() - 3.0, // Smaller than main text
|
||||
)
|
||||
.with_color(theme.nonactive_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
// Create modal content with reduced spacing
|
||||
let content = Flex::column()
|
||||
.with_child(main_text_element)
|
||||
.with_child(
|
||||
Container::new(sub_text_element)
|
||||
.with_padding_top(2.0)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Some(content)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
// Format the text as "{symbol_type} {symbol_name} in {path}:{line_number}"
|
||||
let mut text = String::new();
|
||||
if let Some(symbol_type) = &self.code_symbol.symbol.type_prefix {
|
||||
text.push_str(symbol_type);
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(&self.code_symbol.symbol.name);
|
||||
text.push_str(" in ");
|
||||
text.push_str(&self.code_symbol.file_path.to_string_lossy());
|
||||
text.push(':');
|
||||
text.push_str(&self.code_symbol.symbol.line_number.to_string());
|
||||
|
||||
AIContextMenuSearchableAction::InsertText { text }
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!(
|
||||
"Code symbol: {} in {}:{}",
|
||||
self.code_symbol.symbol.name,
|
||||
self.code_symbol.file_path.to_string_lossy(),
|
||||
self.code_symbol.symbol.line_number
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use super::search_item::CommandSearchItem;
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
use crate::terminal::History;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use std::collections::HashSet;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
const MAX_RESULTS: usize = 50;
|
||||
|
||||
pub struct CommandDataSource;
|
||||
|
||||
impl CommandDataSource {
|
||||
#[allow(dead_code)]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Get terminal commands from all sessions' history
|
||||
fn get_terminal_commands(&self, app: &AppContext) -> Vec<String> {
|
||||
let history = History::as_ref(app);
|
||||
let mut unique_commands = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
// Get all live session IDs from history
|
||||
let session_ids = history.all_live_session_ids();
|
||||
|
||||
// Collect commands from all sessions, prioritizing more recent commands
|
||||
let mut all_commands = Vec::new();
|
||||
|
||||
for session_id in session_ids {
|
||||
if let Some(commands) = history.commands(session_id) {
|
||||
// Add commands with their timestamps for sorting
|
||||
for entry in commands.iter() {
|
||||
if !entry.command.trim().is_empty() {
|
||||
all_commands.push((entry.command.clone(), entry.start_ts));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp (most recent first), using start_ts when available
|
||||
all_commands.sort_by(|a, b| {
|
||||
match (a.1, b.1) {
|
||||
(Some(a_time), Some(b_time)) => b_time.cmp(&a_time),
|
||||
(Some(_), None) => std::cmp::Ordering::Less, // timestamped commands first
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
}
|
||||
});
|
||||
|
||||
// Deduplicate while preserving order (most recent occurrence wins)
|
||||
for (command, _) in all_commands {
|
||||
if !seen.contains(&command) {
|
||||
seen.insert(command.clone());
|
||||
unique_commands.push(command);
|
||||
|
||||
// Limit to reasonable number of commands
|
||||
if unique_commands.len() >= MAX_RESULTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unique_commands
|
||||
}
|
||||
|
||||
/// Performs fuzzy matching on commands
|
||||
fn fuzzy_match_command(&self, command: &str, query: &str) -> Option<FuzzyMatchResult> {
|
||||
if query.is_empty() {
|
||||
return Some(FuzzyMatchResult::no_match());
|
||||
}
|
||||
|
||||
fuzzy_match::match_indices_case_insensitive(command, query)
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for CommandDataSource {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let query_text = &query.text;
|
||||
let commands = self.get_terminal_commands(app);
|
||||
|
||||
let results: Vec<QueryResult<AIContextMenuSearchableAction>> = if query_text.is_empty() {
|
||||
// Zero state: show recent commands without fuzzy matching
|
||||
commands
|
||||
.into_iter()
|
||||
.map(|command| {
|
||||
let search_item = CommandSearchItem {
|
||||
command,
|
||||
match_result: FuzzyMatchResult::no_match(),
|
||||
};
|
||||
QueryResult::from(search_item)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
// Non-empty query: use fuzzy matching
|
||||
commands
|
||||
.into_iter()
|
||||
.filter_map(|command| {
|
||||
let match_result = self.fuzzy_match_command(&command, query_text)?;
|
||||
let search_item = CommandSearchItem {
|
||||
command,
|
||||
match_result,
|
||||
};
|
||||
Some(QueryResult::from(search_item))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for CommandDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod data_source;
|
||||
pub mod search_item;
|
||||
@@ -0,0 +1,87 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::{
|
||||
elements::{ConstrainedBox, Container, Icon, Text},
|
||||
AppContext, Element, SingletonEntity,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CommandSearchItem {
|
||||
pub command: String,
|
||||
pub match_result: FuzzyMatchResult,
|
||||
}
|
||||
|
||||
impl SearchItem for CommandSearchItem {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
"bundled/svg/terminal.svg",
|
||||
highlight_state.icon_fill(appearance),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(appearance.monospace_font_size())
|
||||
.with_height(appearance.monospace_font_size())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(12.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
Text::new_inline(
|
||||
self.command.clone(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(highlight_state.main_text_fill(appearance).into_solid())
|
||||
.with_single_highlight(
|
||||
warpui::elements::Highlight::new()
|
||||
.with_properties(
|
||||
warpui::fonts::Properties::default().weight(warpui::fonts::Weight::Bold),
|
||||
)
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
self.match_result.matched_indices.clone(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
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) -> AIContextMenuSearchableAction {
|
||||
AIContextMenuSearchableAction::InsertText {
|
||||
text: self.command.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> AIContextMenuSearchableAction {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Command: {}", self.command)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use super::search_item::ConversationSearchItem;
|
||||
use super::ConversationContextItem;
|
||||
use crate::ai::agent_conversations_model::AgentConversationsModel;
|
||||
use crate::ai::conversation_navigation::ConversationNavigationData;
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use std::collections::HashSet;
|
||||
use warpui::{AppContext, Entity, SingletonEntity};
|
||||
|
||||
const MAX_RESULTS: usize = 50;
|
||||
/// Minimum fuzzy match score to include a conversation in filtered results.
|
||||
const MIN_FUZZY_SCORE: i64 = 25;
|
||||
/// Score assigned to zero-state (unfiltered) results so they rank above low fuzzy matches.
|
||||
const ZERO_STATE_SCORE: i64 = 1000;
|
||||
|
||||
pub struct ConversationDataSource;
|
||||
|
||||
impl ConversationDataSource {
|
||||
/// Merges local conversations and cloud agent tasks, deduplicated by
|
||||
/// `server_conversation_token`.
|
||||
fn collect_conversations(app: &AppContext) -> Vec<ConversationContextItem> {
|
||||
let mut seen_tokens: HashSet<String> = HashSet::new();
|
||||
let mut items: Vec<ConversationContextItem> = Vec::new();
|
||||
|
||||
// Source 1: local + historical conversations (excludes ambient agent conversations).
|
||||
for nav in ConversationNavigationData::all_conversations(app) {
|
||||
if let Some(token) = &nav.server_conversation_token {
|
||||
if !seen_tokens.contains(token.as_str()) {
|
||||
let token_str = token.as_str().to_string();
|
||||
seen_tokens.insert(token_str.clone());
|
||||
items.push(ConversationContextItem {
|
||||
title: nav.title,
|
||||
server_conversation_token: token_str,
|
||||
last_updated: nav.last_updated.to_utc(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Source 2: cloud agent tasks. Every ambient agent conversation has a
|
||||
// corresponding task, so this covers all cloud conversations.
|
||||
let agent_model = AgentConversationsModel::as_ref(app);
|
||||
for task in agent_model.tasks_iter() {
|
||||
if let Some(conv_id) = &task.conversation_id {
|
||||
if seen_tokens.insert(conv_id.clone()) {
|
||||
items.push(ConversationContextItem {
|
||||
title: task.title.clone(),
|
||||
server_conversation_token: conv_id.clone(),
|
||||
last_updated: task.updated_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for ConversationDataSource {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let all_conversations = Self::collect_conversations(app);
|
||||
let query_text = query.text.trim().to_lowercase();
|
||||
|
||||
// Always sort by last_updated ascending so that position-based scores
|
||||
// assign higher values to more recently updated conversations. This ensures
|
||||
// recency acts as a tiebreaker when fuzzy scores are similar.
|
||||
let mut all_conversations = all_conversations;
|
||||
all_conversations.sort_by(|a, b| a.last_updated.cmp(&b.last_updated));
|
||||
let total_conversations = all_conversations.len();
|
||||
|
||||
let mut results: Vec<QueryResult<Self::Action>> = if query_text.is_empty() {
|
||||
// Zero state: score encodes recency so the mixer orders newest items highest.
|
||||
all_conversations
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
let search_item = ConversationSearchItem::new(
|
||||
item,
|
||||
FuzzyMatchResult {
|
||||
score: ZERO_STATE_SCORE
|
||||
+ (30 * (index + 1) / total_conversations) as i64,
|
||||
matched_indices: vec![],
|
||||
},
|
||||
);
|
||||
QueryResult::from(search_item)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
// Fuzzy match on conversation title.
|
||||
all_conversations
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, item)| {
|
||||
let mut match_result =
|
||||
fuzzy_match::match_indices_case_insensitive(&item.title, &query_text)?;
|
||||
|
||||
if match_result.score < MIN_FUZZY_SCORE {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Add a recency bonus (capped at 30) so more recently updated
|
||||
// conversations rank higher among results with similar fuzzy
|
||||
// scores, regardless of the total number of conversations.
|
||||
match_result.score += (30 * (index + 1) / total_conversations) as i64;
|
||||
|
||||
let search_item = ConversationSearchItem::new(item, match_result);
|
||||
Some(QueryResult::from(search_item))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
results.sort_by_key(|r| std::cmp::Reverse(r.score()));
|
||||
results.truncate(MAX_RESULTS);
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ConversationDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
pub mod data_source;
|
||||
mod search_item;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Lightweight representation of a conversation for the @conversations context menu.
|
||||
/// Only carries the fields needed for display and insertion — avoids constructing
|
||||
/// a full `ConversationNavigationData` for cloud conversations that have no local state.
|
||||
#[derive(Debug)]
|
||||
pub struct ConversationContextItem {
|
||||
pub title: String,
|
||||
pub server_conversation_token: String,
|
||||
pub last_updated: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
|
||||
use super::ConversationContextItem;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::ai_context_menu::styles;
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
use crate::util::truncation::truncate_from_end;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, Icon, ParentElement, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
const MAX_TITLE_LENGTH: usize = 45;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ConversationSearchItem {
|
||||
item: ConversationContextItem,
|
||||
match_result: FuzzyMatchResult,
|
||||
}
|
||||
|
||||
impl ConversationSearchItem {
|
||||
pub fn new(item: ConversationContextItem, match_result: FuzzyMatchResult) -> Self {
|
||||
Self { item, match_result }
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for ConversationSearchItem {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
"bundled/svg/conversation.svg",
|
||||
highlight_state.icon_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(styles::ICON_SIZE)
|
||||
.with_height(styles::ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(styles::MARGIN_RIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let char_count = self.item.title.chars().count();
|
||||
let highlight_limit = if char_count > MAX_TITLE_LENGTH {
|
||||
MAX_TITLE_LENGTH.saturating_sub(1)
|
||||
} else {
|
||||
char_count
|
||||
};
|
||||
let title = truncate_from_end(&self.item.title, MAX_TITLE_LENGTH);
|
||||
|
||||
let mut name_text = Text::new(
|
||||
title,
|
||||
appearance.ui_font_family(),
|
||||
(appearance.monospace_font_size() - 1.0).max(1.0),
|
||||
)
|
||||
.with_color(highlight_state.main_text_fill(appearance).into_solid());
|
||||
|
||||
if !self.match_result.matched_indices.is_empty() {
|
||||
let filtered_indices: Vec<usize> = self
|
||||
.match_result
|
||||
.matched_indices
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&i| i < highlight_limit)
|
||||
.collect();
|
||||
if !filtered_indices.is_empty() {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
filtered_indices,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let timestamp_text = Text::new(
|
||||
format_approx_duration_from_now_utc(self.item.last_updated),
|
||||
appearance.ui_font_family(),
|
||||
(appearance.monospace_font_size() - 2.0).max(1.0),
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
Flex::row()
|
||||
.with_child(name_text.finish())
|
||||
.with_child(
|
||||
Container::new(timestamp_text.finish())
|
||||
.with_padding_left(6.)
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
AIContextMenuSearchableAction::InsertConversation {
|
||||
conversation_id: self.item.server_conversation_token.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Conversation: {}", self.item.title)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use super::search_item::DiffSetSearchItem;
|
||||
use crate::code_review::diff_state::DiffMode;
|
||||
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
use warpui::AppContext;
|
||||
|
||||
const UNCOMMITTED_CHANGES_NAME: &str = "uncommitted changes";
|
||||
const MAIN_BRANCH_CHANGES_NAME: &str = "changes vs. main branch";
|
||||
|
||||
pub struct DiffSetDataSource;
|
||||
|
||||
impl SyncDataSource for DiffSetDataSource {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
_app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
// Filter based on query if provided
|
||||
let query_text = &query.text.to_lowercase();
|
||||
let mut results: Vec<QueryResult<Self::Action>> = vec![];
|
||||
|
||||
// Add uncommitted changes option
|
||||
if let Some(match_result) =
|
||||
fuzzy_match::match_indices_case_insensitive(UNCOMMITTED_CHANGES_NAME, query_text)
|
||||
{
|
||||
results.push(
|
||||
DiffSetSearchItem {
|
||||
diff_mode: DiffMode::Head,
|
||||
match_result,
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Add main branch comparison option
|
||||
if let Some(match_result) =
|
||||
fuzzy_match::match_indices_case_insensitive(MAIN_BRANCH_CHANGES_NAME, query_text)
|
||||
{
|
||||
results.push(
|
||||
DiffSetSearchItem {
|
||||
diff_mode: DiffMode::MainBranch,
|
||||
match_result,
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for DiffSetDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(super) mod data_source;
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(super) mod search_item;
|
||||
@@ -0,0 +1,120 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::code_review::diff_state::DiffMode;
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::ai_context_menu::styles;
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Flex, Icon, ParentElement, Text,
|
||||
};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffSetSearchItem {
|
||||
pub diff_mode: DiffMode,
|
||||
pub match_result: FuzzyMatchResult,
|
||||
}
|
||||
|
||||
impl DiffSetSearchItem {
|
||||
pub fn name(&self) -> String {
|
||||
match &self.diff_mode {
|
||||
DiffMode::Head => "Uncommitted changes".to_string(),
|
||||
DiffMode::MainBranch => "Changes vs. main branch".to_string(),
|
||||
DiffMode::OtherBranch(branch) => format!("Changes vs. {branch}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(&self) -> String {
|
||||
match &self.diff_mode {
|
||||
DiffMode::Head => "All uncommitted changes in the working directory".to_string(),
|
||||
DiffMode::MainBranch => "All changes compared to the main branch".to_string(),
|
||||
DiffMode::OtherBranch(branch) => format!("All changes compared to {branch}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for DiffSetSearchItem {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
"bundled/svg/diff.svg",
|
||||
highlight_state.icon_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(styles::ICON_SIZE)
|
||||
.with_height(styles::ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(styles::MARGIN_RIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let name_text = Text::new(
|
||||
self.name(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
)
|
||||
.with_color(highlight_state.main_text_fill(appearance).into_solid());
|
||||
|
||||
let description_text = Text::new(
|
||||
self.description(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.0,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(name_text.finish())
|
||||
.with_child(
|
||||
Container::new(description_text.finish())
|
||||
.with_padding_left(6.)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn priority_tier(&self) -> u8 {
|
||||
// Prioritize diffsets above other items.
|
||||
1
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
AIContextMenuSearchableAction::InsertDiffSet {
|
||||
diff_mode: self.diff_mode.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("{} - {}", self.name(), self.description())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "search_item_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,17 @@
|
||||
use super::DiffSetSearchItem;
|
||||
use crate::code_review::diff_state::DiffMode;
|
||||
use crate::search::item::SearchItem;
|
||||
|
||||
#[test]
|
||||
fn diffset_has_higher_priority_tier() {
|
||||
let match_result =
|
||||
fuzzy_match::match_indices_case_insensitive("uncommitted changes", "uncommitted")
|
||||
.expect("query should match");
|
||||
|
||||
let item = DiffSetSearchItem {
|
||||
diff_mode: DiffMode::Head,
|
||||
match_result,
|
||||
};
|
||||
|
||||
assert_eq!(item.priority_tier(), 1);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
#![cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
use super::search_item::FileSearchItem;
|
||||
use crate::code::opened_files::OpenedFilesModel;
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::async_snapshot_data_source::AsyncSnapshotDataSource;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::files::model::FileSearchModel;
|
||||
use crate::search::files::search_item::FileSearchResult;
|
||||
use crate::search::mixer::{BoxFuture, DataSourceRunErrorWrapper};
|
||||
use crate::workspace::ActiveSession;
|
||||
use futures_lite::future::yield_now;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use itertools::Itertools;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
const MAX_RESULTS: usize = 200;
|
||||
|
||||
pub(crate) struct FileSnapshot {
|
||||
pub(crate) contents: Arc<Vec<FileSearchResult>>,
|
||||
pub(crate) git_changed_files: HashSet<String>,
|
||||
pub(crate) query_text: String,
|
||||
/// Last-opened timestamps for files, keyed by path. Populated from
|
||||
/// `OpenedFilesModel` at snapshot time. Used as a secondary recency
|
||||
/// signal within each scoring tier.
|
||||
pub(crate) last_opened: HashMap<String, instant::Instant>,
|
||||
}
|
||||
|
||||
/// Builds the repository-backed file search source used by the AI context menu.
|
||||
/// For empty queries, snapshots repo contents with git-change status to prioritize modified files,
|
||||
/// and for non-empty queries snapshots repo contents only for faster fuzzy matching.
|
||||
pub fn file_data_source_for_current_repo(
|
||||
) -> AsyncSnapshotDataSource<FileSnapshot, AIContextMenuSearchableAction> {
|
||||
AsyncSnapshotDataSource::new(
|
||||
|query: &Query, app: &AppContext| {
|
||||
if FileSearchModel::should_skip_overly_broad_query(&query.text) {
|
||||
return FileSnapshot {
|
||||
contents: Arc::new(Vec::new()),
|
||||
git_changed_files: HashSet::new(),
|
||||
query_text: query.text.clone(),
|
||||
last_opened: HashMap::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let file_search_model = FileSearchModel::as_ref(app);
|
||||
let last_opened = snapshot_last_opened(app);
|
||||
if query.text.is_empty() {
|
||||
let (contents, git_changed_files) =
|
||||
file_search_model.get_repo_contents_with_git_status(app);
|
||||
FileSnapshot {
|
||||
contents,
|
||||
git_changed_files,
|
||||
query_text: query.text.clone(),
|
||||
last_opened,
|
||||
}
|
||||
} else {
|
||||
let contents = file_search_model.get_repo_contents(app);
|
||||
FileSnapshot {
|
||||
contents,
|
||||
git_changed_files: HashSet::new(),
|
||||
query_text: query.text.clone(),
|
||||
last_opened,
|
||||
}
|
||||
}
|
||||
},
|
||||
fuzzy_match_files,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn file_data_source_for_pwd(
|
||||
app: &AppContext,
|
||||
) -> AsyncSnapshotDataSource<FileSnapshot, AIContextMenuSearchableAction> {
|
||||
let file_search_model = FileSearchModel::as_ref(app);
|
||||
let mut cached_contents = file_search_model.get_folder_contents(app);
|
||||
// Reverse sort to put what you'd expect at the top for zero-state
|
||||
cached_contents.sort_by(|a, b| b.path.cmp(&a.path));
|
||||
let cached_contents = Arc::new(cached_contents);
|
||||
|
||||
AsyncSnapshotDataSource::new(
|
||||
move |query: &Query, _app: &AppContext| {
|
||||
if FileSearchModel::should_skip_overly_broad_query(&query.text) {
|
||||
return FileSnapshot {
|
||||
contents: Arc::new(Vec::new()),
|
||||
git_changed_files: HashSet::new(),
|
||||
query_text: query.text.clone(),
|
||||
last_opened: HashMap::new(),
|
||||
};
|
||||
}
|
||||
|
||||
FileSnapshot {
|
||||
contents: cached_contents.clone(),
|
||||
git_changed_files: HashSet::new(),
|
||||
query_text: query.text.clone(),
|
||||
last_opened: HashMap::new(),
|
||||
}
|
||||
},
|
||||
fuzzy_match_files,
|
||||
)
|
||||
}
|
||||
|
||||
/// Captures last-opened timestamps from `OpenedFilesModel` for the active
|
||||
/// repo at snapshot time. Returns an empty map when no repo is active.
|
||||
fn snapshot_last_opened(app: &AppContext) -> HashMap<String, instant::Instant> {
|
||||
let git_repo_path = app
|
||||
.windows()
|
||||
.state()
|
||||
.active_window
|
||||
.and_then(|window_id| ActiveSession::as_ref(app).path_if_local(window_id))
|
||||
.and_then(|current_dir| {
|
||||
DetectedRepositories::as_ref(app).get_root_for_path(Path::new(current_dir))
|
||||
});
|
||||
|
||||
let Some(repo_path) = git_repo_path else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
let opened_files_model = OpenedFilesModel::as_ref(app);
|
||||
let Some(opened_in_repo) = opened_files_model.opened_files_for_repo(&repo_path) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
// Convert PathBuf keys to String keys matching FileSearchResult.path
|
||||
// (relative paths from repo root).
|
||||
opened_in_repo
|
||||
.iter()
|
||||
.map(|(path, ts)| (path.to_string_lossy().to_string(), *ts))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Routes file matching to zero-state ranking or query-based fuzzy scoring.
|
||||
pub(crate) fn fuzzy_match_files(
|
||||
snapshot: FileSnapshot,
|
||||
) -> BoxFuture<
|
||||
'static,
|
||||
Result<Vec<QueryResult<AIContextMenuSearchableAction>>, DataSourceRunErrorWrapper>,
|
||||
> {
|
||||
Box::pin(async move {
|
||||
if snapshot.query_text.is_empty() {
|
||||
Ok(fuzzy_match_files_zero_state(snapshot).await)
|
||||
} else {
|
||||
Ok(fuzzy_match_files_query(snapshot).await)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a recency index: sort files by last-opened timestamp (ascending,
|
||||
/// `None` first) and return a map from path to sort position.
|
||||
fn build_recency_index(
|
||||
contents: &[FileSearchResult],
|
||||
last_opened: &HashMap<String, instant::Instant>,
|
||||
) -> HashMap<String, usize> {
|
||||
let mut opened: Vec<_> = contents
|
||||
.iter()
|
||||
.filter_map(|item| last_opened.get(&item.path).map(|ts| (&item.path, ts)))
|
||||
.collect();
|
||||
opened.sort_by_key(|(_, ts)| *ts);
|
||||
opened
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(rank, (path, _))| (path.clone(), rank + 1))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns zero-state file results with two scoring tiers and recency
|
||||
/// as a secondary sort within each tier.
|
||||
async fn fuzzy_match_files_zero_state(
|
||||
snapshot: FileSnapshot,
|
||||
) -> Vec<QueryResult<AIContextMenuSearchableAction>> {
|
||||
let recency_index = build_recency_index(&snapshot.contents, &snapshot.last_opened);
|
||||
let max_recency = recency_index.len();
|
||||
let mut results: Vec<QueryResult<AIContextMenuSearchableAction>> = Vec::new();
|
||||
|
||||
// Pass 1: git-changed or recently-opened files (guaranteed inclusion)
|
||||
for chunk in snapshot.contents.chunks(512) {
|
||||
for item in chunk {
|
||||
let is_git_changed = snapshot.git_changed_files.contains(&item.path);
|
||||
let is_recently_opened = snapshot.last_opened.contains_key(&item.path);
|
||||
|
||||
if is_git_changed || is_recently_opened {
|
||||
let rank = recency_index.get(&item.path).copied().unwrap_or(0);
|
||||
let recency_bonus = if max_recency > 0 {
|
||||
(30 * rank / max_recency) as i64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let base_score = if is_git_changed { 10000 } else { 0 };
|
||||
let match_result = FuzzyMatchResult {
|
||||
score: base_score + recency_bonus,
|
||||
matched_indices: vec![],
|
||||
};
|
||||
let search_item = FileSearchItem {
|
||||
path: PathBuf::from(&item.path),
|
||||
match_result,
|
||||
is_directory: item.is_directory,
|
||||
};
|
||||
results.push(QueryResult::from(search_item));
|
||||
}
|
||||
}
|
||||
yield_now().await;
|
||||
}
|
||||
|
||||
// Pass 2: fill remaining capacity with untouched files
|
||||
for chunk in snapshot.contents.chunks(512) {
|
||||
for item in chunk {
|
||||
if !snapshot.git_changed_files.contains(&item.path)
|
||||
&& !snapshot.last_opened.contains_key(&item.path)
|
||||
&& results.len() < MAX_RESULTS
|
||||
{
|
||||
let match_result = FuzzyMatchResult {
|
||||
score: 0,
|
||||
matched_indices: vec![],
|
||||
};
|
||||
let search_item = FileSearchItem {
|
||||
path: PathBuf::from(&item.path),
|
||||
match_result,
|
||||
is_directory: item.is_directory,
|
||||
};
|
||||
results.push(QueryResult::from(search_item));
|
||||
}
|
||||
}
|
||||
yield_now().await;
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Returns fuzzy-ranked file results for non-empty queries.
|
||||
async fn fuzzy_match_files_query(
|
||||
snapshot: FileSnapshot,
|
||||
) -> Vec<QueryResult<AIContextMenuSearchableAction>> {
|
||||
let recency_index = build_recency_index(&snapshot.contents, &snapshot.last_opened);
|
||||
let max_recency = recency_index.len();
|
||||
let mut results = Vec::new();
|
||||
|
||||
for chunk in snapshot.contents.chunks(512) {
|
||||
for item in chunk {
|
||||
if let Some(mut match_result) =
|
||||
FileSearchModel::fuzzy_match_path(&item.path, &snapshot.query_text)
|
||||
{
|
||||
// Give files a slight boost over directories to prioritize them when names are similar
|
||||
if !item.is_directory {
|
||||
match_result.score += 100;
|
||||
}
|
||||
|
||||
// Add a recency bonus, capped at 30.
|
||||
let rank = recency_index.get(&item.path).copied().unwrap_or(0);
|
||||
let recency_bonus = if max_recency > 0 {
|
||||
(30 * rank / max_recency) as i64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
match_result.score += recency_bonus;
|
||||
|
||||
let search_item = FileSearchItem {
|
||||
path: PathBuf::from(&item.path),
|
||||
match_result,
|
||||
is_directory: item.is_directory,
|
||||
};
|
||||
results.push(QueryResult::from(search_item));
|
||||
}
|
||||
}
|
||||
yield_now().await;
|
||||
}
|
||||
|
||||
results
|
||||
.into_iter()
|
||||
.k_largest_relaxed_by_key(MAX_RESULTS, |item| item.score())
|
||||
.collect()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
pub mod data_source;
|
||||
pub mod search_item;
|
||||
|
||||
#[cfg(test)]
|
||||
mod data_source_tests;
|
||||
@@ -0,0 +1,87 @@
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use std::fmt::Debug;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::ai_context_menu::styles;
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use warpui::elements::{ConstrainedBox, Container, Icon};
|
||||
use warpui::{AppContext, Element};
|
||||
|
||||
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 match_result: FuzzyMatchResult,
|
||||
pub is_directory: bool,
|
||||
}
|
||||
|
||||
impl SearchItem for FileSearchItem {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
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::ICON_SIZE)
|
||||
.with_height(styles::ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(styles::MARGIN_RIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
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 {
|
||||
AIContextMenuSearchableAction::InsertFilePath {
|
||||
file_path: self.path.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use crate::cloud_object::ObjectType;
|
||||
use crate::code_review::diff_state::DiffMode;
|
||||
use crate::search::mixer::SearchMixer;
|
||||
|
||||
pub type AIContextMenuMixer = SearchMixer<AIContextMenuSearchableAction>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AIContextMenuSearchableAction {
|
||||
InsertFilePath {
|
||||
/// This is the file path relative to the root of the current git
|
||||
/// repository. If this changes, this could break how we resolve
|
||||
/// the file path outside of AI mode, so just note the downstream
|
||||
/// dependencies.
|
||||
file_path: String,
|
||||
},
|
||||
InsertText {
|
||||
/// Text to insert into the input buffer.
|
||||
text: String,
|
||||
},
|
||||
InsertDriveObject {
|
||||
/// The type of the drive object (Workflow, Notebook, etc.)
|
||||
object_type: ObjectType,
|
||||
/// The UID of the drive object to insert as <object_type:{uid}>
|
||||
object_uid: String,
|
||||
},
|
||||
InsertPlan {
|
||||
/// The UID of the AI document to insert as <plan:{uid}>
|
||||
ai_document_uid: String,
|
||||
},
|
||||
InsertDiffSet {
|
||||
/// The diff mode indicating what base to compare against
|
||||
diff_mode: DiffMode,
|
||||
},
|
||||
InsertConversation {
|
||||
/// The conversation identifier to insert as <convo:{id}>.
|
||||
conversation_id: String,
|
||||
},
|
||||
InsertSkill {
|
||||
/// The skill name to insert as /{name} into the buffer.
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
mod blocks;
|
||||
mod code;
|
||||
mod commands;
|
||||
mod conversations;
|
||||
mod diffset;
|
||||
mod files;
|
||||
pub mod mixer;
|
||||
mod notebooks;
|
||||
mod rules;
|
||||
pub mod search;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod skills;
|
||||
mod styles;
|
||||
pub mod view;
|
||||
mod workflows;
|
||||
|
||||
/// Safely truncate a string at the given byte index, ensuring we don't split UTF-8 characters
|
||||
pub fn safe_truncate(s: &mut String, new_len: usize) {
|
||||
if new_len >= s.len() {
|
||||
return;
|
||||
}
|
||||
let safe_len = floor_char_boundary(s, new_len);
|
||||
s.truncate(safe_len);
|
||||
}
|
||||
|
||||
/// Find the largest valid character boundary at or before the given byte index
|
||||
pub fn floor_char_boundary(original_string: &str, idx: usize) -> usize {
|
||||
if idx >= original_string.len() {
|
||||
original_string.len()
|
||||
} else {
|
||||
let mut curr = idx;
|
||||
while curr > 0 && !original_string.is_char_boundary(curr) {
|
||||
curr -= 1;
|
||||
}
|
||||
curr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
use super::search_item::NotebookSearchItem;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::CloudModelType;
|
||||
use crate::notebooks::manager::{NotebookManager, NotebookSource};
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
const MAX_RESULTS: usize = 50;
|
||||
/// Base score for zero-state results. Each item gets an additional bonus based on
|
||||
/// recency so the mixer's score-based ordering places more recent items higher.
|
||||
const ZERO_STATE_BASE_SCORE: i64 = 1000;
|
||||
|
||||
pub struct NotebookDataSource {
|
||||
is_plan: bool,
|
||||
}
|
||||
|
||||
impl NotebookDataSource {
|
||||
#[allow(dead_code)]
|
||||
pub fn new(is_plan: bool) -> Self {
|
||||
Self { is_plan }
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for NotebookDataSource {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let query_text = &query.text;
|
||||
|
||||
// Get all notebooks from CloudModel
|
||||
let cloud_model = CloudModel::as_ref(app);
|
||||
let _user_workspaces = UserWorkspaces::as_ref(app);
|
||||
|
||||
// Get notebooks from all spaces the user has access to
|
||||
let mut notebook_results = Vec::new();
|
||||
let notebook_manager = NotebookManager::as_ref(app);
|
||||
|
||||
let mut notebooks: Vec<_> = cloud_model
|
||||
.get_all_active_notebooks()
|
||||
.filter(|notebook| {
|
||||
// Notebooks and plans have separate filters.
|
||||
self.is_plan == notebook.model().ai_document_id.is_some()
|
||||
})
|
||||
.filter(|notebook| !notebook.metadata.is_welcome_object)
|
||||
.collect();
|
||||
|
||||
// Always sort by revision timestamp ascending so that position-based
|
||||
// scores assign higher values to more recently updated items. This ensures
|
||||
// recency acts as a tiebreaker when fuzzy scores are similar.
|
||||
notebooks.sort_by(|a, b| {
|
||||
let a_ts = a.metadata.revision.as_ref().map(|r| r.timestamp());
|
||||
let b_ts = b.metadata.revision.as_ref().map(|r| r.timestamp());
|
||||
a_ts.cmp(&b_ts)
|
||||
});
|
||||
|
||||
let total_notebooks = notebooks.len();
|
||||
for (index, notebook) in notebooks.into_iter().enumerate() {
|
||||
let notebook_name = notebook.model().display_name();
|
||||
// Use the first few lines of raw text (without markdown) as description for hover info
|
||||
let raw_text = notebook_manager
|
||||
.notebook_raw_text(notebook.id)
|
||||
.unwrap_or(notebook.model().data.as_str());
|
||||
let content_lines: Vec<&str> = raw_text.lines().take(3).collect();
|
||||
let content_preview = content_lines.join("\n");
|
||||
let notebook_description = if content_preview.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(if content_preview.len() > 200 {
|
||||
// Use char_indices to find the last valid character boundary before position 197
|
||||
let truncated = content_preview
|
||||
.char_indices()
|
||||
.take_while(|(i, _)| *i <= 197)
|
||||
.last()
|
||||
.map(|(i, c)| &content_preview[..i + c.len_utf8()])
|
||||
.unwrap_or("");
|
||||
format!("{truncated}...")
|
||||
} else {
|
||||
content_preview
|
||||
})
|
||||
};
|
||||
let notebook_uid = notebook.id.uid();
|
||||
|
||||
// Check if this notebook is currently open
|
||||
let is_open = notebook_manager
|
||||
.find_pane(&NotebookSource::Existing(notebook.id))
|
||||
.is_some();
|
||||
let recency_bonus = (30 * (index + 1) / total_notebooks) as i64;
|
||||
|
||||
let (base_match_result, is_match_on_name) = if query_text.is_empty() {
|
||||
// Zero state: score encodes recency so the mixer orders newest items highest.
|
||||
(
|
||||
FuzzyMatchResult {
|
||||
score: ZERO_STATE_BASE_SCORE + recency_bonus,
|
||||
matched_indices: vec![],
|
||||
},
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
// Fuzzy match against notebook name
|
||||
let name_match =
|
||||
fuzzy_match::match_indices_case_insensitive(¬ebook_name, query_text);
|
||||
|
||||
// Also try matching against description if available
|
||||
let description_match = notebook_description
|
||||
.as_deref()
|
||||
.and_then(|desc| fuzzy_match::match_indices_case_insensitive(desc, query_text));
|
||||
|
||||
// Use the best match, tracking whether it was on the name
|
||||
let (mut result, on_name) = match (name_match, description_match) {
|
||||
(Some(name), Some(desc)) if desc.score > name.score => (desc, false),
|
||||
(Some(name), _) => (name, true),
|
||||
(None, Some(desc)) => (desc, false),
|
||||
(None, None) => continue, // No match, skip this notebook
|
||||
};
|
||||
// Add a recency bonus, capped at 30.
|
||||
result.score += recency_bonus;
|
||||
(result, on_name)
|
||||
};
|
||||
let mut match_result = base_match_result;
|
||||
|
||||
// Heavily prioritize open notebooks by adding a large bonus to their score
|
||||
if is_open {
|
||||
match_result.score += 10000;
|
||||
}
|
||||
|
||||
let ai_document_uid = notebook.model().ai_document_id;
|
||||
let search_item = NotebookSearchItem {
|
||||
notebook_name,
|
||||
notebook_description,
|
||||
notebook_uid,
|
||||
match_result,
|
||||
ai_document_uid: ai_document_uid.map(|id| id.to_string()),
|
||||
is_match_on_name,
|
||||
};
|
||||
|
||||
notebook_results.push(QueryResult::from(search_item));
|
||||
}
|
||||
|
||||
// Sort by score and take the top results
|
||||
notebook_results.sort_by_key(|b| std::cmp::Reverse(b.score()));
|
||||
notebook_results.truncate(MAX_RESULTS);
|
||||
|
||||
Ok(notebook_results)
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for NotebookDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use settings::manager::SettingsManager;
|
||||
use warpui::{App, SingletonEntity};
|
||||
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::model::view::CloudViewModel;
|
||||
use crate::cloud_object::{Owner, Revision, ServerMetadata, ServerNotebook, ServerPermissions};
|
||||
use crate::notebooks::manager::NotebookManager;
|
||||
use crate::notebooks::CloudNotebookModel;
|
||||
use crate::search::ai_context_menu::notebooks::data_source::NotebookDataSource;
|
||||
use crate::search::data_source::Query;
|
||||
use crate::search::mixer::SyncDataSource;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::ids::{ServerId, SyncId};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::settings::AISettings;
|
||||
use crate::system::SystemStats;
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_profiles::UserProfiles;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::NetworkStatus;
|
||||
|
||||
use crate::server::server_api::object::MockObjectClient;
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
|
||||
fn mock_server_notebook_with_revision(
|
||||
id: i64,
|
||||
title: &str,
|
||||
revision: Revision,
|
||||
) -> ServerNotebook {
|
||||
ServerNotebook {
|
||||
id: SyncId::ServerId(id.into()),
|
||||
metadata: ServerMetadata {
|
||||
uid: ServerId::default(),
|
||||
revision,
|
||||
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,
|
||||
},
|
||||
permissions: ServerPermissions {
|
||||
space: Owner::mock_current_user(),
|
||||
guests: Vec::new(),
|
||||
anyone_link_sharing: None,
|
||||
permissions_last_updated_ts: Utc::now().into(),
|
||||
},
|
||||
model: CloudNotebookModel {
|
||||
title: title.to_string(),
|
||||
data: format!("{title} content"),
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_app(app: &mut 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 zero_state_scores_reflect_recency() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let now = Utc::now();
|
||||
CloudModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.upsert_from_server_notebook(
|
||||
mock_server_notebook_with_revision(
|
||||
1,
|
||||
"oldest",
|
||||
(now - Duration::minutes(3)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_notebook(
|
||||
mock_server_notebook_with_revision(
|
||||
2,
|
||||
"middle",
|
||||
(now - Duration::minutes(2)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_notebook(
|
||||
mock_server_notebook_with_revision(
|
||||
3,
|
||||
"newest",
|
||||
(now - Duration::minutes(1)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let data_source = NotebookDataSource::new(false);
|
||||
let results = app.read(|app| data_source.run_query(&Query::from(""), app).unwrap());
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
// run_query sorts descending by score, so first result should be newest
|
||||
let scores: Vec<_> = results.iter().map(|r| r.score()).collect();
|
||||
assert!(
|
||||
scores[0] > scores[1] && scores[1] > scores[2],
|
||||
"Expected scores in strictly descending order (newest first), got {scores:?}"
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_state_adds_recency_bonus_to_equal_matches() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let now = Utc::now();
|
||||
// All titles contain "plan" so fuzzy scores should be similar
|
||||
CloudModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.upsert_from_server_notebook(
|
||||
mock_server_notebook_with_revision(
|
||||
1,
|
||||
"my first plan",
|
||||
(now - Duration::minutes(3)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_notebook(
|
||||
mock_server_notebook_with_revision(
|
||||
2,
|
||||
"my second plan",
|
||||
(now - Duration::minutes(2)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_notebook(
|
||||
mock_server_notebook_with_revision(
|
||||
3,
|
||||
"my third plan",
|
||||
(now - Duration::minutes(1)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let data_source = NotebookDataSource::new(false);
|
||||
let results = app.read(|app| data_source.run_query(&Query::from("plan"), app).unwrap());
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
// All match "plan" similarly; recency bonus should make newer items score higher
|
||||
let scores: Vec<_> = results.iter().map(|r| r.score()).collect();
|
||||
assert!(
|
||||
scores[0] > scores[1] && scores[1] > scores[2],
|
||||
"Expected scores in strictly descending order (newest first), got {scores:?}"
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multibyte_character_truncation() {
|
||||
// Test string with multibyte characters (emojis, accented chars)
|
||||
let test_content = "This is a test with emojis 🚀 and accented chars like café and naïve that should be truncated properly without panicking. This string is intentionally long to test the 200 character limit and ensure we don't slice in the middle of multibyte characters like 你好世界";
|
||||
|
||||
let truncated = if test_content.len() > 200 {
|
||||
let result = test_content
|
||||
.char_indices()
|
||||
.take_while(|(i, _)| *i <= 197)
|
||||
.last()
|
||||
.map(|(i, c)| &test_content[..i + c.len_utf8()])
|
||||
.unwrap_or("");
|
||||
format!("{result}...")
|
||||
} else {
|
||||
test_content.to_string()
|
||||
};
|
||||
|
||||
// Should not panic and should produce a valid string
|
||||
assert!(!truncated.is_empty());
|
||||
assert!(truncated.ends_with("..."));
|
||||
// The truncated string should be valid UTF-8
|
||||
assert!(std::str::from_utf8(truncated.as_bytes()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncation_with_boundary_at_multibyte_char() {
|
||||
// Create a string where byte 197 falls exactly in the middle of a multibyte character
|
||||
let mut test_content = "a".repeat(195); // 195 single-byte chars
|
||||
test_content.push('🚀'); // 4-byte emoji at positions 195-198
|
||||
test_content.push_str("more text after emoji");
|
||||
|
||||
// This should not panic even though byte 197 is in the middle of the emoji
|
||||
let truncated = if test_content.len() > 200 {
|
||||
let result = test_content
|
||||
.char_indices()
|
||||
.take_while(|(i, _)| *i <= 197)
|
||||
.last()
|
||||
.map(|(i, c)| &test_content[..i + c.len_utf8()])
|
||||
.unwrap_or("");
|
||||
format!("{result}...")
|
||||
} else {
|
||||
test_content.to_string()
|
||||
};
|
||||
|
||||
// Should not panic and should produce a valid string
|
||||
assert!(!truncated.is_empty());
|
||||
// The truncated string should be valid UTF-8
|
||||
assert!(std::str::from_utf8(truncated.as_bytes()).is_ok());
|
||||
// Should either include the full emoji or stop before it
|
||||
assert!(!truncated.contains("🚀") || truncated.contains("🚀..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_short_content_not_truncated() {
|
||||
let short_content = "This is a short string with emoji 🚀";
|
||||
|
||||
let result = if short_content.len() > 200 {
|
||||
let truncated = short_content
|
||||
.char_indices()
|
||||
.take_while(|(i, _)| *i <= 197)
|
||||
.last()
|
||||
.map(|(i, c)| &short_content[..i + c.len_utf8()])
|
||||
.unwrap_or("");
|
||||
format!("{truncated}...")
|
||||
} else {
|
||||
short_content.to_string()
|
||||
};
|
||||
|
||||
// Short content should not be truncated
|
||||
assert_eq!(result, short_content);
|
||||
assert!(!result.ends_with("..."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod data_source;
|
||||
pub mod search_item;
|
||||
|
||||
#[cfg(test)]
|
||||
mod data_source_test;
|
||||
@@ -0,0 +1,236 @@
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::ObjectType;
|
||||
use crate::search::ai_context_menu::styles;
|
||||
use crate::search::ai_context_menu::{mixer::AIContextMenuSearchableAction, safe_truncate};
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, Icon, ParentElement, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
const MAX_COMBINED_LENGTH: usize = 55;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NotebookSearchItem {
|
||||
pub notebook_name: String,
|
||||
pub notebook_description: Option<String>,
|
||||
pub notebook_uid: String,
|
||||
pub match_result: FuzzyMatchResult,
|
||||
pub ai_document_uid: Option<String>,
|
||||
/// True if match_result was computed against the notebook name (vs description)
|
||||
pub is_match_on_name: bool,
|
||||
}
|
||||
|
||||
impl SearchItem for NotebookSearchItem {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
if self.ai_document_uid.is_some() {
|
||||
"bundled/svg/compass-3.svg"
|
||||
} else {
|
||||
"bundled/svg/notebook.svg"
|
||||
},
|
||||
highlight_state.icon_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(styles::ICON_SIZE)
|
||||
.with_height(styles::ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(styles::MARGIN_RIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let mut notebook_name = self.notebook_name.clone();
|
||||
let mut notebook_description = self
|
||||
.notebook_description
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Track if we truncated anything for highlight adjustment
|
||||
let mut name_truncated = false;
|
||||
|
||||
// Ensure combined length is reasonable
|
||||
let combined_length = notebook_name.len() + notebook_description.len();
|
||||
|
||||
if combined_length > MAX_COMBINED_LENGTH {
|
||||
// Prioritize showing the notebook name
|
||||
if notebook_name.len() >= MAX_COMBINED_LENGTH {
|
||||
safe_truncate(&mut notebook_name, MAX_COMBINED_LENGTH - 3);
|
||||
notebook_name.push_str("...");
|
||||
name_truncated = true;
|
||||
notebook_description.clear();
|
||||
} else {
|
||||
// Notebook name fits, truncate description
|
||||
let available_for_description = MAX_COMBINED_LENGTH - notebook_name.len();
|
||||
if notebook_description.len() > available_for_description {
|
||||
safe_truncate(
|
||||
&mut notebook_description,
|
||||
available_for_description.saturating_sub(3),
|
||||
);
|
||||
notebook_description.push_str("...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate highlight indices based on where match occurred
|
||||
let name_highlights = if !self.match_result.matched_indices.is_empty()
|
||||
&& !name_truncated
|
||||
&& self.is_match_on_name
|
||||
{
|
||||
self.match_result.matched_indices.clone()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let description_highlights = if !self.match_result.matched_indices.is_empty()
|
||||
&& !self.is_match_on_name
|
||||
&& !notebook_description.is_empty()
|
||||
{
|
||||
self.match_result.matched_indices.clone()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
// Create notebook name with match highlighting
|
||||
let mut name_text = Text::new(
|
||||
notebook_name,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
)
|
||||
.with_color(highlight_state.main_text_fill(appearance).into_solid());
|
||||
|
||||
if !name_highlights.is_empty() {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
name_highlights,
|
||||
);
|
||||
}
|
||||
|
||||
// Create description text with lighter color
|
||||
let description_text = if !notebook_description.is_empty() {
|
||||
let mut desc_text = Text::new(
|
||||
notebook_description,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.0,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
if !description_highlights.is_empty() {
|
||||
desc_text = desc_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
description_highlights,
|
||||
);
|
||||
}
|
||||
|
||||
Some(desc_text)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Create row with notebook name and description
|
||||
let mut row = Flex::row()
|
||||
.with_child(name_text.finish())
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
if let Some(description) = description_text {
|
||||
row.add_child(
|
||||
Container::new(description.finish())
|
||||
.with_padding_left(6.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
if let Some(ai_document_uid) = &self.ai_document_uid {
|
||||
return AIContextMenuSearchableAction::InsertPlan {
|
||||
ai_document_uid: ai_document_uid.clone(),
|
||||
};
|
||||
}
|
||||
AIContextMenuSearchableAction::InsertDriveObject {
|
||||
object_type: ObjectType::Notebook,
|
||||
object_uid: self.notebook_uid.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
if let Some(description) = &self.notebook_description {
|
||||
format!("Notebook: {} - {}", self.notebook_name, description)
|
||||
} else {
|
||||
format!("Notebook: {}", self.notebook_name)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_details(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
|
||||
// Use notebook name, or "Untitled" if empty
|
||||
let display_name = if self.notebook_name.is_empty() {
|
||||
"Untitled".to_string()
|
||||
} else {
|
||||
self.notebook_name.clone()
|
||||
};
|
||||
|
||||
let name_element = Text::new(
|
||||
display_name,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
)
|
||||
.with_color(appearance.theme().active_ui_text_color().into());
|
||||
|
||||
let details = if let Some(content) = &self.notebook_description {
|
||||
let content_element = Text::new(
|
||||
content.clone(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size() - 2.0,
|
||||
)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into());
|
||||
|
||||
Flex::column()
|
||||
.with_child(name_element.finish())
|
||||
.with_child(
|
||||
Container::new(content_element.finish())
|
||||
.with_padding_top(4.0)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
} else {
|
||||
Flex::column().with_child(name_element.finish()).finish()
|
||||
};
|
||||
|
||||
Some(details)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use super::search_item::RuleSearchItem;
|
||||
use crate::ai::facts::{AIFact, CloudAIFactModel};
|
||||
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::CloudObject;
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use warpui::{AppContext, Entity, SingletonEntity};
|
||||
|
||||
const MAX_RESULTS: usize = 50;
|
||||
const ZERO_STATE_BASE_SCORE: i64 = 1000;
|
||||
|
||||
pub struct RulesDataSource;
|
||||
|
||||
impl RulesDataSource {
|
||||
#[allow(dead_code)]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for RulesDataSource {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let query_text = &query.text;
|
||||
|
||||
let cloud_model = CloudModel::as_ref(app);
|
||||
let mut rule_results = Vec::new();
|
||||
|
||||
let mut rules: Vec<_> = cloud_model
|
||||
.get_all_objects_of_type::<GenericStringObjectId, CloudAIFactModel>()
|
||||
.filter(|ai_fact| !ai_fact.is_trashed(cloud_model))
|
||||
.collect();
|
||||
|
||||
// Sort by revision timestamp ascending so that position-based scores
|
||||
// assign higher values to more recently updated rules.
|
||||
rules.sort_by(|a, b| {
|
||||
let a_ts = a.metadata.revision.as_ref().map(|r| r.timestamp());
|
||||
let b_ts = b.metadata.revision.as_ref().map(|r| r.timestamp());
|
||||
a_ts.cmp(&b_ts)
|
||||
});
|
||||
|
||||
let total_rules = rules.len();
|
||||
for (index, ai_fact) in rules.into_iter().enumerate() {
|
||||
let rule_uid = ai_fact.id.uid();
|
||||
let (rule_name, rule_content) = match &ai_fact.model().string_model {
|
||||
AIFact::Memory(memory) => (memory.name.clone(), memory.content.clone()),
|
||||
};
|
||||
let (match_result, is_match_on_rule_name) = if query_text.is_empty() {
|
||||
(
|
||||
FuzzyMatchResult {
|
||||
score: ZERO_STATE_BASE_SCORE + index as i64,
|
||||
matched_indices: vec![],
|
||||
},
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
let name_match = rule_name
|
||||
.as_ref()
|
||||
.and_then(|n| fuzzy_match::match_indices_case_insensitive(n, query_text));
|
||||
let content_match =
|
||||
fuzzy_match::match_indices_case_insensitive(&rule_content, query_text);
|
||||
|
||||
let (mut result, on_name) = match (name_match, content_match) {
|
||||
(Some(name), Some(content)) if content.score > name.score => (content, false),
|
||||
(Some(name), _) => (name, true),
|
||||
(None, Some(content)) => (content, false),
|
||||
(None, None) => continue,
|
||||
};
|
||||
// Add a recency bonus (capped at 30) so more recently updated
|
||||
// rules rank higher among results with similar fuzzy scores,
|
||||
// regardless of the total size of the rules collection.
|
||||
result.score += (30 * (index + 1) / total_rules) as i64;
|
||||
(result, on_name)
|
||||
};
|
||||
|
||||
let search_item = RuleSearchItem {
|
||||
rule_uid,
|
||||
rule_name,
|
||||
rule_content,
|
||||
match_result,
|
||||
is_match_on_rule_name,
|
||||
};
|
||||
|
||||
rule_results.push(QueryResult::from(search_item));
|
||||
}
|
||||
|
||||
// Sort by score and take the top results
|
||||
rule_results.sort_by_key(|b| std::cmp::Reverse(b.score()));
|
||||
rule_results.truncate(MAX_RESULTS);
|
||||
|
||||
Ok(rule_results)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for RulesDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "data_source_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,192 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use settings::manager::SettingsManager;
|
||||
use warpui::{App, SingletonEntity};
|
||||
|
||||
use crate::ai::facts::{AIFact, AIMemory, CloudAIFactModel};
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::generic_string_model::GenericStringModel;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::model::view::CloudViewModel;
|
||||
use crate::cloud_object::{
|
||||
GenericServerObject, Owner, Revision, ServerMetadata, ServerPermissions,
|
||||
};
|
||||
use crate::notebooks::manager::NotebookManager;
|
||||
use crate::search::ai_context_menu::rules::data_source::RulesDataSource;
|
||||
use crate::search::data_source::Query;
|
||||
use crate::search::mixer::SyncDataSource;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::ids::{ServerId, SyncId};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::settings::AISettings;
|
||||
use crate::system::SystemStats;
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_profiles::UserProfiles;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::NetworkStatus;
|
||||
|
||||
use crate::server::server_api::object::MockObjectClient;
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
|
||||
type ServerAIFact = GenericServerObject<
|
||||
crate::cloud_object::model::generic_string_model::GenericStringObjectId,
|
||||
CloudAIFactModel,
|
||||
>;
|
||||
|
||||
fn mock_server_ai_fact(id: i64, name: &str, content: &str, revision: Revision) -> ServerAIFact {
|
||||
GenericServerObject {
|
||||
id: SyncId::ServerId(id.into()),
|
||||
metadata: ServerMetadata {
|
||||
uid: ServerId::default(),
|
||||
revision,
|
||||
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,
|
||||
},
|
||||
permissions: ServerPermissions {
|
||||
space: Owner::mock_current_user(),
|
||||
guests: Vec::new(),
|
||||
anyone_link_sharing: None,
|
||||
permissions_last_updated_ts: Utc::now().into(),
|
||||
},
|
||||
model: GenericStringModel {
|
||||
string_model: AIFact::Memory(AIMemory {
|
||||
name: Some(name.to_string()),
|
||||
content: content.to_string(),
|
||||
is_autogenerated: false,
|
||||
suggested_logging_id: None,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_app(app: &mut 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 zero_state_scores_reflect_recency() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let now = Utc::now();
|
||||
CloudModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.upsert_from_server_object(
|
||||
mock_server_ai_fact(
|
||||
1,
|
||||
"oldest rule",
|
||||
"oldest content",
|
||||
(now - Duration::minutes(3)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_object(
|
||||
mock_server_ai_fact(
|
||||
2,
|
||||
"middle rule",
|
||||
"middle content",
|
||||
(now - Duration::minutes(2)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_object(
|
||||
mock_server_ai_fact(
|
||||
3,
|
||||
"newest rule",
|
||||
"newest content",
|
||||
(now - Duration::minutes(1)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let data_source = RulesDataSource::new();
|
||||
let results = app.read(|app| data_source.run_query(&Query::from(""), app).unwrap());
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
let scores: Vec<_> = results.iter().map(|r| r.score()).collect();
|
||||
assert!(
|
||||
scores[0] > scores[1] && scores[1] > scores[2],
|
||||
"Expected scores in strictly descending order (newest first), got {scores:?}"
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_state_adds_recency_bonus() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let now = Utc::now();
|
||||
// All rules contain "rule" so fuzzy scores should be similar
|
||||
CloudModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.upsert_from_server_object(
|
||||
mock_server_ai_fact(
|
||||
1,
|
||||
"my first rule",
|
||||
"first rule content",
|
||||
(now - Duration::minutes(3)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_object(
|
||||
mock_server_ai_fact(
|
||||
2,
|
||||
"my second rule",
|
||||
"second rule content",
|
||||
(now - Duration::minutes(2)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_object(
|
||||
mock_server_ai_fact(
|
||||
3,
|
||||
"my third rule",
|
||||
"third rule content",
|
||||
(now - Duration::minutes(1)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let data_source = RulesDataSource::new();
|
||||
let results = app.read(|app| data_source.run_query(&Query::from("rule"), app).unwrap());
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
let scores: Vec<_> = results.iter().map(|r| r.score()).collect();
|
||||
assert!(
|
||||
scores[0] > scores[1] && scores[1] > scores[2],
|
||||
"Expected scores in strictly descending order (newest first), got {scores:?}"
|
||||
);
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod data_source;
|
||||
pub mod search_item;
|
||||
@@ -0,0 +1,230 @@
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::{GenericStringObjectFormat, JsonObjectType, ObjectType};
|
||||
use crate::search::ai_context_menu::styles;
|
||||
use crate::search::ai_context_menu::{mixer::AIContextMenuSearchableAction, safe_truncate};
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, Icon, ParentElement, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
const MAX_COMBINED_LENGTH: usize = 55;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RuleSearchItem {
|
||||
pub rule_uid: String,
|
||||
pub rule_name: Option<String>,
|
||||
pub rule_content: String,
|
||||
pub match_result: FuzzyMatchResult,
|
||||
/// True if match_result was computed against the rule name (vs content)
|
||||
pub is_match_on_rule_name: bool,
|
||||
}
|
||||
|
||||
impl SearchItem for RuleSearchItem {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
"bundled/svg/book-open.svg",
|
||||
highlight_state.icon_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(styles::ICON_SIZE)
|
||||
.with_height(styles::ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(styles::MARGIN_RIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
// Use rule_name if available, otherwise fall back to rule_content
|
||||
let (primary_text, secondary_text, is_match_on_primary) = match &self.rule_name {
|
||||
Some(name) if !name.is_empty() => (
|
||||
name.clone(),
|
||||
Some(self.rule_content.clone()),
|
||||
self.is_match_on_rule_name,
|
||||
),
|
||||
_ => (self.rule_content.clone(), None, true),
|
||||
};
|
||||
|
||||
let mut display_primary = primary_text;
|
||||
let mut display_secondary = secondary_text.unwrap_or_default();
|
||||
let mut primary_truncated = false;
|
||||
|
||||
// Ensure combined length is reasonable
|
||||
let combined_length = display_primary.len() + display_secondary.len();
|
||||
|
||||
if combined_length > MAX_COMBINED_LENGTH {
|
||||
if display_primary.len() >= MAX_COMBINED_LENGTH {
|
||||
safe_truncate(&mut display_primary, MAX_COMBINED_LENGTH - 3);
|
||||
display_primary.push_str("...");
|
||||
primary_truncated = true;
|
||||
display_secondary.clear();
|
||||
} else {
|
||||
let available_for_secondary = MAX_COMBINED_LENGTH - display_primary.len();
|
||||
if display_secondary.len() > available_for_secondary {
|
||||
safe_truncate(
|
||||
&mut display_secondary,
|
||||
available_for_secondary.saturating_sub(3),
|
||||
);
|
||||
display_secondary.push_str("...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate highlight indices for primary or secondary text based on where match occurred
|
||||
let primary_highlights = if !self.match_result.matched_indices.is_empty()
|
||||
&& !primary_truncated
|
||||
&& is_match_on_primary
|
||||
{
|
||||
self.match_result.matched_indices.clone()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let secondary_highlights = if !self.match_result.matched_indices.is_empty()
|
||||
&& !is_match_on_primary
|
||||
&& !display_secondary.is_empty()
|
||||
{
|
||||
self.match_result.matched_indices.clone()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let mut primary_text_element = Text::new(
|
||||
display_primary,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
)
|
||||
.with_color(highlight_state.main_text_fill(appearance).into_solid());
|
||||
|
||||
if !primary_highlights.is_empty() {
|
||||
primary_text_element = primary_text_element.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
primary_highlights,
|
||||
);
|
||||
}
|
||||
|
||||
let secondary_text_element = if !display_secondary.is_empty() {
|
||||
let mut secondary_text = Text::new(
|
||||
display_secondary,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.0,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
if !secondary_highlights.is_empty() {
|
||||
secondary_text = secondary_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
secondary_highlights,
|
||||
);
|
||||
}
|
||||
|
||||
Some(secondary_text)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut row = Flex::row()
|
||||
.with_child(primary_text_element.finish())
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
if let Some(secondary) = secondary_text_element {
|
||||
row.add_child(
|
||||
Container::new(secondary.finish())
|
||||
.with_padding_left(6.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn render_details(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let theme = appearance.theme();
|
||||
|
||||
// Determine what to show as the main title
|
||||
let title = if let Some(name) = &self.rule_name {
|
||||
if !name.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
"Rule".to_string()
|
||||
}
|
||||
} else {
|
||||
"Rule".to_string()
|
||||
};
|
||||
|
||||
// Create title element
|
||||
let title_element = Text::new(
|
||||
title,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish();
|
||||
|
||||
// Create content element - show the full rule content
|
||||
let content_element = Text::new(
|
||||
self.rule_content.clone(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
)
|
||||
.with_color(theme.nonactive_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
// Create the details content
|
||||
let content = Flex::column()
|
||||
.with_child(title_element)
|
||||
.with_child(
|
||||
Container::new(content_element)
|
||||
.with_padding_top(8.0)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Some(content)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
AIContextMenuSearchableAction::InsertDriveObject {
|
||||
object_type: ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
|
||||
JsonObjectType::AIFact,
|
||||
)),
|
||||
object_uid: self.rule_uid.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Rule: {}", self.rule_content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
const MAX_NEW_SPACES: usize = 2;
|
||||
|
||||
/// If this is ever false, we close the AI context menu.
|
||||
pub fn is_valid_search_query(is_navigation: bool, prev_query: &str, query: &str) -> bool {
|
||||
if query.contains('\n') || query.contains(" ") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if is_navigation {
|
||||
// We need a simple heuristic to handle when somebody jumps to the end
|
||||
// of the line. Since spaces are valid characters, we only count
|
||||
// how many spaces the users likely jumped over between queries
|
||||
let new_chars = query.chars().skip(prev_query.len());
|
||||
return new_chars.filter(|c| *c == ' ').count() < MAX_NEW_SPACES;
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use super::search_item::SkillSearchItem;
|
||||
use crate::ai::skills::SkillManager;
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use std::path::PathBuf;
|
||||
use warpui::{AppContext, Entity, SingletonEntity};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::workspace::ActiveSession;
|
||||
|
||||
const MAX_RESULTS: usize = 50;
|
||||
|
||||
pub struct SkillsDataSource;
|
||||
|
||||
impl SkillsDataSource {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for SkillsDataSource {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let query_text = &query.text;
|
||||
|
||||
// Resolve the current working directory from the active window's session.
|
||||
let cwd: Option<PathBuf> = {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
app.windows()
|
||||
.state()
|
||||
.active_window
|
||||
.and_then(|window_id| ActiveSession::as_ref(app).path_if_local(window_id))
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
#[cfg(target_family = "wasm")]
|
||||
{
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let skills =
|
||||
SkillManager::as_ref(app).get_skills_for_working_directory(cwd.as_deref(), app);
|
||||
|
||||
let mut results: Vec<QueryResult<Self::Action>> = if query_text.is_empty() {
|
||||
// Zero state: show all skills with a uniform high score.
|
||||
skills
|
||||
.into_iter()
|
||||
.map(|skill| {
|
||||
QueryResult::from(SkillSearchItem {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
provider: skill.provider,
|
||||
icon_override: skill.icon_override,
|
||||
match_result: FuzzyMatchResult {
|
||||
score: 1000,
|
||||
matched_indices: vec![],
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
// Fuzzy match against skill name.
|
||||
skills
|
||||
.into_iter()
|
||||
.filter_map(|skill| {
|
||||
let match_result =
|
||||
fuzzy_match::match_indices_case_insensitive(&skill.name, query_text)?;
|
||||
// Skip very weak matches once the user has typed more than one character.
|
||||
if query_text.len() > 1 && match_result.score < 10 {
|
||||
return None;
|
||||
}
|
||||
Some(QueryResult::from(SkillSearchItem {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
provider: skill.provider,
|
||||
icon_override: skill.icon_override,
|
||||
match_result,
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
results.sort_by_key(|r| std::cmp::Reverse(r.score()));
|
||||
results.truncate(MAX_RESULTS);
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SkillsDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod data_source;
|
||||
pub mod search_item;
|
||||
@@ -0,0 +1,134 @@
|
||||
use ai::skills::SkillProvider;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::ai_context_menu::styles;
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use warp_core::ui::icons::Icon;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, ParentElement, Shrinkable, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
const MAX_DESCRIPTION_LEN: usize = 60;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SkillSearchItem {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub provider: SkillProvider,
|
||||
pub icon_override: Option<Icon>,
|
||||
pub match_result: FuzzyMatchResult,
|
||||
}
|
||||
|
||||
impl SearchItem for SkillSearchItem {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_color = highlight_state.icon_fill(appearance).into_solid();
|
||||
|
||||
let icon_element = if let Some(override_icon) = self.icon_override {
|
||||
override_icon.to_warpui_icon(icon_color.into()).finish()
|
||||
} else {
|
||||
self.provider
|
||||
.icon()
|
||||
.to_warpui_icon(self.provider.icon_fill(icon_color.into()))
|
||||
.finish()
|
||||
};
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(icon_element)
|
||||
.with_width(styles::ICON_SIZE)
|
||||
.with_height(styles::ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(styles::MARGIN_RIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let font_size = appearance.monospace_font_size() - 1.0;
|
||||
|
||||
let mut name_text = Text::new(self.name.clone(), appearance.ui_font_family(), font_size)
|
||||
.with_color(highlight_state.main_text_fill(appearance).into_solid());
|
||||
|
||||
if !self.match_result.matched_indices.is_empty() {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
self.match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(name_text.finish());
|
||||
|
||||
if !self.description.is_empty() {
|
||||
let mut display_description = self.description.clone();
|
||||
if display_description.len() > MAX_DESCRIPTION_LEN {
|
||||
let truncate_at = display_description
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.take_while(|&i| i <= MAX_DESCRIPTION_LEN - 3)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
display_description.truncate(truncate_at);
|
||||
display_description.push_str("...");
|
||||
}
|
||||
|
||||
let description_text = Text::new(
|
||||
display_description,
|
||||
appearance.ui_font_family(),
|
||||
font_size - 1.0,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
row.add_child(
|
||||
Shrinkable::new(
|
||||
1.0,
|
||||
Container::new(description_text.finish())
|
||||
.with_padding_left(6.0)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn render_details(&self, _app: &AppContext) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
AIContextMenuSearchableAction::InsertSkill {
|
||||
name: self.name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Skill: {}", self.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub const ICON_SIZE: f32 = 16.0;
|
||||
pub const MARGIN_RIGHT: f32 = 8.0;
|
||||
pub const ESTIMATED_RESULT_HEIGHT: f32 = 24.0;
|
||||
pub const MENU_ITEM_HORIZONTAL_PADDING: f32 = 16.0;
|
||||
pub const MENU_ITEM_VERTICAL_PADDING: f32 = 4.0;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
use super::search_item::WorkflowSearchItem;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::CloudModelType;
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
const MAX_RESULTS: usize = 50;
|
||||
/// Base score for zero-state results. Each item gets an additional bonus based on
|
||||
/// recency so the mixer's score-based ordering places more recent items higher.
|
||||
const ZERO_STATE_BASE_SCORE: i64 = 1000;
|
||||
|
||||
pub struct WorkflowDataSource;
|
||||
|
||||
impl WorkflowDataSource {
|
||||
#[allow(dead_code)]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for WorkflowDataSource {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let query_text = &query.text;
|
||||
|
||||
// Get all workflows from CloudModel
|
||||
let cloud_model = CloudModel::as_ref(app);
|
||||
let _user_workspaces = UserWorkspaces::as_ref(app);
|
||||
|
||||
// Get workflows from all spaces the user has access to
|
||||
let mut workflow_results = Vec::new();
|
||||
|
||||
// Collect non-welcome workflows, sorted by revision timestamp in zero state
|
||||
let mut workflows: Vec<_> = cloud_model
|
||||
.get_all_active_workflows()
|
||||
.filter(|w| !w.metadata.is_welcome_object)
|
||||
.collect();
|
||||
|
||||
// Always sort by revision timestamp ascending so that position-based
|
||||
// scores assign higher values to more recently updated items. This ensures
|
||||
// recency acts as a tiebreaker when fuzzy scores are similar.
|
||||
workflows.sort_by(|a, b| {
|
||||
let a_ts = a.metadata.revision.as_ref().map(|r| r.timestamp());
|
||||
let b_ts = b.metadata.revision.as_ref().map(|r| r.timestamp());
|
||||
a_ts.cmp(&b_ts)
|
||||
});
|
||||
|
||||
let total_workflows = workflows.len();
|
||||
for (index, workflow) in workflows.into_iter().enumerate() {
|
||||
let workflow_name = workflow.model().display_name();
|
||||
// Use workflow content for hover details, with first few lines as preview
|
||||
let workflow_content = workflow.model().data.content();
|
||||
let content_lines: Vec<&str> = workflow_content.lines().take(3).collect();
|
||||
let content_preview = content_lines.join("\n");
|
||||
let workflow_description = if content_preview.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(if content_preview.len() > 200 {
|
||||
format!("{}...", &content_preview[..197])
|
||||
} else {
|
||||
content_preview
|
||||
})
|
||||
};
|
||||
let workflow_uid = workflow.id.uid();
|
||||
let recency_bonus = (30 * (index + 1) / total_workflows) as i64;
|
||||
|
||||
let (match_result, is_match_on_name) = if query_text.is_empty() {
|
||||
// Zero state: score encodes recency so the mixer orders newest items highest.
|
||||
(
|
||||
FuzzyMatchResult {
|
||||
score: ZERO_STATE_BASE_SCORE + recency_bonus,
|
||||
matched_indices: vec![],
|
||||
},
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
// Fuzzy match against workflow name
|
||||
let name_match =
|
||||
fuzzy_match::match_indices_case_insensitive(&workflow_name, query_text);
|
||||
|
||||
// Also try matching against description if available
|
||||
let description_match = workflow_description
|
||||
.as_deref()
|
||||
.and_then(|desc| fuzzy_match::match_indices_case_insensitive(desc, query_text));
|
||||
|
||||
// Use the best match, tracking whether it was on the name
|
||||
let (mut result, on_name) = match (name_match, description_match) {
|
||||
(Some(name), Some(desc)) if desc.score > name.score => (desc, false),
|
||||
(Some(name), _) => (name, true),
|
||||
(None, Some(desc)) => (desc, false),
|
||||
(None, None) => continue, // No match, skip this workflow
|
||||
};
|
||||
// Add a recency bonus (capped at 30) so more recently updated
|
||||
// items rank higher among results with similar fuzzy scores,
|
||||
// regardless of the total size of the workflows collection.
|
||||
result.score += recency_bonus;
|
||||
(result, on_name)
|
||||
};
|
||||
|
||||
let search_item = WorkflowSearchItem {
|
||||
workflow_name,
|
||||
workflow_description,
|
||||
workflow_uid,
|
||||
match_result,
|
||||
is_match_on_name,
|
||||
};
|
||||
|
||||
workflow_results.push(QueryResult::from(search_item));
|
||||
}
|
||||
|
||||
// Sort by score and take the top results
|
||||
workflow_results.sort_by_key(|b| std::cmp::Reverse(b.score()));
|
||||
workflow_results.truncate(MAX_RESULTS);
|
||||
|
||||
Ok(workflow_results)
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for WorkflowDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod data_source;
|
||||
pub mod search_item;
|
||||
@@ -0,0 +1,219 @@
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::ObjectType;
|
||||
use crate::search::ai_context_menu::styles;
|
||||
use crate::search::ai_context_menu::{mixer::AIContextMenuSearchableAction, safe_truncate};
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, Icon, ParentElement, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
const MAX_COMBINED_LENGTH: usize = 55;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WorkflowSearchItem {
|
||||
pub workflow_name: String,
|
||||
pub workflow_description: Option<String>,
|
||||
pub workflow_uid: String,
|
||||
pub match_result: FuzzyMatchResult,
|
||||
/// True if match_result was computed against the workflow name (vs description)
|
||||
pub is_match_on_name: bool,
|
||||
}
|
||||
|
||||
impl SearchItem for WorkflowSearchItem {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
"bundled/svg/workflow.svg",
|
||||
highlight_state.icon_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(styles::ICON_SIZE)
|
||||
.with_height(styles::ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(styles::MARGIN_RIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let mut workflow_name = self.workflow_name.clone();
|
||||
let mut workflow_description = self
|
||||
.workflow_description
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Track if we truncated anything for highlight adjustment
|
||||
let mut name_truncated = false;
|
||||
|
||||
// Ensure combined length is reasonable
|
||||
let combined_length = workflow_name.len() + workflow_description.len();
|
||||
|
||||
if combined_length > MAX_COMBINED_LENGTH {
|
||||
// Prioritize showing the workflow name
|
||||
if workflow_name.len() >= MAX_COMBINED_LENGTH {
|
||||
safe_truncate(&mut workflow_name, MAX_COMBINED_LENGTH - 3);
|
||||
workflow_name.push_str("...");
|
||||
name_truncated = true;
|
||||
workflow_description.clear();
|
||||
} else {
|
||||
// Workflow name fits, truncate description
|
||||
let available_for_description = MAX_COMBINED_LENGTH - workflow_name.len();
|
||||
if workflow_description.len() > available_for_description {
|
||||
safe_truncate(
|
||||
&mut workflow_description,
|
||||
available_for_description.saturating_sub(3),
|
||||
);
|
||||
workflow_description.push_str("...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate highlight indices based on where match occurred
|
||||
let name_highlights = if !self.match_result.matched_indices.is_empty()
|
||||
&& !name_truncated
|
||||
&& self.is_match_on_name
|
||||
{
|
||||
self.match_result.matched_indices.clone()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let description_highlights = if !self.match_result.matched_indices.is_empty()
|
||||
&& !self.is_match_on_name
|
||||
&& !workflow_description.is_empty()
|
||||
{
|
||||
self.match_result.matched_indices.clone()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
// Create workflow name with match highlighting
|
||||
let mut name_text = Text::new(
|
||||
workflow_name,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
)
|
||||
.with_color(highlight_state.main_text_fill(appearance).into_solid());
|
||||
|
||||
if !name_highlights.is_empty() {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
name_highlights,
|
||||
);
|
||||
}
|
||||
|
||||
// Create description text with lighter color
|
||||
let description_text = if !workflow_description.is_empty() {
|
||||
let mut desc_text = Text::new(
|
||||
workflow_description,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.0,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
if !description_highlights.is_empty() {
|
||||
desc_text = desc_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
description_highlights,
|
||||
);
|
||||
}
|
||||
|
||||
Some(desc_text)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Create row with workflow name and description
|
||||
let mut row = Flex::row()
|
||||
.with_child(name_text.finish())
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
if let Some(description) = description_text {
|
||||
row.add_child(
|
||||
Container::new(description.finish())
|
||||
.with_padding_left(6.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
AIContextMenuSearchableAction::InsertDriveObject {
|
||||
object_type: ObjectType::Workflow,
|
||||
object_uid: self.workflow_uid.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
if let Some(description) = &self.workflow_description {
|
||||
format!("Workflow: {} - {}", self.workflow_name, description)
|
||||
} else {
|
||||
format!("Workflow: {}", self.workflow_name)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_details(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
|
||||
let name_element = Text::new(
|
||||
self.workflow_name.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
)
|
||||
.with_color(appearance.theme().active_ui_text_color().into());
|
||||
|
||||
let details = if let Some(description) = &self.workflow_description {
|
||||
let content_element = Text::new(
|
||||
description.clone(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size() - 2.0,
|
||||
)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into());
|
||||
|
||||
Flex::column()
|
||||
.with_child(name_element.finish())
|
||||
.with_child(
|
||||
Container::new(content_element.finish())
|
||||
.with_padding_top(4.0)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
} else {
|
||||
Flex::column().with_child(name_element.finish()).finish()
|
||||
};
|
||||
|
||||
Some(details)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
|
||||
/// Result of fuzzy matching the user AI queries in history.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FuzzyMatchAIQueryResults {
|
||||
/// Result of the attempted fuzzy match on the query text including matched string indices and
|
||||
/// score.
|
||||
pub query_text_match_result: FuzzyMatchResult,
|
||||
}
|
||||
|
||||
impl FuzzyMatchAIQueryResults {
|
||||
/// Attempt to fuzzy match the user's search text with the AI query in history.
|
||||
pub fn try_match(query: &str, ai_query: &str) -> Option<Self> {
|
||||
fuzzy_match::match_indices_case_insensitive(ai_query, query).map(|result| Self {
|
||||
query_text_match_result: result,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the score of the match if any, and the lowest number possible if there was no match.
|
||||
pub fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.query_text_match_result.score as f64)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod fuzzy_match;
|
||||
@@ -0,0 +1,67 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use warpui::{Action, AppContext};
|
||||
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::{AsyncDataSource, BoxFuture, DataSourceRunErrorWrapper};
|
||||
|
||||
type SnapshotFn<S> = dyn Fn(&Query, &AppContext) -> S + Send + Sync;
|
||||
|
||||
type MatchFn<S, A> = dyn Fn(S) -> BoxFuture<'static, Result<Vec<QueryResult<A>>, DataSourceRunErrorWrapper>>
|
||||
+ Send
|
||||
+ Sync;
|
||||
|
||||
/// This is a basic wrapper on top of the AsyncDataSource that separates sourcing into two steps:
|
||||
/// 1. `snapshot_fn` — this is called on the main thread to capture an owned snapshot of the data
|
||||
/// needed for matching (we need app context to do this, so it has to be synchronous).
|
||||
/// Avoid deep-cloning large datasets here and prefer cloning shared handles (e.g. `Arc` collections)
|
||||
/// captured by the data source.
|
||||
/// 2. `match_fn` — called async with the snapshot to perform the expensive fuzzy matching and produce query results.
|
||||
///
|
||||
/// This split lets data sources that depend on `AppContext` (e.g. reading model state) run
|
||||
/// their expensive work without blocking the UI.
|
||||
pub struct AsyncSnapshotDataSource<S, A>
|
||||
where
|
||||
S: Send + 'static,
|
||||
A: Action + Clone,
|
||||
{
|
||||
snapshot_fn: Arc<SnapshotFn<S>>,
|
||||
match_fn: Arc<MatchFn<S, A>>,
|
||||
}
|
||||
|
||||
impl<S, A> AsyncSnapshotDataSource<S, A>
|
||||
where
|
||||
S: Send + 'static,
|
||||
A: Action + Clone,
|
||||
{
|
||||
pub fn new(
|
||||
snapshot_fn: impl Fn(&Query, &AppContext) -> S + Send + Sync + 'static,
|
||||
match_fn: impl Fn(S) -> BoxFuture<'static, Result<Vec<QueryResult<A>>, DataSourceRunErrorWrapper>>
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
snapshot_fn: Arc::new(snapshot_fn),
|
||||
match_fn: Arc::new(match_fn),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, A> AsyncDataSource for AsyncSnapshotDataSource<S, A>
|
||||
where
|
||||
S: Send + 'static,
|
||||
A: Action + Clone,
|
||||
{
|
||||
type Action = A;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> BoxFuture<'static, Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper>> {
|
||||
let snapshot = (self.snapshot_fn)(query, app);
|
||||
let match_fn = self.match_fn.clone();
|
||||
(match_fn)(snapshot)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use warpui::{Entity, EntityId, WindowId};
|
||||
|
||||
use crate::util::bindings::CommandBinding;
|
||||
|
||||
/// Type alias for the filter function that determines which command bindings to show
|
||||
pub type BindingFilterFn = Option<Arc<dyn Fn(&CommandBinding) -> bool>>;
|
||||
|
||||
/// A model for tracking the current source of bindings for the command palette
|
||||
///
|
||||
/// This is necessary due to a quirk in how the UI Framework handles event handlers / callbacks:
|
||||
///
|
||||
/// In order to work around Rusts restriction on having two mutable references to the same data,
|
||||
/// the framework _removes_ a view from the map of all views before calling a handler (it then
|
||||
/// immediately re-inserts it into the map afterwards). This means then when a handler is being
|
||||
/// executed in a given View, that View is _not_ in the global map. Since the Command Palette is
|
||||
/// launched from the Workspace, which is the root of all terminal views, if we attempt to load the
|
||||
/// key bindings from somewhere within that view (even by calling `command_palette.update()`), it
|
||||
/// will fail with the Workspace missing from the map.
|
||||
///
|
||||
/// Instead, we create a small Model to cache the binding source information (window and view id)
|
||||
/// and subscribe to any changes to that model from here. Then the model update handler is
|
||||
/// scheduled after the event handler callback completes. This means that the update handler is
|
||||
/// called on the CommandPalette directly, rather than the Workspace. This is safe because the
|
||||
/// CommandPalette won't ever be the parent of any View that launches itself, so the fact that it
|
||||
/// won't be in the view map won't affect our ability to load the key bindings for other views.
|
||||
pub enum BindingSource {
|
||||
None,
|
||||
View {
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
binding_filter_fn: BindingFilterFn,
|
||||
},
|
||||
}
|
||||
|
||||
impl Entity for BindingSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
use crate::ai::agent::conversation::{AIConversation, AIConversationId};
|
||||
use crate::ai::conversation_navigation::ConversationNavigationData;
|
||||
use crate::search::command_palette::conversations::search::{
|
||||
ConversationMatchResult, ConversationSearcher, FuzzyConversationSearcher, MatchedConversation,
|
||||
};
|
||||
use crate::search::command_palette::conversations::search_item::{
|
||||
ConversationAction, ConversationSearchItem,
|
||||
};
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::separator_search_item::SeparatorSearchItem;
|
||||
use crate::search::data_source::{DataSourceSearchError, Query, QueryResult};
|
||||
use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
use crate::search::SyncDataSource;
|
||||
use crate::workspace::Workspace;
|
||||
use itertools::Itertools;
|
||||
use std::collections::HashMap;
|
||||
use warpui::{AppContext, Entity};
|
||||
|
||||
/// Sections for grouping conversations in the command palette.
|
||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||
enum ConversationSection {
|
||||
ActivePane,
|
||||
OtherActive,
|
||||
Past,
|
||||
}
|
||||
|
||||
impl ConversationSection {
|
||||
fn title(&self) -> &'static str {
|
||||
match self {
|
||||
ConversationSection::ActivePane => "Active pane conversations",
|
||||
ConversationSection::OtherActive => "Other active conversations",
|
||||
ConversationSection::Past => "Past conversations",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the ordering of the sections for display in the command palette
|
||||
/// (the command palette renders items in reverse order).
|
||||
fn reverse_order() -> [ConversationSection; 3] {
|
||||
[
|
||||
ConversationSection::Past,
|
||||
ConversationSection::OtherActive,
|
||||
ConversationSection::ActivePane,
|
||||
]
|
||||
}
|
||||
|
||||
fn for_conversation(conversation: &ConversationNavigationData) -> Self {
|
||||
if conversation.is_historical() {
|
||||
ConversationSection::Past
|
||||
} else if conversation.is_in_active_pane {
|
||||
ConversationSection::ActivePane
|
||||
} else {
|
||||
ConversationSection::OtherActive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data source that produces conversations for a user to navigate to.
|
||||
pub struct DataSource {
|
||||
searcher: FuzzyConversationSearcher,
|
||||
/// Whether to include extra conversation actions (i.e. new conversation & fork conversation)
|
||||
add_conversation_actions: bool,
|
||||
}
|
||||
|
||||
impl Default for DataSource {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DataSource {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
searcher: FuzzyConversationSearcher::new(),
|
||||
add_conversation_actions: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn historical() -> Self {
|
||||
Self {
|
||||
searcher: FuzzyConversationSearcher::historical(),
|
||||
add_conversation_actions: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a [`QueryResult`] for a conversation identified by `conversation_id`. `None` if no result was
|
||||
/// found with the given ID.
|
||||
pub fn query_result(
|
||||
conversation_id: &AIConversationId,
|
||||
app: &AppContext,
|
||||
) -> Option<QueryResult<CommandPaletteItemAction>> {
|
||||
let all_conversations = ConversationNavigationData::all_conversations(app);
|
||||
|
||||
all_conversations
|
||||
.into_iter()
|
||||
.find(|conversation| &conversation.id == conversation_id)
|
||||
.map(|conversation| {
|
||||
let search_item = ConversationSearchItem::new(ConversationAction::Resume(
|
||||
Box::new(MatchedConversation {
|
||||
conversation,
|
||||
match_result: ConversationMatchResult::no_match(),
|
||||
}),
|
||||
));
|
||||
QueryResult::from(search_item)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn top_n(
|
||||
&self,
|
||||
limit: usize,
|
||||
app: &AppContext,
|
||||
) -> impl Iterator<Item = QueryResult<<Self as SyncDataSource>::Action>> {
|
||||
self.searcher
|
||||
.searchable_conversations(app)
|
||||
.into_iter()
|
||||
.k_largest_by_key(limit, |conversation| conversation.last_updated)
|
||||
.map(|conversation| {
|
||||
QueryResult::from(ConversationSearchItem::new(ConversationAction::Resume(
|
||||
Box::new(MatchedConversation {
|
||||
conversation,
|
||||
match_result: ConversationMatchResult::no_match(),
|
||||
}),
|
||||
)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the selected conversation in the focused pane.
|
||||
fn selected_conversation_in_focused_pane(app: &AppContext) -> Option<&AIConversation> {
|
||||
app.windows().active_window().and_then(|window_id| {
|
||||
app.views_of_type::<Workspace>(window_id)
|
||||
.and_then(|views| views.first().cloned())
|
||||
.and_then(|workspace| {
|
||||
workspace.read(app, |workspace, workspace_ctx| {
|
||||
workspace.active_tab_pane_group().read(
|
||||
workspace_ctx,
|
||||
|pane_group, pane_group_ctx| {
|
||||
pane_group.focused_session_view(pane_group_ctx).and_then(
|
||||
|terminal_view| {
|
||||
terminal_view
|
||||
.as_ref(pane_group_ctx)
|
||||
.ai_context_model()
|
||||
.as_ref(pane_group_ctx)
|
||||
.selected_conversation(app)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
impl SyncDataSource for DataSource {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
// When the query is empty, we want to insert special separator items between historical conversations,
|
||||
// open conversations, conversations in the active pane, and the conversation action items (i.e. new conversation & fork conversation).
|
||||
let result = if query.text.trim().is_empty() {
|
||||
let conversations = self.searcher.searchable_conversations(app);
|
||||
let mut results = Vec::new();
|
||||
|
||||
// Group conversations by section.
|
||||
let mut grouped: HashMap<ConversationSection, Vec<ConversationNavigationData>> =
|
||||
HashMap::new();
|
||||
for conversation in conversations {
|
||||
let section = ConversationSection::for_conversation(&conversation);
|
||||
grouped.entry(section).or_default().push(conversation);
|
||||
}
|
||||
grouped.values_mut().for_each(|group| group.sort());
|
||||
|
||||
// The command palette renders items in reverse order, so we need to add the sections in reverse order
|
||||
// and add each separator item after all of the items in the section.
|
||||
for section in ConversationSection::reverse_order() {
|
||||
if let Some(conversations) = grouped.get(§ion) {
|
||||
if !conversations.is_empty() {
|
||||
for conversation in conversations {
|
||||
let matched_conversation = MatchedConversation {
|
||||
conversation: conversation.clone(),
|
||||
match_result: ConversationMatchResult::no_match(),
|
||||
};
|
||||
results.push(
|
||||
ConversationSearchItem::new(ConversationAction::Resume(Box::new(
|
||||
matched_conversation,
|
||||
)))
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
results.push(SeparatorSearchItem::new(section.title().to_string()).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
} else {
|
||||
self.searcher
|
||||
.search(&query.text.trim().to_lowercase(), app)
|
||||
.map_err(|err| {
|
||||
let search_error = DataSourceSearchError {
|
||||
message: err.to_string(),
|
||||
};
|
||||
Box::new(search_error) as DataSourceRunErrorWrapper
|
||||
})
|
||||
};
|
||||
|
||||
// When the query is empty, we want to add the "new conversation" and "fork conversation" items.
|
||||
if self.add_conversation_actions && query.text.trim().is_empty() {
|
||||
result.map(|mut results| {
|
||||
if !cfg!(target_family = "wasm") {
|
||||
if let Some(conversation) = selected_conversation_in_focused_pane(app) {
|
||||
// Only surface the fork option if the selected conversation is done.
|
||||
if conversation.status().is_done() {
|
||||
results.push(
|
||||
ConversationSearchItem::new(ConversationAction::Fork {
|
||||
conversation_id: conversation.id(),
|
||||
title: conversation.title().unwrap_or_default().to_string(),
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
results.push(ConversationSearchItem::new(ConversationAction::New).into());
|
||||
results
|
||||
})
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod data_source;
|
||||
mod search;
|
||||
mod search_item;
|
||||
|
||||
#[cfg(test)]
|
||||
mod search_test;
|
||||
|
||||
pub use crate::ai::conversation_navigation::ConversationNavigationData;
|
||||
pub use data_source::DataSource;
|
||||
@@ -0,0 +1,230 @@
|
||||
use crate::ai::conversation_navigation::ConversationNavigationData;
|
||||
use crate::search::command_palette::conversations::search_item::ConversationAction;
|
||||
use crate::search::command_palette::conversations::search_item::ConversationSearchItem;
|
||||
use crate::search::command_palette::conversations::DataSource;
|
||||
use crate::search::data_source::QueryResult;
|
||||
use crate::search::SyncDataSource;
|
||||
use fuzzy_match::match_indices_case_insensitive;
|
||||
use warpui::AppContext;
|
||||
|
||||
/// A conversation that was fuzzy matched against a search term.
|
||||
#[derive(Debug)]
|
||||
pub struct MatchedConversation {
|
||||
pub conversation: ConversationNavigationData,
|
||||
pub match_result: ConversationMatchResult,
|
||||
}
|
||||
|
||||
impl MatchedConversation {
|
||||
/// Returns the score for the [`MatchedConversation`]. If there was no match result, a score of `0`
|
||||
/// is returned.
|
||||
pub fn score(&self) -> i64 {
|
||||
self.match_result.score
|
||||
}
|
||||
|
||||
/// Returns the [`ConversationHighlightIndices`] belonging to the matched conversation.
|
||||
pub fn highlight_indices(&self) -> &ConversationHighlightIndices {
|
||||
&self.match_result.highlight_indices
|
||||
}
|
||||
}
|
||||
|
||||
/// Result from matching a conversation.
|
||||
#[derive(Debug)]
|
||||
pub struct ConversationMatchResult {
|
||||
score: i64,
|
||||
highlight_indices: ConversationHighlightIndices,
|
||||
}
|
||||
|
||||
impl ConversationMatchResult {
|
||||
/// Returns a dummy match result when there is no match.
|
||||
pub fn no_match() -> Self {
|
||||
ConversationMatchResult {
|
||||
score: 0,
|
||||
highlight_indices: ConversationHighlightIndices {
|
||||
title_indices: vec![],
|
||||
initial_query_indices: vec![],
|
||||
working_directory_indices: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn score(&self) -> i64 {
|
||||
self.score
|
||||
}
|
||||
}
|
||||
|
||||
/// Matching indices for a matched conversation.
|
||||
#[derive(Debug)]
|
||||
pub struct ConversationHighlightIndices {
|
||||
pub(super) title_indices: Vec<usize>,
|
||||
pub(super) initial_query_indices: Vec<usize>,
|
||||
pub(super) working_directory_indices: Vec<usize>,
|
||||
}
|
||||
|
||||
impl ConversationHighlightIndices {
|
||||
fn new(
|
||||
title_indices: Vec<usize>,
|
||||
initial_query_indices: Vec<usize>,
|
||||
working_directory_indices: Vec<usize>,
|
||||
) -> ConversationHighlightIndices {
|
||||
ConversationHighlightIndices {
|
||||
title_indices,
|
||||
initial_query_indices,
|
||||
working_directory_indices,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the highlight indices for the conversation title.
|
||||
pub fn title_indices(&self) -> &Vec<usize> {
|
||||
&self.title_indices
|
||||
}
|
||||
|
||||
/// Returns the highlight indices for the initial query.
|
||||
pub fn initial_query_indices(&self) -> &Vec<usize> {
|
||||
&self.initial_query_indices
|
||||
}
|
||||
|
||||
/// Returns the highlight indices for the working directory.
|
||||
pub fn working_directory_indices(&self) -> &Vec<usize> {
|
||||
&self.working_directory_indices
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator of conversations that match `search_term`.
|
||||
pub fn filter_conversations<'a, 'b, I>(
|
||||
conversations_iter: I,
|
||||
search_term: &'b str,
|
||||
) -> impl Iterator<Item = MatchedConversation> + use<'a, 'b, I>
|
||||
where
|
||||
I: IntoIterator<Item = &'a ConversationNavigationData>,
|
||||
{
|
||||
conversations_iter
|
||||
.into_iter()
|
||||
.filter_map(move |conversation| {
|
||||
if search_term.is_empty() {
|
||||
Some((ConversationMatchResult::no_match(), conversation.clone()))
|
||||
} else {
|
||||
// Match against title, initial_query, and initial_working_directory
|
||||
let title_match = match_indices_case_insensitive(&conversation.title, search_term);
|
||||
let initial_query_match =
|
||||
conversation
|
||||
.initial_query
|
||||
.as_deref()
|
||||
.and_then(|initial_query| {
|
||||
match_indices_case_insensitive(initial_query, search_term)
|
||||
});
|
||||
let working_directory_match = conversation
|
||||
.initial_working_directory
|
||||
.as_deref()
|
||||
.and_then(|initial_working_directory| {
|
||||
match_indices_case_insensitive(initial_working_directory, search_term)
|
||||
});
|
||||
|
||||
// If none of the fields match, filter this conversation out
|
||||
if title_match.is_none()
|
||||
&& initial_query_match.is_none()
|
||||
&& working_directory_match.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Determine the best score among all matches
|
||||
let best_score = [
|
||||
title_match.as_ref(),
|
||||
initial_query_match.as_ref(),
|
||||
working_directory_match.as_ref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|r| r.score)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
let title_indices = title_match.map(|r| r.matched_indices).unwrap_or_default();
|
||||
let initial_query_indices = initial_query_match
|
||||
.map(|r| r.matched_indices)
|
||||
.unwrap_or_default();
|
||||
let working_directory_indices = working_directory_match
|
||||
.map(|r| r.matched_indices)
|
||||
.unwrap_or_default();
|
||||
|
||||
let highlight_indices = ConversationHighlightIndices::new(
|
||||
title_indices,
|
||||
initial_query_indices,
|
||||
working_directory_indices,
|
||||
);
|
||||
|
||||
Some((
|
||||
ConversationMatchResult {
|
||||
score: best_score,
|
||||
highlight_indices,
|
||||
},
|
||||
conversation.clone(),
|
||||
))
|
||||
}
|
||||
})
|
||||
.map(|(match_result, conversation)| MatchedConversation {
|
||||
conversation,
|
||||
match_result,
|
||||
})
|
||||
}
|
||||
|
||||
type SearcherAction = <DataSource as SyncDataSource>::Action;
|
||||
|
||||
pub trait ConversationSearcher {
|
||||
fn search(
|
||||
&self,
|
||||
_search_term: &str,
|
||||
_app: &AppContext,
|
||||
) -> anyhow::Result<Vec<QueryResult<SearcherAction>>>;
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum ConversationType {
|
||||
All,
|
||||
Historical,
|
||||
}
|
||||
|
||||
pub struct FuzzyConversationSearcher {
|
||||
filter: ConversationType,
|
||||
}
|
||||
|
||||
impl FuzzyConversationSearcher {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
filter: ConversationType::All,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn historical() -> Self {
|
||||
Self {
|
||||
filter: ConversationType::Historical,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn searchable_conversations(&self, app: &AppContext) -> Vec<ConversationNavigationData> {
|
||||
match self.filter {
|
||||
ConversationType::Historical => {
|
||||
ConversationNavigationData::historical_conversations(app)
|
||||
}
|
||||
ConversationType::All => ConversationNavigationData::all_conversations(app),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConversationSearcher for FuzzyConversationSearcher {
|
||||
fn search(
|
||||
&self,
|
||||
search_term: &str,
|
||||
app: &AppContext,
|
||||
) -> anyhow::Result<Vec<QueryResult<SearcherAction>>> {
|
||||
let conversations = self.searchable_conversations(app);
|
||||
Ok(filter_conversations(conversations.as_slice(), search_term)
|
||||
.map(|matched_conversation| {
|
||||
ConversationSearchItem::new(ConversationAction::Resume(Box::new(
|
||||
matched_conversation,
|
||||
)))
|
||||
.into()
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::command_palette::conversations::search::MatchedConversation;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::render_util::render_search_item_icon;
|
||||
use crate::search::command_palette::view::Action;
|
||||
use crate::search::item::IconLocation;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::search::SearchItem;
|
||||
use crate::ui_components::buttons::icon_button;
|
||||
use crate::util::time_format::format_approx_duration_from_now;
|
||||
use ordered_float::OrderedFloat;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::color::{blend::Blend, coloru_with_opacity};
|
||||
use warp_core::ui::icons::Icon;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warpui::elements::{
|
||||
AnchorPair, Container, CrossAxisAlignment, Expanded, Fill, Flex, Highlight, MainAxisSize,
|
||||
MouseStateHandle, OffsetPositioning, OffsetType, ParentElement, ParentOffsetBounds,
|
||||
PositioningAxis, Stack, Text, XAxisAnchor, YAxisAnchor,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::ui_components::button::ButtonTooltipPosition;
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, Gradient, SingletonEntity};
|
||||
|
||||
/// Information about which action to take once the conversation item is accepted.
|
||||
#[derive(Debug)]
|
||||
pub enum ConversationAction {
|
||||
/// Start a new conversation in the current view.
|
||||
New,
|
||||
/// Fork the current active conversation into a new view.
|
||||
Fork {
|
||||
conversation_id: AIConversationId,
|
||||
title: String,
|
||||
},
|
||||
/// Resume the matched conversation in its associated view.
|
||||
Resume(Box<MatchedConversation>),
|
||||
}
|
||||
|
||||
/// Search item to render a conversation within the command palette.
|
||||
/// When matched_conversation is None, we render this as a new conversation item.
|
||||
#[derive(Debug)]
|
||||
pub struct ConversationSearchItem {
|
||||
action_info: ConversationAction,
|
||||
action_button_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl ConversationSearchItem {
|
||||
pub fn new(action_info: ConversationAction) -> Self {
|
||||
Self {
|
||||
action_info,
|
||||
action_button_mouse_state: MouseStateHandle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the new conversation item for the command palette.
|
||||
pub fn render_new_conversation_action_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Text::new_inline(
|
||||
"New conversation",
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_fork_conversation_action_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
conversation_title: &str,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let action_title = Text::new_inline(
|
||||
"Fork current conversation",
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.with_style(Properties::default().weight(Weight::Bold));
|
||||
|
||||
let conversation_title = Text::new_inline(
|
||||
conversation_title.to_owned(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
Flex::column()
|
||||
.with_child(action_title.finish())
|
||||
.with_child(conversation_title.finish())
|
||||
.with_spacing(4.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_matched_conversation_item(
|
||||
&self,
|
||||
matched_conversation: &MatchedConversation,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let conversation = matched_conversation.conversation.clone();
|
||||
let sub_text_font_size = appearance.monospace_font_size() - 2.;
|
||||
|
||||
let mut conversation_title_element = Text::new_inline(
|
||||
conversation.title().to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.with_style(Properties::default().weight(Weight::Bold));
|
||||
|
||||
let mut working_directory_element = Text::new_inline(
|
||||
conversation
|
||||
.initial_working_directory
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
appearance.ui_font_family(),
|
||||
sub_text_font_size,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
// When the search query is empty, we only show the conversation's title and working directory.
|
||||
// Otherwise, we show the conversation's title, initial user query, and working directory.
|
||||
// We also highlight the indices in those elements that match the search query.
|
||||
let mut left_container = Flex::column().with_spacing(4.);
|
||||
if !self.query_is_empty() {
|
||||
// The first user query that was submitted for this conversation.
|
||||
let mut initial_query_element = Text::new_inline(
|
||||
conversation.initial_query.clone().unwrap_or_default(),
|
||||
appearance.ui_font_family(),
|
||||
sub_text_font_size,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
// Apply highlights for the search query's matching indices.
|
||||
let highlight = Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid());
|
||||
let highlight_indices = matched_conversation.highlight_indices();
|
||||
if !highlight_indices.title_indices().is_empty() {
|
||||
conversation_title_element = conversation_title_element
|
||||
.with_single_highlight(highlight, highlight_indices.title_indices().clone());
|
||||
}
|
||||
if !highlight_indices.initial_query_indices().is_empty() {
|
||||
initial_query_element = initial_query_element.with_single_highlight(
|
||||
highlight,
|
||||
highlight_indices.initial_query_indices().clone(),
|
||||
);
|
||||
}
|
||||
if !highlight_indices.working_directory_indices().is_empty() {
|
||||
working_directory_element = working_directory_element.with_single_highlight(
|
||||
highlight,
|
||||
highlight_indices.working_directory_indices().clone(),
|
||||
);
|
||||
}
|
||||
|
||||
// Add the conversation title and initial user query to the left container.
|
||||
left_container = left_container
|
||||
.with_child(conversation_title_element.finish())
|
||||
.with_child(initial_query_element.finish());
|
||||
} else {
|
||||
// When the search query is empty, we only show the conversation's title and working directory.
|
||||
left_container = left_container.with_child(conversation_title_element.finish());
|
||||
}
|
||||
// In all cases, we show the conversation's working directory last.
|
||||
left_container = left_container.with_child(working_directory_element.finish());
|
||||
|
||||
let last_updated = format_approx_duration_from_now(conversation.last_updated());
|
||||
let last_updated_element = Container::new(
|
||||
Text::new_inline(
|
||||
last_updated,
|
||||
appearance.ui_font_family(),
|
||||
sub_text_font_size,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(8.)
|
||||
.finish();
|
||||
|
||||
let search_item_content = Flex::row()
|
||||
.with_child(Expanded::new(1.0, left_container.finish()).finish())
|
||||
.with_child(last_updated_element)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.finish();
|
||||
|
||||
// We only want to show the fork button if the conversation is completed
|
||||
// (i.e. the agent has finished responding and there are no blocked commands).
|
||||
let conversation_is_done = BlocklistAIHistoryModel::as_ref(app)
|
||||
.conversation(&conversation.id())
|
||||
.map(|c| c.status().is_done())
|
||||
.unwrap_or(true);
|
||||
|
||||
if highlight_state.is_hovered() && conversation_is_done && !cfg!(target_family = "wasm") {
|
||||
// Base row content (unchanged layout for existing children)
|
||||
let base_row = Flex::row()
|
||||
.with_child(Expanded::new(1.0, search_item_content).finish())
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.finish();
|
||||
|
||||
// Overlay fork button on the right, positioned absolutely so it doesn't affect the layout.
|
||||
let fork_button_positioning = OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_parent(
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Right, XAxisAnchor::Right),
|
||||
),
|
||||
PositioningAxis::relative_to_parent(
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Top),
|
||||
),
|
||||
);
|
||||
|
||||
// We create a gradient background that is semi-transparent on the left and the item background color on the right.
|
||||
// The end color is the highlight_bg_color over surface_2 at the given highlight state's opacity.
|
||||
// The start color is fully transparent.
|
||||
let base_bg = appearance.theme().surface_2().into_solid();
|
||||
let end_color = base_bg.blend(&coloru_with_opacity(
|
||||
Fill::from(appearance.theme().accent()).start_color(),
|
||||
highlight_state.container_background_opacity(),
|
||||
));
|
||||
let start_color = ColorU::new(end_color.r, end_color.g, end_color.b, 0);
|
||||
|
||||
let fork_button_tool_tip = appearance
|
||||
.ui_builder()
|
||||
.tool_tip("Fork conversation".to_string())
|
||||
.build();
|
||||
|
||||
let fork_button_inner = icon_button(
|
||||
appearance,
|
||||
Icon::ArrowSplit,
|
||||
false,
|
||||
self.action_button_mouse_state.clone(),
|
||||
)
|
||||
.with_hovered_styles(
|
||||
UiComponentStyles::default()
|
||||
.set_background(internal_colors::fg_overlay_3(appearance.theme()).into()),
|
||||
)
|
||||
.with_clicked_styles(
|
||||
UiComponentStyles::default()
|
||||
.set_background(internal_colors::fg_overlay_5(appearance.theme()).into()),
|
||||
)
|
||||
.with_tooltip(|| fork_button_tool_tip.finish())
|
||||
.with_tooltip_position(ButtonTooltipPosition::AboveRight)
|
||||
.build()
|
||||
.on_click(move |ctx, _app, _pos| {
|
||||
ctx.dispatch_typed_action(Action::ResultClicked {
|
||||
action: CommandPaletteItemAction::ForkConversation {
|
||||
conversation_id: conversation.id(),
|
||||
},
|
||||
});
|
||||
})
|
||||
.finish();
|
||||
|
||||
// When the fork button itself is hovered, we use a solid background equal to the
|
||||
// gradient's end color. Otherwise, we use the original gradient.
|
||||
let is_hovered = self
|
||||
.action_button_mouse_state
|
||||
.lock()
|
||||
.map(|s| s.is_hovered())
|
||||
.unwrap_or(false);
|
||||
let fork_button = if is_hovered {
|
||||
Container::new(fork_button_inner)
|
||||
.with_background_color(end_color)
|
||||
.finish()
|
||||
} else {
|
||||
Container::new(fork_button_inner)
|
||||
.with_background_gradient(
|
||||
vec2f(0.0, 0.0),
|
||||
vec2f(0.2, 0.0),
|
||||
Gradient {
|
||||
start: start_color,
|
||||
end: end_color,
|
||||
},
|
||||
)
|
||||
.finish()
|
||||
};
|
||||
|
||||
let mut stack = Stack::new().with_child(base_row);
|
||||
stack.add_positioned_child(fork_button, fork_button_positioning);
|
||||
stack.finish()
|
||||
} else {
|
||||
search_item_content
|
||||
}
|
||||
}
|
||||
|
||||
fn query_is_empty(&self) -> bool {
|
||||
match &self.action_info {
|
||||
ConversationAction::Resume(matched_conversation) => {
|
||||
// If the score is empty, the query must be empty (otherwise, we would not be showing this item)
|
||||
matched_conversation.as_ref().match_result.score() == 0
|
||||
}
|
||||
ConversationAction::Fork { .. } | ConversationAction::New => {
|
||||
// We only show these items when the search query is empty.
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for ConversationSearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn is_multiline(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let (color, icon) = match &self.action_info {
|
||||
ConversationAction::Resume(..) => (
|
||||
appearance.theme().foreground().into_solid(),
|
||||
Icon::Conversation,
|
||||
),
|
||||
ConversationAction::New => (appearance.theme().foreground().into_solid(), Icon::Plus),
|
||||
ConversationAction::Fork { .. } => (
|
||||
appearance.theme().foreground().into_solid(),
|
||||
Icon::ArrowSplit,
|
||||
),
|
||||
};
|
||||
|
||||
render_search_item_icon(appearance, icon, color, highlight_state)
|
||||
}
|
||||
|
||||
fn icon_location(&self, appearance: &Appearance) -> IconLocation {
|
||||
if matches!(self.action_info, ConversationAction::New) {
|
||||
IconLocation::Centered
|
||||
} else {
|
||||
// The icon has the size of the monospace font, whereas the text has a height of
|
||||
// `line_height_ratio * font_size`. Offset the icon by this difference so it is rendered
|
||||
// centered with the text.
|
||||
let margin_top = (appearance.line_height_ratio() * appearance.monospace_font_size())
|
||||
- appearance.monospace_font_size();
|
||||
IconLocation::Top { margin_top }
|
||||
}
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
match &self.action_info {
|
||||
ConversationAction::Resume(matched_conversation) => self
|
||||
.render_matched_conversation_item(
|
||||
matched_conversation.as_ref(),
|
||||
highlight_state,
|
||||
app,
|
||||
),
|
||||
ConversationAction::New => {
|
||||
self.render_new_conversation_action_item(highlight_state, app)
|
||||
}
|
||||
ConversationAction::Fork { title, .. } => {
|
||||
self.render_fork_conversation_action_item(highlight_state, title, app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
let score = match &self.action_info {
|
||||
ConversationAction::Resume(matched_conversation) => matched_conversation.score() as f64,
|
||||
ConversationAction::Fork { .. } => f64::NAN,
|
||||
ConversationAction::New => f64::NAN,
|
||||
};
|
||||
OrderedFloat::from(score)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
match &self.action_info {
|
||||
ConversationAction::Resume(matched_conversation) => {
|
||||
let conversation = &matched_conversation.as_ref().conversation;
|
||||
CommandPaletteItemAction::NavigateToConversation {
|
||||
pane_view_locator: conversation.pane_view_locator(),
|
||||
window_id: conversation.window_id(),
|
||||
conversation_id: conversation.id(),
|
||||
terminal_view_id: conversation.terminal_view_id,
|
||||
}
|
||||
}
|
||||
ConversationAction::Fork {
|
||||
conversation_id, ..
|
||||
} => CommandPaletteItemAction::ForkConversation {
|
||||
conversation_id: *conversation_id,
|
||||
},
|
||||
ConversationAction::New => CommandPaletteItemAction::NewConversation,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
match &self.action_info {
|
||||
ConversationAction::Resume(matched_conversation) => {
|
||||
format!(
|
||||
"Conversation: {}",
|
||||
matched_conversation.as_ref().conversation.title()
|
||||
)
|
||||
}
|
||||
ConversationAction::Fork { title, .. } => {
|
||||
format!("Fork current conversation ({title})")
|
||||
}
|
||||
ConversationAction::New => "New conversation".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn accessibility_help_message(&self) -> Option<String> {
|
||||
match &self.action_info {
|
||||
ConversationAction::Resume(matched_conversation) => Some(format!(
|
||||
"Press enter to navigate to conversation \"{}\".",
|
||||
matched_conversation.as_ref().conversation.title()
|
||||
)),
|
||||
ConversationAction::Fork { .. } => {
|
||||
Some("Press enter to fork the current conversation into a new conversation.".into())
|
||||
}
|
||||
ConversationAction::New => Some("Press enter to create a new conversation.".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use crate::ai::{
|
||||
agent::conversation::AIConversationId, conversation_navigation::ConversationNavigationData,
|
||||
};
|
||||
use warpui::{EntityId, WindowId};
|
||||
|
||||
#[test]
|
||||
fn test_conversation_navigation_data_ordering() {
|
||||
// Create test data with different active states and timestamps
|
||||
let now = chrono::Local::now();
|
||||
let one_hour_ago = now - chrono::Duration::hours(1);
|
||||
let two_hours_ago = now - chrono::Duration::hours(2);
|
||||
|
||||
let active_recent = ConversationNavigationData {
|
||||
id: AIConversationId::new(),
|
||||
title: "Active Recent".to_string(),
|
||||
initial_query: None,
|
||||
last_updated: now,
|
||||
terminal_view_id: Some(EntityId::new()),
|
||||
window_id: Some(WindowId::new()),
|
||||
pane_view_locator: None,
|
||||
initial_working_directory: None,
|
||||
latest_working_directory: None,
|
||||
is_selected: true,
|
||||
is_closed: false,
|
||||
server_conversation_token: None,
|
||||
is_in_active_pane: true,
|
||||
};
|
||||
|
||||
let active_old = ConversationNavigationData {
|
||||
id: AIConversationId::new(),
|
||||
title: "Active Old".to_string(),
|
||||
initial_query: None,
|
||||
last_updated: two_hours_ago,
|
||||
terminal_view_id: Some(EntityId::new()),
|
||||
window_id: Some(WindowId::new()),
|
||||
pane_view_locator: None,
|
||||
initial_working_directory: None,
|
||||
latest_working_directory: None,
|
||||
is_selected: true,
|
||||
is_closed: false,
|
||||
server_conversation_token: None,
|
||||
is_in_active_pane: true,
|
||||
};
|
||||
|
||||
let inactive_recent = ConversationNavigationData {
|
||||
id: AIConversationId::new(),
|
||||
title: "Inactive Recent".to_string(),
|
||||
initial_query: None,
|
||||
last_updated: now,
|
||||
terminal_view_id: Some(EntityId::new()),
|
||||
window_id: Some(WindowId::new()),
|
||||
pane_view_locator: None,
|
||||
initial_working_directory: None,
|
||||
latest_working_directory: None,
|
||||
is_selected: false,
|
||||
is_closed: false,
|
||||
server_conversation_token: None,
|
||||
is_in_active_pane: false,
|
||||
};
|
||||
|
||||
let inactive_old = ConversationNavigationData {
|
||||
id: AIConversationId::new(),
|
||||
title: "Inactive Old".to_string(),
|
||||
initial_query: None,
|
||||
last_updated: one_hour_ago,
|
||||
terminal_view_id: Some(EntityId::new()),
|
||||
window_id: Some(WindowId::new()),
|
||||
pane_view_locator: None,
|
||||
initial_working_directory: None,
|
||||
latest_working_directory: None,
|
||||
is_selected: false,
|
||||
is_closed: false,
|
||||
server_conversation_token: None,
|
||||
is_in_active_pane: false,
|
||||
};
|
||||
|
||||
let historical_recent = ConversationNavigationData {
|
||||
id: AIConversationId::new(),
|
||||
title: "Historical Recent".to_string(),
|
||||
initial_query: None,
|
||||
last_updated: now,
|
||||
terminal_view_id: None,
|
||||
window_id: None,
|
||||
pane_view_locator: None,
|
||||
initial_working_directory: None,
|
||||
latest_working_directory: None,
|
||||
is_selected: false,
|
||||
is_closed: false,
|
||||
server_conversation_token: None,
|
||||
is_in_active_pane: false,
|
||||
};
|
||||
|
||||
let historical_old = ConversationNavigationData {
|
||||
id: AIConversationId::new(),
|
||||
title: "Historical Old".to_string(),
|
||||
initial_query: None,
|
||||
last_updated: one_hour_ago,
|
||||
terminal_view_id: None,
|
||||
window_id: None,
|
||||
pane_view_locator: None,
|
||||
initial_working_directory: None,
|
||||
latest_working_directory: None,
|
||||
is_selected: false,
|
||||
is_closed: false,
|
||||
server_conversation_token: None,
|
||||
is_in_active_pane: false,
|
||||
};
|
||||
|
||||
// Test sorting a vector
|
||||
let mut conversations = [
|
||||
inactive_old.clone(),
|
||||
active_old.clone(),
|
||||
inactive_recent.clone(),
|
||||
active_recent.clone(),
|
||||
historical_old.clone(),
|
||||
historical_recent.clone(),
|
||||
];
|
||||
|
||||
conversations.sort();
|
||||
|
||||
assert_eq!(conversations[0].title, "Historical Old");
|
||||
assert_eq!(conversations[1].title, "Historical Recent");
|
||||
assert_eq!(conversations[2].title, "Inactive Old");
|
||||
assert_eq!(conversations[3].title, "Inactive Recent");
|
||||
assert_eq!(conversations[4].title, "Active Old");
|
||||
assert_eq!(conversations[5].title, "Active Recent");
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::drive::settings::WarpDriveSettings;
|
||||
use crate::search::action::CommandBindingDataSource;
|
||||
use crate::search::binding_source::BindingSource;
|
||||
use crate::search::command_palette::files;
|
||||
use crate::search::command_palette::launch_config;
|
||||
use crate::search::command_palette::mixer::{CommandPaletteItemAction, ItemSummary};
|
||||
use crate::search::command_palette::new_session::NewSessionDataSource;
|
||||
use crate::search::command_palette::repos::RepoDataSource;
|
||||
use crate::search::command_palette::{navigation, CommandPaletteMixer};
|
||||
use crate::search::data_source::QueryResult;
|
||||
use crate::search::files::model::FileSearchModel;
|
||||
use crate::search::mixer::AddAsyncSourceOptions;
|
||||
use crate::search::QueryFilter;
|
||||
use crate::session_management::SessionSource;
|
||||
use crate::settings::AISettings;
|
||||
use warp_core::context_flag::ContextFlag;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::keymap::BindingId;
|
||||
use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::conversations;
|
||||
use super::warp_drive;
|
||||
|
||||
/// Store of all of the [`crate::search::DataSource`]s for the command palette.
|
||||
pub struct DataSourceStore {
|
||||
actions_data_source: ModelHandle<CommandBindingDataSource>,
|
||||
sessions_data_source: ModelHandle<navigation::DataSource>,
|
||||
warp_drive_data_source: ModelHandle<warp_drive::DataSource>,
|
||||
launch_config_data_source: ModelHandle<launch_config::DataSource>,
|
||||
new_session_data_source: Option<ModelHandle<NewSessionDataSource>>,
|
||||
historical_conversation_data_source: ModelHandle<conversations::DataSource>,
|
||||
all_conversation_data_source: ModelHandle<conversations::DataSource>,
|
||||
repo_data_source: ModelHandle<RepoDataSource>,
|
||||
}
|
||||
|
||||
impl DataSourceStore {
|
||||
pub fn new(
|
||||
binding_source: ModelHandle<BindingSource>,
|
||||
active_session_handle: ModelHandle<SessionSource>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let actions_data_source =
|
||||
ctx.add_model(|ctx| CommandBindingDataSource::new(binding_source.clone(), ctx));
|
||||
|
||||
let sessions_data_source =
|
||||
ctx.add_model(|_| navigation::DataSource::new(active_session_handle));
|
||||
|
||||
let warp_drive_data_source = ctx.add_model(warp_drive::DataSource::new);
|
||||
|
||||
let launch_config_data_source = ctx.add_model(launch_config::DataSource::new);
|
||||
|
||||
let new_session_data_source = (FeatureFlag::ShellSelector.is_enabled()
|
||||
&& cfg!(feature = "local_tty"))
|
||||
.then_some(ctx.add_model(|ctx| NewSessionDataSource::new(binding_source, ctx)));
|
||||
|
||||
let historical_conversation_data_source: ModelHandle<conversations::DataSource> =
|
||||
ctx.add_model(|_| conversations::DataSource::historical());
|
||||
|
||||
let all_conversation_data_source: ModelHandle<conversations::DataSource> =
|
||||
ctx.add_model(|_| conversations::DataSource::new());
|
||||
|
||||
let repo_data_source = ctx.add_model(|_| RepoDataSource::new());
|
||||
|
||||
Self {
|
||||
actions_data_source,
|
||||
sessions_data_source,
|
||||
warp_drive_data_source,
|
||||
launch_config_data_source,
|
||||
new_session_data_source,
|
||||
historical_conversation_data_source,
|
||||
all_conversation_data_source,
|
||||
repo_data_source,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the [`CommandPaletteMixer`] to the set of data sources that are relevant for the command palette.
|
||||
pub fn reset_search_mixer(
|
||||
&mut self,
|
||||
mixer: ModelHandle<CommandPaletteMixer>,
|
||||
is_shared_session_viewer: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.reset(ctx);
|
||||
|
||||
if ContextFlag::LaunchConfigurations.is_enabled() {
|
||||
mixer.add_sync_source(
|
||||
self.launch_config_data_source.clone(),
|
||||
HashSet::from([QueryFilter::LaunchConfigurations]),
|
||||
);
|
||||
}
|
||||
|
||||
mixer.add_sync_source(
|
||||
self.sessions_data_source.clone(),
|
||||
HashSet::from([QueryFilter::Sessions]),
|
||||
);
|
||||
|
||||
if WarpDriveSettings::is_warp_drive_enabled(ctx) {
|
||||
let mut warp_drive_filters = HashSet::from([
|
||||
QueryFilter::Notebooks,
|
||||
QueryFilter::Plans,
|
||||
QueryFilter::Drive,
|
||||
QueryFilter::Workflows,
|
||||
]);
|
||||
|
||||
warp_drive_filters.insert(QueryFilter::EnvironmentVariables);
|
||||
|
||||
if AISettings::as_ref(ctx).is_any_ai_enabled(ctx) {
|
||||
warp_drive_filters.insert(QueryFilter::AgentModeWorkflows);
|
||||
}
|
||||
mixer.add_sync_source(self.warp_drive_data_source.clone(), warp_drive_filters);
|
||||
}
|
||||
|
||||
mixer.add_sync_source(
|
||||
self.actions_data_source.clone(),
|
||||
HashSet::from([QueryFilter::Actions]),
|
||||
);
|
||||
|
||||
if let Some(new_session_data_source) = &self.new_session_data_source {
|
||||
mixer.add_sync_source(
|
||||
new_session_data_source.clone(),
|
||||
HashSet::from([QueryFilter::Actions]),
|
||||
);
|
||||
}
|
||||
|
||||
if FeatureFlag::CommandPaletteFileSearch.is_enabled() && !is_shared_session_viewer {
|
||||
let file_search_model = FileSearchModel::as_ref(ctx);
|
||||
let repo_root = file_search_model.repo_root(ctx);
|
||||
let is_in_git_repo = repo_root.is_some();
|
||||
|
||||
let files_data_source = if is_in_git_repo {
|
||||
ctx.add_model(|_| files::data_source::FileDataSource::new())
|
||||
} else {
|
||||
ctx.add_model(|ctx| files::data_source::FileDataSource::new_current_folder(ctx))
|
||||
};
|
||||
mixer.add_async_source(
|
||||
files_data_source,
|
||||
HashSet::from([QueryFilter::Files]),
|
||||
AddAsyncSourceOptions {
|
||||
debounce_interval: None,
|
||||
run_in_zero_state: true,
|
||||
run_when_unfiltered: true,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
// Add conversation search if AI is enabled
|
||||
if AISettings::as_ref(ctx).is_any_ai_enabled(ctx) {
|
||||
mixer.add_sync_source(
|
||||
self.all_conversation_data_source.clone(),
|
||||
HashSet::from([QueryFilter::Conversations]),
|
||||
);
|
||||
|
||||
mixer.add_sync_source(
|
||||
self.historical_conversation_data_source.clone(),
|
||||
HashSet::from([QueryFilter::HistoricalConversations]),
|
||||
);
|
||||
}
|
||||
|
||||
mixer.add_sync_source(
|
||||
self.repo_data_source.clone(),
|
||||
HashSet::from([QueryFilter::Repos]),
|
||||
);
|
||||
|
||||
ctx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns a [`QueryResult`] from the data sources identified by the `summary`. `None` if none
|
||||
/// of the data sources contained an item with given summary.
|
||||
pub fn query_result_from_summary(
|
||||
&self,
|
||||
summary: &ItemSummary,
|
||||
app: &AppContext,
|
||||
) -> Option<QueryResult<CommandPaletteItemAction>> {
|
||||
match summary {
|
||||
ItemSummary::Action { binding_id } => self
|
||||
.actions_data_source
|
||||
.as_ref(app)
|
||||
.query_result(*binding_id),
|
||||
ItemSummary::Workflow { id } => self
|
||||
.warp_drive_data_source
|
||||
.as_ref(app)
|
||||
.query_result(id, app),
|
||||
ItemSummary::EnvVarCollection { id } => self
|
||||
.warp_drive_data_source
|
||||
.as_ref(app)
|
||||
.query_result(id, app),
|
||||
ItemSummary::Notebook { id } => self
|
||||
.warp_drive_data_source
|
||||
.as_ref(app)
|
||||
.query_result(id, app),
|
||||
ItemSummary::Session { pane_view_locator } => self
|
||||
.sessions_data_source
|
||||
.as_ref(app)
|
||||
.query_result(*pane_view_locator, app),
|
||||
ItemSummary::LaunchConfiguration => {
|
||||
// TODO(CLD-205): Launch configurations are not supported in the recent section of the
|
||||
// zero state yet.
|
||||
None
|
||||
}
|
||||
ItemSummary::CloudObject => {
|
||||
// We don't yet support all cloud objects in the command palette but
|
||||
// we have a `ViewInWarpDrive` action that supports all of them, so
|
||||
// this is necessary to make the compiler happy.
|
||||
None
|
||||
}
|
||||
ItemSummary::NewSession { id } => self
|
||||
.new_session_data_source
|
||||
.as_ref()
|
||||
.and_then(|source| source.as_ref(app).query_result(id)),
|
||||
ItemSummary::File {
|
||||
path,
|
||||
project_directory,
|
||||
line_and_column_arg,
|
||||
} => {
|
||||
// Create a file search item from the summary
|
||||
use crate::search::command_palette::files::search_item::FileSearchItem;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
|
||||
let search_item = FileSearchItem {
|
||||
path: PathBuf::from(path),
|
||||
project_directory: project_directory.clone(),
|
||||
match_result: FuzzyMatchResult::no_match(),
|
||||
line_and_column_arg: *line_and_column_arg,
|
||||
is_directory: false,
|
||||
};
|
||||
Some(QueryResult::from(search_item))
|
||||
}
|
||||
ItemSummary::Directory {
|
||||
path,
|
||||
project_directory,
|
||||
} => {
|
||||
// Create a directory search item from the summary
|
||||
use crate::search::command_palette::files::search_item::FileSearchItem;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
|
||||
let search_item = FileSearchItem {
|
||||
path: PathBuf::from(path),
|
||||
project_directory: project_directory.clone(),
|
||||
match_result: FuzzyMatchResult::no_match(),
|
||||
line_and_column_arg: None,
|
||||
is_directory: true,
|
||||
};
|
||||
Some(QueryResult::from(search_item))
|
||||
}
|
||||
ItemSummary::Project { path: _ } => {
|
||||
// For project summaries, we would need a project data source to reconstruct the item,
|
||||
// but this is typically handled by the welcome palette, not the command palette.
|
||||
// For now, return None as projects aren't expected in the regular command palette.
|
||||
None
|
||||
}
|
||||
ItemSummary::Conversation { id } => conversations::DataSource::query_result(id, app),
|
||||
|
||||
ItemSummary::NewConversation => {
|
||||
// The new conversation item should not show up in the recent command list,
|
||||
// as its use is specific to the conversation filter.
|
||||
None
|
||||
}
|
||||
|
||||
ItemSummary::ForkConversation => {
|
||||
// The forked conversation item should not show up in the recent command list,
|
||||
// as its use is specific to the conversation filter.
|
||||
None
|
||||
}
|
||||
|
||||
ItemSummary::NoOp => {
|
||||
// No-op action (used for non-interactable separator items that don't do anything on click).
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a [`QueryResult`] for a binding with `binding_id`. `None` if no result was found
|
||||
/// with the given ID.
|
||||
pub fn query_result_for_binding_id(
|
||||
&self,
|
||||
binding_id: BindingId,
|
||||
app: &AppContext,
|
||||
) -> Option<QueryResult<CommandPaletteItemAction>> {
|
||||
self.query_result_from_summary(&ItemSummary::Action { binding_id }, app)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DataSourceStore {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "data_sources_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,308 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use settings::manager::SettingsManager;
|
||||
use warpui::{App, SingletonEntity};
|
||||
|
||||
use super::*;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::Owner;
|
||||
use crate::notebooks::manager::NotebookManager;
|
||||
use crate::notebooks::CloudNotebookModel;
|
||||
use crate::server::ids::ServerId;
|
||||
use crate::server::ids::SyncId::{self};
|
||||
use crate::settings::AISettings;
|
||||
use crate::workflows::workflow::Workflow;
|
||||
use crate::workflows::CloudWorkflowModel;
|
||||
use crate::{
|
||||
cloud_object::{
|
||||
model::{persistence::CloudModel, view::CloudViewModel},
|
||||
Revision, ServerMetadata, ServerNotebook, ServerPermissions, ServerWorkflow,
|
||||
},
|
||||
network::NetworkStatus,
|
||||
notebooks::NotebookId,
|
||||
search::data_source::Query,
|
||||
server::{
|
||||
cloud_objects::update_manager::UpdateManager, server_api::ServerApiProvider,
|
||||
sync_queue::SyncQueue,
|
||||
},
|
||||
system::SystemStats,
|
||||
workflows::WorkflowId,
|
||||
workspaces::{
|
||||
team_tester::TeamTesterStatus, user_profiles::UserProfiles, user_workspaces::UserWorkspaces,
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::server::server_api::object::MockObjectClient;
|
||||
#[cfg(test)]
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
#[cfg(test)]
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
|
||||
fn mock_server_metadata() -> ServerMetadata {
|
||||
ServerMetadata {
|
||||
uid: ServerId::default(),
|
||||
revision: Revision::now(),
|
||||
metadata_last_updated_ts: Utc::now().into(),
|
||||
trashed_ts: None,
|
||||
folder_id: None,
|
||||
is_welcome_object: false,
|
||||
creator_uid: None,
|
||||
last_editor_uid: None,
|
||||
current_editor_uid: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_server_permissions(owner: Owner) -> ServerPermissions {
|
||||
ServerPermissions {
|
||||
space: owner,
|
||||
guests: Vec::new(),
|
||||
anyone_link_sharing: None,
|
||||
permissions_last_updated_ts: Utc::now().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_server_workflow(id: WorkflowId, owner: Owner) -> ServerWorkflow {
|
||||
ServerWorkflow {
|
||||
id: SyncId::ServerId(id.into()),
|
||||
metadata: mock_server_metadata(),
|
||||
permissions: mock_server_permissions(owner),
|
||||
model: CloudWorkflowModel::new(Workflow::new(format!("foo{id}"), format!("bar{id}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_server_notebook(id: NotebookId, owner: Owner) -> ServerNotebook {
|
||||
ServerNotebook {
|
||||
id: SyncId::ServerId(id.into()),
|
||||
metadata: mock_server_metadata(),
|
||||
permissions: mock_server_permissions(owner),
|
||||
model: CloudNotebookModel {
|
||||
title: format!("foo{id}"),
|
||||
data: format!("bar{id}"),
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_app(app: &mut App) {
|
||||
// Add the necessary singleton models to the App
|
||||
app.add_singleton_model(|_| NetworkStatus::new());
|
||||
app.add_singleton_model(|_| SystemStats::new());
|
||||
let mock_team_client = Arc::new(MockTeamClient::new());
|
||||
let mock_workspace_client = Arc::new(MockWorkspaceClient::new());
|
||||
app.add_singleton_model(|ctx| {
|
||||
UserWorkspaces::mock(
|
||||
mock_team_client.clone(),
|
||||
mock_workspace_client.clone(),
|
||||
vec![],
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
app.add_singleton_model(TeamTesterStatus::new);
|
||||
app.add_singleton_model(SyncQueue::mock);
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|ctx| UpdateManager::new(None, Arc::new(MockObjectClient::new()), ctx));
|
||||
app.add_singleton_model(|_| UserProfiles::new(Vec::new()));
|
||||
app.add_singleton_model(CloudViewModel::new);
|
||||
app.add_singleton_model(NotebookManager::mock);
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
app.add_singleton_model(|_| SettingsManager::default());
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.update(crate::settings::init_and_register_user_preferences);
|
||||
app.update(AISettings::register_and_subscribe_to_events);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drive_data_source_correctly_filters_drive_filter() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
// Initialize CloudModel
|
||||
CloudModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.upsert_from_server_notebook(
|
||||
mock_server_notebook(1.into(), Owner::mock_current_user()),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_workflow(
|
||||
mock_server_workflow(2.into(), Owner::mock_current_user()),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
let mixer = app.add_model(|_| CommandPaletteMixer::new());
|
||||
let data_source_handle = app.add_model(warp_drive::DataSource::new);
|
||||
mixer.update(&mut app, |mixer, ctx| {
|
||||
// Add the drive data source with the relevant filters
|
||||
mixer.add_sync_source(
|
||||
data_source_handle,
|
||||
[
|
||||
QueryFilter::Drive,
|
||||
QueryFilter::Notebooks,
|
||||
QueryFilter::Workflows,
|
||||
],
|
||||
);
|
||||
|
||||
// Run the query with the drive filter
|
||||
mixer.run_query(
|
||||
Query {
|
||||
filters: HashSet::from([QueryFilter::Drive]),
|
||||
text: "foo".into(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
app.read(|app| {
|
||||
let results = mixer.as_ref(app).results();
|
||||
|
||||
// Expect both of the results to be included
|
||||
assert_eq!(results.len(), 2);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drive_data_source_correctly_filters_no_filter() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
// Initialize CloudModel
|
||||
CloudModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.upsert_from_server_notebook(
|
||||
mock_server_notebook(1.into(), Owner::mock_current_user()),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_workflow(
|
||||
mock_server_workflow(2.into(), Owner::mock_current_user()),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let mixer = app.add_model(|_| CommandPaletteMixer::new());
|
||||
let data_source_handle = app.add_model(warp_drive::DataSource::new);
|
||||
mixer.update(&mut app, |mixer, ctx| {
|
||||
// Add the drive data source with the relevant filters
|
||||
mixer.add_sync_source(
|
||||
data_source_handle,
|
||||
[
|
||||
QueryFilter::Drive,
|
||||
QueryFilter::Notebooks,
|
||||
QueryFilter::Workflows,
|
||||
],
|
||||
);
|
||||
|
||||
// Run the query with no filter
|
||||
mixer.run_query(
|
||||
Query {
|
||||
filters: HashSet::new(),
|
||||
text: "foo".into(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
app.read(|app| {
|
||||
let results = mixer.as_ref(app).results();
|
||||
|
||||
// Expect both of the results to be included
|
||||
assert_eq!(results.len(), 2);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drive_data_source_correctly_filters_workflow_filter() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
// Initialize CloudModel
|
||||
CloudModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.upsert_from_server_notebook(
|
||||
mock_server_notebook(1.into(), Owner::mock_current_user()),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_workflow(
|
||||
mock_server_workflow(2.into(), Owner::mock_current_user()),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let mixer = app.add_model(|_| CommandPaletteMixer::new());
|
||||
let data_source_handle = app.add_model(warp_drive::DataSource::new);
|
||||
mixer.update(&mut app, |mixer, ctx| {
|
||||
// Add the drive data source with the relevant filters
|
||||
mixer.add_sync_source(
|
||||
data_source_handle,
|
||||
[
|
||||
QueryFilter::Drive,
|
||||
QueryFilter::Notebooks,
|
||||
QueryFilter::Workflows,
|
||||
],
|
||||
);
|
||||
|
||||
// Run the query with no filter
|
||||
mixer.run_query(
|
||||
Query {
|
||||
filters: HashSet::from([QueryFilter::Workflows]),
|
||||
text: "foo".into(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
app.read(|app| {
|
||||
let results = mixer.as_ref(app).results();
|
||||
|
||||
// Expect only the workflow result to be included
|
||||
assert_eq!(results.len(), 1);
|
||||
|
||||
assert!(results[0].accessibility_label().starts_with("Workflow:"));
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drive_data_source_correctly_filters_notebook_filter() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
// Initialize CloudModel
|
||||
CloudModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.upsert_from_server_notebook(
|
||||
mock_server_notebook(1.into(), Owner::mock_current_user()),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_workflow(
|
||||
mock_server_workflow(2.into(), Owner::mock_current_user()),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let mixer = app.add_model(|_| CommandPaletteMixer::new());
|
||||
let data_source_handle = app.add_model(warp_drive::DataSource::new);
|
||||
mixer.update(&mut app, |mixer, ctx| {
|
||||
// Add the drive data source with the relevant filters
|
||||
mixer.add_sync_source(
|
||||
data_source_handle,
|
||||
[
|
||||
QueryFilter::Drive,
|
||||
QueryFilter::Notebooks,
|
||||
QueryFilter::Workflows,
|
||||
],
|
||||
);
|
||||
|
||||
// Run the query with no filter
|
||||
mixer.run_query(
|
||||
Query {
|
||||
filters: HashSet::from([QueryFilter::Notebooks]),
|
||||
text: "foo".into(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
app.read(|app| {
|
||||
let results = mixer.as_ref(app).results();
|
||||
|
||||
// Expect only the workflow result to be included
|
||||
assert_eq!(results.len(), 1);
|
||||
|
||||
assert!(results[0].accessibility_label().starts_with("Notebook:"));
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
#![cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
use super::search_item::{CreateFileSearchItem, FileSearchItem};
|
||||
use crate::code::opened_files::OpenedFilesModel;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::files::model::FileSearchModel;
|
||||
use crate::search::files::search_item::FileSearchResult;
|
||||
use crate::search::mixer::{AsyncDataSource, BoxFuture, DataSourceRunErrorWrapper};
|
||||
use futures_lite::FutureExt;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use instant::Instant;
|
||||
use itertools::Itertools;
|
||||
use std::collections::HashSet;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use warp_util::path::CleanPathResult;
|
||||
use warpui::{AppContext, Entity, SingletonEntity};
|
||||
|
||||
const MAX_RESULTS: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum FileRanking {
|
||||
None,
|
||||
ChangedInGit,
|
||||
OpenedInWarp { timestamp: Instant },
|
||||
}
|
||||
|
||||
pub struct FileDataSource {
|
||||
mode: FileDataSourceMode,
|
||||
}
|
||||
|
||||
enum FileDataSourceMode {
|
||||
/// Search across the repository (existing behavior)
|
||||
Repo,
|
||||
/// Search within the current folder only, using cached contents computed at creation time
|
||||
CurrentFolder {
|
||||
cached_contents: Vec<FileSearchResult>,
|
||||
},
|
||||
}
|
||||
|
||||
impl FileDataSource {
|
||||
pub fn new() -> Self {
|
||||
// Default to repo search to preserve existing call sites
|
||||
Self {
|
||||
mode: FileDataSourceMode::Repo,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a data source that searches only within the current folder.
|
||||
/// This will read folder contents once at creation and reuse them for subsequent queries.
|
||||
pub fn new_current_folder(app: &AppContext) -> Self {
|
||||
let file_search_model = FileSearchModel::as_ref(app);
|
||||
let contents = file_search_model.get_folder_contents(app);
|
||||
Self {
|
||||
mode: FileDataSourceMode::CurrentFolder {
|
||||
cached_contents: contents,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncDataSource for FileDataSource {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> BoxFuture<'static, Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper>> {
|
||||
// Get the search query text
|
||||
let query_text = &query.text;
|
||||
|
||||
// Early exit for very broad wildcard patterns that would match everything
|
||||
if FileSearchModel::should_skip_overly_broad_query(query_text) {
|
||||
return futures::future::ready(Ok(vec![])).boxed();
|
||||
}
|
||||
|
||||
// Zero state: fetch git-changed files and prioritize them
|
||||
if query_text.is_empty() {
|
||||
self.run_zero_state_query(app)
|
||||
} else {
|
||||
// Non-empty query: use fuzzy matching
|
||||
self.run_fuzzy_search_query(app, query_text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileDataSource {
|
||||
fn contents_with_git_changes(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
) -> (Arc<Vec<FileSearchResult>>, HashSet<String>) {
|
||||
match &self.mode {
|
||||
FileDataSourceMode::Repo => {
|
||||
let file_search_model = FileSearchModel::as_ref(app);
|
||||
file_search_model.get_repo_contents_with_git_status(app)
|
||||
}
|
||||
FileDataSourceMode::CurrentFolder { cached_contents } => {
|
||||
(Arc::new(cached_contents.clone()), HashSet::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn contents(&self, app: &AppContext) -> Arc<Vec<FileSearchResult>> {
|
||||
match &self.mode {
|
||||
FileDataSourceMode::Repo => {
|
||||
let file_search_model = FileSearchModel::as_ref(app);
|
||||
file_search_model.get_repo_contents(app)
|
||||
}
|
||||
FileDataSourceMode::CurrentFolder { cached_contents } => {
|
||||
Arc::new(cached_contents.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle zero state query - prioritize git-changed files without fuzzy matching
|
||||
fn run_zero_state_query(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
) -> BoxFuture<
|
||||
'static,
|
||||
Result<Vec<QueryResult<CommandPaletteItemAction>>, DataSourceRunErrorWrapper>,
|
||||
> {
|
||||
let file_search_model = FileSearchModel::as_ref(app);
|
||||
|
||||
let (contents, git_changed_files) = self.contents_with_git_changes(app);
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
let opened_files = OpenedFilesModel::as_ref(app);
|
||||
|
||||
let repo_root = file_search_model.repo_root(app);
|
||||
let opened_files =
|
||||
repo_root.and_then(|repo_root| opened_files.opened_files_for_repo(&repo_root));
|
||||
|
||||
for item in contents.iter() {
|
||||
let mut file_ranking = if git_changed_files.contains(&item.path) {
|
||||
FileRanking::ChangedInGit
|
||||
} else {
|
||||
FileRanking::None
|
||||
};
|
||||
|
||||
if let Some(last_opened_timestamp) =
|
||||
opened_files.and_then(|opened_files| opened_files.get(&PathBuf::from(&item.path)))
|
||||
{
|
||||
file_ranking = FileRanking::OpenedInWarp {
|
||||
timestamp: *last_opened_timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
let match_result = FuzzyMatchResult {
|
||||
score: 0,
|
||||
matched_indices: vec![], // No highlighting needed for zero state
|
||||
};
|
||||
|
||||
let search_item = FileSearchItem {
|
||||
path: PathBuf::from(&item.path),
|
||||
project_directory: item.project_directory.clone(),
|
||||
match_result,
|
||||
line_and_column_arg: None,
|
||||
is_directory: item.is_directory,
|
||||
};
|
||||
results.push((file_ranking, QueryResult::from(search_item)));
|
||||
}
|
||||
|
||||
results.sort_by_key(|(ranking, _)| *ranking);
|
||||
|
||||
Box::pin(async move { Ok(results.into_iter().map(|(_, ranking)| ranking).collect()) })
|
||||
}
|
||||
|
||||
/// Handle non-empty query with fuzzy matching (no git status needed)
|
||||
fn run_fuzzy_search_query(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
query_text: &str,
|
||||
) -> BoxFuture<
|
||||
'static,
|
||||
Result<Vec<QueryResult<CommandPaletteItemAction>>, DataSourceRunErrorWrapper>,
|
||||
> {
|
||||
let file_search_model = FileSearchModel::as_ref(app);
|
||||
|
||||
let contents = self.contents(app);
|
||||
|
||||
// Strip any trailing : in case user is in the middle of typing a line / column arg.
|
||||
let query_text = query_text.strip_suffix(':').unwrap_or(query_text);
|
||||
|
||||
let text = CleanPathResult::with_line_and_column_number(query_text);
|
||||
let query_file_content = text.path;
|
||||
|
||||
let opened_files = OpenedFilesModel::as_ref(app);
|
||||
|
||||
let repo_root = file_search_model.repo_root(app);
|
||||
|
||||
// For the "Create file" fallback, use the expanded (but not repo-root-stripped)
|
||||
// path so that absolute paths work correctly with Path::join.
|
||||
let query_file_name = shellexpand::tilde(&query_file_content).into_owned();
|
||||
|
||||
// Get the current directory for the "Create file" option and for path stripping.
|
||||
#[cfg(feature = "local_fs")]
|
||||
let current_directory = {
|
||||
use crate::workspace::ActiveSession;
|
||||
let active_window_id = app.windows().state().active_window;
|
||||
active_window_id
|
||||
.and_then(|window_id| ActiveSession::as_ref(app).path_if_local(window_id))
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
};
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
let current_directory: Option<String> = None;
|
||||
|
||||
// If the query looks like an absolute path, strip the common prefix with the
|
||||
// repo root (first) or working directory (second) so it can match against the
|
||||
// relative paths stored in the file index. This allows users to paste absolute
|
||||
// paths — e.g. copied via "Copy file path" in the Code Review pane — directly
|
||||
// into the Command-Palette file picker. We pass the tilde-expanded
|
||||
// `query_file_name` so that `~/...` paths are also handled.
|
||||
#[cfg(feature = "local_fs")]
|
||||
let query_file_content = FileSearchModel::strip_absolute_path_prefix(
|
||||
&query_file_name,
|
||||
repo_root.as_deref(),
|
||||
current_directory.as_deref().map(Path::new),
|
||||
)
|
||||
.unwrap_or(query_file_content);
|
||||
|
||||
let opened_files = repo_root
|
||||
.and_then(|repo_root| opened_files.opened_files_for_repo(&repo_root))
|
||||
.cloned();
|
||||
|
||||
const CHUNK_SIZE: usize = 50;
|
||||
|
||||
Box::pin(async move {
|
||||
let mut results = Vec::with_capacity(contents.len());
|
||||
|
||||
// Iterate in chunks of 50, yielding at the end of each chunk to
|
||||
// allow the main thread to abort the search if needed.
|
||||
for chunk in contents.chunks(CHUNK_SIZE) {
|
||||
for item in chunk {
|
||||
let Some(mut match_result) =
|
||||
FileSearchModel::fuzzy_match_path(&item.path, &query_file_content)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Never show directories -- there's no way to open them currently.
|
||||
if item.is_directory {
|
||||
continue;
|
||||
}
|
||||
|
||||
if opened_files
|
||||
.as_ref()
|
||||
.and_then(|opened_files| opened_files.get(&PathBuf::from(&item.path)))
|
||||
.is_some()
|
||||
{
|
||||
// Apply a boost to opened files to rank them above non-opened files.
|
||||
match_result.score += 100;
|
||||
};
|
||||
|
||||
let search_item = FileSearchItem {
|
||||
path: PathBuf::from(&item.path),
|
||||
project_directory: item.project_directory.clone(),
|
||||
line_and_column_arg: text.line_and_column_num,
|
||||
match_result,
|
||||
is_directory: item.is_directory,
|
||||
};
|
||||
results.push(search_item);
|
||||
}
|
||||
futures_lite::future::yield_now().await;
|
||||
}
|
||||
|
||||
let mut results: Vec<QueryResult<CommandPaletteItemAction>> = results
|
||||
.into_iter()
|
||||
.k_largest_relaxed_by_key(MAX_RESULTS, |item| item.match_result.score)
|
||||
.map(QueryResult::from)
|
||||
.collect();
|
||||
|
||||
// If no files matched and we have a valid query and current directory,
|
||||
// add a "Create <filename>..." option
|
||||
if results.is_empty() && !query_file_name.trim().is_empty() {
|
||||
if let Some(current_dir) = current_directory {
|
||||
let create_item = CreateFileSearchItem {
|
||||
file_name: query_file_name,
|
||||
current_directory: current_dir,
|
||||
};
|
||||
results.push(QueryResult::from(create_item));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for FileDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod data_source;
|
||||
pub mod search_item;
|
||||
@@ -0,0 +1,210 @@
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use std::fmt::Debug;
|
||||
use std::path::PathBuf;
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::styles;
|
||||
use crate::search::item::{IconLocation, SearchItem};
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use warpui::elements::{Align, ConstrainedBox, Container, Flex, Icon, ParentElement, Text};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
use crate::search::files::icon::icon_from_file_path;
|
||||
use crate::ui_components::render_file_search_row::{render_file_search_row, FileSearchRowOptions};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FileSearchItem {
|
||||
pub path: PathBuf,
|
||||
pub project_directory: String,
|
||||
pub match_result: FuzzyMatchResult,
|
||||
pub line_and_column_arg: Option<LineAndColumnArg>,
|
||||
pub is_directory: bool,
|
||||
}
|
||||
|
||||
impl SearchItem for FileSearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(if self.is_directory {
|
||||
Icon::new(
|
||||
"bundled/svg/completion-folder.svg",
|
||||
highlight_state.icon_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish()
|
||||
} else {
|
||||
icon_from_file_path(&self.path.to_string_lossy(), appearance, highlight_state)
|
||||
})
|
||||
.with_width(styles::SEARCH_ITEM_TEXT_PADDING * 4.0)
|
||||
.with_height(styles::SEARCH_ITEM_TEXT_PADDING * 4.0)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(styles::SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn icon_location(&self, _appearance: &Appearance) -> IconLocation {
|
||||
IconLocation::Centered
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
render_file_search_row(
|
||||
&self.path,
|
||||
FileSearchRowOptions {
|
||||
match_result: Some(&self.match_result),
|
||||
highlight_state,
|
||||
..Default::default()
|
||||
},
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
if self.is_directory {
|
||||
CommandPaletteItemAction::OpenDirectory {
|
||||
path: self.path.to_string_lossy().to_string(),
|
||||
project_directory: self.project_directory.clone(),
|
||||
}
|
||||
} else {
|
||||
CommandPaletteItemAction::OpenFile {
|
||||
path: self.path.to_string_lossy().to_string(),
|
||||
project_directory: self.project_directory.clone(),
|
||||
line_and_column_arg: self.line_and_column_arg,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
if self.is_directory {
|
||||
format!("Directory: {}", self.path.display())
|
||||
} else {
|
||||
format!("File: {}", self.path.display())
|
||||
}
|
||||
}
|
||||
|
||||
fn accessibility_help_message(&self) -> Option<String> {
|
||||
Some(if self.is_directory {
|
||||
"Press Enter to navigate to this directory".to_string()
|
||||
} else {
|
||||
"Press Enter to open this file".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn render_details(&self, _ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A search item for creating a new file with the specified name
|
||||
#[derive(Debug)]
|
||||
pub struct CreateFileSearchItem {
|
||||
pub file_name: String,
|
||||
pub current_directory: String,
|
||||
}
|
||||
|
||||
impl SearchItem for CreateFileSearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
"bundled/svg/plus-circle.svg",
|
||||
highlight_state.icon_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(styles::SEARCH_ITEM_TEXT_PADDING * 4.0)
|
||||
.with_height(styles::SEARCH_ITEM_TEXT_PADDING * 4.0)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(styles::SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn icon_location(&self, _appearance: &Appearance) -> IconLocation {
|
||||
IconLocation::Centered
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let text_color = highlight_state.sub_text_fill(appearance).into_solid();
|
||||
|
||||
let label = Text::new_inline(
|
||||
format!("Create {}…", &self.file_name),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(text_color)
|
||||
.with_style(Properties::default().weight(Weight::Normal))
|
||||
.finish();
|
||||
|
||||
ConstrainedBox::new(
|
||||
Align::new(Flex::row().with_child(label).finish())
|
||||
.left()
|
||||
.finish(),
|
||||
)
|
||||
.with_height(40.0)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
// Give it a very low score so it appears at the bottom
|
||||
OrderedFloat(-100000.0)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::CreateFile {
|
||||
file_name: self.file_name.clone(),
|
||||
current_directory: self.current_directory.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Create file: {}", self.file_name)
|
||||
}
|
||||
|
||||
fn accessibility_help_message(&self) -> Option<String> {
|
||||
Some(format!(
|
||||
"Press Enter to create {} in the current directory",
|
||||
self.file_name
|
||||
))
|
||||
}
|
||||
|
||||
fn render_details(&self, _ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::drive::cloud_object_styling::warp_drive_icon_color;
|
||||
use crate::drive::DriveObjectType;
|
||||
use crate::search::FilterChipRenderer as CommonFilterChipRenderer;
|
||||
use crate::search::QueryFilter;
|
||||
use crate::util::color::{ContrastingColor, MinimumAllowedContrast};
|
||||
use pathfinder_color::ColorU;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Hoverable, Icon,
|
||||
MouseStateHandle, ParentElement, Radius, Text,
|
||||
};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::{Element, EventContext};
|
||||
|
||||
/// Trait to render filter chips for the command palette.
|
||||
pub trait FilterChipRenderer: crate::search::FilterChipRenderer {
|
||||
/// Renders the filter chip. When the filter chip is clicked, `on_click_fn` is called.
|
||||
fn render_filter_chip(
|
||||
&self,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
on_click_fn: fn(&mut EventContext, Self),
|
||||
) -> Box<dyn Element>;
|
||||
|
||||
/// Returns the color of the icon for the filter chip.
|
||||
fn icon_color(&self, appearance: &Appearance) -> ColorU;
|
||||
}
|
||||
|
||||
impl FilterChipRenderer for QueryFilter {
|
||||
fn render_filter_chip(
|
||||
&self,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
on_click_fn: fn(&mut EventContext, Self),
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let self_copy: QueryFilter = *self;
|
||||
Hoverable::new(mouse_state_handle, |mouse_state| {
|
||||
let font_size = appearance.monospace_font_size() - 2.;
|
||||
Container::new({
|
||||
let flex_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Text::new_inline(
|
||||
self.display_name(),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().surface_2())
|
||||
.into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
match self.icon_svg_path() {
|
||||
None => flex_row.finish(),
|
||||
Some(icon_name) => {
|
||||
let icon_size = font_size + self.icon_size_offset();
|
||||
|
||||
let icon = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
icon_name,
|
||||
self.icon_color(appearance).on_background(
|
||||
appearance.theme().surface_2().into_solid(),
|
||||
MinimumAllowedContrast::NonText,
|
||||
),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(self.icon_margin_top());
|
||||
flex_row
|
||||
.with_child(icon.with_margin_left(8.).finish())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
})
|
||||
.with_vertical_padding(styles::vertical_padding(mouse_state))
|
||||
.with_horizontal_padding(styles::horizontal_padding(mouse_state))
|
||||
.with_background(styles::background_fill(mouse_state, theme))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.with_border(styles::border(mouse_state, theme))
|
||||
.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |event_ctx, _, _| on_click_fn(event_ctx, self_copy))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn icon_color(&self, appearance: &Appearance) -> ColorU {
|
||||
match self {
|
||||
QueryFilter::History
|
||||
| QueryFilter::NaturalLanguage
|
||||
| QueryFilter::Actions
|
||||
| QueryFilter::Sessions
|
||||
| QueryFilter::Drive
|
||||
| QueryFilter::LaunchConfigurations
|
||||
| QueryFilter::PromptHistory
|
||||
| QueryFilter::Files
|
||||
| QueryFilter::Commands
|
||||
| QueryFilter::Blocks
|
||||
| QueryFilter::Code
|
||||
| QueryFilter::Rules
|
||||
| QueryFilter::Repos
|
||||
| QueryFilter::DiffSets
|
||||
| QueryFilter::StaticSlashCommands
|
||||
| QueryFilter::Skills
|
||||
| QueryFilter::BaseModels
|
||||
| QueryFilter::FullTerminalUseModels
|
||||
| QueryFilter::CurrentDirectoryConversations => appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().surface_2())
|
||||
.into_solid(),
|
||||
QueryFilter::Conversations | QueryFilter::HistoricalConversations => appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().surface_2())
|
||||
.into_solid(),
|
||||
QueryFilter::Workflows => warp_drive_icon_color(appearance, DriveObjectType::Workflow),
|
||||
QueryFilter::Notebooks => warp_drive_icon_color(
|
||||
appearance,
|
||||
DriveObjectType::Notebook {
|
||||
is_ai_document: false,
|
||||
},
|
||||
),
|
||||
QueryFilter::Plans => warp_drive_icon_color(
|
||||
appearance,
|
||||
DriveObjectType::Notebook {
|
||||
is_ai_document: true,
|
||||
},
|
||||
),
|
||||
QueryFilter::EnvironmentVariables => {
|
||||
warp_drive_icon_color(appearance, DriveObjectType::EnvVarCollection)
|
||||
}
|
||||
QueryFilter::AgentModeWorkflows => {
|
||||
warp_drive_icon_color(appearance, DriveObjectType::AgentModeWorkflow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod styles {
|
||||
use crate::themes::theme::{Blend, Fill, WarpTheme};
|
||||
use warpui::elements::{Border, MouseState};
|
||||
|
||||
/// Size of the border when the query filter is hovered.
|
||||
const HOVERED_BORDER_SIZE: f32 = 2.;
|
||||
/// Size of the border when the query filter is _not_ hovered.
|
||||
const BORDER_SIZE: f32 = 1.;
|
||||
|
||||
/// Vertical padding when the query filter is _not_ hovered.
|
||||
const VERTICAL_PADDING: f32 = 8.;
|
||||
|
||||
/// Horizontal padding when the query filter is _not_ hovered.
|
||||
const HORIZONTAL_PADDING: f32 = 16.;
|
||||
|
||||
/// Returns the amount of vertical padding that should be applied to the query filter while also
|
||||
/// ensuring the query filter doesn't "jump" when it is hovered.
|
||||
pub fn vertical_padding(mouse_state: &MouseState) -> f32 {
|
||||
if mouse_state.is_hovered() {
|
||||
VERTICAL_PADDING - (HOVERED_BORDER_SIZE - BORDER_SIZE)
|
||||
} else {
|
||||
VERTICAL_PADDING
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the amount of horizontal padding that should be applied to the query filter while also
|
||||
/// ensuring the query filter doesn't "jump" when it is hovered.
|
||||
pub fn horizontal_padding(mouse_state: &MouseState) -> f32 {
|
||||
if mouse_state.is_hovered() {
|
||||
HORIZONTAL_PADDING - (HOVERED_BORDER_SIZE - BORDER_SIZE)
|
||||
} else {
|
||||
HORIZONTAL_PADDING
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the border that should be applied to the query filter.
|
||||
pub fn border(mouse_state: &MouseState, theme: &WarpTheme) -> Border {
|
||||
if mouse_state.is_hovered() {
|
||||
Border::all(HOVERED_BORDER_SIZE).with_border_fill(theme.accent())
|
||||
} else {
|
||||
Border::all(BORDER_SIZE).with_border_fill(theme.sub_text_color(theme.surface_2()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the background [`Fill`] that should be applied to the query filter.
|
||||
pub fn background_fill(mouse_state: &MouseState, theme: &WarpTheme) -> Fill {
|
||||
if mouse_state.is_hovered() {
|
||||
theme
|
||||
.surface_2()
|
||||
.blend(&theme.dark_overlay().with_opacity(25))
|
||||
} else {
|
||||
theme.surface_2()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
use crate::launch_configs::launch_config::LaunchConfig;
|
||||
use crate::search::command_palette::launch_config::search_item::SearchItem;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::data_source::{DataSourceSearchError, Query, QueryResult};
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
use crate::user_config::{WarpConfig, WarpConfigUpdateEvent};
|
||||
use fuzzy_match::match_indices_case_insensitive;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
/// Datasource that searches against `LaunchConfig`s.
|
||||
pub struct DataSource {
|
||||
searcher: Box<dyn LaunchConfigSearcher>,
|
||||
}
|
||||
|
||||
impl DataSource {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
if warp_core::features::FeatureFlag::UseTantivySearch.is_enabled() {
|
||||
Self::new_full_text(ctx)
|
||||
} else {
|
||||
Self::new_fuzzy(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self::new_fuzzy(ctx)
|
||||
}
|
||||
|
||||
fn new_fuzzy(ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(&WarpConfig::handle(ctx), Self::handle_config_event);
|
||||
let mut searcher = Box::new(FuzzyLaunchConfigSearcher::default());
|
||||
searcher.refresh_search_index(ctx);
|
||||
Self { searcher }
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn new_full_text(ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(&WarpConfig::handle(ctx), Self::handle_config_event);
|
||||
let mut searcher = Box::new(full_text_searcher::FullTextLaunchConfigSearcher::new(
|
||||
ctx.background_executor(),
|
||||
));
|
||||
searcher.refresh_search_index(ctx);
|
||||
Self { searcher }
|
||||
}
|
||||
|
||||
fn handle_config_event(&mut self, event: &WarpConfigUpdateEvent, ctx: &mut ModelContext<Self>) {
|
||||
if matches!(event, WarpConfigUpdateEvent::LaunchConfigs) {
|
||||
self.searcher.refresh_search_index(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SyncDataSource for DataSource {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
_app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
Ok(self
|
||||
.searcher
|
||||
.search(&query.text.trim().to_lowercase())
|
||||
.map_err(|err| {
|
||||
Box::new(DataSourceSearchError {
|
||||
message: err.to_string(),
|
||||
}) as DataSourceRunErrorWrapper
|
||||
})?
|
||||
.into_iter()
|
||||
.map(QueryResult::from)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DataSource {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
trait LaunchConfigSearcher {
|
||||
fn search(&self, search_term: &str) -> anyhow::Result<Vec<SearchItem>>;
|
||||
|
||||
fn refresh_search_index(&mut self, app: &AppContext);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FuzzyLaunchConfigSearcher {
|
||||
configs: HashMap<String, LaunchConfig>,
|
||||
}
|
||||
|
||||
impl LaunchConfigSearcher for FuzzyLaunchConfigSearcher {
|
||||
fn search(&self, search_term: &str) -> anyhow::Result<Vec<SearchItem>> {
|
||||
Ok(self
|
||||
.configs
|
||||
.values()
|
||||
.filter_map(|launch_config| {
|
||||
let match_result =
|
||||
match_indices_case_insensitive(&launch_config.name, search_term)?;
|
||||
|
||||
Some(SearchItem::new(
|
||||
Arc::new(launch_config.clone()),
|
||||
match_result,
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn refresh_search_index(&mut self, app: &AppContext) {
|
||||
self.configs = WarpConfig::as_ref(app)
|
||||
.launch_configs()
|
||||
.iter()
|
||||
.map(|config| (config.name.to_lowercase(), config.clone()))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod full_text_searcher {
|
||||
use crate::define_search_schema;
|
||||
use crate::launch_configs::launch_config::LaunchConfig;
|
||||
use crate::search::command_palette::launch_config::data_source::LaunchConfigSearcher;
|
||||
use crate::search::command_palette::launch_config::search_item::SearchItem;
|
||||
use crate::search::searcher::{AsyncSearcher, DEFAULT_MEMORY_BUDGET, SCORE_CONVERSION_FACTOR};
|
||||
use crate::user_config::WarpConfig;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use warpui::r#async::executor::Background;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
// The name of the launch configs are duplicated to ensure that the searcher
|
||||
// hashes the name to uniquely identify the launch config.
|
||||
// Also, it makes sense from a schema POV: the name is the identifying key.
|
||||
// TODO: Add a proper Launch Config ID
|
||||
define_search_schema!(
|
||||
schema_name: LAUNCH_CONFIG_SCHEMA,
|
||||
config_name: ConfigSearcherConfig,
|
||||
search_doc: LaunchConfigDocument,
|
||||
identifying_doc: LaunchConfigIdDocument,
|
||||
search_fields: [name: 1.0],
|
||||
id_fields: [name_id: String]
|
||||
);
|
||||
|
||||
pub(crate) struct FullTextLaunchConfigSearcher {
|
||||
background_executor: Arc<Background>,
|
||||
searcher: AsyncSearcher<ConfigSearcherConfig>,
|
||||
configs: HashMap<String, LaunchConfig>,
|
||||
}
|
||||
|
||||
impl LaunchConfigSearcher for FullTextLaunchConfigSearcher {
|
||||
fn search(&self, search_term: &str) -> anyhow::Result<Vec<SearchItem>> {
|
||||
if search_term.is_empty() {
|
||||
return Ok(self
|
||||
.configs
|
||||
.values()
|
||||
.map(|config| {
|
||||
SearchItem::new(Arc::new(config.clone()), FuzzyMatchResult::no_match())
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
|
||||
Ok(self
|
||||
.searcher
|
||||
.search_id(search_term)?
|
||||
.into_iter()
|
||||
.filter_map(|match_result| {
|
||||
let launch_config = self.configs.get(&match_result.values.name_id)?;
|
||||
let match_result = FuzzyMatchResult {
|
||||
score: (match_result.score * SCORE_CONVERSION_FACTOR) as i64,
|
||||
matched_indices: match_result.highlights.name,
|
||||
};
|
||||
|
||||
Some(SearchItem::new(
|
||||
Arc::new(launch_config.clone()),
|
||||
match_result,
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn refresh_search_index(&mut self, app: &AppContext) {
|
||||
self.configs = WarpConfig::as_ref(app)
|
||||
.launch_configs()
|
||||
.iter()
|
||||
.map(|config| (config.name.to_lowercase(), config.clone()))
|
||||
.collect();
|
||||
if self.rebuild_search_index().is_err() {
|
||||
log::error!("Failed to create search index writer for launch configs");
|
||||
self.clear_search_index();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FullTextLaunchConfigSearcher {
|
||||
pub(crate) fn new(background_executor: Arc<Background>) -> Self {
|
||||
Self {
|
||||
background_executor: background_executor.clone(),
|
||||
searcher: LAUNCH_CONFIG_SCHEMA
|
||||
.create_async_searcher(DEFAULT_MEMORY_BUDGET, background_executor),
|
||||
configs: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild_search_index(&mut self) -> Result<(), anyhow::Error> {
|
||||
self.clear_search_index();
|
||||
let documents = self.configs.keys().map(|name| LaunchConfigDocument {
|
||||
name: name.clone(),
|
||||
name_id: name.clone(),
|
||||
});
|
||||
self.searcher.build_index_async(documents)
|
||||
}
|
||||
|
||||
fn clear_search_index(&mut self) {
|
||||
if self.searcher.clear_search_index_async().is_err() {
|
||||
// As a workaround, we can create a new index and replace the old one.
|
||||
self.searcher = LAUNCH_CONFIG_SCHEMA
|
||||
.create_async_searcher(DEFAULT_MEMORY_BUDGET, self.background_executor.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod data_source;
|
||||
mod renderer;
|
||||
mod search_item;
|
||||
|
||||
pub use data_source::DataSource;
|
||||
@@ -0,0 +1,158 @@
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, Border, ConstrainedBox, Container, CornerRadius, Flex, Highlight, ParentElement,
|
||||
Radius, Shrinkable, Text,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
ui_components::{
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
text::Span,
|
||||
},
|
||||
Element,
|
||||
};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::launch_configs::launch_config::LaunchConfig;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::themes::theme::Fill;
|
||||
|
||||
impl LaunchConfig {
|
||||
/// Renders a [`LaunchConfig`] using a [`StylesProvider`]. Any character indices of the launch
|
||||
/// config title contained within `highlighted_indices` are highlighted in bold.
|
||||
pub(super) fn render(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
item_highlight_state: ItemHighlightState,
|
||||
highlight_indices: Vec<usize>,
|
||||
) -> Box<dyn Element> {
|
||||
let bg_color = background_fill(item_highlight_state, appearance);
|
||||
|
||||
let text_color = appearance.theme().main_text_color(bg_color).into_solid();
|
||||
|
||||
let highlight = Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(text_color);
|
||||
|
||||
let label = self
|
||||
.render_launch_config_name(appearance, item_highlight_state)
|
||||
.with_single_highlight(highlight, highlight_indices)
|
||||
.finish();
|
||||
|
||||
let mut configuration = Flex::row();
|
||||
configuration.add_child(Shrinkable::new(1., Align::new(label).left().finish()).finish());
|
||||
|
||||
configuration.add_child(
|
||||
Container::new(self.render_config_description(appearance))
|
||||
.with_margin_right(14.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
ConstrainedBox::new(configuration.finish())
|
||||
.with_height(40.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn default_pill_styles(appearance: &Appearance) -> UiComponentStyles {
|
||||
UiComponentStyles {
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_size: Some(appearance.monospace_font_size()),
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.hint_text_color(appearance.theme().background())
|
||||
.into_solid(),
|
||||
),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
|
||||
background: Some(appearance.theme().background().into()),
|
||||
height: Some(24.),
|
||||
padding: Some(Coords::default().left(6.).right(6.)),
|
||||
margin: Some(Coords::default().left(3.)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_string_with_pill_styling(
|
||||
str: impl Into<String>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let style = Self::default_pill_styles(appearance);
|
||||
let mut container =
|
||||
Container::new(Align::new(Span::new(str.into(), style).build().finish()).finish());
|
||||
let mut border = Border::all(style.border_width.unwrap_or_default());
|
||||
if let Some(border_color) = style.border_color {
|
||||
border = border.with_border_fill(border_color);
|
||||
}
|
||||
container = container.with_border(border);
|
||||
if let Some(padding) = style.padding {
|
||||
container = container
|
||||
.with_padding_top(padding.top)
|
||||
.with_padding_right(padding.right)
|
||||
.with_padding_bottom(padding.bottom)
|
||||
.with_padding_left(padding.left);
|
||||
}
|
||||
if let Some(radius) = style.border_radius {
|
||||
container = container.with_corner_radius(radius);
|
||||
}
|
||||
if let Some(background_color) = style.background {
|
||||
container = container.with_background(background_color);
|
||||
}
|
||||
let mut sized_container = ConstrainedBox::new(container.finish());
|
||||
if let Some(width) = style.width {
|
||||
sized_container = sized_container.with_width(width);
|
||||
}
|
||||
if let Some(height) = style.height {
|
||||
sized_container = sized_container.with_height(height);
|
||||
}
|
||||
let mut container = Container::new(Align::new(sized_container.finish()).finish());
|
||||
if let Some(margin) = style.margin {
|
||||
container = container
|
||||
.with_margin_top(margin.top)
|
||||
.with_margin_right(margin.right)
|
||||
.with_margin_bottom(margin.bottom)
|
||||
.with_margin_left(margin.left);
|
||||
}
|
||||
container.finish()
|
||||
}
|
||||
|
||||
fn render_config_description(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let num_windows = self.windows.len();
|
||||
let num_tabs: usize = self.windows.iter().map(|window| window.tabs.len()).sum();
|
||||
let mut windows_str = num_windows.to_string();
|
||||
match num_windows {
|
||||
1 => windows_str.push_str(" window "),
|
||||
_ => windows_str.push_str(" windows"),
|
||||
}
|
||||
let mut tabs_str = num_tabs.to_string();
|
||||
match num_tabs {
|
||||
1 => tabs_str.push_str(" tab "),
|
||||
_ => tabs_str.push_str(" tabs"),
|
||||
}
|
||||
Flex::row()
|
||||
.with_children(vec![
|
||||
Self::render_string_with_pill_styling(windows_str, appearance),
|
||||
Self::render_string_with_pill_styling(tabs_str, appearance),
|
||||
])
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_launch_config_name(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
item_highlight_state: ItemHighlightState,
|
||||
) -> Text {
|
||||
let text = Text::new_inline(
|
||||
self.name.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
);
|
||||
|
||||
let bg_color = background_fill(item_highlight_state, appearance);
|
||||
text.with_color(appearance.theme().sub_text_color(bg_color).into_solid())
|
||||
}
|
||||
}
|
||||
|
||||
fn background_fill(item_highlight_state: ItemHighlightState, appearance: &Appearance) -> Fill {
|
||||
item_highlight_state
|
||||
.container_background_fill(appearance)
|
||||
.unwrap_or_else(|| appearance.theme().surface_2())
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use crate::launch_configs::launch_config::LaunchConfig;
|
||||
use crate::{appearance::Appearance, ui_components::icons::Icon};
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::render_util::render_search_item_icon;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
|
||||
use ordered_float::OrderedFloat;
|
||||
use std::sync::Arc;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
/// SearchItem for a matching [`LaunchConfig`].
|
||||
#[derive(Debug)]
|
||||
pub struct SearchItem {
|
||||
match_result: FuzzyMatchResult,
|
||||
launch_config: Arc<LaunchConfig>,
|
||||
}
|
||||
|
||||
impl SearchItem {
|
||||
pub fn new(launch_config: Arc<LaunchConfig>, match_result: FuzzyMatchResult) -> Self {
|
||||
Self {
|
||||
match_result,
|
||||
launch_config,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::search::item::SearchItem for SearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let color = appearance.theme().foreground().into_solid();
|
||||
render_search_item_icon(appearance, Icon::Navigation, color, highlight_state)
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
self.launch_config.render(
|
||||
appearance,
|
||||
highlight_state,
|
||||
self.match_result.matched_indices.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat::from(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::OpenLaunchConfiguration {
|
||||
config: self.launch_config.clone(),
|
||||
open_in_active_window: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::OpenLaunchConfiguration {
|
||||
config: self.launch_config.clone(),
|
||||
open_in_active_window: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Selected {}.", self.launch_config.name)
|
||||
}
|
||||
|
||||
fn accessibility_help_message(&self) -> Option<String> {
|
||||
Some("Press enter to use this launch configuration.".into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::launch_configs::launch_config::LaunchConfig;
|
||||
use crate::search::command_palette::new_session::{NewSessionOption, NewSessionOptionId};
|
||||
use crate::search::mixer::SearchMixer;
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::util::bindings::CommandBinding;
|
||||
use crate::workspace::PaneViewLocator;
|
||||
use std::sync::Arc;
|
||||
use strum_macros::IntoStaticStr;
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
use warpui::keymap::BindingId;
|
||||
use warpui::{EntityId, WindowId};
|
||||
|
||||
pub type CommandPaletteMixer = SearchMixer<CommandPaletteItemAction>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CommandPaletteItemAction {
|
||||
/// A binding result was clicked.
|
||||
AcceptBinding {
|
||||
binding: Arc<CommandBinding>,
|
||||
},
|
||||
ExecuteWorkflow {
|
||||
id: SyncId,
|
||||
},
|
||||
OpenNotebook {
|
||||
id: SyncId,
|
||||
},
|
||||
ViewInWarpDrive {
|
||||
id: CloudObjectTypeAndId,
|
||||
},
|
||||
InvokeEnvironmentVariables {
|
||||
id: SyncId,
|
||||
},
|
||||
/// Navigate to the session identified by `pane_view`.
|
||||
NavigateToSession {
|
||||
pane_view_locator: PaneViewLocator,
|
||||
window_id: WindowId,
|
||||
},
|
||||
/// Navigate to a specific conversation.
|
||||
NavigateToConversation {
|
||||
pane_view_locator: Option<PaneViewLocator>,
|
||||
window_id: Option<WindowId>,
|
||||
conversation_id: AIConversationId,
|
||||
terminal_view_id: Option<EntityId>,
|
||||
},
|
||||
ForkConversation {
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
OpenLaunchConfiguration {
|
||||
config: Arc<LaunchConfig>,
|
||||
/// See [`OpenLaunchConfigArg::open_in_active_window`].
|
||||
open_in_active_window: bool,
|
||||
},
|
||||
NewSession {
|
||||
source: Arc<NewSessionOption>,
|
||||
},
|
||||
OpenFile {
|
||||
path: String,
|
||||
project_directory: String,
|
||||
line_and_column_arg: Option<LineAndColumnArg>,
|
||||
},
|
||||
OpenDirectory {
|
||||
path: String,
|
||||
project_directory: String,
|
||||
},
|
||||
CreateFile {
|
||||
file_name: String,
|
||||
current_directory: String,
|
||||
},
|
||||
NewConversationInProject {
|
||||
path: String,
|
||||
project_name: String,
|
||||
},
|
||||
/// Start a new AI conversation
|
||||
NewConversation,
|
||||
/// No-op action (used for non-interactable separator items that don't do anything on click).
|
||||
NoOp,
|
||||
}
|
||||
|
||||
impl CommandPaletteItemAction {
|
||||
pub fn to_summary(&self) -> ItemSummary {
|
||||
match self {
|
||||
CommandPaletteItemAction::AcceptBinding { binding } => ItemSummary::Action {
|
||||
binding_id: binding.id,
|
||||
},
|
||||
CommandPaletteItemAction::OpenNotebook { id } => ItemSummary::Notebook { id: *id },
|
||||
CommandPaletteItemAction::ExecuteWorkflow { id } => ItemSummary::Workflow { id: *id },
|
||||
CommandPaletteItemAction::InvokeEnvironmentVariables { id } => {
|
||||
ItemSummary::EnvVarCollection { id: *id }
|
||||
}
|
||||
CommandPaletteItemAction::NavigateToSession {
|
||||
pane_view_locator, ..
|
||||
} => ItemSummary::Session {
|
||||
pane_view_locator: *pane_view_locator,
|
||||
},
|
||||
CommandPaletteItemAction::NavigateToConversation {
|
||||
conversation_id, ..
|
||||
} => ItemSummary::Conversation {
|
||||
id: *conversation_id,
|
||||
},
|
||||
CommandPaletteItemAction::ForkConversation { .. } => ItemSummary::ForkConversation,
|
||||
CommandPaletteItemAction::NewSession { source } => ItemSummary::NewSession {
|
||||
id: source.id().clone(),
|
||||
},
|
||||
CommandPaletteItemAction::OpenLaunchConfiguration { .. } => {
|
||||
ItemSummary::LaunchConfiguration
|
||||
}
|
||||
CommandPaletteItemAction::ViewInWarpDrive { id } => match id {
|
||||
CloudObjectTypeAndId::Notebook(_)
|
||||
| CloudObjectTypeAndId::Folder(_)
|
||||
| CloudObjectTypeAndId::GenericStringObject { .. } => ItemSummary::CloudObject,
|
||||
CloudObjectTypeAndId::Workflow(id) => ItemSummary::Workflow { id: *id },
|
||||
},
|
||||
CommandPaletteItemAction::OpenFile {
|
||||
path,
|
||||
project_directory,
|
||||
line_and_column_arg,
|
||||
} => ItemSummary::File {
|
||||
path: path.clone(),
|
||||
project_directory: project_directory.clone(),
|
||||
line_and_column_arg: *line_and_column_arg,
|
||||
},
|
||||
CommandPaletteItemAction::OpenDirectory {
|
||||
path,
|
||||
project_directory,
|
||||
} => ItemSummary::Directory {
|
||||
path: path.clone(),
|
||||
project_directory: project_directory.clone(),
|
||||
},
|
||||
CommandPaletteItemAction::CreateFile { .. } => {
|
||||
// CreateFile actions should not show up in recent items
|
||||
ItemSummary::NoOp
|
||||
}
|
||||
CommandPaletteItemAction::NewConversationInProject { path, .. } => {
|
||||
ItemSummary::Project { path: path.clone() }
|
||||
}
|
||||
CommandPaletteItemAction::NewConversation => ItemSummary::NewConversation,
|
||||
CommandPaletteItemAction::NoOp => ItemSummary::NoOp,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn result_type(&self) -> &'static str {
|
||||
self.to_summary().into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary of items that were selected via the command palette. This is needed so that we have a
|
||||
/// unique way to identify a selected item so we can show it in the "recent" section of the
|
||||
/// palette. We choose to not use the entire [`CommandPaletteItemAction`] since we only need a
|
||||
/// unique identifier to store. Additionally, parts of the `CommandPaletteItemAction` could change
|
||||
/// in between invocations of the command palette (such as the content or title of a workflow or the
|
||||
/// trigger for a keybinding) that should not be factored in when determining whether to show it in
|
||||
/// the recent section of the palette.
|
||||
#[derive(Clone, Debug, PartialEq, IntoStaticStr)]
|
||||
pub enum ItemSummary {
|
||||
Action {
|
||||
binding_id: BindingId,
|
||||
},
|
||||
Workflow {
|
||||
id: SyncId,
|
||||
},
|
||||
EnvVarCollection {
|
||||
id: SyncId,
|
||||
},
|
||||
Notebook {
|
||||
id: SyncId,
|
||||
},
|
||||
Session {
|
||||
pane_view_locator: PaneViewLocator,
|
||||
},
|
||||
NewSession {
|
||||
id: NewSessionOptionId,
|
||||
},
|
||||
/// Dummy enum variant for launch configurations until we support showing them in recent section
|
||||
/// of the zero state
|
||||
LaunchConfiguration,
|
||||
/// Dummy enum variant for cloud objects that aren't supported yet in command palette
|
||||
CloudObject,
|
||||
File {
|
||||
path: String,
|
||||
project_directory: String,
|
||||
line_and_column_arg: Option<LineAndColumnArg>,
|
||||
},
|
||||
Directory {
|
||||
path: String,
|
||||
project_directory: String,
|
||||
},
|
||||
Project {
|
||||
path: String,
|
||||
},
|
||||
Conversation {
|
||||
id: AIConversationId,
|
||||
},
|
||||
ForkConversation,
|
||||
NewConversation,
|
||||
/// No-op action (used for non-interactable separator items that don't do anything on click).
|
||||
NoOp,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
pub mod conversations;
|
||||
mod data_sources;
|
||||
mod files;
|
||||
mod filter_chip_renderer;
|
||||
pub mod launch_config;
|
||||
pub mod mixer;
|
||||
pub mod navigation;
|
||||
#[cfg_attr(not(feature = "local_tty"), allow(dead_code))]
|
||||
pub mod new_session;
|
||||
pub mod render_util;
|
||||
pub mod repos;
|
||||
mod selected_items;
|
||||
pub mod separator_search_item;
|
||||
pub mod view;
|
||||
pub mod warp_drive;
|
||||
mod zero_state;
|
||||
|
||||
use filter_chip_renderer::FilterChipRenderer;
|
||||
pub use mixer::{CommandPaletteMixer, ItemSummary};
|
||||
pub use selected_items::SelectedItems;
|
||||
pub use view::View;
|
||||
|
||||
pub mod styles {
|
||||
pub const SEARCH_ITEM_TEXT_PADDING: f32 = 4.;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::navigation::search::{
|
||||
FuzzySessionSearcher, MatchedSession, SessionMatchResult, SessionSearcher,
|
||||
};
|
||||
use crate::search::command_palette::navigation::search_item::SearchItem;
|
||||
use crate::search::data_source::{DataSourceSearchError, Query, QueryResult};
|
||||
use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
use crate::search::SyncDataSource;
|
||||
use crate::session_management::{SessionNavigationData, SessionSource};
|
||||
use crate::workspace::PaneViewLocator;
|
||||
use warpui::{AppContext, Entity, ModelHandle};
|
||||
|
||||
/// Data source that produces possible running sessions a user could navigate to.
|
||||
pub struct DataSource {
|
||||
searcher: Box<dyn SessionSearcher>,
|
||||
}
|
||||
|
||||
impl DataSource {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn new(active_session_handle: ModelHandle<SessionSource>) -> Self {
|
||||
if warp_core::features::FeatureFlag::UseTantivySearch.is_enabled() {
|
||||
Self::new_full_text(active_session_handle)
|
||||
} else {
|
||||
Self::new_fuzzy(active_session_handle)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn new(active_session_handle: ModelHandle<SessionSource>) -> Self {
|
||||
Self::new_fuzzy(active_session_handle)
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn new_full_text(active_session_handle: ModelHandle<SessionSource>) -> Self {
|
||||
use crate::search::command_palette::navigation::search::FullTextSessionSearcher;
|
||||
let searcher = Box::new(FullTextSessionSearcher::new(active_session_handle));
|
||||
Self { searcher }
|
||||
}
|
||||
|
||||
fn new_fuzzy(active_session_handle: ModelHandle<SessionSource>) -> Self {
|
||||
let searcher = Box::new(FuzzySessionSearcher {
|
||||
session_source_handle: active_session_handle,
|
||||
});
|
||||
Self { searcher }
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for DataSource {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
self.searcher
|
||||
.search(&query.text.trim().to_lowercase(), app)
|
||||
.map_err(|err| {
|
||||
let search_error = DataSourceSearchError {
|
||||
message: err.to_string(),
|
||||
};
|
||||
Box::new(search_error) as DataSourceRunErrorWrapper
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl DataSource {
|
||||
/// Returns a [`QueryResult`] for a workflow identified by `sync_id`. `None` if no result was
|
||||
/// found with the given ID.
|
||||
pub fn query_result(
|
||||
&self,
|
||||
pane_view_locator: PaneViewLocator,
|
||||
app: &AppContext,
|
||||
) -> Option<QueryResult<CommandPaletteItemAction>> {
|
||||
let session = SessionNavigationData::all_sessions(app)
|
||||
.find(|session| session.pane_view_locator() == pane_view_locator)?;
|
||||
|
||||
let matched_session = MatchedSession {
|
||||
session,
|
||||
match_result: SessionMatchResult::no_match(),
|
||||
};
|
||||
|
||||
let active_session_id = self.searcher.active_session_id(app);
|
||||
|
||||
Some(SearchItem::new(matched_session, active_session_id).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod data_source;
|
||||
pub mod render;
|
||||
pub mod search;
|
||||
mod search_item;
|
||||
|
||||
pub use data_source::DataSource;
|
||||
@@ -0,0 +1,394 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::context_chips::display_chip::{
|
||||
chip_container, render_git_diff_stats_content, render_udi_chip, udi_font_size, GitLineChanges,
|
||||
UdiChipConfig,
|
||||
};
|
||||
use crate::context_chips::prompt_snapshot::PromptSnapshot;
|
||||
use crate::context_chips::{ChipValue, ContextChipKind};
|
||||
use crate::search::command_palette::navigation::search::SessionHighlightIndices;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::session_management::{CommandContext, SessionNavigationData};
|
||||
use crate::settings::FontSettings;
|
||||
use crate::terminal::blockgrid_element::BlockGridElement;
|
||||
use crate::terminal::grid_size_util::grid_cell_dimensions;
|
||||
use crate::terminal::ligature_settings::should_use_ligature_rendering;
|
||||
use crate::terminal::model::blockgrid::BlockGrid;
|
||||
use crate::terminal::model::grid::Dimensions;
|
||||
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
|
||||
use crate::terminal::SizeInfo;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warpui::elements::{
|
||||
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Highlight,
|
||||
ParentElement, Radius, Shrinkable, Wrap,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::units::IntoPixels;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
/// Renders a navigation session.
|
||||
pub fn render_navigation_session(
|
||||
session: &SessionNavigationData,
|
||||
appearance: &Appearance,
|
||||
item_highlight_state: ItemHighlightState,
|
||||
is_active_session: bool,
|
||||
highlight_indices: &SessionHighlightIndices,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
render_navigation_session_internal(
|
||||
render_session_label(
|
||||
session,
|
||||
appearance,
|
||||
item_highlight_state,
|
||||
is_active_session,
|
||||
highlight_indices,
|
||||
app,
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_navigation_session_internal(label: Box<dyn Element>) -> Box<dyn Element> {
|
||||
ConstrainedBox::new(label)
|
||||
.with_height(styles::NAVIGATION_PALETTE_ITEM_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_session_label(
|
||||
session: &SessionNavigationData,
|
||||
appearance: &Appearance,
|
||||
item_highlight_state: ItemHighlightState,
|
||||
is_active_session: bool,
|
||||
highlight_indices: &SessionHighlightIndices,
|
||||
app: &AppContext,
|
||||
) -> Flex {
|
||||
let mut navigation_palette_item = Flex::column();
|
||||
|
||||
let prompt = if let Some(ps1_grid) = &session.prompt_elements().ps1_prompt_grid {
|
||||
render_prompt_ps1(ps1_grid, appearance, app)
|
||||
} else if let Some(snapshot) = &session.prompt_elements().prompt_chip_snapshot {
|
||||
render_prompt_udi(snapshot, appearance)
|
||||
} else {
|
||||
// Fallback: empty container if neither is available (e.g. very early startup).
|
||||
Container::new(Flex::row().finish()).finish()
|
||||
};
|
||||
|
||||
let command_info = render_command_context(
|
||||
session,
|
||||
item_highlight_state,
|
||||
is_active_session,
|
||||
highlight_indices.command_indices.clone(),
|
||||
highlight_indices.hint_text_indices.clone(),
|
||||
appearance,
|
||||
);
|
||||
|
||||
navigation_palette_item.add_child(
|
||||
Container::new(prompt)
|
||||
.with_margin_right(styles::NAVIGATION_PALETTE_ROW_HORIZONTAL_SPACING)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
navigation_palette_item.add_child(
|
||||
Container::new(command_info)
|
||||
.with_margin_top(styles::NAVIGATION_PALETTE_ROW_VERTICAL_SPACING)
|
||||
.with_margin_right(styles::NAVIGATION_PALETTE_ROW_HORIZONTAL_SPACING)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
navigation_palette_item
|
||||
}
|
||||
|
||||
fn render_current_session_pill(
|
||||
command_context: CommandContext,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let current_session_pill = appearance
|
||||
.ui_builder()
|
||||
.span("Current".to_string())
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.monospace_font_family()),
|
||||
// The font size is scaled down to make sure the pill fits in the row with its padding.
|
||||
font_size: Some(appearance.monospace_font_size() * 0.85),
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().background())
|
||||
.into_solid(),
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_padding_left(5.)
|
||||
.with_padding_right(5.)
|
||||
.with_margin_left(10.)
|
||||
.with_margin_right(8.)
|
||||
.with_background_color(appearance.theme().background().into_solid())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.finish();
|
||||
|
||||
Shrinkable::new(
|
||||
// We need different flex values when different hint texts are present, otherwise the actual command won't take up enough room.
|
||||
match command_context {
|
||||
CommandContext::LastRunCommand { .. } | CommandContext::LastRunAIBlock { .. } => 0.5,
|
||||
CommandContext::RunningCommand { .. } | CommandContext::RunningAIBlock { .. } => 0.35,
|
||||
CommandContext::None => 1.,
|
||||
},
|
||||
Align::new(
|
||||
ConstrainedBox::new(current_session_pill)
|
||||
.with_max_width(135.)
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders the prompt as UDI-style context chips from a [`PromptSnapshot`].
|
||||
fn render_prompt_udi(snapshot: &PromptSnapshot, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let mut chip_row = Wrap::row().with_spacing(4.);
|
||||
|
||||
for chip_result in snapshot.chips() {
|
||||
let Some(value) = chip_result.value() else {
|
||||
continue;
|
||||
};
|
||||
// GitDiffStats are rendered differently than other chips, so we handle them separately.
|
||||
// This ensures that the rendered chip matches the live input chip.
|
||||
if matches!(chip_result.kind(), ContextChipKind::GitDiffStats) {
|
||||
let line_changes = match value {
|
||||
ChipValue::GitDiffStats(g) => g.clone(),
|
||||
ChipValue::Text(raw) => {
|
||||
let Some(parsed) = GitLineChanges::parse_from_git_output(raw) else {
|
||||
continue;
|
||||
};
|
||||
parsed
|
||||
}
|
||||
};
|
||||
let font_size = udi_font_size(appearance);
|
||||
let content = render_git_diff_stats_content(
|
||||
&line_changes,
|
||||
font_size,
|
||||
appearance.monospace_font_family(),
|
||||
font_size,
|
||||
appearance,
|
||||
);
|
||||
chip_row.add_child(chip_container(content, Some(Border::all(0.)), appearance).finish());
|
||||
continue;
|
||||
}
|
||||
|
||||
let color = chip_result
|
||||
.kind()
|
||||
.default_styles(appearance, false)
|
||||
.value_color;
|
||||
let value_text = value.to_string();
|
||||
let config = if let Some(icon) = chip_result.kind().udi_icon() {
|
||||
UdiChipConfig::new_with_icon(icon, color, value_text)
|
||||
} else {
|
||||
UdiChipConfig::new(color, value_text)
|
||||
}
|
||||
.with_border_override(Border::all(0.));
|
||||
chip_row.add_child(render_udi_chip(config, appearance));
|
||||
}
|
||||
|
||||
let prompt_section = Container::new(chip_row.finish())
|
||||
.with_margin_right(styles::NAVIGATION_PALETTE_COMMAND_HINT_MARGIN * 2.);
|
||||
|
||||
prompt_section.finish()
|
||||
}
|
||||
|
||||
/// Renders the prompt from the raw PS1 terminal grid, preserving full
|
||||
/// fidelity of the user's custom prompt (colors, glyphs, etc.).
|
||||
fn render_prompt_ps1(
|
||||
prompt_grid: &BlockGrid,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let cell_dimensions = grid_cell_dimensions(
|
||||
app.font_cache(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
appearance.line_height_ratio(),
|
||||
);
|
||||
// Derive the SizeInfo width from the grid's own column count so the
|
||||
// element renders at its natural size. The parent flex layout will
|
||||
// constrain it to the available palette width.
|
||||
let grid_width_px = prompt_grid.grid_handler().columns() as f32 * cell_dimensions.x();
|
||||
let size_info = SizeInfo::new(
|
||||
vec2f(grid_width_px, cell_dimensions.y()),
|
||||
cell_dimensions.x().into_pixels(),
|
||||
cell_dimensions.y().into_pixels(),
|
||||
0.0.into_pixels(),
|
||||
0.0.into_pixels(),
|
||||
);
|
||||
let enforce_minimum_contrast = *FontSettings::as_ref(app).enforce_minimum_contrast;
|
||||
let obfuscate_secrets = get_secret_obfuscation_mode(app);
|
||||
let mut block_grid_element = BlockGridElement::new(
|
||||
prompt_grid,
|
||||
appearance,
|
||||
enforce_minimum_contrast,
|
||||
obfuscate_secrets,
|
||||
size_info,
|
||||
);
|
||||
if should_use_ligature_rendering(app) {
|
||||
block_grid_element = block_grid_element.with_ligature_rendering();
|
||||
}
|
||||
|
||||
let prompt_section = Container::new(block_grid_element.finish())
|
||||
.with_margin_right(styles::NAVIGATION_PALETTE_COMMAND_HINT_MARGIN * 2.);
|
||||
|
||||
prompt_section.finish()
|
||||
}
|
||||
|
||||
fn render_command_context(
|
||||
session: &SessionNavigationData,
|
||||
item_highlight_state: ItemHighlightState,
|
||||
is_active_session: bool,
|
||||
command_indices: Option<Vec<usize>>,
|
||||
hint_text_indices: Vec<usize>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let command_render_info = CommandRenderInfo::from_context(session.command_context());
|
||||
|
||||
let mut command_row = Flex::row();
|
||||
let command_row_font_size = appearance.monospace_font_size() - 2.;
|
||||
|
||||
if let Some(command_text) = command_render_info.command_text {
|
||||
if !command_text.is_empty() {
|
||||
let running_command_text_color =
|
||||
item_highlight_state.main_text_fill(appearance).into_solid();
|
||||
|
||||
let mut running_command_text =
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span(command_text)
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.monospace_font_family()),
|
||||
font_size: Some(command_row_font_size),
|
||||
font_color: Some(running_command_text_color),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
if let Some(command_indices) = command_indices {
|
||||
let highlight = Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(running_command_text_color);
|
||||
running_command_text =
|
||||
running_command_text.with_highlights(command_indices, highlight);
|
||||
}
|
||||
|
||||
command_row.add_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Container::new(running_command_text.build().finish())
|
||||
.with_margin_right(command_render_info.row_spacing)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let hint_font_color = item_highlight_state.sub_text_fill(appearance).into_solid();
|
||||
|
||||
let mut hint_text = appearance
|
||||
.ui_builder()
|
||||
.span(command_render_info.hint_text)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(hint_font_color),
|
||||
font_family_id: Some(appearance.monospace_font_family()),
|
||||
font_size: Some(command_row_font_size),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let highlight = Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(hint_font_color);
|
||||
hint_text = hint_text.with_highlights(hint_text_indices, highlight);
|
||||
|
||||
command_row.add_child(
|
||||
Container::new(hint_text.build().finish())
|
||||
.with_margin_left(command_render_info.hint_margin)
|
||||
.with_margin_right(command_render_info.hint_margin)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if is_active_session {
|
||||
command_row.add_child(render_current_session_pill(
|
||||
session.command_context(),
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
|
||||
command_row = command_row.with_cross_axis_alignment(CrossAxisAlignment::End);
|
||||
|
||||
command_row.finish()
|
||||
}
|
||||
|
||||
pub(super) struct CommandRenderInfo {
|
||||
pub command_text: Option<String>,
|
||||
pub hint_text: String,
|
||||
row_spacing: f32,
|
||||
hint_margin: f32,
|
||||
}
|
||||
|
||||
impl CommandRenderInfo {
|
||||
pub fn from_context(command_context: CommandContext) -> CommandRenderInfo {
|
||||
match command_context {
|
||||
CommandContext::RunningCommand { running_command } => CommandRenderInfo {
|
||||
command_text: Some(running_command),
|
||||
hint_text: "Running...".to_string(),
|
||||
row_spacing: styles::NAVIGATION_PALETTE_COMMAND_ROW_SPACING,
|
||||
hint_margin: styles::NAVIGATION_PALETTE_COMMAND_HINT_MARGIN,
|
||||
},
|
||||
CommandContext::LastRunCommand {
|
||||
last_run_command,
|
||||
mins_since_completion,
|
||||
} => CommandRenderInfo {
|
||||
row_spacing: match last_run_command.is_empty() {
|
||||
true => 0., // Don't include any spacing if the command is empty.
|
||||
false => styles::NAVIGATION_PALETTE_COMMAND_ROW_SPACING,
|
||||
},
|
||||
hint_margin: match last_run_command.is_empty() {
|
||||
true => 0., // Don't include any margin if the command is empty.
|
||||
false => styles::NAVIGATION_PALETTE_COMMAND_HINT_MARGIN,
|
||||
},
|
||||
command_text: Some(last_run_command),
|
||||
hint_text: match mins_since_completion {
|
||||
Some(mins) if mins >= 60 => "Completed over 1 hour ago".to_string(),
|
||||
Some(mins) if mins == 1 => format!("Completed {mins} minute ago"),
|
||||
Some(mins) => format!("Completed {mins} minutes ago"),
|
||||
None => "No timestamp found".to_string(),
|
||||
},
|
||||
},
|
||||
CommandContext::RunningAIBlock { prompt } => CommandRenderInfo {
|
||||
command_text: Some(prompt),
|
||||
hint_text: "Running...".to_string(),
|
||||
row_spacing: styles::NAVIGATION_PALETTE_COMMAND_ROW_SPACING,
|
||||
hint_margin: styles::NAVIGATION_PALETTE_COMMAND_HINT_MARGIN,
|
||||
},
|
||||
CommandContext::LastRunAIBlock { prompt } => CommandRenderInfo {
|
||||
command_text: Some(prompt),
|
||||
hint_text: "Completed".to_string(),
|
||||
row_spacing: styles::NAVIGATION_PALETTE_COMMAND_ROW_SPACING,
|
||||
hint_margin: styles::NAVIGATION_PALETTE_COMMAND_HINT_MARGIN,
|
||||
},
|
||||
CommandContext::None => CommandRenderInfo {
|
||||
command_text: Some(String::new()),
|
||||
hint_text: "Empty Session".to_string(),
|
||||
row_spacing: 0.,
|
||||
hint_margin: 0.,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod styles {
|
||||
pub const NAVIGATION_PALETTE_ITEM_HEIGHT: f32 = 70.;
|
||||
|
||||
pub const NAVIGATION_PALETTE_ROW_VERTICAL_SPACING: f32 = 4.;
|
||||
|
||||
pub const NAVIGATION_PALETTE_ROW_HORIZONTAL_SPACING: f32 = 5.;
|
||||
|
||||
pub const NAVIGATION_PALETTE_COMMAND_ROW_SPACING: f32 = 10.;
|
||||
pub const NAVIGATION_PALETTE_COMMAND_HINT_MARGIN: f32 = 5.;
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
use crate::pane_group::PaneId;
|
||||
use crate::search::command_palette::navigation::render::CommandRenderInfo;
|
||||
use crate::search::command_palette::navigation::search_item::SearchItem;
|
||||
use crate::search::command_palette::navigation::DataSource;
|
||||
use crate::search::data_source::QueryResult;
|
||||
use crate::search::SyncDataSource;
|
||||
use crate::session_management::{CommandContext, SessionNavigationData, SessionSource};
|
||||
use fuzzy_match::match_indices_case_insensitive;
|
||||
use itertools::Itertools;
|
||||
use std::ops::Range;
|
||||
use warpui::{AppContext, ModelHandle};
|
||||
|
||||
/// A session that was fuzzy matched against a search term.
|
||||
pub struct MatchedSession {
|
||||
pub session: SessionNavigationData,
|
||||
pub match_result: SessionMatchResult,
|
||||
}
|
||||
|
||||
impl MatchedSession {
|
||||
/// Returns the score for the [`MatchedSession`]. If there was no match result, a score of `0`
|
||||
/// is returned.
|
||||
pub fn score(&self) -> i64 {
|
||||
self.match_result.score
|
||||
}
|
||||
|
||||
/// Returns the [`SessionHighlightIndices`] belonging to the matched session.
|
||||
pub fn highlight_indices(&self) -> &SessionHighlightIndices {
|
||||
&self.match_result.highlight_indices
|
||||
}
|
||||
}
|
||||
|
||||
/// Result from matching a session.
|
||||
#[derive(Debug)]
|
||||
pub struct SessionMatchResult {
|
||||
score: i64,
|
||||
highlight_indices: SessionHighlightIndices,
|
||||
}
|
||||
|
||||
impl SessionMatchResult {
|
||||
/// Returns a dummy match result when there is no match.
|
||||
pub fn no_match() -> Self {
|
||||
SessionMatchResult {
|
||||
score: 0,
|
||||
highlight_indices: SessionHighlightIndices {
|
||||
command_indices: None,
|
||||
hint_text_indices: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Matching indices for a matched session.
|
||||
#[derive(Debug)]
|
||||
pub struct SessionHighlightIndices {
|
||||
pub(super) command_indices: Option<Vec<usize>>,
|
||||
pub(super) hint_text_indices: Vec<usize>,
|
||||
}
|
||||
|
||||
impl SessionHighlightIndices {
|
||||
fn new(
|
||||
matched_indices: Vec<usize>,
|
||||
session_highlights: SearchableSessionStringRanges,
|
||||
) -> SessionHighlightIndices {
|
||||
// Allow lazy evaluations here. Using `then_some` will eagerly compute these
|
||||
// values, which can lead to underflow.
|
||||
#[allow(clippy::unnecessary_lazy_evaluations)]
|
||||
let command_indices = session_highlights.command_range.map(|command_range| {
|
||||
matched_indices
|
||||
.iter()
|
||||
.filter(|&idx| command_range.contains(idx))
|
||||
.map(|idx| *idx - command_range.start)
|
||||
.collect::<Vec<usize>>()
|
||||
});
|
||||
|
||||
#[allow(clippy::unnecessary_lazy_evaluations)]
|
||||
let hint_text_indices = matched_indices
|
||||
.iter()
|
||||
.filter(|&idx| session_highlights.hint_text_range.contains(idx))
|
||||
.map(|idx| *idx - session_highlights.hint_text_range.start)
|
||||
.collect::<Vec<usize>>();
|
||||
|
||||
SessionHighlightIndices {
|
||||
command_indices,
|
||||
hint_text_indices,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator of sessions that match `search_term`.
|
||||
pub fn filter_sessions<'a, 'b, I>(
|
||||
sessions_iter: I,
|
||||
search_term: &'b str,
|
||||
) -> impl Iterator<Item = MatchedSession> + use<'a, 'b, I>
|
||||
where
|
||||
I: IntoIterator<Item = &'a SessionNavigationData>,
|
||||
{
|
||||
sessions_iter
|
||||
.into_iter()
|
||||
.filter_map(move |session| {
|
||||
if search_term.is_empty() {
|
||||
Some((SessionMatchResult::no_match(), session.clone()))
|
||||
} else {
|
||||
let (searchable_string, session_highlights) =
|
||||
searchable_session_string_and_ranges(session);
|
||||
|
||||
match_indices_case_insensitive(&searchable_string, search_term).map(|result| {
|
||||
let highlight_indices =
|
||||
SessionHighlightIndices::new(result.matched_indices, session_highlights);
|
||||
(
|
||||
SessionMatchResult {
|
||||
score: result.score,
|
||||
highlight_indices,
|
||||
},
|
||||
session.clone(),
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
.map(|(match_result, session)| MatchedSession {
|
||||
session,
|
||||
match_result,
|
||||
})
|
||||
}
|
||||
|
||||
/// The searchable string format is: [prompt] [command] [hint text],
|
||||
/// where [command] may or may not be present.
|
||||
fn searchable_session_string_and_ranges(
|
||||
session: &SessionNavigationData,
|
||||
) -> (String, SearchableSessionStringRanges) {
|
||||
let mut searchable_string = session.prompt().to_string();
|
||||
let prompt_end = session.prompt().chars().count();
|
||||
|
||||
let command_range = match session.command_context() {
|
||||
CommandContext::LastRunCommand {
|
||||
last_run_command,
|
||||
mins_since_completion: _,
|
||||
} => {
|
||||
// Fuzzy search gives different weights to characters in the same word vs different words.
|
||||
searchable_string.push(' ');
|
||||
searchable_string.push_str(last_run_command.as_str());
|
||||
|
||||
let start = prompt_end + 1;
|
||||
let end = start + last_run_command.chars().count();
|
||||
Some(start..end)
|
||||
}
|
||||
CommandContext::RunningCommand { running_command } => {
|
||||
// Fuzzy search gives different weights to characters in the same word vs different words.
|
||||
searchable_string.push(' ');
|
||||
searchable_string.push_str(running_command.as_str());
|
||||
|
||||
let start = prompt_end + 1;
|
||||
let end = start + running_command.chars().count();
|
||||
Some(start..end)
|
||||
}
|
||||
CommandContext::LastRunAIBlock { prompt } | CommandContext::RunningAIBlock { prompt } => {
|
||||
// Fuzzy search gives different weights to characters in the same word vs different words.
|
||||
searchable_string.push(' ');
|
||||
searchable_string.push_str(prompt.as_str());
|
||||
|
||||
let start = prompt_end + 1;
|
||||
let end = start + prompt.chars().count();
|
||||
Some(start..end)
|
||||
}
|
||||
CommandContext::None => None,
|
||||
};
|
||||
|
||||
let command_info = CommandRenderInfo::from_context(session.command_context());
|
||||
searchable_string.push(' ');
|
||||
searchable_string.push_str(command_info.hint_text.as_str());
|
||||
let hint_text_range = match &command_range {
|
||||
Some(command_range) => {
|
||||
let start = command_range.end + 1;
|
||||
let end = start + command_info.hint_text.chars().count();
|
||||
start..end
|
||||
}
|
||||
None => {
|
||||
let start = prompt_end + 1;
|
||||
let end = start + command_info.hint_text.chars().count();
|
||||
start..end
|
||||
}
|
||||
};
|
||||
|
||||
(
|
||||
searchable_string,
|
||||
SearchableSessionStringRanges {
|
||||
command_range,
|
||||
hint_text_range,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
struct SearchableSessionStringRanges {
|
||||
command_range: Option<Range<usize>>,
|
||||
hint_text_range: Range<usize>,
|
||||
}
|
||||
|
||||
type SearcherAction = <DataSource as SyncDataSource>::Action;
|
||||
|
||||
pub trait SessionSearcher {
|
||||
fn search(
|
||||
&self,
|
||||
_search_term: &str,
|
||||
_app: &AppContext,
|
||||
) -> anyhow::Result<Vec<QueryResult<SearcherAction>>>;
|
||||
|
||||
fn active_session_id(&self, app: &AppContext) -> Option<PaneId>;
|
||||
}
|
||||
|
||||
pub struct FuzzySessionSearcher {
|
||||
pub(crate) session_source_handle: ModelHandle<SessionSource>,
|
||||
}
|
||||
|
||||
impl SessionSearcher for FuzzySessionSearcher {
|
||||
fn search(
|
||||
&self,
|
||||
search_term: &str,
|
||||
app: &AppContext,
|
||||
) -> anyhow::Result<Vec<QueryResult<SearcherAction>>> {
|
||||
let active_session_id = match self.session_source_handle.as_ref(app) {
|
||||
SessionSource::None => None,
|
||||
SessionSource::Set { active_pane_id, .. } => Some(*active_pane_id),
|
||||
};
|
||||
|
||||
// Sort sessions by last focus timestamp so sessions that were focused first are shown first.
|
||||
let all_sessions =
|
||||
SessionNavigationData::all_sessions(app).sorted_by_key(|x| x.last_focus_ts());
|
||||
|
||||
Ok(filter_sessions(all_sessions.as_slice(), search_term)
|
||||
.map(|matched_session| SearchItem::new(matched_session, active_session_id).into())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn active_session_id(&self, app: &AppContext) -> Option<PaneId> {
|
||||
match self.session_source_handle.as_ref(app) {
|
||||
SessionSource::None => None,
|
||||
SessionSource::Set { active_pane_id, .. } => Some(*active_pane_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub use full_text_searcher::FullTextSessionSearcher;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod full_text_searcher {
|
||||
use crate::define_search_schema;
|
||||
use crate::pane_group::PaneId;
|
||||
use crate::search::command_palette::navigation::search::{
|
||||
searchable_session_string_and_ranges, MatchedSession, SearcherAction,
|
||||
SessionHighlightIndices, SessionMatchResult, SessionSearcher,
|
||||
};
|
||||
use crate::search::command_palette::navigation::search_item::SearchItem;
|
||||
use crate::search::data_source::QueryResult;
|
||||
use crate::search::searcher::{DEFAULT_MEMORY_BUDGET, SCORE_CONVERSION_FACTOR};
|
||||
use crate::session_management::{SessionNavigationData, SessionSource};
|
||||
use itertools::Itertools;
|
||||
use std::collections::HashMap;
|
||||
use warpui::{AppContext, ModelHandle};
|
||||
|
||||
define_search_schema!(
|
||||
schema_name: SESSION_SEARCH_SCHEMA,
|
||||
config_name: SessionSearchConfig,
|
||||
search_doc: SessionSearchDocument,
|
||||
identifying_doc: SessionIdDocument,
|
||||
search_fields: [session: 1.0],
|
||||
id_fields: [search_id: u64],
|
||||
);
|
||||
|
||||
pub struct FullTextSessionSearcher {
|
||||
pub(crate) session_source_handle: ModelHandle<SessionSource>,
|
||||
}
|
||||
|
||||
impl SessionSearcher for FullTextSessionSearcher {
|
||||
fn search(
|
||||
&self,
|
||||
search_term: &str,
|
||||
app: &AppContext,
|
||||
) -> anyhow::Result<Vec<QueryResult<SearcherAction>>> {
|
||||
let searcher = SESSION_SEARCH_SCHEMA.create_searcher(DEFAULT_MEMORY_BUDGET);
|
||||
|
||||
let mut sessions = HashMap::new();
|
||||
let documents =
|
||||
SessionNavigationData::all_sessions(app)
|
||||
.enumerate()
|
||||
.map(|(idx, session)| {
|
||||
let (search_string, highlight) =
|
||||
searchable_session_string_and_ranges(&session);
|
||||
let search_id = SessionSearchId(idx);
|
||||
|
||||
sessions.insert(search_id, (session, highlight, search_string.clone()));
|
||||
SessionSearchDocument {
|
||||
session: search_string,
|
||||
search_id: search_id.0 as u64,
|
||||
}
|
||||
});
|
||||
|
||||
searcher.build_index(documents)?;
|
||||
|
||||
let active_session_id = match self.session_source_handle.as_ref(app) {
|
||||
SessionSource::None => None,
|
||||
SessionSource::Set { active_pane_id, .. } => Some(*active_pane_id),
|
||||
};
|
||||
|
||||
if search_term.is_empty() {
|
||||
return Ok(sessions
|
||||
.into_iter()
|
||||
.sorted_by_key(|(_, (session, ..))| session.last_focus_ts())
|
||||
.map(|(_, (session, ..))| {
|
||||
let matched_session = MatchedSession {
|
||||
session,
|
||||
match_result: SessionMatchResult::no_match(),
|
||||
};
|
||||
SearchItem::new(matched_session, active_session_id).into()
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
|
||||
let matched_sessions = searcher.search_id(search_term)?;
|
||||
Ok(matched_sessions
|
||||
.into_iter()
|
||||
.filter_map(|search_match| {
|
||||
let (session, highlight, search_string) = sessions
|
||||
.remove(&SessionSearchId(search_match.values.search_id as usize))?;
|
||||
|
||||
let char_indices = byte_indices_to_char_indices(
|
||||
&search_string,
|
||||
search_match.highlights.session,
|
||||
);
|
||||
let highlight_indices = SessionHighlightIndices::new(char_indices, highlight);
|
||||
let match_result = SessionMatchResult {
|
||||
score: (search_match.score * SCORE_CONVERSION_FACTOR) as i64,
|
||||
highlight_indices,
|
||||
};
|
||||
let matched_session = MatchedSession {
|
||||
session,
|
||||
match_result,
|
||||
};
|
||||
Some(SearchItem::new(matched_session, active_session_id).into())
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn active_session_id(&self, app: &AppContext) -> Option<PaneId> {
|
||||
match self.session_source_handle.as_ref(app) {
|
||||
SessionSource::None => None,
|
||||
SessionSource::Set { active_pane_id, .. } => Some(*active_pane_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FullTextSessionSearcher {
|
||||
pub fn new(session_source_handle: ModelHandle<SessionSource>) -> Self {
|
||||
Self {
|
||||
session_source_handle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts byte-based indices (from Tantivy snippet highlighting) into
|
||||
/// char-based indices that align with the char-based ranges used by
|
||||
/// [`SessionHighlightIndices`].
|
||||
pub(super) fn byte_indices_to_char_indices(text: &str, byte_indices: Vec<usize>) -> Vec<usize> {
|
||||
let byte_to_char: HashMap<usize, usize> = text
|
||||
.char_indices()
|
||||
.enumerate()
|
||||
.map(|(char_idx, (byte_idx, _))| (byte_idx, char_idx))
|
||||
.collect();
|
||||
|
||||
byte_indices
|
||||
.into_iter()
|
||||
.filter_map(|byte_idx| byte_to_char.get(&byte_idx).copied())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A unique identifier for a session.
|
||||
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct SessionSearchId(usize);
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_family = "wasm")))]
|
||||
#[path = "search_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,117 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::pane_group::PaneId;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::navigation::render::render_navigation_session;
|
||||
use crate::search::command_palette::navigation::search::MatchedSession;
|
||||
use crate::search::command_palette::render_util::render_search_item_icon;
|
||||
use crate::search::item::IconLocation;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::session_management::SessionNavigationData;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::elements::Container;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
/// Search item to render a session within the command palette.
|
||||
pub struct SearchItem {
|
||||
matched_session: MatchedSession,
|
||||
/// The current active session. `None` if there is no active session or we were
|
||||
/// unable to determine which session is currently active.
|
||||
active_session: Option<PaneId>,
|
||||
}
|
||||
|
||||
impl SearchItem {
|
||||
fn navigation_data(&self) -> &SessionNavigationData {
|
||||
&self.matched_session.session
|
||||
}
|
||||
|
||||
pub fn new(matched_session: MatchedSession, active_session: Option<PaneId>) -> Self {
|
||||
Self {
|
||||
matched_session,
|
||||
active_session,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::search::item::SearchItem for SearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn is_multiline(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let color = appearance.theme().foreground().into_solid();
|
||||
|
||||
render_search_item_icon(appearance, Icon::TerminalInput, color, highlight_state)
|
||||
}
|
||||
|
||||
fn icon_location(&self, appearance: &Appearance) -> IconLocation {
|
||||
// The icon is has the size of the monospace font, whereas the text have a height of
|
||||
// `line_height_ratio * font_size`. Offset the icon by this difference so it is rendered
|
||||
// centered with the text.
|
||||
let margin_top = (appearance.line_height_ratio() * appearance.monospace_font_size())
|
||||
- appearance.monospace_font_size();
|
||||
IconLocation::Top { margin_top }
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let is_active_session = self
|
||||
.active_session
|
||||
.is_some_and(|id| self.navigation_data().is_for_session(id));
|
||||
|
||||
let session_element = render_navigation_session(
|
||||
self.navigation_data(),
|
||||
appearance,
|
||||
highlight_state,
|
||||
is_active_session,
|
||||
self.matched_session.highlight_indices(),
|
||||
app,
|
||||
);
|
||||
Container::new(session_element).finish()
|
||||
}
|
||||
|
||||
fn render_details(&self, _: &AppContext) -> Option<Box<dyn Element>> {
|
||||
// Navigation search items don't support rendering a details panel.
|
||||
None
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat::from(self.matched_session.score() as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::NavigateToSession {
|
||||
pane_view_locator: self.navigation_data().pane_view_locator(),
|
||||
window_id: self.navigation_data().window_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!(
|
||||
"Selected {}. {}.",
|
||||
self.navigation_data().prompt(),
|
||||
self.navigation_data()
|
||||
.command_context()
|
||||
.a11y_description()
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
fn accessibility_help_message(&self) -> Option<String> {
|
||||
Some("Press enter to navigate to this session.".into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
use super::full_text_searcher::byte_indices_to_char_indices;
|
||||
use super::{SearchableSessionStringRanges, SessionHighlightIndices};
|
||||
|
||||
// ── byte_indices_to_char_indices ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn ascii_only_is_identity() {
|
||||
let text = "hello world";
|
||||
assert_eq!(
|
||||
byte_indices_to_char_indices(text, vec![0, 6, 10]),
|
||||
vec![0, 6, 10]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_byte_chars_shift_indices() {
|
||||
// '→' is 3 bytes. Layout:
|
||||
// byte 0..3 = '→' (char 0)
|
||||
// byte 3 = ' ' (char 1)
|
||||
// byte 4 = 'l' (char 2)
|
||||
// byte 5 = 's' (char 3)
|
||||
let text = "→ ls";
|
||||
assert_eq!(
|
||||
byte_indices_to_char_indices(text, vec![0, 3, 4, 5]),
|
||||
vec![0, 1, 2, 3]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_bytes_are_filtered_out() {
|
||||
// '→' occupies bytes 0, 1, 2. Only byte 0 is a char boundary.
|
||||
let text = "→ls";
|
||||
assert_eq!(
|
||||
byte_indices_to_char_indices(text, vec![0, 1, 2, 3, 4]),
|
||||
vec![0, 1, 2] // char 0='→', char 1='l', char 2='s'
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_inputs() {
|
||||
assert_eq!(
|
||||
byte_indices_to_char_indices("", vec![]),
|
||||
Vec::<usize>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
byte_indices_to_char_indices("abc", vec![]),
|
||||
Vec::<usize>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_width_characters() {
|
||||
// 'é' is 2 bytes, '→' is 3 bytes, 'a' is 1 byte.
|
||||
// Layout: é(0..2) →(2..5) a(5)
|
||||
let text = "é→a";
|
||||
assert_eq!(
|
||||
byte_indices_to_char_indices(text, vec![0, 2, 5]),
|
||||
vec![0, 1, 2] // char 0='é', char 1='→', char 2='a'
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_bounds_byte_indices_are_dropped() {
|
||||
let text = "ab";
|
||||
assert_eq!(
|
||||
byte_indices_to_char_indices(text, vec![0, 1, 99]),
|
||||
vec![0, 1]
|
||||
);
|
||||
}
|
||||
|
||||
// ── End-to-end: highlight pipeline with multi-byte prompt ────────────
|
||||
|
||||
/// Simulates the same range construction that `searchable_session_string_and_ranges`
|
||||
/// performs, then verifies that char-converted Tantivy byte indices produce
|
||||
/// correct per-element highlights.
|
||||
#[test]
|
||||
fn highlight_indices_correct_after_byte_to_char_conversion() {
|
||||
// Prompt with multi-byte chars: "→⇒≠" = 3 chars, 9 bytes.
|
||||
let prompt = "→⇒≠";
|
||||
let command = "ls";
|
||||
let hint = "Running...";
|
||||
|
||||
// Build the searchable string the same way the production code does.
|
||||
let mut searchable = prompt.to_string();
|
||||
let prompt_end = prompt.chars().count(); // 3
|
||||
|
||||
searchable.push(' ');
|
||||
searchable.push_str(command);
|
||||
let cmd_start = prompt_end + 1; // 4
|
||||
let cmd_end = cmd_start + command.chars().count(); // 6
|
||||
let command_range = Some(cmd_start..cmd_end);
|
||||
|
||||
searchable.push(' ');
|
||||
searchable.push_str(hint);
|
||||
let hint_start = cmd_end + 1; // 7
|
||||
let hint_end = hint_start + hint.chars().count(); // 17
|
||||
let hint_text_range = hint_start..hint_end;
|
||||
|
||||
// Simulate Tantivy returning byte offsets for "ls" in the searchable
|
||||
// string. "→⇒≠ ls Running..." — 'l' is at byte 10, 's' at byte 11.
|
||||
let byte_of_l = searchable.find('l').unwrap();
|
||||
let byte_of_s = byte_of_l + 1;
|
||||
assert_eq!(byte_of_l, 10, "precondition: 'l' should be at byte 10");
|
||||
|
||||
// Without conversion these byte offsets (10, 11) would NOT fall in the
|
||||
// char-based command_range (4..6), so highlights would be lost.
|
||||
let char_indices = byte_indices_to_char_indices(&searchable, vec![byte_of_l, byte_of_s]);
|
||||
|
||||
let ranges = SearchableSessionStringRanges {
|
||||
command_range,
|
||||
hint_text_range,
|
||||
};
|
||||
let highlights = SessionHighlightIndices::new(char_indices, ranges);
|
||||
|
||||
// 'l' and 's' should map to command-relative indices 0 and 1.
|
||||
assert_eq!(highlights.command_indices, Some(vec![0, 1]));
|
||||
assert!(highlights.hint_text_indices.is_empty());
|
||||
}
|
||||
|
||||
/// Same scenario but without the conversion — demonstrates the bug.
|
||||
#[test]
|
||||
fn raw_byte_indices_produce_wrong_highlights() {
|
||||
let prompt = "→⇒≠";
|
||||
let command = "ls";
|
||||
let hint = "Running...";
|
||||
|
||||
let mut searchable = prompt.to_string();
|
||||
let prompt_end = prompt.chars().count(); // 3
|
||||
|
||||
searchable.push(' ');
|
||||
searchable.push_str(command);
|
||||
let cmd_start = prompt_end + 1;
|
||||
let cmd_end = cmd_start + command.chars().count();
|
||||
let command_range = Some(cmd_start..cmd_end);
|
||||
|
||||
searchable.push(' ');
|
||||
searchable.push_str(hint);
|
||||
let hint_start = cmd_end + 1;
|
||||
let hint_end = hint_start + hint.chars().count();
|
||||
let hint_text_range = hint_start..hint_end;
|
||||
|
||||
// Feed raw byte offsets (10, 11) directly — the bug path.
|
||||
let byte_of_l = searchable.find('l').unwrap(); // 10
|
||||
let byte_of_s = byte_of_l + 1; // 11
|
||||
|
||||
let ranges = SearchableSessionStringRanges {
|
||||
command_range,
|
||||
hint_text_range,
|
||||
};
|
||||
let highlights = SessionHighlightIndices::new(vec![byte_of_l, byte_of_s], ranges);
|
||||
|
||||
// Byte 10 and 11 fall in the char-based hint_text_range (7..17), NOT the
|
||||
// command_range (4..6), so command highlights are lost and hint highlights
|
||||
// land on wrong characters.
|
||||
assert_eq!(highlights.command_indices, Some(vec![]));
|
||||
assert_eq!(highlights.hint_text_indices, vec![3, 4]); // wrong!
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
use super::new_session_option::{Direction, NewSessionConfig};
|
||||
use super::new_session_option::{NewSessionOption, NewSessionOptionId};
|
||||
use super::search_item::SearchItem;
|
||||
use crate::search::data_source::DataSourceSearchError;
|
||||
use crate::search::{
|
||||
binding_source::BindingSource,
|
||||
command_palette::mixer::CommandPaletteItemAction,
|
||||
data_source::{Query, QueryResult},
|
||||
mixer::{DataSourceRunErrorWrapper, SyncDataSource},
|
||||
};
|
||||
use crate::terminal::available_shells::AvailableShells;
|
||||
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
/// Controls which kinds of new sessions the data source should surface.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct AllowedSessionKinds {
|
||||
pub windows: bool,
|
||||
pub tabs: bool,
|
||||
pub panes: bool,
|
||||
}
|
||||
|
||||
impl Default for AllowedSessionKinds {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
windows: true,
|
||||
tabs: true,
|
||||
panes: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AllowedSessionKinds {
|
||||
pub fn tabs_only() -> Self {
|
||||
Self {
|
||||
windows: false,
|
||||
tabs: true,
|
||||
panes: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A data source that provides options for creating new terminal sessions
|
||||
/// Gathers this data by:
|
||||
/// - Listening for any binding source changes
|
||||
/// - Comparing the options in binding sources (open new tab, open new window, etc.)
|
||||
/// to the list of available shells, and creates an interesction of those items.
|
||||
pub struct NewSessionDataSource {
|
||||
searcher: Box<dyn NewSessionSearcher>,
|
||||
allowed: AllowedSessionKinds,
|
||||
}
|
||||
|
||||
impl NewSessionDataSource {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn new(binding_source: ModelHandle<BindingSource>, ctx: &mut ModelContext<Self>) -> Self {
|
||||
if FeatureFlag::UseTantivySearch.is_enabled() {
|
||||
Self::new_full_text(binding_source, ctx)
|
||||
} else {
|
||||
Self::new_fuzzy(binding_source, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn new(binding_source: ModelHandle<BindingSource>, ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self::new_fuzzy(binding_source, ctx)
|
||||
}
|
||||
|
||||
fn new_fuzzy(binding_source: ModelHandle<BindingSource>, ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.observe(&binding_source, Self::on_binding_source_changed);
|
||||
Self {
|
||||
searcher: Box::new(FuzzyNewSessionSearcher::default()),
|
||||
allowed: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn new_full_text(
|
||||
binding_source: ModelHandle<BindingSource>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
ctx.observe(&binding_source, Self::on_binding_source_changed);
|
||||
Self {
|
||||
searcher: Box::new(full_text_searcher::FullTextNewSessionSearcher::new(
|
||||
ctx.background_executor(),
|
||||
)),
|
||||
allowed: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_allowed_kinds(mut self, allowed: AllowedSessionKinds) -> Self {
|
||||
self.allowed = allowed;
|
||||
self
|
||||
}
|
||||
|
||||
fn on_binding_source_changed(
|
||||
&mut self,
|
||||
source: ModelHandle<BindingSource>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if !FeatureFlag::ShellSelector.is_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let (window_id, view_id) = match source.as_ref(ctx) {
|
||||
BindingSource::None => return,
|
||||
BindingSource::View {
|
||||
window_id, view_id, ..
|
||||
} => (*window_id, *view_id),
|
||||
};
|
||||
|
||||
let shell_id_to_options = self.searcher.bindings_mut();
|
||||
|
||||
let mut has_tabs = false;
|
||||
let mut has_panes = false;
|
||||
for lens in ctx.key_bindings_for_view(window_id, view_id) {
|
||||
match lens.name {
|
||||
"workspace:new_tab" => has_tabs = true,
|
||||
"pane_group:add_down" => has_panes = true,
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
shell_id_to_options.clear();
|
||||
|
||||
for shell in AvailableShells::as_ref(ctx).get_available_shells() {
|
||||
let Some(id_str) = shell.id() else { continue };
|
||||
|
||||
if self.allowed.windows {
|
||||
let id = NewSessionOptionId::new(format!("new_window:{id_str}"));
|
||||
let new_option = Arc::new(NewSessionOption::new(
|
||||
id.clone(),
|
||||
NewSessionConfig::NewWindow(shell.clone()),
|
||||
));
|
||||
shell_id_to_options.insert(id, new_option);
|
||||
}
|
||||
|
||||
if self.allowed.tabs && has_tabs {
|
||||
let id = NewSessionOptionId::new(format!("new_tab:{id_str}"));
|
||||
let new_option = Arc::new(NewSessionOption::new(
|
||||
id.clone(),
|
||||
NewSessionConfig::NewTab(shell.clone()),
|
||||
));
|
||||
shell_id_to_options.insert(id, new_option);
|
||||
}
|
||||
|
||||
if self.allowed.panes && has_panes {
|
||||
for (id_str, direction) in [
|
||||
(format!("split_down:{id_str}"), Direction::Down),
|
||||
(format!("split_right:{id_str}"), Direction::Right),
|
||||
(format!("split_up:{id_str}"), Direction::Up),
|
||||
(format!("split_left:{id_str}"), Direction::Left),
|
||||
] {
|
||||
let id = NewSessionOptionId::new(id_str);
|
||||
let new_option = Arc::new(NewSessionOption::new(
|
||||
id.clone(),
|
||||
NewSessionConfig::Split(direction, shell.clone()),
|
||||
));
|
||||
shell_id_to_options.insert(id, new_option);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.searcher.build_index();
|
||||
}
|
||||
|
||||
pub fn query_result(
|
||||
&self,
|
||||
id: &NewSessionOptionId,
|
||||
) -> Option<QueryResult<CommandPaletteItemAction>> {
|
||||
self.searcher
|
||||
.bindings()
|
||||
.get(id)
|
||||
.map(|option| SearchItem::new(option.clone(), FuzzyMatchResult::no_match()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for NewSessionDataSource {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
/// Does a fuzzy search on the descriptions of the new session options.
|
||||
/// Logic is mostly copied from actions/data_source.rs
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
_app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let search_term = query.text.as_str();
|
||||
self.searcher.search(search_term).map_err(|err| {
|
||||
let search_error = DataSourceSearchError {
|
||||
message: err.to_string(),
|
||||
};
|
||||
Box::new(search_error) as DataSourceRunErrorWrapper
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NewSessionDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
type SearcherAction = <NewSessionDataSource as SyncDataSource>::Action;
|
||||
|
||||
const SEARCHER_BASE_STRINGS: [&str; 6] = [
|
||||
"Create New Tab",
|
||||
"Create New Window",
|
||||
"Split Pane Down",
|
||||
"Split Pane Right",
|
||||
"Split Pane Up",
|
||||
"Split Pane Left",
|
||||
];
|
||||
|
||||
trait NewSessionSearcher {
|
||||
fn search(&self, _search_term: &str) -> anyhow::Result<Vec<QueryResult<SearcherAction>>>;
|
||||
|
||||
fn build_index(&mut self);
|
||||
|
||||
fn bindings(&self) -> &HashMap<NewSessionOptionId, Arc<NewSessionOption>>;
|
||||
fn bindings_mut(&mut self) -> &mut HashMap<NewSessionOptionId, Arc<NewSessionOption>>;
|
||||
|
||||
/// Computes the maximum match score for the given query string given
|
||||
/// the "base options". We want to make sure that the default command
|
||||
/// for any given variant is listed before the variant. Ex:
|
||||
/// "Create New Tab" should always be ranked higher than
|
||||
/// "Create New Tab: Zsh"
|
||||
/// This function computes the lowest possible ranking score
|
||||
/// for any base strings that match the query. All variant
|
||||
/// matches should have this value as a ceiling.
|
||||
fn compute_max_match(&self, query_str: &str) -> Option<f64>;
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct FuzzyNewSessionSearcher {
|
||||
shell_id_to_options: HashMap<NewSessionOptionId, Arc<NewSessionOption>>,
|
||||
}
|
||||
|
||||
impl NewSessionSearcher for FuzzyNewSessionSearcher {
|
||||
fn search(&self, search_term: &str) -> anyhow::Result<Vec<QueryResult<SearcherAction>>> {
|
||||
let max_match = self.compute_max_match(search_term);
|
||||
|
||||
Ok(self
|
||||
.shell_id_to_options
|
||||
.values()
|
||||
.filter_map(move |new_session_option| {
|
||||
// Binding descriptions are almost always upper case. If a user searches with
|
||||
// lowercase text, the fuzzy matcher will weight this match lower because the case
|
||||
// between the search term and the description differ. As a result, we lowercase
|
||||
// both the search term and the description to ensure that we are matching the two
|
||||
// with the same casing.
|
||||
match_indices_case_insensitive(
|
||||
new_session_option.description().to_lowercase().as_str(),
|
||||
search_term.to_lowercase().as_str(),
|
||||
)
|
||||
.map(|result| {
|
||||
// If for some reason the variant (ex: "Create New Tab: Powershell") ranks higher
|
||||
// than a match for a base string (ex: "Create New Tab"), we want to cap the score
|
||||
// to be one less than the base string.
|
||||
if let Some(max_match) = max_match {
|
||||
FuzzyMatchResult {
|
||||
score: std::cmp::min(result.score, max_match.round() as i64 - 1),
|
||||
matched_indices: result.matched_indices,
|
||||
}
|
||||
} else {
|
||||
result
|
||||
}
|
||||
})
|
||||
.map(|result| (result, new_session_option))
|
||||
})
|
||||
.map(|(match_result, new_session_config)| {
|
||||
SearchItem::new(new_session_config.clone(), match_result).into()
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// This method is a no-op for the fuzzy searcher since it does not maintain an index.
|
||||
fn build_index(&mut self) {}
|
||||
|
||||
fn bindings(&self) -> &HashMap<NewSessionOptionId, Arc<NewSessionOption>> {
|
||||
&self.shell_id_to_options
|
||||
}
|
||||
|
||||
fn bindings_mut(&mut self) -> &mut HashMap<NewSessionOptionId, Arc<NewSessionOption>> {
|
||||
&mut self.shell_id_to_options
|
||||
}
|
||||
|
||||
fn compute_max_match(&self, query_str: &str) -> Option<f64> {
|
||||
SEARCHER_BASE_STRINGS
|
||||
.iter()
|
||||
.filter_map(|base| {
|
||||
match_indices_case_insensitive(
|
||||
base.to_lowercase().as_str(),
|
||||
query_str.to_lowercase().as_str(),
|
||||
)
|
||||
.map(|result| result.score)
|
||||
})
|
||||
.min()
|
||||
.map(|score| score as f64)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod full_text_searcher {
|
||||
use crate::define_search_schema;
|
||||
use crate::search::command_palette::new_session::data_source::{
|
||||
NewSessionSearcher, SearcherAction, SEARCHER_BASE_STRINGS,
|
||||
};
|
||||
use crate::search::command_palette::new_session::search_item::SearchItem;
|
||||
use crate::search::command_palette::new_session::{NewSessionOption, NewSessionOptionId};
|
||||
use crate::search::data_source::QueryResult;
|
||||
use crate::search::searcher::{
|
||||
AsyncSearcher, DEFAULT_MEMORY_BUDGET, MIN_MEMORY_BUDGET, SCORE_CONVERSION_FACTOR,
|
||||
};
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use warpui::r#async::executor::Background;
|
||||
|
||||
define_search_schema!(
|
||||
schema_name: NEW_SESSION_SEARCH_SCHEMA,
|
||||
config_name: NewSessionConfig,
|
||||
search_doc: NewSessionDocument,
|
||||
identifying_doc: NewSessionIdDocument,
|
||||
search_fields: [new_session_option: 1.0],
|
||||
id_fields: [id: String]
|
||||
);
|
||||
define_search_schema!(
|
||||
schema_name: BASE_TEXT_SEARCH_SCHEMA,
|
||||
config_name: BaseTextConfig,
|
||||
search_doc: BaseTextDocument,
|
||||
identifying_doc: BaseTextIdDocument,
|
||||
search_fields: [base_text: 1.0],
|
||||
id_fields: []
|
||||
);
|
||||
|
||||
pub(crate) struct FullTextNewSessionSearcher {
|
||||
background_executor: Arc<Background>,
|
||||
searcher: AsyncSearcher<NewSessionConfig>,
|
||||
max_match_searcher: AsyncSearcher<BaseTextConfig>,
|
||||
shell_id_to_options: HashMap<NewSessionOptionId, Arc<NewSessionOption>>,
|
||||
}
|
||||
|
||||
impl NewSessionSearcher for FullTextNewSessionSearcher {
|
||||
fn search(&self, search_term: &str) -> anyhow::Result<Vec<QueryResult<SearcherAction>>> {
|
||||
let max_match = self.compute_max_match(search_term);
|
||||
let search_result = self.searcher.search_id(search_term)?;
|
||||
Ok(search_result
|
||||
.into_iter()
|
||||
.filter_map(|result| {
|
||||
let matched_indices = result.highlights.new_session_option;
|
||||
let new_session_option = self
|
||||
.shell_id_to_options
|
||||
.get(&NewSessionOptionId(result.values.id))?;
|
||||
|
||||
// If for some reason the variant (ex: "Create New Tab: Powershell") ranks higher
|
||||
// than a match for a base string (ex: "Create New Tab"), we want to cap the score
|
||||
// to be one less than the base string.
|
||||
let capped_score = Self::cap_score(result.score, max_match);
|
||||
|
||||
Some(
|
||||
SearchItem::new(
|
||||
new_session_option.clone(),
|
||||
FuzzyMatchResult {
|
||||
score: (capped_score * SCORE_CONVERSION_FACTOR) as i64,
|
||||
matched_indices,
|
||||
},
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn build_index(&mut self) {
|
||||
if self.rebuild_search_index().is_err() {
|
||||
log::error!("Failed to create search index writer for new session options");
|
||||
self.clear_search_index();
|
||||
}
|
||||
}
|
||||
|
||||
fn bindings(&self) -> &HashMap<NewSessionOptionId, Arc<NewSessionOption>> {
|
||||
&self.shell_id_to_options
|
||||
}
|
||||
|
||||
fn bindings_mut(&mut self) -> &mut HashMap<NewSessionOptionId, Arc<NewSessionOption>> {
|
||||
&mut self.shell_id_to_options
|
||||
}
|
||||
|
||||
fn compute_max_match(&self, query_str: &str) -> Option<f64> {
|
||||
self.max_match_searcher
|
||||
.search_id(query_str)
|
||||
.ok()?
|
||||
.iter()
|
||||
.map(|result| result.score)
|
||||
.reduce(|min, score| if score < min { score } else { min })
|
||||
}
|
||||
}
|
||||
|
||||
impl FullTextNewSessionSearcher {
|
||||
pub(crate) fn new(background_executor: Arc<Background>) -> Self {
|
||||
let searcher = NEW_SESSION_SEARCH_SCHEMA
|
||||
.create_async_searcher(DEFAULT_MEMORY_BUDGET, background_executor.clone());
|
||||
let mut max_match_searcher = BASE_TEXT_SEARCH_SCHEMA
|
||||
.create_async_searcher(MIN_MEMORY_BUDGET, background_executor.clone());
|
||||
let max_match_documents = SEARCHER_BASE_STRINGS.iter().map(|base| BaseTextDocument {
|
||||
base_text: base.to_string(),
|
||||
});
|
||||
if max_match_searcher
|
||||
.build_index_async(max_match_documents)
|
||||
.is_err()
|
||||
{
|
||||
log::error!("Failed to build search index for base text of new session search");
|
||||
if max_match_searcher.clear_search_index_async().is_err() {
|
||||
max_match_searcher = BASE_TEXT_SEARCH_SCHEMA
|
||||
.create_async_searcher(MIN_MEMORY_BUDGET, background_executor.clone())
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
background_executor,
|
||||
searcher,
|
||||
max_match_searcher,
|
||||
shell_id_to_options: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild_search_index(&mut self) -> Result<(), anyhow::Error> {
|
||||
self.clear_search_index();
|
||||
let documents = self.shell_id_to_options.iter().map(|(id, option)| {
|
||||
let binding_description = option.description().to_lowercase();
|
||||
|
||||
NewSessionDocument {
|
||||
new_session_option: binding_description.clone(),
|
||||
id: id.0.clone(),
|
||||
}
|
||||
});
|
||||
self.searcher.build_index_async(documents)
|
||||
}
|
||||
|
||||
fn clear_search_index(&mut self) {
|
||||
if self.searcher.clear_search_index_async().is_err() {
|
||||
// As a workaround, we can create a new index and replace the old one.
|
||||
self.searcher = NEW_SESSION_SEARCH_SCHEMA
|
||||
.create_async_searcher(DEFAULT_MEMORY_BUDGET, self.background_executor.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn cap_score(score: f64, max_match_score: Option<f64>) -> f64 {
|
||||
if let Some(max_match) = max_match_score {
|
||||
// The use of 0.02 comes from the fact that fuzzy search scores are reduced by 1 in this case,
|
||||
// and we boosted the Tantivy score by a factor of 50 to roughly match the fuzzy search scores.
|
||||
if score > max_match - 0.02 {
|
||||
max_match - 0.02
|
||||
} else {
|
||||
score
|
||||
}
|
||||
} else {
|
||||
score
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod new_session_option;
|
||||
|
||||
pub use new_session_option::{NewSessionOption, NewSessionOptionId};
|
||||
|
||||
mod data_source;
|
||||
mod renderer;
|
||||
mod search_item;
|
||||
|
||||
pub use data_source::{AllowedSessionKinds, NewSessionDataSource};
|
||||
@@ -0,0 +1,128 @@
|
||||
use crate::server::telemetry::AddTabWithShellSource;
|
||||
use crate::terminal::available_shells::AvailableShell;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::WorkspaceAction;
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
use warpui::Action;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct NewSessionOptionId(pub(crate) String);
|
||||
impl NewSessionOptionId {
|
||||
#[cfg_attr(not(feature = "local_tty"), allow(dead_code))]
|
||||
pub(super) fn new(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum Direction {
|
||||
Down,
|
||||
Right,
|
||||
Up,
|
||||
Left,
|
||||
}
|
||||
|
||||
impl fmt::Display for Direction {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
Direction::Down => "Down",
|
||||
Direction::Right => "Right",
|
||||
Direction::Up => "Up",
|
||||
Direction::Left => "Left",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum NewSessionConfig {
|
||||
NewTab(AvailableShell),
|
||||
NewWindow(AvailableShell),
|
||||
Split(Direction, AvailableShell),
|
||||
}
|
||||
|
||||
impl NewSessionConfig {
|
||||
fn shell(&self) -> &AvailableShell {
|
||||
match self {
|
||||
NewSessionConfig::NewTab(shell) => shell,
|
||||
NewSessionConfig::NewWindow(shell) => shell,
|
||||
NewSessionConfig::Split(_, shell) => shell,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// An option for creating a new terminal session
|
||||
///
|
||||
/// Contains configuration information like:
|
||||
/// - which shell to use
|
||||
/// - how to display the option in the command palette
|
||||
pub struct NewSessionOption {
|
||||
id: NewSessionOptionId,
|
||||
description: String,
|
||||
config: NewSessionConfig,
|
||||
}
|
||||
|
||||
impl NewSessionOption {
|
||||
pub fn id(&self) -> &NewSessionOptionId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Returns the description (a.k.a. the top line in the command palette entry)
|
||||
pub fn description(&self) -> &str {
|
||||
self.description.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl NewSessionOption {
|
||||
pub(super) fn new(id: NewSessionOptionId, config: NewSessionConfig) -> Self {
|
||||
let description = match &config {
|
||||
NewSessionConfig::NewTab(shell) => format!("Create New Tab: {}", shell.short_name()),
|
||||
NewSessionConfig::NewWindow(shell) => {
|
||||
format!("Create New Window: {}", shell.short_name())
|
||||
}
|
||||
NewSessionConfig::Split(direction, shell) => {
|
||||
format!("Split Pane {direction}: {}", shell.short_name())
|
||||
}
|
||||
};
|
||||
Self {
|
||||
id,
|
||||
description,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an action that should be triggered if this entry is accepted
|
||||
pub fn action(&self) -> Box<dyn Action> {
|
||||
match &self.config {
|
||||
NewSessionConfig::NewTab(shell) => Box::new(WorkspaceAction::AddTabWithShell {
|
||||
shell: shell.clone(),
|
||||
source: AddTabWithShellSource::CommandPalette,
|
||||
}),
|
||||
NewSessionConfig::NewWindow(shell) => Box::new(WorkspaceAction::AddWindowWithShell {
|
||||
shell: shell.clone(),
|
||||
}),
|
||||
NewSessionConfig::Split(Direction::Down, shell) => {
|
||||
Box::new(TerminalAction::SplitDown(Some(shell.clone())))
|
||||
}
|
||||
NewSessionConfig::Split(Direction::Up, shell) => {
|
||||
Box::new(TerminalAction::SplitUp(Some(shell.clone())))
|
||||
}
|
||||
NewSessionConfig::Split(Direction::Right, shell) => {
|
||||
Box::new(TerminalAction::SplitRight(Some(shell.clone())))
|
||||
}
|
||||
NewSessionConfig::Split(Direction::Left, shell) => {
|
||||
Box::new(TerminalAction::SplitLeft(Some(shell.clone())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the details (a.k.a. the second line in the command palette entry)
|
||||
pub fn details(&self) -> Cow<'_, str> {
|
||||
self.config.shell().details()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use super::new_session_option::NewSessionOption;
|
||||
|
||||
use crate::search::command_palette::styles::SEARCH_ITEM_TEXT_PADDING;
|
||||
use warpui::{
|
||||
elements::{Container, Flex, Highlight, ParentElement, Text},
|
||||
fonts::{Properties, Weight},
|
||||
Element,
|
||||
};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
|
||||
impl NewSessionOption {
|
||||
pub(super) fn render(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
highlight_state: ItemHighlightState,
|
||||
highlight_indices: Vec<usize>,
|
||||
) -> Box<dyn Element> {
|
||||
let highlight = Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid());
|
||||
|
||||
let display_text = Text::new_inline(
|
||||
self.description().to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.with_single_highlight(highlight, highlight_indices)
|
||||
.finish();
|
||||
|
||||
let details = Text::new_inline(
|
||||
self.details().to_string(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size() - 2.,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_child(Container::new(display_text).finish())
|
||||
.with_child(
|
||||
Container::new(details)
|
||||
.with_padding_top(SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use super::new_session_option::NewSessionOption;
|
||||
use crate::{
|
||||
appearance::Appearance, search::command_palette::render_util::render_search_item_icon,
|
||||
ui_components::icons::Icon,
|
||||
};
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
|
||||
use ordered_float::OrderedFloat;
|
||||
use std::sync::Arc;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SearchItem {
|
||||
match_result: FuzzyMatchResult,
|
||||
option: Arc<NewSessionOption>,
|
||||
}
|
||||
|
||||
impl SearchItem {
|
||||
pub fn new(option: Arc<NewSessionOption>, match_result: FuzzyMatchResult) -> Self {
|
||||
Self {
|
||||
match_result,
|
||||
option,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::search::item::SearchItem for SearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn is_multiline(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
render_search_item_icon(
|
||||
appearance,
|
||||
Icon::Terminal,
|
||||
appearance.theme().foreground().into_solid(),
|
||||
highlight_state,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
self.option.render(
|
||||
appearance,
|
||||
highlight_state,
|
||||
self.match_result.matched_indices.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat::from(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::NewSession {
|
||||
source: self.option.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Selected {}.", self.option.description())
|
||||
}
|
||||
|
||||
fn accessibility_help_message(&self) -> Option<String> {
|
||||
Some("Press enter to launch this session.".into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::themes::theme::Blend;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::util::color::{ContrastingColor, MinimumAllowedContrast};
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{Align, ConstrainedBox, Container, Empty};
|
||||
use warpui::Element;
|
||||
|
||||
/// Helper function to render an icon for any search item within the command palette with consistent
|
||||
/// styling.
|
||||
pub fn render_search_item_icon(
|
||||
appearance: &Appearance,
|
||||
icon: Icon,
|
||||
icon_color: ColorU,
|
||||
highlight_state: ItemHighlightState,
|
||||
) -> Box<dyn Element> {
|
||||
let base_background = appearance.theme().surface_2();
|
||||
let background_color = match highlight_state.container_background_fill(appearance) {
|
||||
None => base_background,
|
||||
Some(highlight) => base_background.blend(&highlight),
|
||||
};
|
||||
let icon_color = icon_color.on_background(
|
||||
background_color.into_solid(),
|
||||
MinimumAllowedContrast::NonText,
|
||||
);
|
||||
let icon_element = icon.to_warpui_icon(Fill::Solid(icon_color)).finish();
|
||||
render_search_item_icon_inner(appearance, icon_element)
|
||||
}
|
||||
|
||||
/// Helper function to render a placeholder element when a search item does not have an icon.
|
||||
pub fn render_search_item_icon_placeholder(appearance: &Appearance) -> Box<dyn Element> {
|
||||
render_search_item_icon_inner(appearance, Empty::new().finish())
|
||||
}
|
||||
|
||||
fn render_search_item_icon_inner(
|
||||
appearance: &Appearance,
|
||||
inner_element: Box<dyn Element>,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(Align::new(inner_element).finish())
|
||||
.with_width(appearance.monospace_font_size())
|
||||
.with_height(appearance.monospace_font_size())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(12.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub mod colors {
|
||||
pub const WARP_AI: u32 = 0xF3B911FF;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod repo_data_source;
|
||||
pub mod repo_search_item;
|
||||
|
||||
pub use repo_data_source::*;
|
||||
pub use repo_search_item::*;
|
||||
@@ -0,0 +1,72 @@
|
||||
use ai::workspace::WorkspaceMetadata;
|
||||
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
|
||||
use itertools::Itertools;
|
||||
use warpui::{AppContext, Entity, SingletonEntity};
|
||||
|
||||
use super::RepoSearchItem;
|
||||
use crate::ai::persisted_workspace::PersistedWorkspace;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
|
||||
const MAX_REPOS_CONSIDERED: usize = 50;
|
||||
|
||||
pub struct RepoDataSource {}
|
||||
|
||||
impl Default for RepoDataSource {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RepoDataSource {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
pub fn top_n(&self, limit: usize, app: &AppContext) -> impl Iterator<Item = RepoSearchItem> {
|
||||
PersistedWorkspace::as_ref(app)
|
||||
.workspaces()
|
||||
.filter(|cbm| cbm.path.is_dir())
|
||||
.sorted_by(WorkspaceMetadata::most_recently_navigated)
|
||||
.take(limit)
|
||||
.map(RepoSearchItem::new)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for RepoDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SyncDataSource for RepoDataSource {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let query_str = query.text.as_str();
|
||||
|
||||
let repos = self.top_n(MAX_REPOS_CONSIDERED, app);
|
||||
|
||||
let results = repos
|
||||
.filter_map(|mut repo| {
|
||||
let match_result = if query_str.is_empty() {
|
||||
Some(FuzzyMatchResult::no_match())
|
||||
} else {
|
||||
match_indices_case_insensitive(repo.display_name.as_str(), query_str)
|
||||
};
|
||||
|
||||
// Boost repo results so they compete fairly with other sources
|
||||
match_result.map(|mut match_result| {
|
||||
match_result.score *= 4;
|
||||
repo.match_result = match_result;
|
||||
repo.into()
|
||||
})
|
||||
})
|
||||
.collect_vec();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use ai::workspace::WorkspaceMetadata;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use std::path::Path;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::{
|
||||
elements::{Align, ConstrainedBox, Flex, Highlight, ParentElement, Shrinkable, Text},
|
||||
fonts::{Properties, Weight},
|
||||
AppContext, Element, SingletonEntity,
|
||||
};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::action::search_item::styles;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::render_util;
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::ui_components::icons::Icon as UiIcon;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RepoSearchItem {
|
||||
pub display_name: String,
|
||||
pub metadata: WorkspaceMetadata,
|
||||
pub match_result: FuzzyMatchResult,
|
||||
}
|
||||
|
||||
fn repo_display_name(repo_path: &Path) -> String {
|
||||
// Try to create a relative path from the user's home directory
|
||||
dirs::home_dir()
|
||||
.and_then(|home| repo_path.strip_prefix(&home).ok())
|
||||
.map(|relative_path| format!("~/{}", relative_path.display()))
|
||||
.unwrap_or_else(|| repo_path.display().to_string())
|
||||
}
|
||||
|
||||
impl RepoSearchItem {
|
||||
pub fn new(metadata: WorkspaceMetadata) -> Self {
|
||||
RepoSearchItem {
|
||||
display_name: repo_display_name(&metadata.path),
|
||||
metadata,
|
||||
match_result: FuzzyMatchResult::no_match(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
item_highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let label = self.render_label(item_highlight_state, appearance);
|
||||
let mut binding = Flex::row();
|
||||
|
||||
binding.add_child(Shrinkable::new(1., Align::new(label).left().finish()).finish());
|
||||
|
||||
ConstrainedBox::new(binding.finish())
|
||||
.with_height(styles::SEARCH_ITEM_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_label(
|
||||
&self,
|
||||
item_highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Text::new_inline(
|
||||
repo_display_name(&self.metadata.path),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(item_highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(
|
||||
item_highlight_state.main_text_fill(appearance).into_solid(),
|
||||
),
|
||||
self.match_result.matched_indices.clone(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for RepoSearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_color: Fill = appearance.theme().terminal_colors().normal.cyan.into();
|
||||
|
||||
render_util::render_search_item_icon(
|
||||
appearance,
|
||||
UiIcon::Folder,
|
||||
icon_color.into_solid(),
|
||||
highlight_state,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
self.render(highlight_state, appearance)
|
||||
}
|
||||
|
||||
fn render_details(&self, _ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> CommandPaletteItemAction {
|
||||
// Convert the absolute repo path into parent + basename for OpenDirectory
|
||||
let repo_path: &Path = &self.metadata.path;
|
||||
let parent = repo_path.parent().unwrap_or(Path::new("/"));
|
||||
let basename = repo_path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| repo_path.to_string_lossy().to_string());
|
||||
|
||||
CommandPaletteItemAction::OpenDirectory {
|
||||
path: basename,
|
||||
project_directory: parent.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> CommandPaletteItemAction {
|
||||
// For projects, execute and accept have the same behavior
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Repo: {}", self.metadata.path.display())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use crate::search::command_palette::mixer::ItemSummary;
|
||||
use bounded_vec_deque::BoundedVecDeque;
|
||||
use warpui::{Entity, SingletonEntity};
|
||||
|
||||
/// Maximum number of elements to store. Per the [`BoundedVecDeque`] docs, it is recommended that
|
||||
/// this is one less than the power of two to avoid unnecessary allocations.
|
||||
///
|
||||
/// Only a small set of selected items are stored (15). However, we store more items than we render
|
||||
/// in the command palette since it's not guaranteed that all of the items are available at a
|
||||
/// given time (available bindings are dependent on which view is focused, sessions could have been
|
||||
/// closed, workflows could have been deleted).
|
||||
const MAX_SIZE: usize = 15;
|
||||
|
||||
/// Store of all of recently selected items within the command palette. Only one item of any given
|
||||
/// [`ItemSummary`] type is stored.
|
||||
pub struct SelectedItems {
|
||||
items: BoundedVecDeque<ItemSummary>,
|
||||
}
|
||||
|
||||
impl Default for SelectedItems {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectedItems {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
items: BoundedVecDeque::new(MAX_SIZE),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueue a new `summary` into the list of [`SelectedItems`]. If the item is already in the
|
||||
/// list, it is removed and reinserted at the end.
|
||||
///
|
||||
/// Upon insertion, if the max number of items exceeds that of [`MAX_SIZE`], items from the
|
||||
/// beginning of the list are removed.
|
||||
pub fn enqueue(&mut self, summary: ItemSummary) {
|
||||
if let Some(index) = self.items.iter().position(|item| item == &summary) {
|
||||
self.items.remove(index);
|
||||
}
|
||||
|
||||
self.items.push_back(summary);
|
||||
}
|
||||
|
||||
/// Returns an iterator of the recently selected items in reverse order of when they were
|
||||
/// selected (newly selected items are returned first).
|
||||
pub fn iter(&self) -> impl Iterator<Item = &ItemSummary> {
|
||||
self.items.iter().rev()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SelectedItems {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for SelectedItems {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "selected_items_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,49 @@
|
||||
use super::*;
|
||||
use itertools::Itertools;
|
||||
use warpui::keymap::BindingId;
|
||||
|
||||
#[test]
|
||||
fn test_enqueue_new_item() {
|
||||
let mut selected_items = SelectedItems::new();
|
||||
|
||||
let summary_1 = ItemSummary::Action {
|
||||
binding_id: BindingId::new(),
|
||||
};
|
||||
let summary_2 = ItemSummary::Action {
|
||||
binding_id: BindingId::new(),
|
||||
};
|
||||
|
||||
// Enqueue two items.
|
||||
selected_items.enqueue(summary_1.clone());
|
||||
selected_items.enqueue(summary_2.clone());
|
||||
|
||||
// Items should be returned in reverse order of they were enqueued.
|
||||
assert_eq!(
|
||||
selected_items.iter().collect_vec(),
|
||||
vec![&summary_2, &summary_1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enqueue_existing_item() {
|
||||
let mut selected_items = SelectedItems::new();
|
||||
|
||||
let summary_1 = ItemSummary::Action {
|
||||
binding_id: BindingId::new(),
|
||||
};
|
||||
let summary_2 = ItemSummary::Action {
|
||||
binding_id: BindingId::new(),
|
||||
};
|
||||
|
||||
// Enqueue `summary_1` twice.
|
||||
selected_items.enqueue(summary_1.clone());
|
||||
selected_items.enqueue(summary_2.clone());
|
||||
selected_items.enqueue(summary_1.clone());
|
||||
|
||||
// Ensure `summary_1` is returned first since it was enqueued more recently and that it isn't
|
||||
// included in the selected items list twice.
|
||||
assert_eq!(
|
||||
selected_items.iter().collect_vec(),
|
||||
vec![&summary_1, &summary_2]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::{appearance::Appearance, search::command_palette::mixer::CommandPaletteItemAction};
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::{
|
||||
elements::{Empty, Text},
|
||||
AppContext, Element, SingletonEntity,
|
||||
};
|
||||
|
||||
/// A simple separator item that displays a title to visually separate sections in search results.
|
||||
#[derive(Debug)]
|
||||
pub struct SeparatorSearchItem {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
impl SeparatorSearchItem {
|
||||
pub fn new(title: String) -> Self {
|
||||
Self { title }
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for SeparatorSearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
_appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
Text::new_inline(
|
||||
self.title.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() * 0.85,
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.disabled_text_color(appearance.theme().surface_2())
|
||||
.into_solid(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
// Give separators a neutral score - they should be positioned explicitly
|
||||
OrderedFloat(0.0)
|
||||
}
|
||||
|
||||
/// Separators are non-interactable, so we should not do anything when they are accepted.
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::NoOp
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::NoOp
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Section: {}", self.title)
|
||||
}
|
||||
|
||||
fn is_static_separator(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,175 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::CloudObject;
|
||||
use crate::drive::cloud_object_styling::warp_drive_icon_color;
|
||||
use crate::drive::{CloudObjectTypeAndId, DriveObjectType};
|
||||
use crate::env_vars::CloudEnvVarCollection;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::render_util::render_search_item_icon;
|
||||
use crate::search::command_palette::styles::SEARCH_ITEM_TEXT_PADDING;
|
||||
use crate::search::env_var_collections::fuzzy_match::FuzzyMatchEnvVarCollectionResult;
|
||||
use crate::search::item::{IconLocation, SearchItem};
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use itertools::Itertools;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::elements::{Container, Flex, Highlight, ParentElement, Text};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
pub const ENV_VAR_NAME_SEPARATOR: &str = ", ";
|
||||
|
||||
/// Search item result for a cloud EnvVarCollection.
|
||||
#[derive(Debug)]
|
||||
pub struct EnvVarCollectionSearchItem {
|
||||
pub match_result: FuzzyMatchEnvVarCollectionResult,
|
||||
pub cloud_env_var_collection: CloudEnvVarCollection,
|
||||
}
|
||||
|
||||
impl SearchItem for EnvVarCollectionSearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn is_multiline(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let color = warp_drive_icon_color(appearance, DriveObjectType::EnvVarCollection);
|
||||
render_search_item_icon(appearance, Icon::EnvVarCollection, color, highlight_state)
|
||||
}
|
||||
|
||||
fn icon_location(&self, appearance: &Appearance) -> IconLocation {
|
||||
// The icon is has the size of the monospace font, whereas the text have a height of
|
||||
// `line_height_ratio * font_size`. Offset the icon by this difference so it is rendered
|
||||
// centered with the text.
|
||||
let margin_top = (appearance.line_height_ratio() * appearance.monospace_font_size())
|
||||
- appearance.monospace_font_size();
|
||||
IconLocation::Top { margin_top }
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let mut title_text = Text::new_inline(
|
||||
self.cloud_env_var_collection
|
||||
.model()
|
||||
.string_model
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or("Untitled".to_owned())
|
||||
.to_owned(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.with_style(Properties::default().weight(Weight::Bold));
|
||||
|
||||
if let Some(title_match_result) = &self.match_result.title_match_result {
|
||||
title_text = title_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
title_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let vars_text = self
|
||||
.cloud_env_var_collection
|
||||
.model()
|
||||
.string_model
|
||||
.vars
|
||||
.iter()
|
||||
.map(|var| var.name.clone())
|
||||
.collect_vec()
|
||||
.join(ENV_VAR_NAME_SEPARATOR);
|
||||
|
||||
let mut vars_element = Text::new_inline(
|
||||
vars_text.clone(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size() - 2.,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
if let Some(var_name_match_result) = &self.match_result.var_name_match_result {
|
||||
vars_element = vars_element.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
var_name_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut breadcrumbs_text: Text = Text::new_inline(
|
||||
self.cloud_env_var_collection.breadcrumbs(app),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
if let Some(breadcrumbs_match_result) = &self.match_result.breadcrumbs_match_result {
|
||||
breadcrumbs_text = breadcrumbs_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
breadcrumbs_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut item = Flex::column()
|
||||
.with_child(Container::new(title_text.finish()).finish())
|
||||
.with_child(
|
||||
Container::new(breadcrumbs_text.finish())
|
||||
.with_padding_top(SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
item.add_child(
|
||||
Container::new(vars_element.finish())
|
||||
.with_padding_top(SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
item.finish()
|
||||
}
|
||||
|
||||
fn render_details(&self, _: &AppContext) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.match_result.score()
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::InvokeEnvironmentVariables {
|
||||
id: self.cloud_env_var_collection.id,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::ViewInWarpDrive {
|
||||
id: CloudObjectTypeAndId::GenericStringObject {
|
||||
object_type: crate::cloud_object::GenericStringObjectFormat::Json(
|
||||
crate::cloud_object::JsonObjectType::EnvVarCollection,
|
||||
),
|
||||
id: self.cloud_env_var_collection.id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!(
|
||||
"Environment Variables: {}",
|
||||
self.cloud_env_var_collection
|
||||
.model()
|
||||
.string_model
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or("Untitled".to_owned())
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod data_source;
|
||||
mod env_var_collection_search_item;
|
||||
mod notebook_search_item;
|
||||
mod workflow_search_item;
|
||||
|
||||
pub use data_source::DataSource;
|
||||
pub use workflow_search_item::WorkflowSearchItem;
|
||||
@@ -0,0 +1,146 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::CloudObject;
|
||||
use crate::drive::cloud_object_styling::warp_drive_icon_color;
|
||||
use crate::drive::{CloudObjectTypeAndId, DriveObjectType};
|
||||
use crate::notebooks::CloudNotebook;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::render_util::render_search_item_icon;
|
||||
use crate::search::command_palette::styles::SEARCH_ITEM_TEXT_PADDING;
|
||||
use crate::search::item::{IconLocation, SearchItem};
|
||||
use crate::search::notebooks::fuzzy_match::{
|
||||
render_notebook_matched_content_with_highlight, FuzzyMatchNotebookResult,
|
||||
};
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::elements::{Container, Flex, Highlight, ParentElement, Text};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
/// Search item result for a cloud notebook.
|
||||
#[derive(Debug)]
|
||||
pub struct NotebookSearchItem {
|
||||
pub cloud_notebook: CloudNotebook,
|
||||
pub match_result: FuzzyMatchNotebookResult,
|
||||
}
|
||||
|
||||
impl SearchItem for NotebookSearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn is_multiline(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let color = warp_drive_icon_color(
|
||||
appearance,
|
||||
DriveObjectType::Notebook {
|
||||
is_ai_document: false,
|
||||
},
|
||||
);
|
||||
render_search_item_icon(appearance, Icon::Notebook, color, highlight_state)
|
||||
}
|
||||
|
||||
fn icon_location(&self, appearance: &Appearance) -> IconLocation {
|
||||
// The icon is has the size of the monospace font, whereas the text have a height of
|
||||
// `line_height_ratio * font_size`. Offset the icon by this difference so it is rendered
|
||||
// centered with the text.
|
||||
let margin_top = (appearance.line_height_ratio() * appearance.monospace_font_size())
|
||||
- appearance.monospace_font_size();
|
||||
IconLocation::Top { margin_top }
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let title = if self.cloud_notebook.model().title.is_empty() {
|
||||
"Untitled".to_string()
|
||||
} else {
|
||||
self.cloud_notebook.model().title.clone()
|
||||
};
|
||||
let mut name_text = Text::new_inline(
|
||||
title,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.with_style(Properties::default().weight(Weight::Bold));
|
||||
|
||||
if let Some(name_match_result) = &self.match_result.name_match_result {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
name_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut breadcrumbs_text: Text = Text::new_inline(
|
||||
self.cloud_notebook.breadcrumbs(app),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
if let Some(folder_match_result) = &self.match_result.folder_match_result {
|
||||
breadcrumbs_text = breadcrumbs_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
folder_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let notebook_content = render_notebook_matched_content_with_highlight(
|
||||
self.cloud_notebook.id,
|
||||
&self.cloud_notebook.model().data,
|
||||
&self.match_result.content_match_result,
|
||||
highlight_state,
|
||||
app,
|
||||
);
|
||||
|
||||
Flex::column()
|
||||
.with_child(Container::new(name_text.finish()).finish())
|
||||
.with_child(
|
||||
Container::new(breadcrumbs_text.finish())
|
||||
.with_padding_top(SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(notebook_content.finish())
|
||||
.with_padding_top(SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_details(&self, _: &AppContext) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.match_result.score()
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::OpenNotebook {
|
||||
id: self.cloud_notebook.id,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::ViewInWarpDrive {
|
||||
id: CloudObjectTypeAndId::Notebook(self.cloud_notebook.id),
|
||||
}
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Notebook: {}", self.cloud_notebook.model().title)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::CloudObject;
|
||||
use crate::drive::cloud_object_styling::warp_drive_icon_color;
|
||||
use crate::drive::{CloudObjectTypeAndId, DriveObjectType};
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::command_palette::render_util::render_search_item_icon;
|
||||
use crate::search::command_palette::styles::SEARCH_ITEM_TEXT_PADDING;
|
||||
use crate::search::item::{IconLocation, SearchItem};
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::search::workflows::fuzzy_match::FuzzyMatchWorkflowResult;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::workflows::CloudWorkflow;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::elements::{Clipped, Container, Flex, Highlight, ParentElement, Shrinkable, Text};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
/// Search item result for a cloud workflow.
|
||||
#[derive(Debug)]
|
||||
pub struct WorkflowSearchItem {
|
||||
pub match_result: FuzzyMatchWorkflowResult,
|
||||
pub cloud_workflow: CloudWorkflow,
|
||||
}
|
||||
|
||||
impl SearchItem for WorkflowSearchItem {
|
||||
type Action = CommandPaletteItemAction;
|
||||
|
||||
fn is_multiline(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let (icon, icon_color) = if self.cloud_workflow.model().data.is_agent_mode_workflow() {
|
||||
(
|
||||
Icon::Prompt,
|
||||
warp_drive_icon_color(appearance, DriveObjectType::AgentModeWorkflow),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Icon::Workflow,
|
||||
warp_drive_icon_color(appearance, DriveObjectType::Workflow),
|
||||
)
|
||||
};
|
||||
render_search_item_icon(appearance, icon, icon_color, highlight_state)
|
||||
}
|
||||
|
||||
fn icon_location(&self, appearance: &Appearance) -> IconLocation {
|
||||
// The icon is has the size of the monospace font, whereas the text have a height of
|
||||
// `line_height_ratio * font_size`. Offset the icon by this difference so it is rendered
|
||||
// centered with the text.
|
||||
let margin_top = (appearance.line_height_ratio() * appearance.monospace_font_size())
|
||||
- appearance.monospace_font_size();
|
||||
IconLocation::Top { margin_top }
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let mut name_text = Text::new_inline(
|
||||
self.cloud_workflow.model().data.name().to_owned(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
|
||||
.with_style(Properties::default().weight(Weight::Bold));
|
||||
|
||||
if let Some(name_match_result) = &self.match_result.name_match_result {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
name_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut breadcrumbs_text: Text = Text::new_inline(
|
||||
self.cloud_workflow.breadcrumbs(app),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
if let Some(folder_match_result) = &self.match_result.folder_match_result {
|
||||
breadcrumbs_text = breadcrumbs_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
folder_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut content_text = Text::new_inline(
|
||||
self.cloud_workflow.model().data.content().to_owned(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size() - 2.,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
if let Some(command_match_result) = &self.match_result.content_match_result {
|
||||
content_text = content_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
command_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let contents = Flex::column()
|
||||
.with_child(Container::new(name_text.finish()).finish())
|
||||
.with_child(
|
||||
Container::new(breadcrumbs_text.finish())
|
||||
.with_padding_top(SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(content_text.finish())
|
||||
.with_padding_top(SEARCH_ITEM_TEXT_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Clipped::new(Shrinkable::new(1., contents).finish()).finish()
|
||||
}
|
||||
|
||||
fn render_details(&self, _: &AppContext) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.match_result.score()
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::ExecuteWorkflow {
|
||||
id: self.cloud_workflow.id,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
CommandPaletteItemAction::ViewInWarpDrive {
|
||||
id: CloudObjectTypeAndId::Workflow(self.cloud_workflow.id),
|
||||
}
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Workflow: {}", self.cloud_workflow.model().data.name())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
mod items;
|
||||
pub use items::Items;
|
||||
use warp_core::context_flag::ContextFlag;
|
||||
use warp_core::features::FeatureFlag;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::command_palette::FilterChipRenderer;
|
||||
|
||||
use crate::drive::settings::WarpDriveSettings;
|
||||
use crate::search::QueryFilter;
|
||||
use crate::settings::AISettings;
|
||||
use crate::workspace::Workspace;
|
||||
use std::collections::HashMap;
|
||||
use warpui::elements::{Container, Flex, MouseStateHandle, ParentElement, Shrinkable, Wrap};
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
WindowId,
|
||||
};
|
||||
|
||||
/// A zero-state view for the command palette.
|
||||
pub struct ZeroState {
|
||||
filter_chip_to_mouse_state_handle: HashMap<QueryFilter, MouseStateHandle>,
|
||||
items: ModelHandle<Items>,
|
||||
// Store the window this view belongs to so we don't rely on the global active window
|
||||
window_id: WindowId,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Action {
|
||||
FilterChipClicked { filter: QueryFilter },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Event {
|
||||
FilterChipSelected { filter: QueryFilter },
|
||||
}
|
||||
|
||||
impl ZeroState {
|
||||
pub fn new(results_model: ModelHandle<Items>, ctx: &mut ViewContext<Self>) -> Self {
|
||||
ctx.observe(&results_model, |_, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
Self {
|
||||
filter_chip_to_mouse_state_handle: QueryFilter::all()
|
||||
.map(|filter| (filter, MouseStateHandle::default()))
|
||||
.collect(),
|
||||
|
||||
items: results_model,
|
||||
window_id: ctx.window_id(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a clickable chip for each valid query filter. When a chip is
|
||||
/// clicked, the filter is emitted in a [`Event::FilterChipSelected`] event.
|
||||
fn render_filter_chips(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
valid_filters: impl IntoIterator<Item = QueryFilter>,
|
||||
) -> Box<dyn Element> {
|
||||
let wrap = Wrap::row()
|
||||
.with_run_spacing(styles::FILTER_CHIP_MARGIN)
|
||||
.with_children(valid_filters.into_iter().map(|filter| {
|
||||
Container::new(filter.render_filter_chip(
|
||||
self.filter_chip_to_mouse_state_handle[&filter].clone(),
|
||||
appearance,
|
||||
|event_ctx, filter| {
|
||||
event_ctx.dispatch_typed_action(Action::FilterChipClicked { filter })
|
||||
},
|
||||
))
|
||||
.with_margin_right(styles::FILTER_CHIP_MARGIN)
|
||||
.finish()
|
||||
}));
|
||||
|
||||
Container::new(wrap.finish())
|
||||
.with_margin_bottom(styles::FILTER_CHIPS_MARGIN_BOTTOM)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Returns the set of valid query filters for this zero state view.
|
||||
fn valid_query_filters(
|
||||
app: &AppContext,
|
||||
window_id: WindowId,
|
||||
) -> impl Iterator<Item = QueryFilter> {
|
||||
let show_warp_drive = WarpDriveSettings::is_warp_drive_enabled(app);
|
||||
|
||||
let mut valid_filters = vec![];
|
||||
if show_warp_drive {
|
||||
valid_filters.push(QueryFilter::Workflows);
|
||||
if FeatureFlag::AgentModeWorkflows.is_enabled()
|
||||
&& AISettings::as_ref(app).is_any_ai_enabled(app)
|
||||
{
|
||||
valid_filters.push(QueryFilter::AgentModeWorkflows);
|
||||
}
|
||||
valid_filters.push(QueryFilter::Notebooks);
|
||||
|
||||
valid_filters.push(QueryFilter::EnvironmentVariables);
|
||||
}
|
||||
|
||||
// Don't show Files filter if the user is a viewer of a shared session
|
||||
if FeatureFlag::CommandPaletteFileSearch.is_enabled() {
|
||||
let is_shared_session_viewer_focused = app
|
||||
.views_of_type::<Workspace>(window_id)
|
||||
.and_then(|workspaces| workspaces.first().cloned())
|
||||
.is_some_and(|workspace| {
|
||||
workspace.as_ref(app).is_shared_session_viewer_focused(app)
|
||||
});
|
||||
if !is_shared_session_viewer_focused {
|
||||
valid_filters.push(QueryFilter::Files);
|
||||
}
|
||||
}
|
||||
|
||||
if show_warp_drive {
|
||||
valid_filters.push(QueryFilter::Drive);
|
||||
}
|
||||
valid_filters.extend([QueryFilter::Actions, QueryFilter::Sessions]);
|
||||
|
||||
if ContextFlag::LaunchConfigurations.is_enabled() {
|
||||
valid_filters.push(QueryFilter::LaunchConfigurations);
|
||||
}
|
||||
|
||||
if AISettings::as_ref(app).is_any_ai_enabled(app) {
|
||||
valid_filters.push(QueryFilter::Conversations);
|
||||
}
|
||||
|
||||
valid_filters.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ZeroState {
|
||||
type Event = Event;
|
||||
}
|
||||
|
||||
impl View for ZeroState {
|
||||
fn ui_name() -> &'static str {
|
||||
"CommandPaletteZeroState"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let mut flex = Flex::column().with_child(
|
||||
self.render_filter_chips(appearance, Self::valid_query_filters(app, self.window_id)),
|
||||
);
|
||||
|
||||
let zero_state_items = self.items.as_ref(app).render(app);
|
||||
flex.add_child(Shrinkable::new(1., zero_state_items).finish());
|
||||
|
||||
Container::new(flex.finish())
|
||||
.with_vertical_padding(styles::PADDING_VERTICAL)
|
||||
.with_horizontal_padding(styles::PADDING_HORIZONTAL)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for ZeroState {
|
||||
type Action = Action;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
Action::FilterChipClicked { filter } => {
|
||||
ctx.emit(Event::FilterChipSelected { filter: *filter })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod styles {
|
||||
pub const FILTER_CHIP_MARGIN: f32 = 8.;
|
||||
pub const FILTER_CHIPS_MARGIN_BOTTOM: f32 = 16.;
|
||||
|
||||
/// Horizontal padding around all inner content within the view.
|
||||
pub const PADDING_HORIZONTAL: f32 = 24.;
|
||||
|
||||
/// Vertical padding around all inner content within the view.
|
||||
pub const PADDING_VERTICAL: f32 = 8.;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::command_palette::mixer::CommandPaletteItemAction;
|
||||
use crate::search::result_renderer::QueryResultRenderer;
|
||||
use crate::search::search_bar::SelectionUpdate;
|
||||
|
||||
use warpui::elements::{Container, Flex, ParentElement};
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::ui_components::text::WrappableText;
|
||||
use warpui::{AppContext, Element, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
/// List of items shown within the zero state. "Recent" items are shown first followed by
|
||||
/// "Suggested" items.
|
||||
pub struct Items {
|
||||
recent: Vec<QueryResultRenderer<CommandPaletteItemAction>>,
|
||||
suggested: Vec<QueryResultRenderer<CommandPaletteItemAction>>,
|
||||
selected_index: Option<SelectedIndex>,
|
||||
}
|
||||
|
||||
/// Current selected index within the list of zero state items.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
enum SelectedIndex {
|
||||
Recent(usize),
|
||||
Suggested(usize),
|
||||
}
|
||||
|
||||
impl Items {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
recent: vec![],
|
||||
suggested: vec![],
|
||||
selected_index: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders title text for a section of the zero state.
|
||||
fn render_section_text(
|
||||
header_text: impl Into<String>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
WrappableText::build(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(header_text.into(), false)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(12.),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_vertical_padding(styles::ZERO_STATE_SECTION_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_query_result(
|
||||
query_result: &QueryResultRenderer<CommandPaletteItemAction>,
|
||||
index: usize,
|
||||
is_selected: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(query_result.render(index, is_selected, app))
|
||||
.with_horizontal_padding(-super::styles::PADDING_HORIZONTAL)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Sets the recent items in the zero state to that of `recent`.
|
||||
pub fn set_recent_items(
|
||||
&mut self,
|
||||
recent: Vec<QueryResultRenderer<CommandPaletteItemAction>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.recent = recent;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Sets the suggested items in the zero state to that of `suggested`.
|
||||
pub fn set_suggested_items(
|
||||
&mut self,
|
||||
suggested: Vec<QueryResultRenderer<CommandPaletteItemAction>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.suggested = suggested;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Returns the current selected item. `None` if no item is selected.
|
||||
pub fn selected_item(&self) -> Option<&QueryResultRenderer<CommandPaletteItemAction>> {
|
||||
let selected_item = self.selected_index?;
|
||||
match selected_item {
|
||||
SelectedIndex::Recent(index) => self.recent.get(index),
|
||||
SelectedIndex::Suggested(index) => self.suggested.get(index),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator of all of the [`SelectedIndex`]s in the order they would appear.
|
||||
fn all_indices(&self) -> impl Iterator<Item = SelectedIndex> + '_ {
|
||||
self.recent
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, _)| SelectedIndex::Recent(idx))
|
||||
.chain(
|
||||
self.suggested
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, _)| SelectedIndex::Suggested(idx)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the current [`SelectedIndex`] as a total index across both recent and suggested
|
||||
/// items.
|
||||
fn total_index(&self) -> Option<usize> {
|
||||
self.selected_index
|
||||
.map(|selected_index| match selected_index {
|
||||
SelectedIndex::Recent(index) => index,
|
||||
SelectedIndex::Suggested(index) => self.recent.len() + index,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the next [`SelectedIndex`]. `None` if the next selected index would exceed all of
|
||||
/// the items in the list.
|
||||
fn next_selected_index(&self) -> Option<SelectedIndex> {
|
||||
match self.total_index() {
|
||||
None => self.all_indices().next(),
|
||||
Some(index) => self.all_indices().nth(index + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the previous [`SelectedIndex`]. `None` if the selected item would exceed the first
|
||||
/// item in the list.
|
||||
fn prev_selected_index(&self) -> Option<SelectedIndex> {
|
||||
match self.total_index() {
|
||||
None => None,
|
||||
Some(0) => None,
|
||||
// We don't use `saturating_sub` because you don't wanna be stuck on 0.
|
||||
Some(index) => self.all_indices().nth(index - 1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_selection_update(
|
||||
&mut self,
|
||||
selection_update: SelectionUpdate,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
match selection_update {
|
||||
SelectionUpdate::Up => {
|
||||
self.selected_index = self.prev_selected_index();
|
||||
ctx.notify();
|
||||
}
|
||||
SelectionUpdate::Down => {
|
||||
// Only update the selected item if not `None` to prevent unsetting the selected
|
||||
// item if the user presses down when the last item is selected.
|
||||
if let Some(next_index) = self.next_selected_index() {
|
||||
self.selected_index = Some(next_index);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
SelectionUpdate::Clear => {
|
||||
self.selected_index = None;
|
||||
ctx.notify();
|
||||
}
|
||||
// We don't want an item selected by default in the zero state, so noop here.
|
||||
SelectionUpdate::Bottom | SelectionUpdate::Top => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let mut flex = Flex::column();
|
||||
|
||||
if !self.recent.is_empty() {
|
||||
flex.add_child(Self::render_section_text("Recent", appearance));
|
||||
|
||||
flex.add_children(self.recent.iter().enumerate().map(|(idx, result)| {
|
||||
Self::render_query_result(
|
||||
result,
|
||||
idx,
|
||||
Some(SelectedIndex::Recent(idx)) == self.selected_index,
|
||||
app,
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
if !self.suggested.is_empty() {
|
||||
flex.add_child(Self::render_section_text("Suggested", appearance));
|
||||
|
||||
flex.add_children(self.suggested.iter().enumerate().map(|(idx, result)| {
|
||||
Self::render_query_result(
|
||||
result,
|
||||
idx,
|
||||
Some(SelectedIndex::Suggested(idx)) == self.selected_index,
|
||||
app,
|
||||
)
|
||||
}));
|
||||
}
|
||||
flex.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Items {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
mod styles {
|
||||
pub const ZERO_STATE_SECTION_PADDING: f32 = 8.;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use itertools::Itertools;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::search::ai_queries::fuzzy_match::FuzzyMatchAIQueryResults;
|
||||
use crate::search::command_search::searcher::CommandSearchItemAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
|
||||
use super::AIQuerySearchResultItem;
|
||||
|
||||
/// Manages querying the AI queries in history for Command Search.
|
||||
pub struct AIQueriesDataSource {}
|
||||
|
||||
impl AIQueriesDataSource {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for AIQueriesDataSource {
|
||||
type Action = CommandSearchItemAction;
|
||||
|
||||
/// Performs a query on the AI queries in history and returns a collection of matches.
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let query_str = query.text.as_str();
|
||||
|
||||
let ai_queries: Vec<_> = BlocklistAIHistoryModel::as_ref(app)
|
||||
.all_ai_queries(None)
|
||||
.collect();
|
||||
// Only show the most recent query for each unique query text.
|
||||
// all_ai_queries() returns results sorted by start_time ascending, so reversing
|
||||
// before unique_by ensures we keep the most recent entry per query text.
|
||||
let mut unique_queries = ai_queries
|
||||
.into_iter()
|
||||
.rev()
|
||||
.unique_by(|query| query.query_text.clone())
|
||||
.collect_vec();
|
||||
// Reverse back to ascending start_time order (most recent on bottom).
|
||||
unique_queries.reverse();
|
||||
|
||||
Ok(unique_queries
|
||||
.into_iter()
|
||||
.filter_map(|ai_query| -> Option<QueryResult<Self::Action>> {
|
||||
FuzzyMatchAIQueryResults::try_match(query_str, &ai_query.query_text).map(
|
||||
|match_result| {
|
||||
AIQuerySearchResultItem {
|
||||
query_text: ai_query.query_text.to_owned(),
|
||||
fuzzy_match_results: match_result,
|
||||
start_time: ai_query.start_time,
|
||||
output_status: ai_query.output_status,
|
||||
working_directory: ai_query.working_directory,
|
||||
}
|
||||
.into()
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect_vec())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use crate::{
|
||||
ai::blocklist::AIQueryHistoryOutputStatus,
|
||||
terminal::rich_history::{render_row_with_icon_and_paragraph, DETAILS_PARAGRAPH_SPACING},
|
||||
util::time_format::format_approx_duration_from_now,
|
||||
};
|
||||
use chrono::{DateTime, Local};
|
||||
use ordered_float::OrderedFloat;
|
||||
use warp_core::ui::builder::MIN_FONT_SIZE;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, Icon,
|
||||
MainAxisAlignment, MainAxisSize, ParentElement, Shrinkable, Text,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
||||
AppContext, Element, SingletonEntity,
|
||||
};
|
||||
|
||||
use crate::ui_components::icons::Icon as UiIcon;
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
search::{
|
||||
ai_queries::fuzzy_match::FuzzyMatchAIQueryResults,
|
||||
command_search::searcher::CommandSearchItemAction, item::SearchItem,
|
||||
result_renderer::ItemHighlightState,
|
||||
},
|
||||
};
|
||||
|
||||
/// Stores data needed to display an AI query search result item in Command Search.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AIQuerySearchResultItem {
|
||||
/// The query text of the [`crate::ai::blocklist::AIQueryHistory`].
|
||||
pub query_text: String,
|
||||
/// When the query was originally submitted by the user.
|
||||
pub start_time: DateTime<Local>,
|
||||
/// The output status of the [`crate::ai::blocklist::AIQueryHistory`].
|
||||
pub output_status: AIQueryHistoryOutputStatus,
|
||||
/// The directory the AI query was submitted in.
|
||||
pub(crate) working_directory: Option<String>,
|
||||
// Match result on the [`crate::ai::blocklist::AIQueryHistory`]'s query text including its
|
||||
// score and matching string indices.
|
||||
pub fuzzy_match_results: FuzzyMatchAIQueryResults,
|
||||
}
|
||||
|
||||
impl SearchItem for AIQuerySearchResultItem {
|
||||
type Action = CommandSearchItemAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(UiIcon::Prompt.into(), highlight_state.icon_fill(appearance)).finish(),
|
||||
)
|
||||
.with_width(appearance.ui_font_size())
|
||||
.with_height(appearance.ui_font_size())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(12.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let query_text = Text::new_inline(
|
||||
self.query_text.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.autosize_text(MIN_FONT_SIZE)
|
||||
.with_color(highlight_state.main_text_fill(appearance).into_solid())
|
||||
.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
self.fuzzy_match_results
|
||||
.query_text_match_result
|
||||
.matched_indices
|
||||
.clone(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let query_text_col = Flex::column()
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(query_text)
|
||||
.finish();
|
||||
|
||||
let metadata_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
self.output_status.icon().into(),
|
||||
highlight_state.main_text_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_max_height(appearance.ui_font_size())
|
||||
.with_max_width(appearance.ui_font_size())
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span(format_approx_duration_from_now(self.start_time))
|
||||
.with_style(UiComponentStyles {
|
||||
margin: Some(Coords::uniform(0.).left(8.)),
|
||||
font_color: Some(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(Shrinkable::new(1., Align::new(query_text_col).left().finish()).finish())
|
||||
.with_child(metadata_row)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_details(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let ui_builder = appearance.ui_builder();
|
||||
|
||||
let mut details_column = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(render_row_with_icon_and_paragraph(
|
||||
self.output_status.icon().into(),
|
||||
self.output_status.display_text(),
|
||||
appearance,
|
||||
));
|
||||
|
||||
if let Some(working_directory) = &self.working_directory {
|
||||
details_column.add_child(
|
||||
Container::new(render_row_with_icon_and_paragraph(
|
||||
UiIcon::Folder.into(),
|
||||
working_directory.clone(),
|
||||
appearance,
|
||||
))
|
||||
.with_margin_top(DETAILS_PARAGRAPH_SPACING)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
details_column.add_child(
|
||||
Container::new(
|
||||
ui_builder
|
||||
.paragraph(format!(
|
||||
"Ran {}",
|
||||
format_approx_duration_from_now(self.start_time)
|
||||
))
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(DETAILS_PARAGRAPH_SPACING)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
Some(details_column.finish())
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.fuzzy_match_results.score()
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> CommandSearchItemAction {
|
||||
CommandSearchItemAction::AcceptAIQuery(self.query_text.clone())
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> CommandSearchItemAction {
|
||||
CommandSearchItemAction::RunAIQuery(self.query_text.clone())
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("AI query: {}", self.query_text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod ai_queries_data_source;
|
||||
mod ai_queries_search_item;
|
||||
|
||||
pub use ai_queries_data_source::*;
|
||||
pub use ai_queries_search_item::*;
|
||||
@@ -0,0 +1,235 @@
|
||||
use itertools::Itertools;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, Icon, MainAxisAlignment,
|
||||
MainAxisSize, ParentElement, Text,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
AppContext, Element, SingletonEntity,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
env_vars::CloudEnvVarCollection,
|
||||
search::{
|
||||
command_search::searcher::CommandSearchItemAction,
|
||||
env_var_collections::fuzzy_match::FuzzyMatchEnvVarCollectionResult, item::SearchItem,
|
||||
result_renderer::ItemHighlightState,
|
||||
},
|
||||
};
|
||||
|
||||
const ENV_VAR_COLLECTION_ICON_PATH: &str = "bundled/svg/env-var-collection.svg";
|
||||
|
||||
/// Struct designed to be the implementation of CommandSearchItem for EnvVarCollections.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EnvVarCollectionSearchItem {
|
||||
pub env_var_collection: CloudEnvVarCollection,
|
||||
pub fuzzy_matched_env_var_collection: FuzzyMatchEnvVarCollectionResult,
|
||||
}
|
||||
|
||||
impl EnvVarCollectionSearchItem {
|
||||
fn render_name(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let env_var_collection = self.env_var_collection.model().string_model.clone();
|
||||
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(
|
||||
env_var_collection
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or("Untitled".to_owned()),
|
||||
true,
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().background())
|
||||
.into(),
|
||||
),
|
||||
font_size: Some(
|
||||
appearance.monospace_font_size() * styles::TITLE_FONT_SIZE_SCALE_FACTOR,
|
||||
),
|
||||
font_weight: Some(Weight::Bold),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for EnvVarCollectionSearchItem {
|
||||
type Action = CommandSearchItemAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
ENV_VAR_COLLECTION_ICON_PATH,
|
||||
highlight_state.icon_fill(appearance),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(appearance.monospace_font_size())
|
||||
.with_height(appearance.monospace_font_size())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(12.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let env_var_collection = self.env_var_collection.model().string_model.clone();
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let mut title_text = Text::new_inline(
|
||||
env_var_collection
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or("Untitled".to_owned()),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(highlight_state.main_text_fill(appearance).into_solid());
|
||||
|
||||
if let Some(name_match_result) = &self.fuzzy_matched_env_var_collection.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()),
|
||||
name_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let vars_text = self
|
||||
.env_var_collection
|
||||
.model()
|
||||
.string_model
|
||||
.vars
|
||||
.iter()
|
||||
.map(|var| var.name.clone())
|
||||
.collect_vec()
|
||||
.join(", ");
|
||||
|
||||
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.fuzzy_matched_env_var_collection.var_name_match_result
|
||||
{
|
||||
vars_element = vars_element.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
var_name_match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
Flex::column()
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(title_text.finish())
|
||||
.with_child(vars_element.finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_details(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let mut flex_column = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(
|
||||
Container::new(self.render_name(appearance))
|
||||
.with_margin_bottom(16.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let env_var_collection = self.env_var_collection.model().string_model.clone();
|
||||
|
||||
if let Some(description) = env_var_collection.description.clone() {
|
||||
let mut description_text = appearance
|
||||
.ui_builder()
|
||||
.paragraph(description.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_2())
|
||||
.into(),
|
||||
),
|
||||
font_size: Some(appearance.monospace_font_size()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
if let Some(description_match_result) = &self
|
||||
.fuzzy_matched_env_var_collection
|
||||
.description_match_result
|
||||
{
|
||||
description_text = description_text.with_highlights(
|
||||
description_match_result.matched_indices.clone(),
|
||||
Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().background())
|
||||
.into(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
flex_column.add_child(
|
||||
Container::new(description_text.build().finish())
|
||||
.with_margin_bottom(16.)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
|
||||
Some(flex_column.finish())
|
||||
}
|
||||
|
||||
/// The match score for a EnvVarCollection is an average of the match scores
|
||||
/// against the name and description of the EVC.
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.fuzzy_matched_env_var_collection.score()
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> CommandSearchItemAction {
|
||||
CommandSearchItemAction::AcceptEnvVarCollection(Box::new(self.env_var_collection.clone()))
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> CommandSearchItemAction {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
let env_var_collection = self.env_var_collection.model().string_model.clone();
|
||||
|
||||
format!(
|
||||
"Environment Variables: {}",
|
||||
env_var_collection
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or("Untitled".to_owned())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
mod styles {
|
||||
pub const TITLE_FONT_SIZE_SCALE_FACTOR: f32 = 1.12;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use itertools::Itertools;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
|
||||
use super::EnvVarCollectionSearchItem;
|
||||
use crate::search::command_search::searcher::CommandSearchItemAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::env_var_collections::fuzzy_match::FuzzyMatchEnvVarCollectionResult;
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
|
||||
pub struct EnvVarCollectionDataSource {}
|
||||
|
||||
impl EnvVarCollectionDataSource {
|
||||
/// Creates a new EnvVarCollectionDataSource containing personal and team EVCs.
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for EnvVarCollectionDataSource {
|
||||
type Action = CommandSearchItemAction;
|
||||
|
||||
/// Runs fuzzy matching of the query against all EVCs (specifically, against their names and descriptions).
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let query_str = query.text.as_str();
|
||||
let env_var_collections = CloudModel::as_ref(app).get_all_active_env_var_collections();
|
||||
|
||||
Ok(env_var_collections
|
||||
.flat_map(
|
||||
move |env_var_collection| -> Option<QueryResult<Self::Action>> {
|
||||
FuzzyMatchEnvVarCollectionResult::try_match(
|
||||
query_str,
|
||||
&env_var_collection.model().string_model.clone(),
|
||||
"",
|
||||
)
|
||||
.map(|match_result| {
|
||||
EnvVarCollectionSearchItem {
|
||||
env_var_collection: env_var_collection.clone(),
|
||||
fuzzy_matched_env_var_collection: match_result,
|
||||
}
|
||||
.into()
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect_vec())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod env_var_collection_search_item;
|
||||
mod env_var_collections_data_source;
|
||||
|
||||
pub use env_var_collection_search_item::*;
|
||||
pub use env_var_collections_data_source::*;
|
||||
@@ -0,0 +1,88 @@
|
||||
use futures_lite::future::yield_now;
|
||||
use std::sync::Arc;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::search::async_snapshot_data_source::AsyncSnapshotDataSource;
|
||||
use crate::search::command_search::searcher::CommandSearchItemAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::{BoxFuture, DataSourceRunErrorWrapper};
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal;
|
||||
use crate::terminal::model::session::SessionId;
|
||||
use crate::terminal::HistoryEntry;
|
||||
|
||||
use super::HistorySearchItem;
|
||||
|
||||
pub(crate) struct HistorySnapshot {
|
||||
commands: Arc<[Arc<HistoryEntry>]>,
|
||||
query_text: String,
|
||||
}
|
||||
|
||||
/// Creates an async data source for shell history commands.
|
||||
#[cfg(test)]
|
||||
pub fn history_data_source(
|
||||
commands: Vec<HistoryEntry>,
|
||||
) -> AsyncSnapshotDataSource<HistorySnapshot, CommandSearchItemAction> {
|
||||
let commands: Arc<[Arc<HistoryEntry>]> = commands.into_iter().map(Arc::new).collect();
|
||||
history_data_source_from_shared(commands)
|
||||
}
|
||||
|
||||
fn history_data_source_from_shared(
|
||||
commands: Arc<[Arc<HistoryEntry>]>,
|
||||
) -> AsyncSnapshotDataSource<HistorySnapshot, CommandSearchItemAction> {
|
||||
AsyncSnapshotDataSource::new(
|
||||
move |query: &Query, _app: &AppContext| HistorySnapshot {
|
||||
// Historical commands are all stored as Arcs (with COW semantics and very infrequent writes),
|
||||
// so cloning the commands to pass them in to the async sort function is a negligible cost.
|
||||
commands: commands.clone(),
|
||||
query_text: query.text.clone(),
|
||||
},
|
||||
fuzzy_match_history,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn history_data_source_for_session(
|
||||
session_id: SessionId,
|
||||
history_model: &terminal::History,
|
||||
app: &AppContext,
|
||||
) -> AsyncSnapshotDataSource<HistorySnapshot, CommandSearchItemAction> {
|
||||
let include_agent_commands = *AISettings::as_ref(app).include_agent_commands_in_history;
|
||||
let commands: Arc<[Arc<HistoryEntry>]> = history_model
|
||||
.commands_shared(session_id)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|entry| include_agent_commands || !entry.is_agent_executed)
|
||||
.collect();
|
||||
history_data_source_from_shared(commands)
|
||||
}
|
||||
|
||||
pub(crate) fn fuzzy_match_history(
|
||||
snapshot: HistorySnapshot,
|
||||
) -> BoxFuture<'static, Result<Vec<QueryResult<CommandSearchItemAction>>, DataSourceRunErrorWrapper>>
|
||||
{
|
||||
Box::pin(async move {
|
||||
let mut results = Vec::new();
|
||||
|
||||
// History entries are cheap to match (single short string), so we use a large chunk
|
||||
// size to reduce yield overhead while still allowing cancellation of stale queries.
|
||||
for chunk in snapshot.commands.chunks(512) {
|
||||
for entry in chunk {
|
||||
if let Some(match_result) = fuzzy_match::match_indices_case_insensitive(
|
||||
entry.command.as_str(),
|
||||
snapshot.query_text.as_str(),
|
||||
) {
|
||||
results.push(
|
||||
HistorySearchItem {
|
||||
entry: entry.clone(),
|
||||
match_result,
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
yield_now().await;
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use crate::ui_components::icons::Icon as UiIcon;
|
||||
use ordered_float::OrderedFloat;
|
||||
use std::sync::Arc;
|
||||
use warp_core::ui::builder;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, Icon,
|
||||
MainAxisAlignment, MainAxisSize, ParentElement, Shrinkable, Text,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
||||
AppContext, Element, SingletonEntity,
|
||||
};
|
||||
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::{
|
||||
command_search::searcher::AcceptedHistoryItem, result_renderer::ItemHighlightState,
|
||||
};
|
||||
use crate::{
|
||||
appearance::Appearance, terminal::rich_history::render_rich_history,
|
||||
util::time_format::format_approx_duration_from_now,
|
||||
};
|
||||
use crate::{search::command_search::searcher::CommandSearchItemAction, terminal::HistoryEntry};
|
||||
|
||||
const COMMAND_METADATA_LEFT_MARGIN_FROM_METADATA: f32 = 8.;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HistorySearchItem {
|
||||
pub entry: Arc<HistoryEntry>,
|
||||
pub match_result: fuzzy_match::FuzzyMatchResult,
|
||||
}
|
||||
|
||||
impl SearchItem for HistorySearchItem {
|
||||
type Action = CommandSearchItemAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
"bundled/svg/history.svg",
|
||||
highlight_state.icon_fill(appearance),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(appearance.monospace_font_size())
|
||||
.with_height(appearance.monospace_font_size())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(12.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let command = Align::new(
|
||||
Text::new_inline(
|
||||
self.entry.command.clone(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.autosize_text(builder::MIN_FONT_SIZE)
|
||||
.with_color(highlight_state.main_text_fill(appearance).into_solid())
|
||||
.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
|
||||
self.match_result.matched_indices.clone(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.left()
|
||||
.finish();
|
||||
|
||||
let mut command_and_workflow = Flex::column()
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(command);
|
||||
|
||||
if let Some(workflow) = self.entry.linked_workflow(app) {
|
||||
command_and_workflow.add_child(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_children([
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
"bundled/svg/workflow.svg",
|
||||
highlight_state.sub_text_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(appearance.monospace_font_size() - 4.)
|
||||
.with_width(appearance.monospace_font_size() - 4.)
|
||||
.finish(),
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Align::new(
|
||||
Container::new(
|
||||
Text::new_inline(
|
||||
workflow.name().to_owned(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.,
|
||||
)
|
||||
.with_color(
|
||||
highlight_state.sub_text_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(4.)
|
||||
.finish(),
|
||||
)
|
||||
.left()
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
])
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut item = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(Shrinkable::new(1., command_and_workflow.finish()).finish());
|
||||
|
||||
if let Some(metadata) = self.render_command_level_metadata(&highlight_state, appearance) {
|
||||
item.add_child(metadata);
|
||||
}
|
||||
item.finish()
|
||||
}
|
||||
|
||||
fn render_details(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
self.entry
|
||||
.has_metadata()
|
||||
.then(|| render_rich_history(self.entry.as_ref(), ctx))
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> CommandSearchItemAction {
|
||||
CommandSearchItemAction::AcceptHistory(AcceptedHistoryItem {
|
||||
command: self.entry.command.clone(),
|
||||
linked_workflow_data: self.entry.linked_workflow_data(),
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> CommandSearchItemAction {
|
||||
CommandSearchItemAction::ExecuteHistory(self.entry.command.clone())
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("History item: {}", self.entry.command)
|
||||
}
|
||||
}
|
||||
|
||||
impl HistorySearchItem {
|
||||
fn render_command_level_metadata(
|
||||
&self,
|
||||
item_highlight_state: &ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
if self.entry.start_ts.is_none() && self.entry.exit_code.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut metadata_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
if let Some(exit_code) = self.entry.exit_code {
|
||||
if !exit_code.was_successful() {
|
||||
metadata_row.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
UiIcon::AlertTriangle.into(),
|
||||
item_highlight_state.main_text_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_max_height(appearance.ui_font_size())
|
||||
.with_max_width(appearance.ui_font_size())
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(start) = self.entry.start_ts {
|
||||
metadata_row.add_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span(format_approx_duration_from_now(start))
|
||||
.with_style(UiComponentStyles {
|
||||
margin: Some(
|
||||
Coords::uniform(0.).left(COMMAND_METADATA_LEFT_MARGIN_FROM_METADATA),
|
||||
),
|
||||
font_color: Some(
|
||||
item_highlight_state.main_text_fill(appearance).into_solid(),
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
Some(Container::new(metadata_row.finish()).finish())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod history_data_source;
|
||||
mod history_search_item;
|
||||
|
||||
pub(crate) use history_data_source::*;
|
||||
pub use history_search_item::*;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user