Files
galaxy/app/src/search/command_palette/new_session/data_source.rs
T

463 lines
17 KiB
Rust

use super::new_session_option::{Direction, NewSessionConfig};
use super::new_session_option::{NewSessionOption, NewSessionOptionId};
use super::search_item::SearchItem;
use crate::search::data_source::DataSourceSearchError;
use crate::search::{
binding_source::BindingSource,
command_palette::mixer::CommandPaletteItemAction,
data_source::{Query, QueryResult},
mixer::{DataSourceRunErrorWrapper, SyncDataSource},
};
use crate::terminal::available_shells::AvailableShells;
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
use std::collections::HashMap;
use std::sync::Arc;
use warp_core::features::FeatureFlag;
use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
/// Controls which kinds of new sessions the data source should surface.
#[derive(Copy, Clone, Debug)]
pub struct AllowedSessionKinds {
pub windows: bool,
pub tabs: bool,
pub panes: bool,
}
impl Default for AllowedSessionKinds {
fn default() -> Self {
Self {
windows: true,
tabs: true,
panes: true,
}
}
}
impl AllowedSessionKinds {
pub fn tabs_only() -> Self {
Self {
windows: false,
tabs: true,
panes: false,
}
}
}
/// A data source that provides options for creating new terminal sessions
/// Gathers this data by:
/// - Listening for any binding source changes
/// - Comparing the options in binding sources (open new tab, open new window, etc.)
/// to the list of available shells, and creates an interesction of those items.
pub struct NewSessionDataSource {
searcher: Box<dyn NewSessionSearcher>,
allowed: AllowedSessionKinds,
}
impl NewSessionDataSource {
#[cfg(not(target_family = "wasm"))]
pub fn new(binding_source: ModelHandle<BindingSource>, ctx: &mut ModelContext<Self>) -> Self {
if FeatureFlag::UseTantivySearch.is_enabled() {
Self::new_full_text(binding_source, ctx)
} else {
Self::new_fuzzy(binding_source, ctx)
}
}
#[cfg(target_family = "wasm")]
pub fn new(binding_source: ModelHandle<BindingSource>, ctx: &mut ModelContext<Self>) -> Self {
Self::new_fuzzy(binding_source, ctx)
}
fn new_fuzzy(binding_source: ModelHandle<BindingSource>, ctx: &mut ModelContext<Self>) -> Self {
ctx.observe(&binding_source, Self::on_binding_source_changed);
Self {
searcher: Box::new(FuzzyNewSessionSearcher::default()),
allowed: Default::default(),
}
}
#[cfg(not(target_family = "wasm"))]
fn new_full_text(
binding_source: ModelHandle<BindingSource>,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.observe(&binding_source, Self::on_binding_source_changed);
Self {
searcher: Box::new(full_text_searcher::FullTextNewSessionSearcher::new(
ctx.background_executor(),
)),
allowed: Default::default(),
}
}
pub fn with_allowed_kinds(mut self, allowed: AllowedSessionKinds) -> Self {
self.allowed = allowed;
self
}
fn on_binding_source_changed(
&mut self,
source: ModelHandle<BindingSource>,
ctx: &mut ModelContext<Self>,
) {
if !FeatureFlag::ShellSelector.is_enabled() {
return;
}
let (window_id, view_id) = match source.as_ref(ctx) {
BindingSource::None => return,
BindingSource::View {
window_id, view_id, ..
} => (*window_id, *view_id),
};
let shell_id_to_options = self.searcher.bindings_mut();
let mut has_tabs = false;
let mut has_panes = false;
for lens in ctx.key_bindings_for_view(window_id, view_id) {
match lens.name {
"workspace:new_tab" => has_tabs = true,
"pane_group:add_down" => has_panes = true,
_ => (),
}
}
shell_id_to_options.clear();
for shell in AvailableShells::as_ref(ctx).get_available_shells() {
let Some(id_str) = shell.id() else { continue };
if self.allowed.windows {
let id = NewSessionOptionId::new(format!("new_window:{id_str}"));
let new_option = Arc::new(NewSessionOption::new(
id.clone(),
NewSessionConfig::NewWindow(shell.clone()),
));
shell_id_to_options.insert(id, new_option);
}
if self.allowed.tabs && has_tabs {
let id = NewSessionOptionId::new(format!("new_tab:{id_str}"));
let new_option = Arc::new(NewSessionOption::new(
id.clone(),
NewSessionConfig::NewTab(shell.clone()),
));
shell_id_to_options.insert(id, new_option);
}
if self.allowed.panes && has_panes {
for (id_str, direction) in [
(format!("split_down:{id_str}"), Direction::Down),
(format!("split_right:{id_str}"), Direction::Right),
(format!("split_up:{id_str}"), Direction::Up),
(format!("split_left:{id_str}"), Direction::Left),
] {
let id = NewSessionOptionId::new(id_str);
let new_option = Arc::new(NewSessionOption::new(
id.clone(),
NewSessionConfig::Split(direction, shell.clone()),
));
shell_id_to_options.insert(id, new_option);
}
}
}
self.searcher.build_index();
}
pub fn query_result(
&self,
id: &NewSessionOptionId,
) -> Option<QueryResult<CommandPaletteItemAction>> {
self.searcher
.bindings()
.get(id)
.map(|option| SearchItem::new(option.clone(), FuzzyMatchResult::no_match()).into())
}
}
impl SyncDataSource for NewSessionDataSource {
type Action = CommandPaletteItemAction;
/// Does a fuzzy search on the descriptions of the new session options.
/// Logic is mostly copied from actions/data_source.rs
fn run_query(
&self,
query: &Query,
_app: &AppContext,
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
let search_term = query.text.as_str();
self.searcher.search(search_term).map_err(|err| {
let search_error = DataSourceSearchError {
message: err.to_string(),
};
Box::new(search_error) as DataSourceRunErrorWrapper
})
}
}
impl Entity for NewSessionDataSource {
type Event = ();
}
type SearcherAction = <NewSessionDataSource as SyncDataSource>::Action;
const SEARCHER_BASE_STRINGS: [&str; 6] = [
"Create New Tab",
"Create New Window",
"Split Pane Down",
"Split Pane Right",
"Split Pane Up",
"Split Pane Left",
];
trait NewSessionSearcher {
fn search(&self, _search_term: &str) -> anyhow::Result<Vec<QueryResult<SearcherAction>>>;
fn build_index(&mut self);
fn bindings(&self) -> &HashMap<NewSessionOptionId, Arc<NewSessionOption>>;
fn bindings_mut(&mut self) -> &mut HashMap<NewSessionOptionId, Arc<NewSessionOption>>;
/// Computes the maximum match score for the given query string given
/// the "base options". We want to make sure that the default command
/// for any given variant is listed before the variant. Ex:
/// "Create New Tab" should always be ranked higher than
/// "Create New Tab: Zsh"
/// This function computes the lowest possible ranking score
/// for any base strings that match the query. All variant
/// matches should have this value as a ceiling.
fn compute_max_match(&self, query_str: &str) -> Option<f64>;
}
#[derive(Default)]
struct FuzzyNewSessionSearcher {
shell_id_to_options: HashMap<NewSessionOptionId, Arc<NewSessionOption>>,
}
impl NewSessionSearcher for FuzzyNewSessionSearcher {
fn search(&self, search_term: &str) -> anyhow::Result<Vec<QueryResult<SearcherAction>>> {
let max_match = self.compute_max_match(search_term);
Ok(self
.shell_id_to_options
.values()
.filter_map(move |new_session_option| {
// Binding descriptions are almost always upper case. If a user searches with
// lowercase text, the fuzzy matcher will weight this match lower because the case
// between the search term and the description differ. As a result, we lowercase
// both the search term and the description to ensure that we are matching the two
// with the same casing.
match_indices_case_insensitive(
new_session_option.description().to_lowercase().as_str(),
search_term.to_lowercase().as_str(),
)
.map(|result| {
// If for some reason the variant (ex: "Create New Tab: Powershell") ranks higher
// than a match for a base string (ex: "Create New Tab"), we want to cap the score
// to be one less than the base string.
if let Some(max_match) = max_match {
FuzzyMatchResult {
score: std::cmp::min(result.score, max_match.round() as i64 - 1),
matched_indices: result.matched_indices,
}
} else {
result
}
})
.map(|result| (result, new_session_option))
})
.map(|(match_result, new_session_config)| {
SearchItem::new(new_session_config.clone(), match_result).into()
})
.collect())
}
/// This method is a no-op for the fuzzy searcher since it does not maintain an index.
fn build_index(&mut self) {}
fn bindings(&self) -> &HashMap<NewSessionOptionId, Arc<NewSessionOption>> {
&self.shell_id_to_options
}
fn bindings_mut(&mut self) -> &mut HashMap<NewSessionOptionId, Arc<NewSessionOption>> {
&mut self.shell_id_to_options
}
fn compute_max_match(&self, query_str: &str) -> Option<f64> {
SEARCHER_BASE_STRINGS
.iter()
.filter_map(|base| {
match_indices_case_insensitive(
base.to_lowercase().as_str(),
query_str.to_lowercase().as_str(),
)
.map(|result| result.score)
})
.min()
.map(|score| score as f64)
}
}
#[cfg(not(target_family = "wasm"))]
mod full_text_searcher {
use crate::define_search_schema;
use crate::search::command_palette::new_session::data_source::{
NewSessionSearcher, SearcherAction, SEARCHER_BASE_STRINGS,
};
use crate::search::command_palette::new_session::search_item::SearchItem;
use crate::search::command_palette::new_session::{NewSessionOption, NewSessionOptionId};
use crate::search::data_source::QueryResult;
use crate::search::searcher::{
AsyncSearcher, DEFAULT_MEMORY_BUDGET, MIN_MEMORY_BUDGET, SCORE_CONVERSION_FACTOR,
};
use fuzzy_match::FuzzyMatchResult;
use std::collections::HashMap;
use std::sync::Arc;
use warpui::r#async::executor::Background;
define_search_schema!(
schema_name: NEW_SESSION_SEARCH_SCHEMA,
config_name: NewSessionConfig,
search_doc: NewSessionDocument,
identifying_doc: NewSessionIdDocument,
search_fields: [new_session_option: 1.0],
id_fields: [id: String]
);
define_search_schema!(
schema_name: BASE_TEXT_SEARCH_SCHEMA,
config_name: BaseTextConfig,
search_doc: BaseTextDocument,
identifying_doc: BaseTextIdDocument,
search_fields: [base_text: 1.0],
id_fields: []
);
pub(crate) struct FullTextNewSessionSearcher {
background_executor: Arc<Background>,
searcher: AsyncSearcher<NewSessionConfig>,
max_match_searcher: AsyncSearcher<BaseTextConfig>,
shell_id_to_options: HashMap<NewSessionOptionId, Arc<NewSessionOption>>,
}
impl NewSessionSearcher for FullTextNewSessionSearcher {
fn search(&self, search_term: &str) -> anyhow::Result<Vec<QueryResult<SearcherAction>>> {
let max_match = self.compute_max_match(search_term);
let search_result = self.searcher.search_id(search_term)?;
Ok(search_result
.into_iter()
.filter_map(|result| {
let matched_indices = result.highlights.new_session_option;
let new_session_option = self
.shell_id_to_options
.get(&NewSessionOptionId(result.values.id))?;
// If for some reason the variant (ex: "Create New Tab: Powershell") ranks higher
// than a match for a base string (ex: "Create New Tab"), we want to cap the score
// to be one less than the base string.
let capped_score = Self::cap_score(result.score, max_match);
Some(
SearchItem::new(
new_session_option.clone(),
FuzzyMatchResult {
score: (capped_score * SCORE_CONVERSION_FACTOR) as i64,
matched_indices,
},
)
.into(),
)
})
.collect())
}
fn build_index(&mut self) {
if self.rebuild_search_index().is_err() {
log::error!("Failed to create search index writer for new session options");
self.clear_search_index();
}
}
fn bindings(&self) -> &HashMap<NewSessionOptionId, Arc<NewSessionOption>> {
&self.shell_id_to_options
}
fn bindings_mut(&mut self) -> &mut HashMap<NewSessionOptionId, Arc<NewSessionOption>> {
&mut self.shell_id_to_options
}
fn compute_max_match(&self, query_str: &str) -> Option<f64> {
self.max_match_searcher
.search_id(query_str)
.ok()?
.iter()
.map(|result| result.score)
.reduce(|min, score| if score < min { score } else { min })
}
}
impl FullTextNewSessionSearcher {
pub(crate) fn new(background_executor: Arc<Background>) -> Self {
let searcher = NEW_SESSION_SEARCH_SCHEMA
.create_async_searcher(DEFAULT_MEMORY_BUDGET, background_executor.clone());
let mut max_match_searcher = BASE_TEXT_SEARCH_SCHEMA
.create_async_searcher(MIN_MEMORY_BUDGET, background_executor.clone());
let max_match_documents = SEARCHER_BASE_STRINGS.iter().map(|base| BaseTextDocument {
base_text: base.to_string(),
});
if max_match_searcher
.build_index_async(max_match_documents)
.is_err()
{
log::error!("Failed to build search index for base text of new session search");
if max_match_searcher.clear_search_index_async().is_err() {
max_match_searcher = BASE_TEXT_SEARCH_SCHEMA
.create_async_searcher(MIN_MEMORY_BUDGET, background_executor.clone())
}
}
Self {
background_executor,
searcher,
max_match_searcher,
shell_id_to_options: HashMap::new(),
}
}
fn rebuild_search_index(&mut self) -> Result<(), anyhow::Error> {
self.clear_search_index();
let documents = self.shell_id_to_options.iter().map(|(id, option)| {
let binding_description = option.description().to_lowercase();
NewSessionDocument {
new_session_option: binding_description.clone(),
id: id.0.clone(),
}
});
self.searcher.build_index_async(documents)
}
fn clear_search_index(&mut self) {
if self.searcher.clear_search_index_async().is_err() {
// As a workaround, we can create a new index and replace the old one.
self.searcher = NEW_SESSION_SEARCH_SCHEMA
.create_async_searcher(DEFAULT_MEMORY_BUDGET, self.background_executor.clone());
}
}
fn cap_score(score: f64, max_match_score: Option<f64>) -> f64 {
if let Some(max_match) = max_match_score {
// The use of 0.02 comes from the fact that fuzzy search scores are reduced by 1 in this case,
// and we boosted the Tantivy score by a factor of 50 to roughly match the fuzzy search scores.
if score > max_match - 0.02 {
max_match - 0.02
} else {
score
}
} else {
score
}
}
}
}