first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user