first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+550
View File
@@ -0,0 +1,550 @@
use std::any::Any;
use std::collections::HashSet;
use std::sync::Arc;
use enum_iterator::{Sequence, all};
use lazy_static::lazy_static;
use ordered_float::OrderedFloat;
use serde::{Deserialize, Serialize};
use serde_json::json;
use warp_core::features::FeatureFlag;
use warp_core::ui::appearance::Appearance;
use warp_core::ui::icons::Icon;
use warp_core::ui::theme::Fill;
use warpui_core::{Action, AppContext, Element, Entity, ModelHandle};
use super::item::SearchItem;
use super::mixer::{AsyncDataSource, BoxFuture, DataSourceRunErrorWrapper};
use crate::item::IconLocation;
use crate::mixer::{DataSourceRunError, SyncDataSource};
use crate::result_renderer::ItemHighlightState;
lazy_static! {
static ref HISTORY_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "history:",
aliases: vec!["h:"]
};
static ref WORKFLOWS_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "workflows:",
aliases: vec!["w:"]
};
static ref AGENT_MODE_WORKFLOWS_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "prompts:",
aliases: vec!["p:"]
};
static ref NOTEBOOKS_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "notebooks:",
aliases: vec!["n:"]
};
static ref PLANS_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "plans:",
aliases: vec![]
};
static ref NATURAL_LANGUAGE_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "#",
aliases: vec![]
};
static ref ACTIONS_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "actions:",
aliases: vec![]
};
static ref DRIVE_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "drive:",
aliases: vec![]
};
static ref SESSIONS_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "sessions:",
aliases: vec![]
};
static ref CONVERSATIONS_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "conversations:",
aliases: vec![]
};
static ref LAUNCH_CONFIG_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "launch_configs:",
aliases: vec![]
};
static ref ENV_VARS_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "env_vars:",
aliases: vec![]
};
static ref AI_PROMPTS_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "ai_history:",
aliases: vec![]
};
static ref FILES_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "files:",
aliases: vec![]
};
static ref COMMANDS_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "commands:",
aliases: vec![]
};
static ref BLOCKS_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "blocks:",
aliases: vec!["b:"]
};
static ref CODE_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "code:",
aliases: vec![]
};
static ref RULES_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "rules:",
aliases: vec!["r:"]
};
static ref STATIC_SLASH_COMMANDS_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "slash:",
aliases: vec![]
};
static ref REPOS_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "repos:",
aliases: vec![]
};
static ref DIFFSETS_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "diffsets:",
aliases: vec!["diffs:"]
};
// If a query filter does not have a filter atom, it cannot be applied by typing
static ref NO_FILTER_ATOM: FilterAtom = FilterAtom {
primary_text: "",
aliases: vec![]
};
}
/// Represents a 'filter atom' that may be typed out in the search input to apply a filter.
pub struct FilterAtom {
/// The 'canonical' text representing the atom. This text is used for
/// autosuggestions/tab-completion to apply the filter. For example, this is 'history:' for the
/// history filter.
pub primary_text: &'static str,
/// Alternative strings that may be typed out in the search input to apply the filter. For
/// example, this is ['h:'] for the history filter.
pub aliases: Vec<&'static str>,
}
impl FilterAtom {
/// Returns the atom string that matches the given `query`, if any.
pub fn query_match(&self, query: &str) -> Option<&str> {
// If primary_text is empty, this is NO_ATOM, which never matches
if self.primary_text.is_empty() {
return None;
}
if query.starts_with(self.primary_text) {
Some(self.primary_text)
} else {
self.aliases
.iter()
.find(|alias| query.starts_with(**alias))
.copied()
}
}
}
/// Filters that may be included as part of the universal search query.
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, Serialize, Sequence)]
pub enum QueryFilter {
/// Only include results from HistoryDataSource.
History,
/// Only include command workflows from WorkflowsDataSource.
Workflows,
/// Only include agent mode workflows (prompts) from WorkflowsDataSource.
AgentModeWorkflows,
/// Only include results from NotebooksDataSource.
Notebooks,
/// Only include results from PlansDataSource.
Plans,
/// Only include the Natural Language (AI) command search result.
NaturalLanguage,
/// Filter results for command palette actions.
Actions,
/// Filter results for open sessions.
Sessions,
/// Filter results for open tabs.
Tabs,
/// Filter results for all conversations.
Conversations,
/// Filter results for launch configurations.
LaunchConfigurations,
/// Filter for objects in Warp Drive
Drive,
/// Filter results for environment variables.
EnvironmentVariables,
/// Filter results for historical AI history.
PromptHistory,
/// Filter results for files.
Files,
/// Filter results for commands.
Commands,
/// Filter results for terminal blocks.
Blocks,
/// Filter results for code symbols.
Code,
/// Filter results for AI rules.
Rules,
/// Filter results for known/indexed code repos.
Repos,
/// Filter results for diff sets.
DiffSets,
StaticSlashCommands,
/// Filter results for skills (used for browsing skills).
Skills,
/// Filter results for base agent models in the inline model selector.
BaseModels,
/// Filter results for full terminal use (CLI) models in the inline model selector.
FullTerminalUseModels,
/// Include only conversations whose most recent directory matches the session's current working directory.
CurrentDirectoryConversations,
}
impl QueryFilter {
/// Returns all possible `QueryFilter`s. Note all filters may not be enabled for a given
/// instance of a `SearchMixer`.
pub fn all() -> impl Iterator<Item = QueryFilter> {
all::<Self>()
}
/// Returns placeholder text to be shown in an empty input when the filter is active.
pub fn placeholder_text(&self) -> &'static str {
match self {
QueryFilter::History => "Search history",
QueryFilter::Workflows => "Search workflows",
QueryFilter::AgentModeWorkflows => "Search prompts",
QueryFilter::Notebooks => "Search notebooks",
QueryFilter::Plans => "Search plans",
QueryFilter::NaturalLanguage => "e.g. replace string in file",
QueryFilter::Actions => "Search actions",
QueryFilter::Sessions => "Search sessions",
QueryFilter::Tabs => "Search tabs",
QueryFilter::Conversations => "Search conversations",
QueryFilter::LaunchConfigurations => "Search launch configurations",
QueryFilter::Drive => "Search objects in drive",
QueryFilter::EnvironmentVariables => "Search environment variables",
QueryFilter::PromptHistory => "Search prompt history",
QueryFilter::Files => "Search files",
QueryFilter::Commands => "Search commands",
QueryFilter::Blocks => "Search blocks",
QueryFilter::Code => "Search code symbols",
QueryFilter::Rules => "Search AI rules",
QueryFilter::Repos => "Search code repos",
QueryFilter::DiffSets => "Search diff sets",
QueryFilter::StaticSlashCommands => "Search static slash commands",
QueryFilter::Skills => "Search skills",
QueryFilter::BaseModels => "Search base models",
QueryFilter::FullTerminalUseModels => "Search full terminal use models",
QueryFilter::CurrentDirectoryConversations => {
"Search conversations in current directory"
}
}
}
/// Returns text that is used to represent the filter as a filter 'atom' in the search input.
pub fn filter_atom(&self) -> &'static FilterAtom {
match self {
QueryFilter::History => &HISTORY_FILTER_ATOM,
QueryFilter::Workflows => &WORKFLOWS_FILTER_ATOM,
QueryFilter::AgentModeWorkflows => &AGENT_MODE_WORKFLOWS_FILTER_ATOM,
QueryFilter::Notebooks => &NOTEBOOKS_FILTER_ATOM,
QueryFilter::Plans => &PLANS_FILTER_ATOM,
QueryFilter::NaturalLanguage => &NATURAL_LANGUAGE_FILTER_ATOM,
QueryFilter::Actions => &ACTIONS_FILTER_ATOM,
QueryFilter::Sessions => &SESSIONS_FILTER_ATOM,
QueryFilter::Tabs => &NO_FILTER_ATOM,
QueryFilter::Conversations => &CONVERSATIONS_FILTER_ATOM,
QueryFilter::LaunchConfigurations => &LAUNCH_CONFIG_FILTER_ATOM,
QueryFilter::Drive => &DRIVE_FILTER_ATOM,
QueryFilter::EnvironmentVariables => &ENV_VARS_FILTER_ATOM,
QueryFilter::PromptHistory => &AI_PROMPTS_FILTER_ATOM,
QueryFilter::Files => &FILES_FILTER_ATOM,
QueryFilter::Commands => &COMMANDS_FILTER_ATOM,
QueryFilter::Blocks => &BLOCKS_FILTER_ATOM,
QueryFilter::Code => &CODE_FILTER_ATOM,
QueryFilter::Rules => &RULES_FILTER_ATOM,
QueryFilter::Repos => &REPOS_FILTER_ATOM,
QueryFilter::DiffSets => &DIFFSETS_FILTER_ATOM,
QueryFilter::StaticSlashCommands => &STATIC_SLASH_COMMANDS_FILTER_ATOM,
QueryFilter::Skills => &NO_FILTER_ATOM,
QueryFilter::BaseModels => &NO_FILTER_ATOM,
QueryFilter::FullTerminalUseModels => &NO_FILTER_ATOM,
QueryFilter::CurrentDirectoryConversations => &NO_FILTER_ATOM,
}
}
/// Returns the display name (e.g. the string to be used in UI) representing the filter.
pub fn display_name(&self) -> &'static str {
match self {
QueryFilter::History => "history",
QueryFilter::Workflows => "workflows",
QueryFilter::AgentModeWorkflows => "prompts",
QueryFilter::Notebooks => "notebooks",
QueryFilter::Plans => "plans",
QueryFilter::NaturalLanguage => "AI command suggestions",
QueryFilter::Actions => "actions",
QueryFilter::Sessions => "sessions",
QueryFilter::Tabs => "tabs",
QueryFilter::Conversations => "conversations",
QueryFilter::LaunchConfigurations => "launch configurations",
QueryFilter::Drive => "Galaxy Drive",
QueryFilter::EnvironmentVariables => "environment variables",
QueryFilter::PromptHistory => "prompt history",
QueryFilter::Files => "files",
QueryFilter::Commands => "commands",
QueryFilter::Blocks => "blocks",
QueryFilter::Code => "code",
QueryFilter::Rules => "rules",
QueryFilter::Repos => "repos",
QueryFilter::DiffSets => "diff sets",
QueryFilter::StaticSlashCommands => "slash commands",
QueryFilter::Skills => "skills",
QueryFilter::BaseModels => "base models",
QueryFilter::FullTerminalUseModels => "full terminal use models",
QueryFilter::CurrentDirectoryConversations => "current directory conversations",
}
}
/// Returns the path to the canonical icon for the filter.
pub fn icon_svg_path(&self) -> Option<&'static str> {
match self {
QueryFilter::History => Some("bundled/svg/history.svg"),
QueryFilter::Workflows => Some("bundled/svg/workflow.svg"),
QueryFilter::Notebooks => Some("bundled/svg/notebook.svg"),
QueryFilter::Plans => Some("bundled/svg/compass-3.svg"),
QueryFilter::NaturalLanguage => {
if !FeatureFlag::AgentMode.is_enabled() {
Some(Icon::AiAssistant.into())
} else {
Some(Icon::Oz.into())
}
}
QueryFilter::Actions => None,
QueryFilter::Sessions => Some("bundled/svg/terminal-input.svg"),
QueryFilter::Tabs => Some("bundled/svg/terminal-input.svg"),
QueryFilter::Conversations => Some("bundled/svg/conversation.svg"),
QueryFilter::LaunchConfigurations => Some("bundled/svg/navigation.svg"),
QueryFilter::Drive => Some("bundled/svg/warp-drive.svg"),
QueryFilter::EnvironmentVariables => Some("bundled/svg/env-var-collection.svg"),
QueryFilter::AgentModeWorkflows | QueryFilter::PromptHistory => {
Some(Icon::Prompt.into())
}
QueryFilter::Files => Some("bundled/svg/completion-file.svg"),
QueryFilter::Commands => Some("bundled/svg/terminal.svg"),
QueryFilter::Blocks => Some("bundled/svg/block.svg"),
QueryFilter::Code => Some("bundled/svg/code-02.svg"),
QueryFilter::Rules => Some("bundled/svg/book-open.svg"),
QueryFilter::Repos => Some("bundled/svg/folder.svg"),
QueryFilter::DiffSets => Some("bundled/svg/diff.svg"),
QueryFilter::StaticSlashCommands => None,
QueryFilter::Skills => None,
QueryFilter::BaseModels => None,
QueryFilter::FullTerminalUseModels => None,
QueryFilter::CurrentDirectoryConversations => None,
}
}
}
/// A structure representing a query that can be executed against a data source.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Query {
pub filters: HashSet<QueryFilter>,
pub text: String,
}
/// Allow anything that can be converted into a &str to be converted into a
/// Query.
impl<T> From<T> for Query
where
T: AsRef<str>,
{
fn from(s: T) -> Self {
Self {
filters: Default::default(),
text: s.as_ref().trim().to_owned(),
}
}
}
/// The type of a query result.
#[derive(Clone)]
pub struct QueryResult<T: Action + Clone> {
item: Arc<dyn SearchItem<Action = T>>,
/// Tiebreaker for sorting (results from earlier-registered data sources get a lower value
/// so they appear first among equal-scored results)
pub(crate) source_order: usize,
}
impl<T: Action + Clone> QueryResult<T> {
pub fn is_multiline(&self) -> bool {
self.item.is_multiline()
}
pub fn render_icon(
&self,
highlight_state: ItemHighlightState,
appearance: &Appearance,
) -> Box<dyn Element> {
self.item.render_icon(highlight_state, appearance)
}
pub fn icon_location(&self, appearance: &Appearance) -> IconLocation {
self.item.icon_location(appearance)
}
pub fn render_item(
&self,
highlight_state: ItemHighlightState,
app: &AppContext,
) -> Box<dyn Element> {
self.item.render_item(highlight_state, app)
}
pub fn item_background(
&self,
highlight_state: ItemHighlightState,
appearance: &Appearance,
) -> Option<Fill> {
self.item.item_background(highlight_state, appearance)
}
pub fn render_details(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
self.item.render_details(ctx)
}
pub fn priority_tier(&self) -> u8 {
self.item.priority_tier()
}
pub fn score(&self) -> OrderedFloat<f64> {
self.item.score()
}
pub fn accept_result(&self) -> T {
self.item.accept_result()
}
pub fn execute_result(&self) -> T {
self.item.execute_result()
}
pub fn accessibility_label(&self) -> String {
self.item.accessibility_label()
}
pub fn accessibility_help_message(&self) -> Option<String> {
self.item.accessibility_help_message()
}
pub fn detail_data(&self) -> Option<crate::item::SearchItemDetail> {
self.item.detail_data()
}
/// Returns whether this item is a static separator,
/// meaning it is a non-interactible item that should act as a simple UI element.
pub fn is_static_separator(&self) -> bool {
self.item.is_static_separator()
}
/// Returns whether this item is disabled.
/// Disabled items cannot be accepted or selected.
pub fn is_disabled(&self) -> bool {
self.item.is_disabled()
}
/// Returns an optional tooltip string to display when hovering over this item.
pub fn tooltip(&self) -> Option<String> {
self.item.tooltip()
}
}
impl<G: Action + Clone, T: SearchItem<Action = G> + 'static> From<T> for QueryResult<G> {
fn from(value: T) -> Self {
Self {
item: Arc::new(value),
source_order: usize::MAX,
}
}
}
/// Blanket impl of [`SyncDataSource`] for any [`ModelHandle`] of a type that also implements
/// `SyncDataSource`.
impl<T> SyncDataSource for ModelHandle<T>
where
T: SyncDataSource + Entity,
{
type Action = T::Action;
fn run_query(
&self,
query: &Query,
app: &AppContext,
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
self.as_ref(app).run_query(query, app)
}
}
/// Blanket impl of [`AsyncDataSource`] for any [`ModelHandle`] of a type that also implements
/// `AsyncDataSource`.
impl<T> AsyncDataSource for ModelHandle<T>
where
T: AsyncDataSource + Entity,
{
type Action = T::Action;
fn run_query(
&self,
query: &Query,
app: &AppContext,
) -> BoxFuture<'static, Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper>> {
self.as_ref(app).run_query(query, app)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DataSourceSearchError {
pub(crate) message: String,
}
impl DataSourceSearchError {
pub fn new(message: String) -> Self {
Self { message }
}
}
impl DataSourceRunError for DataSourceSearchError {
fn user_facing_error(&self) -> String {
self.message.clone()
}
fn telemetry_payload(&self) -> serde_json::Value {
json!(self)
}
fn as_any(&self) -> &dyn Any {
self
}
}
+117
View File
@@ -0,0 +1,117 @@
use warp_core::ui::theme::Fill;
use warpui_core::{Action, AppContext, Element};
use ordered_float::OrderedFloat;
use warp_core::ui::appearance::Appearance;
use warpui_core::fonts::FamilyId;
use super::result_renderer::ItemHighlightState;
#[derive(Clone)]
pub struct SearchItemDetail {
pub title: String,
pub description: Option<String>,
pub title_font_family: FamilyId,
}
/// Location where icon should be rendered relative to the [`SearchItem`].
pub enum IconLocation {
/// Icon should be centered within the element.
Centered,
/// Icon should be rendered at the top of the element, offset by `margin_top`.
Top { margin_top: f32 },
}
/// A trait representing a result from searching for a command.
pub trait SearchItem: Send + Sync {
/// The action that is dispatched when an item is accepted.
type Action: Action + Clone;
/// Returns whether this item should be treated as a multiline row.
///
/// This is used for styling decisions in renderers (e.g. applying extra vertical padding).
fn is_multiline(&self) -> bool {
false
}
/// Returns an [`Icon`] element to be rendered in a location determined by
/// [`SearchItem::icon_location`]
fn render_icon(
&self,
highlight_state: ItemHighlightState,
appearance: &Appearance,
) -> Box<dyn Element>;
/// Returns the location in which the icon should be rendered relative to the search item.
fn icon_location(&self, _appearance: &Appearance) -> IconLocation {
IconLocation::Centered
}
/// Returns an element to be rendered as the "body" of the item in the results list.
fn render_item(
&self,
highlight_state: ItemHighlightState,
app: &AppContext,
) -> Box<dyn Element>;
fn item_background(
&self,
highlight_state: ItemHighlightState,
appearance: &Appearance,
) -> Option<Fill> {
highlight_state.container_background_fill(appearance)
}
/// Optionally returns an [`Element`] to be rendered within a floating details panel when the
/// item is highlighted in the results list.
///
/// If this returns `None`, no details panel is shown for the item.
fn render_details(&self, _: &AppContext) -> Option<Box<dyn Element>> {
None
}
/// Returns a priority tier used to group result types.
///
/// Results are primarily ordered by this tier (higher tier wins). Scores are only compared
/// within the same tier.
fn priority_tier(&self) -> u8 {
0
}
/// Returns the "score" of the item used to rank the item in the results list.
fn score(&self) -> OrderedFloat<f64>;
/// Returns the [`CommandSearchItemAction`] to be emitted when the result is "accepted".
fn accept_result(&self) -> Self::Action;
/// Returns the [`CommandSearchItemAction`] to be emitted when the result is "executed".
fn execute_result(&self) -> Self::Action;
/// Returns the text that describes this item for accessibility purposes.
fn accessibility_label(&self) -> String;
/// Returns the a11y help message, if any, that describes this item.
fn accessibility_help_message(&self) -> Option<String> {
None
}
/// Returns whether this item is a static separator,
/// meaning it is a non-interactible item that should act as a simple UI element.
fn is_static_separator(&self) -> bool {
false
}
/// Returns whether this item is disabled.
/// Disabled items cannot be accepted or selected.
fn is_disabled(&self) -> bool {
false
}
/// Returns an optional tooltip string to display when hovering over this item.
fn tooltip(&self) -> Option<String> {
None
}
fn detail_data(&self) -> Option<SearchItemDetail> {
None
}
}
+13
View File
@@ -0,0 +1,13 @@
pub mod data_source;
pub mod item;
pub mod macros;
pub mod mixer;
pub mod result_renderer;
pub mod searcher;
mod telemetry;
// Re-export paste for use by macros.
pub use paste;
// Re-export tantivy for use by macros.
#[cfg(not(target_family = "wasm"))]
pub use tantivy;
+228
View File
@@ -0,0 +1,228 @@
/// Converts Rust types to FullTextSearchFieldTypes variants
#[macro_export]
macro_rules! type_to_field_type {
($t:ty) => {
<$t as $crate::searcher::ToFieldType>::field_type()
};
}
pub use type_to_field_type;
#[macro_export]
macro_rules! data_from_owned_value {
($value:expr, $t:ty) => {
<$t as $crate::searcher::FromOwnedValue>::from_owned_value($value)
};
}
#[macro_export]
macro_rules! get_factor_or_default {
($factor:expr) => {
$factor
};
() => {
1.0
};
}
pub use get_factor_or_default;
/// Macro to define a search schema for a [`crate::searcher::SimpleFullTextSearcher`].
/// ### Parameters
/// * `schema_name` - The name of the schema. This would be the name of the static reference of the schema.
/// * `config_name` - The name of the generated type config corresponding to the defined schema.
/// * `search_doc` - The name of the search document struct. This is the type that the searcher expects when you insert
/// documents into the search index. This struct contains all the fields defined in the schema (both the search and
/// id fields).
/// * `identifying_doc` - The name of the identifying document struct. This is the type that the searcher expects when you
/// attempt to delete documents from the search index. This struct contains only the id fields defined in the schema.
/// It is expected that all the id fields in combination uniquely identify a document.
/// * `search_fields` - A list of fields that are searchable. Each field is a tuple of the field name and the weight.
/// The weight is used to determine the relevance of the field when searching. The higher the weight, the more relevant.
/// Note that the weights do not need to add up to 1 and will be normalized by the searcher.
/// * `id_fields` - A list of fields that are used to identify the document. These fields are not searchable and are the
/// "data" associated with the document. **It is expected that all the id fields together forms a uniquely-identifying key
/// of a document!** Failure to do so will result in unexpected behaviour when inserting and deleting documents.
/// ## Defining a new search schema
/// Here is an example of using this schema to create a simple searcher:
/// ```
/// use itertools::Itertools;
/// use warp_search_core::define_search_schema;
/// use warp_search_core::searcher::{SimpleFullTextSearcher, DEFAULT_MEMORY_BUDGET};
///
/// define_search_schema!(
/// schema_name: MY_SCHEMA,
/// config_name: MyConfig,
/// search_doc: MySearchDoc,
/// identifying_doc: MyIdDoc,
/// search_fields: [name: 1.0, description: 0.5],
/// id_fields: [id: u64]
/// );
///
/// struct SearchWrapper {
/// searcher: SimpleFullTextSearcher<MyConfig>,
/// }
///
/// struct SearchResult {
/// doc_id: usize,
/// /// Byte indices of highlighted matches in the name field.
/// name_highlights: Vec<usize>,
/// /// Byte indices of highlighted matches in the description field.
/// description_highlights: Vec<usize>,
/// /// Relevance score of the match.
/// score: f64,
/// }
///
/// impl SearchWrapper {
/// fn new(initial_index: impl IntoIterator<Item = (String, String, u64)>) -> anyhow::Result<Self> {
/// let searcher = MY_SCHEMA.create_searcher(DEFAULT_MEMORY_BUDGET);
/// searcher.build_index(initial_index.into_iter().map(|(name, description, id)| {
/// MySearchDoc { name, description, id }
/// }))?;
///
/// Ok(Self { searcher })
/// }
///
/// fn add_document(&mut self, name: String, description: String, id: u64) -> anyhow::Result<()> {
/// self.searcher.insert_document(MySearchDoc { name, description, id })
/// }
///
/// fn remove_document_by_id(&mut self, id: u64) -> anyhow::Result<()> {
/// self.searcher.delete_document(MyIdDoc { id })
/// }
///
/// fn search(&self, query: &str) -> anyhow::Result<Vec<SearchResult>> {
/// Ok(self.searcher
/// .search_full_doc(query)?
/// .into_iter()
/// .map(|match_result| {
/// SearchResult {
/// doc_id: match_result.values.id as usize,
/// name_highlights: match_result.highlights.name,
/// description_highlights: match_result.highlights.description,
/// score: match_result.score,
/// }
/// })
/// .sorted_by(|a, b| a.score.partial_cmp(&b.score).unwrap_or(std::cmp::Ordering::Equal))
/// .collect())
/// }
/// }
/// ```
#[macro_export]
macro_rules! define_search_schema {
(schema_name: $schema_name:ident, config_name: $config_name:ident, search_doc: $search_doc:ident, identifying_doc: $id_doc_name:ident, search_fields: [$($s_name:ident: $weight:literal$(,)?)*], id_fields: [$($i_name:ident: $value_type:ty$(,)?)*] $(, boost_factor: $boost_factor:expr)? $(,)?) => {
lazy_static::lazy_static! {
static ref $schema_name: $crate::searcher::FullTextSearchSchema<$config_name> = $crate::searcher::FullTextSearchSchema::new(
std::collections::HashMap::from([
$((stringify!($s_name).to_owned(), $weight)),*
]),
std::collections::HashMap::from([
$((stringify!($i_name).to_owned(), $crate::type_to_field_type!($value_type))),*
]),
$crate::get_factor_or_default!($($boost_factor)*),
);
}
#[derive(Debug, Clone)]
struct $search_doc {
$(
pub $s_name: String,
)*
$(
pub $i_name: $value_type
),*
}
impl $crate::searcher::SearchDocumentEntry for $search_doc {
fn into_document_entry(self) -> $crate::searcher::FullTextSearchDocumentEntry {
let mut entry = std::collections::HashMap::new();
$(
entry.insert(
stringify!($s_name).to_owned(),
self.$s_name.into(),
);
)*
$(
entry.insert(
stringify!($i_name).to_owned(),
self.$i_name.into(),
);
)*
entry
}
}
impl $crate::searcher::FullTextSearchMatchValues for $search_doc {
fn from_match_result_values(mut values: std::collections::HashMap<String, $crate::tantivy::schema::OwnedValue>) -> Option<Self> {
Some(Self {
$(
$s_name: $crate::data_from_owned_value!(values.remove(stringify!($s_name))?, String)?,
)*
$(
$i_name: $crate::data_from_owned_value!(values.remove(stringify!($i_name))?, $value_type)?
),*
})
}
}
#[allow(unused)]
#[derive(Debug, Clone)]
struct $id_doc_name {
$(
pub $i_name: $value_type
),*
}
#[allow(unused)]
impl $crate::searcher::SearchIdentifyingEntry for $id_doc_name {
fn into_identifying_entry(self) -> $crate::searcher::FullTextSearchDocumentEntry {
let mut entry = std::collections::HashMap::new();
$(
entry.insert(
stringify!($i_name).to_owned(),
self.$i_name.into(),
);
)*
entry
}
}
#[allow(unused)]
impl $crate::searcher::FullTextSearchMatchValues for $id_doc_name {
fn from_match_result_values(mut values: std::collections::HashMap<String, $crate::tantivy::schema::OwnedValue>) -> Option<Self> {
Some(Self {
$(
$i_name: $crate::data_from_owned_value!(values.remove(stringify!($i_name))?, $value_type)?,
)*
})
}
}
$crate::paste::paste! {
#[allow(unused)]
#[derive(Debug, Clone)]
struct [<_ $config_name HighlightResult>] {
$(
pub $s_name: Vec<usize>
),*
}
#[allow(unused)]
impl $crate::searcher::FullTextSearchMatchHighlights for [<_ $config_name HighlightResult>] {
fn from_match_result_highlights(mut highlights: std::collections::HashMap<String, Vec<usize>>) -> Option<Self> {
Some(Self {
$(
$s_name: highlights.remove(stringify!($s_name))?,
)*
})
}
}
struct $config_name;
impl $crate::searcher::SearchSchemaConfig for $config_name {
type SearchDocEntry = $search_doc;
type SearchIdEntry = $id_doc_name;
type SearchHighlight = [<_ $config_name HighlightResult>];
}
}
};
}
pub use define_search_schema;
+605
View File
@@ -0,0 +1,605 @@
use std::any::Any;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use async_channel::Sender;
use async_trait::async_trait;
use futures_util::stream::AbortHandle;
use warpui_core::r#async::Timer;
use warpui_core::{Action, AppContext, Entity, ModelContext};
use itertools::Itertools;
use warp_core::r#async::debounce;
use warp_core::send_telemetry_from_ctx;
use super::data_source::{Query, QueryFilter, QueryResult};
use crate::telemetry::TelemetryEvent;
/// Maximum time to wait for matching data sources to return results before showing
/// partial results.
///
/// This is a UX tradeoff: waiting briefly reduces flicker in UIs that mix sync and async
/// sources (e.g. command palette file search), but we still want to show something quickly
/// if an async source is slow.
const INITIAL_RESULTS_TIMEOUT: Duration = Duration::from_millis(500);
pub use warpui_core::r#async::BoxFuture;
/// A structure that combines results from various data sources to produce a
/// single, ordered, heterogeneous list of search results.
#[derive(Default)]
pub struct SearchMixer<T: Action + Clone> {
/// The set of sources to be used to run a query against.
sources: HashMap<DataSourceId, RegisteredDataSource<T>>,
/// The latest set of search results produced by the latest `query`.
results: Vec<QueryResult<T>>,
/// The latest query that was used to search against, if any.
query: Option<Query>,
/// The set of sources that have finished running for the latest query.
finished_sources: HashSet<DataSourceId>,
/// Monotonically increasing counter incremented on each `run_query`. Used to discard stale
/// async callbacks and timeout callbacks whose futures completed before the abort took effect.
query_generation: u64,
/// Results buffered for the current query that haven't been committed to results yet.
/// `Some(vec)` means we're actively buffering (old results remain visible).
/// `None` means results have been committed; late-arriving results go directly to `results`.
pending_results: Option<Vec<QueryResult<T>>>,
/// Tracks whether the current query has emitted its initial set of visible results yet.
initial_results_emitted: bool,
}
impl<T: Action + Clone> Entity for SearchMixer<T> {
type Event = SearchMixerEvent;
}
/// A unique identifier for a DataSource.
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
struct DataSourceId(usize);
impl DataSourceId {
/// Constructs a new globally-unique entity ID.
#[allow(clippy::new_without_default)]
pub fn new() -> DataSourceId {
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
let raw = NEXT_ID.fetch_add(1, Ordering::Relaxed);
DataSourceId(raw)
}
}
pub enum SearchMixerEvent {
ResultsChanged,
}
pub struct AddAsyncSourceOptions {
pub debounce_interval: Option<Duration>,
/// Whether to run this source when the query text is empty
/// (i.e. the user hasn't typed anything yet).
pub run_in_zero_state: bool,
pub run_when_unfiltered: bool,
}
impl<T: Action + Clone> SearchMixer<T> {
pub fn new() -> Self {
Self {
sources: HashMap::new(),
finished_sources: HashSet::new(),
results: vec![],
query: None,
query_generation: 0,
pending_results: None,
initial_results_emitted: false,
}
}
/// Resets the mixer's state.
pub fn reset(&mut self, ctx: &mut ModelContext<Self>) {
self.abort_in_flight_async_queries();
self.query_generation = self.query_generation.wrapping_add(1);
self.sources.clear();
self.finished_sources.clear();
self.results.clear();
self.pending_results = None;
self.query.take();
self.initial_results_emitted = false;
ctx.emit(SearchMixerEvent::ResultsChanged);
}
/// Abort the current in-flight query to avoid stale searches
/// continuing and passing back results when they are no longer wanted.
fn abort_in_flight_async_queries(&mut self) {
for registered_source in self.sources.values_mut() {
if let DataSource::AsyncDataSource {
latest_run_abort_handle,
..
} = &mut registered_source.source
&& let Some(abort_handle) = latest_run_abort_handle.take()
{
abort_handle.abort();
}
}
}
/// Resets the mixer's results. Use the all-encompassing [`reset`] API
/// to clear _all_ of the mixer's state.
pub fn reset_results(&mut self, ctx: &mut ModelContext<Self>) {
self.abort_in_flight_async_queries();
self.query_generation = self.query_generation.wrapping_add(1);
self.results.clear();
self.pending_results = None;
self.query.take();
self.initial_results_emitted = false;
ctx.emit(SearchMixerEvent::ResultsChanged);
}
/// Adds a [`SyncDataSource`] to produce results when the mixer is queried. Query results will
/// be produced from this source if there are no filters provided or if one of the filters
/// within a [`Query`] is equal to this filter.
pub fn add_sync_source(
&mut self,
source: impl SyncDataSource<Action = T>,
filters: impl Into<HashSet<QueryFilter>>,
) {
self.sources.insert(
DataSourceId::new(),
RegisteredDataSource::new(
DataSource::SyncDataSource {
source: Arc::new(source),
},
filters.into(),
),
);
}
/// Adds an [`AsyncDataSource`] to produce results when the mixer is queried.
/// The results will be produced asynchronously and the mixer will notify its
/// subscribers whenever the result set changes.
///
/// A debounce interval can be provided to only query the data source in a debounced fashion.
///
/// By default, async sources only run when the query's filters explicitly match. Set
/// `run_when_unfiltered` to `true` so the source also runs when `query.filters` is empty.
/// Only enable this when the source's work is cheap (e.g. local fuzzy matching) — expensive
/// operations like network requests should not run on every unfiltered keystroke.
pub fn add_async_source(
&mut self,
source: impl AsyncDataSource<Action = T>,
filters: impl Into<HashSet<QueryFilter>>,
options: AddAsyncSourceOptions,
ctx: &mut ModelContext<Self>,
) {
let source = Arc::new(source);
let data_source_id = DataSourceId::new();
let debounce_tx = options.debounce_interval.map(|interval| {
self.start_debounce_stream_for_data_source(data_source_id, interval, ctx)
});
self.sources.insert(
data_source_id,
RegisteredDataSource::new(
DataSource::AsyncDataSource {
source,
debounce_tx,
latest_run_abort_handle: None,
run_in_zero_state: options.run_in_zero_state,
run_when_unfiltered: options.run_when_unfiltered,
},
filters.into(),
),
);
}
pub fn current_query(&self) -> Option<&Query> {
self.query.as_ref()
}
/// Runs a query against the registered data sources using the provided Query configuration.
/// On completion, the mixer emits an event to subscribers to indicate the result set has changed.
///
/// Old results remain visible while new results are buffered. The visible result set is
/// replaced atomically once all sources finish, or after [`INITIAL_RESULTS_TIMEOUT`] elapses.
/// Late-arriving async results are placed at the low-priority edge without reordering existing results.
pub fn run_query(&mut self, query: Query, ctx: &mut ModelContext<Self>) {
self.pending_results = Some(Vec::new());
self.finished_sources.clear();
self.query = Some(query.clone());
self.query_generation = self.query_generation.wrapping_add(1);
self.initial_results_emitted = false;
let query = &query;
// We want to run the queries in the order that the data sources were added.
let data_source_ids_to_run = self.ordered_data_source_ids_for_query(query).collect_vec();
for id in data_source_ids_to_run {
self.run_query_internal(id, false, ctx);
}
// Sync sources (and skipped async sources) will have already finished
// inside the loop. If everything is done, commit immediately.
if self.pending_results.is_some() {
if !self.is_loading() {
self.commit_pending_results_for_current_query(ctx);
} else {
let query_generation = self.query_generation;
let _ = ctx.spawn(
async move { Timer::after(INITIAL_RESULTS_TIMEOUT).await },
move |mixer, _, ctx| {
mixer.commit_pending_results_after_timeout(query_generation, ctx);
},
);
}
}
}
pub fn results(&self) -> &Vec<QueryResult<T>> {
&self.results
}
pub fn are_results_empty(&self) -> bool {
self.results.is_empty()
}
/// Returns all the filters that are currently registered.
pub fn registered_filters(&self) -> impl Iterator<Item = QueryFilter> + '_ {
self.sources
.values()
.flat_map(|source| source.filters.clone())
}
/// Returns the query filter for the first data source that hasn't completed.
pub fn loading_query_filters(&self) -> Option<HashSet<QueryFilter>> {
if self.initial_results_emitted {
return None;
}
let query = self.query.as_ref()?;
self.ordered_data_source_ids_for_query(query)
.find(|id| !self.finished_sources.contains(id))
.and_then(|id| self.sources.get(&id))
.map(|data_source| data_source.filters.clone())
}
/// Returns true iff there is at least one loading data source.
/// Helper that computes over `loading_query_filter`.
pub fn is_loading(&self) -> bool {
self.loading_query_filters().is_some()
}
/// Returns the first error found from running the data sources against the query, if any.
pub fn first_data_source_error(
&self,
) -> Option<(HashSet<QueryFilter>, &DataSourceRunErrorWrapper)> {
let query = self.query.as_ref()?;
self.ordered_data_source_ids_for_query(query)
.find_map(|id| {
self.sources
.get(&id)
.and_then(|s| Some(s.filters.clone()).zip(s.latest_run_error.as_ref()))
})
}
/// Returns an ordered list of data source IDs in the order that the corresponding
/// data sources were registered in.
/// We could use a map that respects insertion order but that will likely be
// overkill since the number of data sources is usually minute.
fn ordered_data_source_ids_for_query<'a>(
&'a self,
query: &'a Query,
) -> impl Iterator<Item = DataSourceId> + 'a {
self.sources
.keys()
.sorted()
.filter(|id| {
self.sources
.get(id)
.is_some_and(|registered_source| registered_source.matches_query(query))
})
.copied()
}
/// Runs the query for the [`DataSource`] identified by the provided `data_source_id`.
/// If `skip_debounce` is true, then the query is started immediately even if queries
/// against the data source are meant to be debounced.
fn run_query_internal(
&mut self,
data_source_id: DataSourceId,
skip_debounce: bool,
ctx: &mut ModelContext<Self>,
) {
let Some(registered_source) = self.sources.get_mut(&data_source_id) else {
return;
};
let Some(query) = self.query.clone() else {
return;
};
// Clear the latest run error, if any, because we're about to run a new query.
registered_source.latest_run_error = None;
match &mut registered_source.source {
DataSource::SyncDataSource { source } => {
let new_results = source.run_query(&query, ctx);
self.add_new_results(data_source_id, new_results, ctx);
}
DataSource::AsyncDataSource {
source,
debounce_tx,
latest_run_abort_handle,
run_in_zero_state,
run_when_unfiltered: _,
} => {
// Abort any existing run before starting a new one.
// This is necessary to do even if we end up debouncing
// because there might already be a running query that's taking long.
if let Some(abort_handle) = latest_run_abort_handle.take() {
abort_handle.abort();
}
// Only run async sources in the zero state if the async source indicated it should run in the
// zero state when registered. It can be costly to run async sources on blank queries so we don't
// do this by default.
if query.text.is_empty() && !*run_in_zero_state {
self.mark_source_as_finished(data_source_id);
if self.pending_results.is_some() && !self.is_loading() {
self.commit_pending_results_for_current_query(ctx);
}
return;
}
// Check if we should just be debouncing the query rather than running it right now.
if let Some(debounce_tx) = debounce_tx
&& !skip_debounce
{
let _ = debounce_tx.try_send(DataSourceDebounceArg {});
return;
}
// If we get here, then we should run the query against the data source right now.
let query_generation = self.query_generation;
let source = source.clone();
let filters = registered_source.filters.to_owned();
let new_abort_handle = ctx.spawn(
source.run_query(&query, ctx),
move |mixer, new_results, ctx| {
// Discard results from a previous query whose future completed before
// the abort took effect.
if mixer.query_generation != query_generation {
source.on_query_finished(ctx);
return;
}
let error_payload =
new_results.as_ref().err().map(|e| e.telemetry_payload());
send_telemetry_from_ctx!(
TelemetryEvent::CommandSearchAsyncQueryCompleted {
filters,
error_payload,
},
ctx
);
mixer.add_new_results(data_source_id, new_results, ctx);
source.on_query_finished(ctx);
},
);
*latest_run_abort_handle = Some(new_abort_handle.abort_handle());
}
}
}
fn add_new_results(
&mut self,
data_source_id: DataSourceId,
new_results: Result<Vec<QueryResult<T>>, DataSourceRunErrorWrapper>,
ctx: &mut ModelContext<Self>,
) {
if self.finished_sources.contains(&data_source_id) {
log::warn!(
"Ignoring duplicate results for source {data_source_id:?} that was already marked finished"
);
return;
}
self.mark_source_as_finished(data_source_id);
match new_results {
Ok(results) => {
let results_with_order = results
.into_iter()
.map(|mut result| {
result.source_order = data_source_id.0;
result
})
.collect_vec();
if let Some(pending) = &mut self.pending_results {
pending.extend(results_with_order);
if !self.is_loading() {
self.commit_pending_results_for_current_query(ctx);
}
} else if self.initial_results_emitted {
let mut late_results = results_with_order;
late_results.sort_by_key(|r| (r.priority_tier(), r.score(), r.source_order));
let mut existing_results = std::mem::take(&mut self.results);
self.results = late_results;
self.results.append(&mut existing_results);
ctx.emit(SearchMixerEvent::ResultsChanged);
} else {
self.results.extend(results_with_order);
self.sort_results();
ctx.emit(SearchMixerEvent::ResultsChanged);
}
}
Err(e) => {
if let Some(source) = self.sources.get_mut(&data_source_id) {
source.latest_run_error = Some(e);
}
if self.pending_results.is_some() && !self.is_loading() {
self.commit_pending_results_for_current_query(ctx);
} else if self.pending_results.is_none() {
ctx.emit(SearchMixerEvent::ResultsChanged);
}
}
}
}
/// Commits buffered results from the current query, replacing the visible result set.
/// After this, any late-arriving results are added directly to the low-priority edge of
/// `results`.
fn commit_pending_results(&mut self, ctx: &mut ModelContext<Self>) {
let Some(pending) = self.pending_results.take() else {
return;
};
self.results = pending;
self.sort_results();
ctx.emit(SearchMixerEvent::ResultsChanged);
}
fn commit_pending_results_for_current_query(&mut self, ctx: &mut ModelContext<Self>) {
self.initial_results_emitted = true;
self.commit_pending_results(ctx);
}
/// Sort by (priority_tier, score, source_order) so that equal-scored results
/// from earlier-registered sources appear first, regardless of async completion order.
fn sort_results(&mut self) {
self.results
.sort_by_key(|r| (r.priority_tier(), r.score(), r.source_order));
}
fn mark_source_as_finished(&mut self, data_source_id: DataSourceId) {
self.finished_sources.insert(data_source_id);
}
fn commit_pending_results_after_timeout(
&mut self,
query_generation: u64,
ctx: &mut ModelContext<Self>,
) {
if query_generation != self.query_generation || self.pending_results.is_none() {
return;
}
self.commit_pending_results_for_current_query(ctx);
}
fn start_debounce_stream_for_data_source(
&mut self,
data_source_id: DataSourceId,
interval: Duration,
ctx: &mut ModelContext<Self>,
) -> Sender<DataSourceDebounceArg> {
let (debounce_tx, debounce_rx) = async_channel::unbounded();
let _ = ctx.spawn_stream_local(
debounce(interval, debounce_rx),
move |mixer, _, ctx| {
mixer.run_query_internal(data_source_id, true, ctx);
},
|_, _| {},
);
debounce_tx
}
}
/// A trait representing a set of data that can be queried for search results synchronously.
pub trait SyncDataSource: 'static {
/// The action that is dispatched when a result produced by this data source is
/// accepted.
type Action: Action + Clone;
fn run_query(
&self,
query: &Query,
app: &AppContext,
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper>;
}
/// A trait representing a set of data that can be queried for search results asynchronously.
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
pub trait AsyncDataSource: 'static + Send + Sync {
/// The action that is dispatched when a result produced by this data source is
/// accepted.
type Action: Action + Clone;
fn run_query(
&self,
query: &Query,
app: &AppContext,
) -> BoxFuture<'static, Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper>>;
/// Function that should be run in the callback after `run_query` finishes.
fn on_query_finished(&self, _ctx: &mut AppContext) {}
}
/// Helper type alias for a DataSourceRunError.
pub type DataSourceRunErrorWrapper = Box<dyn DataSourceRunError>;
pub trait DataSourceRunError: 'static + Send + Sync + std::fmt::Debug {
fn user_facing_error(&self) -> String;
fn telemetry_payload(&self) -> serde_json::Value;
fn as_any(&self) -> &dyn Any;
}
struct DataSourceDebounceArg {}
enum DataSource<T: Action + Clone> {
SyncDataSource {
source: Arc<dyn SyncDataSource<Action = T>>,
},
AsyncDataSource {
latest_run_abort_handle: Option<AbortHandle>,
source: Arc<dyn AsyncDataSource<Action = T>>,
debounce_tx: Option<Sender<DataSourceDebounceArg>>,
run_in_zero_state: bool,
run_when_unfiltered: bool,
},
}
/// A registered [`DataSource`] for a [`SearchMixer`].
struct RegisteredDataSource<T: Action + Clone> {
source: DataSource<T>,
/// Corresponding filter for this data source.
filters: HashSet<QueryFilter>,
/// The error produced by this data source during its last run.
latest_run_error: Option<DataSourceRunErrorWrapper>,
}
impl<T: Action + Clone> RegisteredDataSource<T> {
/// Sync sources always run when the query has no filters. Async sources only run on
/// unfiltered queries when `run_when_unfiltered` is set, to avoid running expensive
/// operations (e.g. network requests) on every keystroke.
fn matches_query(&self, query: &Query) -> bool {
match &self.source {
DataSource::SyncDataSource { .. } => {
query.filters.is_empty() || query.filters.intersection(&self.filters).count() > 0
}
DataSource::AsyncDataSource {
run_when_unfiltered,
..
} => {
(*run_when_unfiltered && query.filters.is_empty())
|| query.filters.intersection(&self.filters).count() > 0
}
}
}
}
impl<T: Action + Clone> RegisteredDataSource<T> {
fn new(source: DataSource<T>, filters: HashSet<QueryFilter>) -> Self {
Self {
source,
filters,
latest_run_error: None,
}
}
}
#[cfg(test)]
#[path = "mixer_tests.rs"]
mod mixer_test;
+461
View File
@@ -0,0 +1,461 @@
use std::collections::HashSet;
use std::time::Duration;
use ordered_float::OrderedFloat;
use warp_core::telemetry::testing::MockTelemetryContextProvider;
use warpui_core::r#async::Timer;
use warpui_core::{App, AppContext, Element};
use super::*;
use crate::item::SearchItem;
#[derive(Clone, Debug, PartialEq)]
struct TestAction {
id: String,
}
#[derive(Debug)]
struct TestSearchItem {
id: String,
priority_tier: u8,
score: f64,
}
impl SearchItem for TestSearchItem {
type Action = TestAction;
fn render_icon(
&self,
_highlight_state: crate::result_renderer::ItemHighlightState,
_appearance: &warp_core::ui::appearance::Appearance,
) -> Box<dyn Element> {
unimplemented!()
}
fn render_item(
&self,
_highlight_state: crate::result_renderer::ItemHighlightState,
_app: &AppContext,
) -> Box<dyn Element> {
unimplemented!()
}
fn priority_tier(&self) -> u8 {
self.priority_tier
}
fn score(&self) -> OrderedFloat<f64> {
OrderedFloat(self.score)
}
fn accept_result(&self) -> Self::Action {
TestAction {
id: self.id.clone(),
}
}
fn execute_result(&self) -> Self::Action {
TestAction {
id: self.id.clone(),
}
}
fn accessibility_label(&self) -> String {
self.id.clone()
}
}
struct StaticSyncSource {
result: TestSearchItem,
}
impl SyncDataSource for StaticSyncSource {
type Action = TestAction;
fn run_query(
&self,
_: &Query,
_: &AppContext,
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
Ok(vec![QueryResult::from(TestSearchItem {
id: self.result.id.clone(),
priority_tier: self.result.priority_tier,
score: self.result.score,
})])
}
}
struct DelayedAsyncSource {
delay: Duration,
result: TestSearchItem,
}
impl AsyncDataSource for DelayedAsyncSource {
type Action = TestAction;
fn run_query(
&self,
_: &Query,
_: &AppContext,
) -> BoxFuture<'static, Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper>> {
let delay = self.delay;
let id = self.result.id.clone();
let priority_tier = self.result.priority_tier;
let score = self.result.score;
Box::pin(async move {
Timer::after(delay).await;
Ok(vec![QueryResult::from(TestSearchItem {
id,
priority_tier,
score,
})])
})
}
}
struct QueryDrivenDelayedAsyncSource;
impl AsyncDataSource for QueryDrivenDelayedAsyncSource {
type Action = TestAction;
fn run_query(
&self,
query: &Query,
_: &AppContext,
) -> BoxFuture<'static, Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper>> {
let (delay, id) = match query.text.as_str() {
"first" => (Duration::from_millis(200), "stale_first".to_string()),
"second" => (Duration::from_millis(300), "fresh_second".to_string()),
text => (Duration::from_millis(50), text.to_string()),
};
Box::pin(async move {
Timer::after(delay).await;
Ok(vec![QueryResult::from(TestSearchItem {
id,
priority_tier: 0,
score: 0.0,
})])
})
}
}
fn initialize_app(app: &mut App) {
app.update(MockTelemetryContextProvider::register);
}
#[test]
fn test_results_are_sorted_by_tier_then_score() {
let mut mixer = SearchMixer::<TestAction>::new();
mixer.results = vec![
QueryResult::from(TestSearchItem {
id: "tier0_high".to_string(),
priority_tier: 0,
score: 100.0,
}),
QueryResult::from(TestSearchItem {
id: "tier1_low".to_string(),
priority_tier: 1,
score: 1.0,
}),
];
mixer
.results
.sort_by_key(|r| (r.priority_tier(), r.score()));
let ordered = mixer.results();
assert_eq!(ordered[0].accept_result().id, "tier0_high");
assert_eq!(ordered[1].accept_result().id, "tier1_low");
}
#[test]
fn test_results_with_equal_tier_and_score_use_source_order_as_tiebreaker() {
let mut mixer = SearchMixer::<TestAction>::new();
let mut source_0 = QueryResult::from(TestSearchItem {
id: "source_0".to_string(),
priority_tier: 0,
score: 10.0,
});
source_0.source_order = 0;
let mut source_1 = QueryResult::from(TestSearchItem {
id: "source_1".to_string(),
priority_tier: 0,
score: 10.0,
});
source_1.source_order = 1;
let mut source_2 = QueryResult::from(TestSearchItem {
id: "source_2".to_string(),
priority_tier: 0,
score: 10.0,
});
source_2.source_order = 2;
mixer.results = vec![source_2, source_1, source_0];
mixer
.results
.sort_by_key(|r| (r.priority_tier(), r.score(), r.source_order));
let ordered = mixer.results();
assert_eq!(ordered[0].accept_result().id, "source_0");
assert_eq!(ordered[1].accept_result().id, "source_1");
assert_eq!(ordered[2].accept_result().id, "source_2");
}
#[test]
fn test_results_with_mixed_tiers_scores_and_sources_sort_consistently() {
let mut mixer = SearchMixer::<TestAction>::new();
let mut tier_0_high_score = QueryResult::from(TestSearchItem {
id: "tier_0_score_100_source_2".to_string(),
priority_tier: 0,
score: 100.0,
});
tier_0_high_score.source_order = 2;
let mut tier_0_mid_score_early_source = QueryResult::from(TestSearchItem {
id: "tier_0_score_50_source_0".to_string(),
priority_tier: 0,
score: 50.0,
});
tier_0_mid_score_early_source.source_order = 0;
let mut tier_0_mid_score_late_source = QueryResult::from(TestSearchItem {
id: "tier_0_score_50_source_1".to_string(),
priority_tier: 0,
score: 50.0,
});
tier_0_mid_score_late_source.source_order = 1;
let mut tier_1_highest_score = QueryResult::from(TestSearchItem {
id: "tier_1_score_999_source_0".to_string(),
priority_tier: 1,
score: 999.0,
});
tier_1_highest_score.source_order = 0;
mixer.results = vec![
tier_1_highest_score,
tier_0_high_score,
tier_0_mid_score_late_source,
tier_0_mid_score_early_source,
];
mixer
.results
.sort_by_key(|r| (r.priority_tier(), r.score(), r.source_order));
let ordered = mixer.results();
assert_eq!(ordered[0].accept_result().id, "tier_0_score_50_source_0");
assert_eq!(ordered[1].accept_result().id, "tier_0_score_50_source_1");
assert_eq!(ordered[2].accept_result().id, "tier_0_score_100_source_2");
assert_eq!(ordered[3].accept_result().id, "tier_1_score_999_source_0");
}
#[test]
fn test_initial_results_timeout_and_appends_late_async_results_without_reordering() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let mixer = app.add_model(|_| SearchMixer::<TestAction>::new());
mixer.update(&mut app, |mixer, ctx| {
mixer.add_sync_source(
StaticSyncSource {
result: TestSearchItem {
id: "sync".to_string(),
priority_tier: 0,
score: 10.0,
},
},
[QueryFilter::Actions],
);
mixer.add_async_source(
DelayedAsyncSource {
delay: Duration::from_millis(700),
result: TestSearchItem {
id: "late_async".to_string(),
priority_tier: 0,
score: 100.0,
},
},
[QueryFilter::Actions],
AddAsyncSourceOptions {
debounce_interval: None,
run_in_zero_state: false,
run_when_unfiltered: false,
},
ctx,
);
mixer.run_query(
Query {
text: "a".to_string(),
filters: HashSet::from([QueryFilter::Actions]),
},
ctx,
);
});
app.read(|app| {
let mixer = mixer.as_ref(app);
assert!(mixer.is_loading());
assert!(!mixer.initial_results_emitted);
assert_eq!(
mixer
.results()
.iter()
.map(|result| result.accept_result().id)
.collect::<Vec<_>>(),
Vec::<&str>::new()
);
});
// After the initial timeout, we should show partial results (sync), without waiting for
// the slow async source to complete.
Timer::after(Duration::from_millis(600)).await;
app.read(|app| {
let mixer = mixer.as_ref(app);
assert!(!mixer.is_loading());
assert!(mixer.initial_results_emitted);
assert_eq!(
mixer
.results()
.iter()
.map(|result| result.accept_result().id)
.collect::<Vec<_>>(),
vec!["sync"]
);
});
// When the async source finishes later, its results are placed at the low-priority edge
// without reordering the already-visible sync results.
Timer::after(Duration::from_millis(200)).await;
app.read(|app| {
let mixer = mixer.as_ref(app);
assert!(!mixer.is_loading());
assert_eq!(
mixer
.results()
.iter()
.map(|result| result.accept_result().id)
.collect::<Vec<_>>(),
vec!["late_async", "sync"]
);
});
});
}
#[test]
fn test_initial_results_commit_keeps_sorted_results_when_async_finishes_before_timeout() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let mixer = app.add_model(|_| SearchMixer::<TestAction>::new());
mixer.update(&mut app, |mixer, ctx| {
mixer.add_sync_source(
StaticSyncSource {
result: TestSearchItem {
id: "sync".to_string(),
priority_tier: 0,
score: 10.0,
},
},
[QueryFilter::Actions],
);
mixer.add_async_source(
DelayedAsyncSource {
delay: Duration::from_millis(50),
result: TestSearchItem {
id: "fast_async".to_string(),
priority_tier: 0,
score: 0.0,
},
},
[QueryFilter::Actions],
AddAsyncSourceOptions {
debounce_interval: None,
run_in_zero_state: false,
run_when_unfiltered: false,
},
ctx,
);
mixer.run_query(
Query {
text: "a".to_string(),
filters: HashSet::from([QueryFilter::Actions]),
},
ctx,
);
});
Timer::after(Duration::from_millis(600)).await;
app.read(|app| {
let mixer = mixer.as_ref(app);
assert!(!mixer.is_loading());
assert!(mixer.initial_results_emitted);
assert_eq!(
mixer
.results()
.iter()
.map(|result| result.accept_result().id)
.collect::<Vec<_>>(),
vec!["fast_async", "sync"]
);
});
});
}
#[test]
fn test_stale_async_results_do_not_poison_newer_query() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let mixer = app.add_model(|_| SearchMixer::<TestAction>::new());
mixer.update(&mut app, |mixer, ctx| {
mixer.add_async_source(
QueryDrivenDelayedAsyncSource,
[QueryFilter::Actions],
AddAsyncSourceOptions {
debounce_interval: None,
run_in_zero_state: false,
run_when_unfiltered: false,
},
ctx,
);
mixer.run_query(
Query {
text: "first".to_string(),
filters: HashSet::from([QueryFilter::Actions]),
},
ctx,
);
});
Timer::after(Duration::from_millis(50)).await;
mixer.update(&mut app, |mixer, ctx| {
mixer.run_query(
Query {
text: "second".to_string(),
filters: HashSet::from([QueryFilter::Actions]),
},
ctx,
);
});
Timer::after(Duration::from_millis(400)).await;
app.read(|app| {
let mixer = mixer.as_ref(app);
assert_eq!(
mixer
.results()
.iter()
.map(|result| result.accept_result().id)
.collect::<Vec<_>>(),
vec!["fresh_second"]
);
});
});
}
@@ -0,0 +1,366 @@
use std::sync::Arc;
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::Fill;
use warpui_core::elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult,
DropShadow, Empty, EventHandler, Fill as ElementFill, Flex, Hoverable, MainAxisSize,
MouseState, MouseStateHandle, ParentElement, Radius, Shrinkable, SizeConstraintCondition,
SizeConstraintSwitch,
};
use warpui_core::platform::Cursor;
use warpui_core::{Action, AppContext, Element, EventContext, SingletonEntity};
use super::data_source::QueryResult;
use crate::item::IconLocation;
const DETAILS_MIN_WIDTH: f32 = 180.;
const DETAILS_MAX_WIDTH: f32 = 480.;
const CORNER_RADIUS: f32 = 8.;
const DEFAULT_RESULT_HORIZONTAL_PADDING: f32 = 8.;
/// Index of the [`QueryResult`] that was clicked.
pub type QueryResultIndex = usize;
/// Function that is executed when a [`QueryResult`] is clicked.
pub type OnQueryResultClickedFn<T> = Arc<dyn Fn(QueryResultIndex, T, &mut EventContext)>;
/// Struct to generate styles for a [`QueryResultRenderer`].
#[derive(Copy, Clone)]
pub struct QueryResultRendererStyles {
/// Function that computes the height of the [`QueryResult`].
pub result_item_height_fn: fn(&Appearance) -> f32,
/// Function that computes the background [`Fill`] for a [`QueryResult`] that has a details
/// panel.
pub panel_background_fill_fn: fn(&Appearance) -> Fill,
/// Function that computes the background [`Border`] for a [`QueryResult`] that has a details
/// panel.
pub panel_border_fn: fn(&Appearance) -> Border,
/// The [`DropShadow`] of the details panel [`QueryResult`].
pub panel_drop_shadow: DropShadow,
/// The [`CornerRadius`] of the details panel [`QueryResult`].
pub panel_corner_radius: CornerRadius,
/// Horizontal padding that should be applied to the inner query result.
pub result_horizontal_padding: f32,
/// Vertical padding that should be applied to the inner query result.
pub result_vertical_padding: f32,
/// Vertical padding that should be applied to the inner query result for multiline items.
pub result_multiline_vertical_padding: f32,
/// Horizontal padding applied to each result row (e.g. to prevent a hovered/selected background
/// from appearing full-bleed against the panel edge).
pub result_outer_horizontal_padding_fn: fn(&Appearance) -> f32,
/// Corner radius to apply to a hovered/selected row background.
pub item_highlight_corner_radius: CornerRadius,
}
impl Default for QueryResultRendererStyles {
fn default() -> Self {
Self {
result_item_height_fn: |appearance| {
appearance.monospace_font_size() * (1.0 + appearance.line_height_ratio())
},
panel_background_fill_fn: |appearance| appearance.theme().surface_2(),
panel_border_fn: |_| Border::new(0.),
panel_drop_shadow: DropShadow::default(),
panel_corner_radius: CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS)),
result_horizontal_padding: DEFAULT_RESULT_HORIZONTAL_PADDING,
result_vertical_padding: 0.,
result_multiline_vertical_padding: 0.,
result_outer_horizontal_padding_fn: |_| 0.,
item_highlight_corner_radius: CornerRadius::with_all(Radius::Pixels(0.)),
}
}
}
impl QueryResultRendererStyles {
fn result_item_height(&self, appearance: &Appearance) -> f32 {
(self.result_item_height_fn)(appearance)
}
fn panel_background_fill(&self, appearance: &Appearance) -> Fill {
(self.panel_background_fill_fn)(appearance)
}
fn panel_border(&self, appearance: &Appearance) -> Border {
(self.panel_border_fn)(appearance)
}
}
/// Struct wrapping a [`QueryResult`], used to render a single query result in the search
/// panel.
///
/// This contains common rendering logic and state required by all search result types. An example
/// of common rendering logic is the common layout of result details (icon, text). An example of
/// commonly required state is the mouse_state_handle used for the [`Hoverable`] element that wraps
/// each result.
pub struct QueryResultRenderer<T: Action + Clone> {
pub mouse_state_handle: MouseStateHandle,
pub search_result: QueryResult<T>,
pub position_id: String,
pub on_result_click_fn: OnQueryResultClickedFn<T>,
renderer_styles: QueryResultRendererStyles,
}
impl<T: Action + Clone> QueryResultRenderer<T> {
/// Creates a new QueryResultRenderer, taking ownership of the given search_result.
///
/// # Arguments
///
/// - `search_result`: The query result that should be rendered.
/// - `position_id`: Unique string denoting the position id (via a [`SavePosition`]) for the
/// element.
/// - `on_result_click_fn`: Function executed when this item is clicked.
pub fn new(
search_result: QueryResult<T>,
position_id: String,
on_item_click_fn: impl Fn(QueryResultIndex, T, &mut EventContext) + 'static,
renderer_styles: QueryResultRendererStyles,
) -> Self {
Self {
mouse_state_handle: Default::default(),
search_result,
position_id,
renderer_styles,
on_result_click_fn: Arc::new(on_item_click_fn),
}
}
/// Renders a single result in the search panel, delegating to more granular
/// [`QueryResult`] render methods to render the internals. This method ensures that
/// result contents are rendered in a uniform way with respect to layout, spacing, and
/// coloring.
pub fn render(
&self,
result_index: usize,
is_selected: bool,
app: &AppContext,
) -> Box<dyn Element> {
// For static separators, render without hover effects or click handling
if self.search_result.is_static_separator() {
return self.render_with_highlight_state(ItemHighlightState::Default, true, app);
}
let accept_result = self.search_result.accept_result();
let on_item_click_fn = self.on_result_click_fn.clone();
EventHandler::new(
Hoverable::new(self.mouse_state_handle.clone(), move |mouse_state| {
self.render_with_highlight_state(
ItemHighlightState::new(is_selected, mouse_state),
false,
app,
)
})
.with_cursor(Cursor::PointingHand)
.finish(),
)
// Reimplement an `on_click` handler using mouse events
// and prevent propagation to the modal close handler on mouse down.
.on_left_mouse_down(move |_, _, _| DispatchEventResult::StopPropagation)
.on_left_mouse_up(move |event_ctx, _, _| {
on_item_click_fn(result_index, accept_result.clone(), event_ctx);
DispatchEventResult::StopPropagation
})
.finish()
}
fn render_with_highlight_state(
&self,
highlight_state: ItemHighlightState,
is_static_separator: bool,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let icon = self.search_result.render_icon(highlight_state, appearance);
let item = self.search_result.render_item(highlight_state, app);
let row_height = self.renderer_styles.result_item_height(appearance);
let (cross_axis_alignment, margin_top) = match self.search_result.icon_location(appearance)
{
IconLocation::Centered => (CrossAxisAlignment::Center, 0.),
IconLocation::Top {
margin_top: padding_top,
} => (CrossAxisAlignment::Start, padding_top),
};
let is_multiline = self.search_result.is_multiline();
let row_vertical_padding = if is_multiline {
self.renderer_styles.result_multiline_vertical_padding
} else {
self.renderer_styles.result_vertical_padding
};
let row_contents = Container::new(
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(cross_axis_alignment)
.with_child(Container::new(icon).with_margin_top(margin_top).finish())
.with_child(Shrinkable::new(1., Container::new(item).finish()).finish())
.finish(),
)
.finish(),
)
.with_horizontal_padding(self.renderer_styles.result_horizontal_padding)
.with_padding_top(row_vertical_padding)
.with_padding_bottom(if is_static_separator {
// Static separators should hug their proceeding element more closely,
// so we don't give them any bottom padding.
0.
} else {
row_vertical_padding
})
.finish();
let row = ConstrainedBox::new(row_contents)
.with_height(row_height)
.finish();
let row = if let Some(background_fill) = self
.search_result
.item_background(highlight_state, appearance)
{
// Never use gradient backgrounds for hovered/selected states in the command palette.
// If a gradient is provided, convert it to a solid using the gradient's start (left/top) color.
let highlighted = Container::new(row)
.with_corner_radius(self.renderer_styles.item_highlight_corner_radius);
let solid_bg: pathfinder_color::ColorU =
ElementFill::from(background_fill).start_color();
highlighted.with_background_color(solid_bg).finish()
} else {
row
};
let outer_padding = (self.renderer_styles.result_outer_horizontal_padding_fn)(appearance);
if outer_padding > 0. {
Container::new(row)
.with_padding_left(outer_padding)
.with_padding_right(outer_padding)
.finish()
} else {
row
}
}
/// Returns a "details" [`Element`] to be rendered in the details pane or `None` if the details
/// pane should not be shown for this result.
pub fn render_details(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
let appearance = Appearance::as_ref(ctx);
self.search_result.render_details(ctx).map(|details| {
SizeConstraintSwitch::new(
ConstrainedBox::new(
Container::new(details)
.with_corner_radius(self.renderer_styles.panel_corner_radius)
.with_background(self.renderer_styles.panel_background_fill(appearance))
.with_border(self.renderer_styles.panel_border(appearance))
.with_drop_shadow(self.renderer_styles.panel_drop_shadow)
.with_uniform_padding(12.)
.finish(),
)
.with_max_width(DETAILS_MAX_WIDTH)
.finish(),
vec![(
SizeConstraintCondition::WidthLessThan(DETAILS_MIN_WIDTH),
Empty::new().finish(),
)],
)
.finish()
})
}
}
/// Represents the UI state of a search result in the panel. If a result is selected via
/// navigation keys _and_ hovered by the mouse, its state should be `Selected`.
#[derive(Copy, Clone, Debug)]
pub enum ItemHighlightState {
/// A selected item can still be hovered, so we want to keep track of that within the selected state.
Selected {
is_hovered: bool,
},
Hovered,
Default,
}
impl ItemHighlightState {
pub fn new(is_selected: bool, mouse_state: &MouseState) -> Self {
if is_selected {
ItemHighlightState::Selected {
is_hovered: mouse_state.is_hovered(),
}
} else if mouse_state.is_hovered() {
ItemHighlightState::Hovered
} else {
ItemHighlightState::Default
}
}
/// Returns the fill to be used for the search result icon.
pub fn icon_fill(&self, appearance: &Appearance) -> Fill {
let theme = appearance.theme();
match self {
ItemHighlightState::Selected { .. } => theme.main_text_color(theme.accent()),
ItemHighlightState::Hovered | ItemHighlightState::Default => {
theme.main_text_color(theme.surface_2()).with_opacity(80)
}
}
}
/// Returns the fill to be used for the search result's main text.
pub fn main_text_fill(&self, appearance: &Appearance) -> Fill {
let theme = appearance.theme();
match self {
ItemHighlightState::Selected { .. } => {
theme.main_text_color(theme.accent().with_opacity(80))
}
ItemHighlightState::Hovered | ItemHighlightState::Default => {
theme.main_text_color(theme.surface_2())
}
}
}
/// Returns the fill to be used for the search result's sub text.
pub fn sub_text_fill(&self, appearance: &Appearance) -> Fill {
let theme = appearance.theme();
match self {
ItemHighlightState::Selected { .. } => {
theme.sub_text_color(theme.accent().with_opacity(80))
}
ItemHighlightState::Hovered | ItemHighlightState::Default => {
theme.sub_text_color(theme.surface_2())
}
}
}
/// Returns the fill to be used as the background of the search result.
pub fn container_background_fill(&self, appearance: &Appearance) -> Option<Fill> {
match self {
ItemHighlightState::Selected { .. } | ItemHighlightState::Hovered => Some(
appearance
.theme()
.accent()
.with_opacity(self.container_background_opacity()),
),
ItemHighlightState::Default => None,
}
}
pub fn container_background_opacity(&self) -> u8 {
match self {
ItemHighlightState::Selected { .. } => 90,
ItemHighlightState::Hovered => 20,
ItemHighlightState::Default => 0,
}
}
pub fn is_hovered(&self) -> bool {
match self {
ItemHighlightState::Selected { is_hovered, .. } => *is_hovered,
ItemHighlightState::Hovered => true,
ItemHighlightState::Default => false,
}
}
pub fn is_selected(&self) -> bool {
matches!(self, ItemHighlightState::Selected { .. })
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,312 @@
use std::sync::Arc;
use itertools::Itertools;
use tantivy::tokenizer::{TextAnalyzer, Token};
use warpui_core::r#async::executor::Background;
use crate::define_search_schema;
use crate::searcher::{CustomTokenizer, MIN_MEMORY_BUDGET};
fn token_stream_helper(text: &str) -> Vec<Token> {
let mut a = TextAnalyzer::from(CustomTokenizer::default());
let mut token_stream = a.token_stream(text);
let mut tokens: Vec<Token> = vec![];
let mut add_token = |token: &Token| {
tokens.push(token.clone());
};
token_stream.process(&mut add_token);
tokens
}
fn assert_token(token: &Token, position: usize, text: &str, from: usize, to: usize) {
assert_eq!(
token.position, position,
"expected position {position} but {token:?}"
);
assert_eq!(token.text, text, "expected text {text} but {token:?}");
assert_eq!(
token.offset_from, from,
"expected offset_from {from} but {token:?}"
);
assert_eq!(token.offset_to, to, "expected offset_to {to} but {token:?}");
}
#[test]
fn test_tokenizer_simple() {
let tokens = token_stream_helper("Hello, happy tax payer!");
assert_eq!(tokens.len(), 4);
assert_token(&tokens[0], 0, "Hello", 0, 5);
assert_token(&tokens[1], 1, "happy", 7, 12);
assert_token(&tokens[2], 2, "tax", 13, 16);
assert_token(&tokens[3], 3, "payer", 17, 22);
}
#[test]
fn test_tokenizer_warp_special_chars() {
// Test string includes warp-related terms with hyphen, underscore, forward slash, backslash, and colon
let test_string = "warp-cli/launch_command:run C:\\\\Program_Files\\\\Warp\\\\core-engine.dll check_status:/dev/warp_drive-0";
let tokens = token_stream_helper(test_string);
assert_eq!(tokens.len(), 25);
assert_token(&tokens[0], 0, "warp-cli/launch_command:run", 0, 27);
assert_token(&tokens[1], 1, "warp", 0, 4);
assert_token(&tokens[2], 2, "cli", 5, 8);
assert_token(&tokens[3], 3, "launch_command", 9, 23);
assert_token(&tokens[4], 4, "launch", 9, 15);
assert_token(&tokens[5], 5, "command", 16, 23);
assert_token(&tokens[6], 6, "run", 24, 27);
assert_token(
&tokens[7],
7,
"C:\\\\Program_Files\\\\Warp\\\\core-engine",
28,
64,
);
assert_token(&tokens[15], 15, "dll", 65, 68);
assert_token(&tokens[16], 16, "check_status:/dev/warp_drive-0", 69, 99);
}
#[test]
fn test_searcher() {
define_search_schema!(
schema_name: TEST_SCHEMA,
config_name: SchemaConfig,
search_doc: SearchDoc,
identifying_doc: IdentifyingDoc,
search_fields: [name: 1.0],
id_fields: [id: u64]
);
let search_strings = ["run warp on web server", "run warp-on-web server"];
let searcher = TEST_SCHEMA.create_searcher(MIN_MEMORY_BUDGET);
searcher
.build_index(
search_strings
.iter()
.enumerate()
.map(|(id, name)| SearchDoc {
name: (*name).to_owned(),
id: id as u64,
}),
)
.unwrap();
let result = searcher.search_full_doc("warp on web").unwrap();
assert_eq!(
result.len(),
2,
"both search strings should match with the custom tokenizer"
);
assert_eq!(
result[0].highlights.name,
vec![4, 5, 6, 7, 9, 10, 12, 13, 14],
"should highlight the correct positions"
);
assert_eq!(
result[1].highlights.name,
vec![4, 5, 6, 7, 9, 10, 12, 13, 14],
"should highlight the correct positions"
);
let result = searcher.search_full_doc("warp-on-web").unwrap();
assert_eq!(
result.len(),
1,
"should only match the second search string"
);
assert_eq!(
result[0].values.name, "run warp-on-web server",
"should match the second search string"
);
assert_eq!(
result[0].highlights.name,
(4..15).collect_vec(),
"should highlight the correct positions"
);
}
#[test]
fn test_searcher_scores() {
define_search_schema!(
schema_name: TEST_SCHEMA,
config_name: SchemaConfig,
search_doc: SearchDoc,
identifying_doc: IdentifyingDoc,
search_fields: [name: 1.0],
id_fields: [id: u64]
);
let search_strings = ["run warp on web server", "run warp_on_web:server"];
let searcher = TEST_SCHEMA.create_searcher(MIN_MEMORY_BUDGET);
searcher
.build_index(
search_strings
.iter()
.enumerate()
.map(|(id, name)| SearchDoc {
name: (*name).to_owned(),
id: id as u64,
}),
)
.unwrap();
let result = searcher.search_full_doc("warp").unwrap();
assert_eq!(
result.len(),
2,
"both search strings should match with the custom tokenizer"
);
let score_delta = result[0].score - result[1].score;
assert!(
score_delta > 0.0,
"the first search string should have a higher score than the second"
);
assert!(
score_delta / result[0].score < 0.15,
"the score difference of similar strings should be less than 15%"
);
let result = searcher.search_full_doc("warp on web").unwrap();
let score_delta = result[0].score - result[1].score;
assert!(
score_delta / result[0].score < 0.15,
"the score difference of similar strings should be less than 15%"
);
}
#[test]
fn test_searcher_async() {
define_search_schema!(
schema_name: TEST_SCHEMA,
config_name: SchemaConfig,
search_doc: SearchDoc,
identifying_doc: IdentifyingDoc,
search_fields: [name: 1.0],
id_fields: [id: u64]
);
let search_strings = [
"Fix clippy formatting after commit",
"Undo the last git commit",
"Run cargo fmt on changed files",
"Run warp-on-web",
"Run fresh warp-local and clear warp-dev permissions",
"Give user unlimited AI",
];
let background_executor = Arc::new(Background::default());
let searcher_async =
TEST_SCHEMA.create_async_searcher(MIN_MEMORY_BUDGET, background_executor.clone());
searcher_async
.build_index_async(
search_strings
.iter()
.enumerate()
.map(|(id, name)| SearchDoc {
name: (*name).to_owned(),
id: id as u64,
}),
)
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(200));
let result = searcher_async.get_all_doc_ids().unwrap();
assert_eq!(
result.len(),
6,
"the index should be populated with all documents"
);
let result = searcher_async.search_full_doc("unlimited").unwrap();
assert_eq!(
result.len(),
1,
"there should be exactly 1 match for 'unlimited'"
);
assert_eq!(
result[0].values.name, "Give user unlimited AI",
"should match the search string"
);
assert_eq!(
result[0].highlights.name,
(10..19).collect_vec(),
"should highlight the correct positions"
);
let result = searcher_async.search_id("Fix clippy formatting").unwrap();
assert!(!result.is_empty(), "the document should exist");
searcher_async
.delete_document_async(IdentifyingDoc { id: 0 })
.unwrap();
searcher_async
.delete_document_async(IdentifyingDoc { id: 1 })
.unwrap();
searcher_async
.insert_document_async(SearchDoc {
name: "Undo the last git commit".to_owned(),
id: 10,
})
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(200));
let result = searcher_async.search_id("Fix clippy formatting").unwrap();
assert!(result.is_empty(), "the document should be deleted");
let result = searcher_async.search_full_doc("Undo").unwrap();
assert_eq!(
result.len(),
1,
"there should be exactly 1 match for 'Undo'"
);
assert_eq!(
result[0].values.id, 10,
"a new document should be inserted with id = 10"
);
assert_eq!(
result[0].highlights.name,
(0..4).collect_vec(),
"should highlight the correct positions"
);
let result = searcher_async
.get_all_documents()
.unwrap()
.into_iter()
.filter(|doc| doc.id == 4)
.collect_vec();
assert_eq!(result.len(), 1, "there should be exactly 1 match for id 4");
assert_eq!(
result[0].name, "Run fresh warp-local and clear warp-dev permissions",
"the original document with id 4 should be unchanged"
);
searcher_async
.insert_document_async(SearchDoc {
name: "Updated name".to_owned(),
id: 4,
})
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(200));
let result = searcher_async
.get_all_documents()
.unwrap()
.into_iter()
.filter(|doc| doc.id == 4)
.collect_vec();
assert_eq!(result.len(), 1, "there should be exactly 1 match for id 4");
assert_eq!(
result[0].name, "Updated name",
"the document with id 4 should be updated on insert"
);
searcher_async.clear_search_index_async().unwrap();
std::thread::sleep(std::time::Duration::from_millis(200));
let result = searcher_async.get_all_doc_ids().unwrap();
assert_eq!(
result.len(),
0,
"the index should be cleared and contain no documents"
);
}
+72
View File
@@ -0,0 +1,72 @@
use std::collections::HashSet;
use serde_json::{Value, json};
use strum_macros::{EnumDiscriminants, EnumIter};
use warp_core::register_telemetry_event;
use warp_core::telemetry::{EnablementState, TelemetryEventDesc};
#[derive(Clone, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
pub enum TelemetryEvent {
CommandSearchAsyncQueryCompleted {
filters: HashSet<crate::data_source::QueryFilter>,
error_payload: Option<Value>,
},
}
impl warp_core::telemetry::TelemetryEvent for TelemetryEvent {
fn name(&self) -> &'static str {
TelemetryEventDiscriminants::from(self).name()
}
fn description(&self) -> &'static str {
TelemetryEventDiscriminants::from(self).description()
}
fn enablement_state(&self) -> EnablementState {
TelemetryEventDiscriminants::from(self).enablement_state()
}
fn payload(&self) -> Option<Value> {
match self {
TelemetryEvent::CommandSearchAsyncQueryCompleted {
filters,
error_payload,
} => Some(json!({ "filter": filters, "error": error_payload })),
}
}
fn contains_ugc(&self) -> bool {
match self {
Self::CommandSearchAsyncQueryCompleted { .. } => false,
}
}
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
warp_core::telemetry::enum_events::<Self>()
}
}
impl TelemetryEventDesc for TelemetryEventDiscriminants {
fn name(&self) -> &'static str {
match self {
Self::CommandSearchAsyncQueryCompleted => "Command Search Async Query Completed",
}
}
fn description(&self) -> &'static str {
match self {
Self::CommandSearchAsyncQueryCompleted => {
"Finished searching for a command in the background"
}
}
}
fn enablement_state(&self) -> EnablementState {
match self {
Self::CommandSearchAsyncQueryCompleted => EnablementState::Always,
}
}
}
register_telemetry_event!(TelemetryEvent);