Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
use super::search_item::RuleSearchItem;
|
||||
use crate::ai::facts::{AIFact, CloudAIFactModel};
|
||||
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::CloudObject;
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use warpui::{AppContext, Entity, SingletonEntity};
|
||||
|
||||
const MAX_RESULTS: usize = 50;
|
||||
const ZERO_STATE_BASE_SCORE: i64 = 1000;
|
||||
|
||||
pub struct RulesDataSource;
|
||||
|
||||
impl RulesDataSource {
|
||||
#[allow(dead_code)]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for RulesDataSource {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let query_text = &query.text;
|
||||
|
||||
let cloud_model = CloudModel::as_ref(app);
|
||||
let mut rule_results = Vec::new();
|
||||
|
||||
let mut rules: Vec<_> = cloud_model
|
||||
.get_all_objects_of_type::<GenericStringObjectId, CloudAIFactModel>()
|
||||
.filter(|ai_fact| !ai_fact.is_trashed(cloud_model))
|
||||
.collect();
|
||||
|
||||
// Sort by revision timestamp ascending so that position-based scores
|
||||
// assign higher values to more recently updated rules.
|
||||
rules.sort_by(|a, b| {
|
||||
let a_ts = a.metadata.revision.as_ref().map(|r| r.timestamp());
|
||||
let b_ts = b.metadata.revision.as_ref().map(|r| r.timestamp());
|
||||
a_ts.cmp(&b_ts)
|
||||
});
|
||||
|
||||
let total_rules = rules.len();
|
||||
for (index, ai_fact) in rules.into_iter().enumerate() {
|
||||
let rule_uid = ai_fact.id.uid();
|
||||
let (rule_name, rule_content) = match &ai_fact.model().string_model {
|
||||
AIFact::Memory(memory) => (memory.name.clone(), memory.content.clone()),
|
||||
};
|
||||
let (match_result, is_match_on_rule_name) = if query_text.is_empty() {
|
||||
(
|
||||
FuzzyMatchResult {
|
||||
score: ZERO_STATE_BASE_SCORE + index as i64,
|
||||
matched_indices: vec![],
|
||||
},
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
let name_match = rule_name
|
||||
.as_ref()
|
||||
.and_then(|n| fuzzy_match::match_indices_case_insensitive(n, query_text));
|
||||
let content_match =
|
||||
fuzzy_match::match_indices_case_insensitive(&rule_content, query_text);
|
||||
|
||||
let (mut result, on_name) = match (name_match, content_match) {
|
||||
(Some(name), Some(content)) if content.score > name.score => (content, false),
|
||||
(Some(name), _) => (name, true),
|
||||
(None, Some(content)) => (content, false),
|
||||
(None, None) => continue,
|
||||
};
|
||||
// Add a recency bonus (capped at 30) so more recently updated
|
||||
// rules rank higher among results with similar fuzzy scores,
|
||||
// regardless of the total size of the rules collection.
|
||||
result.score += (30 * (index + 1) / total_rules) as i64;
|
||||
(result, on_name)
|
||||
};
|
||||
|
||||
let search_item = RuleSearchItem {
|
||||
rule_uid,
|
||||
rule_name,
|
||||
rule_content,
|
||||
match_result,
|
||||
is_match_on_rule_name,
|
||||
};
|
||||
|
||||
rule_results.push(QueryResult::from(search_item));
|
||||
}
|
||||
|
||||
// Sort by score and take the top results
|
||||
rule_results.sort_by_key(|b| std::cmp::Reverse(b.score()));
|
||||
rule_results.truncate(MAX_RESULTS);
|
||||
|
||||
Ok(rule_results)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for RulesDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "data_source_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,192 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use settings::manager::SettingsManager;
|
||||
use warpui::{App, SingletonEntity};
|
||||
|
||||
use crate::ai::facts::{AIFact, AIMemory, CloudAIFactModel};
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::generic_string_model::GenericStringModel;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::model::view::CloudViewModel;
|
||||
use crate::cloud_object::{
|
||||
GenericServerObject, Owner, Revision, ServerMetadata, ServerPermissions,
|
||||
};
|
||||
use crate::notebooks::manager::NotebookManager;
|
||||
use crate::search::ai_context_menu::rules::data_source::RulesDataSource;
|
||||
use crate::search::data_source::Query;
|
||||
use crate::search::mixer::SyncDataSource;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::ids::{ServerId, SyncId};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::settings::AISettings;
|
||||
use crate::system::SystemStats;
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_profiles::UserProfiles;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::NetworkStatus;
|
||||
|
||||
use crate::server::server_api::object::MockObjectClient;
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
|
||||
type ServerAIFact = GenericServerObject<
|
||||
crate::cloud_object::model::generic_string_model::GenericStringObjectId,
|
||||
CloudAIFactModel,
|
||||
>;
|
||||
|
||||
fn mock_server_ai_fact(id: i64, name: &str, content: &str, revision: Revision) -> ServerAIFact {
|
||||
GenericServerObject {
|
||||
id: SyncId::ServerId(id.into()),
|
||||
metadata: ServerMetadata {
|
||||
uid: ServerId::default(),
|
||||
revision,
|
||||
metadata_last_updated_ts: Utc::now().into(),
|
||||
trashed_ts: None,
|
||||
folder_id: None,
|
||||
is_welcome_object: false,
|
||||
creator_uid: None,
|
||||
last_editor_uid: None,
|
||||
current_editor_uid: None,
|
||||
},
|
||||
permissions: ServerPermissions {
|
||||
space: Owner::mock_current_user(),
|
||||
guests: Vec::new(),
|
||||
anyone_link_sharing: None,
|
||||
permissions_last_updated_ts: Utc::now().into(),
|
||||
},
|
||||
model: GenericStringModel {
|
||||
string_model: AIFact::Memory(AIMemory {
|
||||
name: Some(name.to_string()),
|
||||
content: content.to_string(),
|
||||
is_autogenerated: false,
|
||||
suggested_logging_id: None,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_app(app: &mut App) {
|
||||
app.add_singleton_model(|_| NetworkStatus::new());
|
||||
app.add_singleton_model(|_| SystemStats::new());
|
||||
let mock_team_client = Arc::new(MockTeamClient::new());
|
||||
let mock_workspace_client = Arc::new(MockWorkspaceClient::new());
|
||||
app.add_singleton_model(|ctx| {
|
||||
UserWorkspaces::mock(
|
||||
mock_team_client.clone(),
|
||||
mock_workspace_client.clone(),
|
||||
vec![],
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
app.add_singleton_model(TeamTesterStatus::new);
|
||||
app.add_singleton_model(SyncQueue::mock);
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|ctx| UpdateManager::new(None, Arc::new(MockObjectClient::new()), ctx));
|
||||
app.add_singleton_model(|_| UserProfiles::new(Vec::new()));
|
||||
app.add_singleton_model(CloudViewModel::new);
|
||||
app.add_singleton_model(NotebookManager::mock);
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
app.add_singleton_model(|_| SettingsManager::default());
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.update(crate::settings::init_and_register_user_preferences);
|
||||
app.update(AISettings::register_and_subscribe_to_events);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_state_scores_reflect_recency() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let now = Utc::now();
|
||||
CloudModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.upsert_from_server_object(
|
||||
mock_server_ai_fact(
|
||||
1,
|
||||
"oldest rule",
|
||||
"oldest content",
|
||||
(now - Duration::minutes(3)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_object(
|
||||
mock_server_ai_fact(
|
||||
2,
|
||||
"middle rule",
|
||||
"middle content",
|
||||
(now - Duration::minutes(2)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_object(
|
||||
mock_server_ai_fact(
|
||||
3,
|
||||
"newest rule",
|
||||
"newest content",
|
||||
(now - Duration::minutes(1)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let data_source = RulesDataSource::new();
|
||||
let results = app.read(|app| data_source.run_query(&Query::from(""), app).unwrap());
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
let scores: Vec<_> = results.iter().map(|r| r.score()).collect();
|
||||
assert!(
|
||||
scores[0] > scores[1] && scores[1] > scores[2],
|
||||
"Expected scores in strictly descending order (newest first), got {scores:?}"
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_state_adds_recency_bonus() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let now = Utc::now();
|
||||
// All rules contain "rule" so fuzzy scores should be similar
|
||||
CloudModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.upsert_from_server_object(
|
||||
mock_server_ai_fact(
|
||||
1,
|
||||
"my first rule",
|
||||
"first rule content",
|
||||
(now - Duration::minutes(3)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_object(
|
||||
mock_server_ai_fact(
|
||||
2,
|
||||
"my second rule",
|
||||
"second rule content",
|
||||
(now - Duration::minutes(2)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
model.upsert_from_server_object(
|
||||
mock_server_ai_fact(
|
||||
3,
|
||||
"my third rule",
|
||||
"third rule content",
|
||||
(now - Duration::minutes(1)).into(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let data_source = RulesDataSource::new();
|
||||
let results = app.read(|app| data_source.run_query(&Query::from("rule"), app).unwrap());
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
let scores: Vec<_> = results.iter().map(|r| r.score()).collect();
|
||||
assert!(
|
||||
scores[0] > scores[1] && scores[1] > scores[2],
|
||||
"Expected scores in strictly descending order (newest first), got {scores:?}"
|
||||
);
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod data_source;
|
||||
pub mod search_item;
|
||||
@@ -0,0 +1,230 @@
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::{GenericStringObjectFormat, JsonObjectType, ObjectType};
|
||||
use crate::search::ai_context_menu::styles;
|
||||
use crate::search::ai_context_menu::{mixer::AIContextMenuSearchableAction, safe_truncate};
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, Icon, ParentElement, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
const MAX_COMBINED_LENGTH: usize = 55;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RuleSearchItem {
|
||||
pub rule_uid: String,
|
||||
pub rule_name: Option<String>,
|
||||
pub rule_content: String,
|
||||
pub match_result: FuzzyMatchResult,
|
||||
/// True if match_result was computed against the rule name (vs content)
|
||||
pub is_match_on_rule_name: bool,
|
||||
}
|
||||
|
||||
impl SearchItem for RuleSearchItem {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
"bundled/svg/book-open.svg",
|
||||
highlight_state.icon_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(styles::ICON_SIZE)
|
||||
.with_height(styles::ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(styles::MARGIN_RIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
// Use rule_name if available, otherwise fall back to rule_content
|
||||
let (primary_text, secondary_text, is_match_on_primary) = match &self.rule_name {
|
||||
Some(name) if !name.is_empty() => (
|
||||
name.clone(),
|
||||
Some(self.rule_content.clone()),
|
||||
self.is_match_on_rule_name,
|
||||
),
|
||||
_ => (self.rule_content.clone(), None, true),
|
||||
};
|
||||
|
||||
let mut display_primary = primary_text;
|
||||
let mut display_secondary = secondary_text.unwrap_or_default();
|
||||
let mut primary_truncated = false;
|
||||
|
||||
// Ensure combined length is reasonable
|
||||
let combined_length = display_primary.len() + display_secondary.len();
|
||||
|
||||
if combined_length > MAX_COMBINED_LENGTH {
|
||||
if display_primary.len() >= MAX_COMBINED_LENGTH {
|
||||
safe_truncate(&mut display_primary, MAX_COMBINED_LENGTH - 3);
|
||||
display_primary.push_str("...");
|
||||
primary_truncated = true;
|
||||
display_secondary.clear();
|
||||
} else {
|
||||
let available_for_secondary = MAX_COMBINED_LENGTH - display_primary.len();
|
||||
if display_secondary.len() > available_for_secondary {
|
||||
safe_truncate(
|
||||
&mut display_secondary,
|
||||
available_for_secondary.saturating_sub(3),
|
||||
);
|
||||
display_secondary.push_str("...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate highlight indices for primary or secondary text based on where match occurred
|
||||
let primary_highlights = if !self.match_result.matched_indices.is_empty()
|
||||
&& !primary_truncated
|
||||
&& is_match_on_primary
|
||||
{
|
||||
self.match_result.matched_indices.clone()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let secondary_highlights = if !self.match_result.matched_indices.is_empty()
|
||||
&& !is_match_on_primary
|
||||
&& !display_secondary.is_empty()
|
||||
{
|
||||
self.match_result.matched_indices.clone()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let mut primary_text_element = Text::new(
|
||||
display_primary,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
)
|
||||
.with_color(highlight_state.main_text_fill(appearance).into_solid());
|
||||
|
||||
if !primary_highlights.is_empty() {
|
||||
primary_text_element = primary_text_element.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
primary_highlights,
|
||||
);
|
||||
}
|
||||
|
||||
let secondary_text_element = if !display_secondary.is_empty() {
|
||||
let mut secondary_text = Text::new(
|
||||
display_secondary,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.0,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
if !secondary_highlights.is_empty() {
|
||||
secondary_text = secondary_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
secondary_highlights,
|
||||
);
|
||||
}
|
||||
|
||||
Some(secondary_text)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut row = Flex::row()
|
||||
.with_child(primary_text_element.finish())
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
if let Some(secondary) = secondary_text_element {
|
||||
row.add_child(
|
||||
Container::new(secondary.finish())
|
||||
.with_padding_left(6.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn render_details(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let theme = appearance.theme();
|
||||
|
||||
// Determine what to show as the main title
|
||||
let title = if let Some(name) = &self.rule_name {
|
||||
if !name.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
"Rule".to_string()
|
||||
}
|
||||
} else {
|
||||
"Rule".to_string()
|
||||
};
|
||||
|
||||
// Create title element
|
||||
let title_element = Text::new(
|
||||
title,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish();
|
||||
|
||||
// Create content element - show the full rule content
|
||||
let content_element = Text::new(
|
||||
self.rule_content.clone(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
)
|
||||
.with_color(theme.nonactive_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
// Create the details content
|
||||
let content = Flex::column()
|
||||
.with_child(title_element)
|
||||
.with_child(
|
||||
Container::new(content_element)
|
||||
.with_padding_top(8.0)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Some(content)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
AIContextMenuSearchableAction::InsertDriveObject {
|
||||
object_type: ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
|
||||
JsonObjectType::AIFact,
|
||||
)),
|
||||
object_uid: self.rule_uid.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Rule: {}", self.rule_content)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user