Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
use super::search_item::WorkflowSearchItem;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::CloudModelType;
|
||||
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
const MAX_RESULTS: usize = 50;
|
||||
/// Base score for zero-state results. Each item gets an additional bonus based on
|
||||
/// recency so the mixer's score-based ordering places more recent items higher.
|
||||
const ZERO_STATE_BASE_SCORE: i64 = 1000;
|
||||
|
||||
pub struct WorkflowDataSource;
|
||||
|
||||
impl WorkflowDataSource {
|
||||
#[allow(dead_code)]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for WorkflowDataSource {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let query_text = &query.text;
|
||||
|
||||
// Get all workflows from CloudModel
|
||||
let cloud_model = CloudModel::as_ref(app);
|
||||
let _user_workspaces = UserWorkspaces::as_ref(app);
|
||||
|
||||
// Get workflows from all spaces the user has access to
|
||||
let mut workflow_results = Vec::new();
|
||||
|
||||
// Collect non-welcome workflows, sorted by revision timestamp in zero state
|
||||
let mut workflows: Vec<_> = cloud_model
|
||||
.get_all_active_workflows()
|
||||
.filter(|w| !w.metadata.is_welcome_object)
|
||||
.collect();
|
||||
|
||||
// Always sort by revision timestamp ascending so that position-based
|
||||
// scores assign higher values to more recently updated items. This ensures
|
||||
// recency acts as a tiebreaker when fuzzy scores are similar.
|
||||
workflows.sort_by(|a, b| {
|
||||
let a_ts = a.metadata.revision.as_ref().map(|r| r.timestamp());
|
||||
let b_ts = b.metadata.revision.as_ref().map(|r| r.timestamp());
|
||||
a_ts.cmp(&b_ts)
|
||||
});
|
||||
|
||||
let total_workflows = workflows.len();
|
||||
for (index, workflow) in workflows.into_iter().enumerate() {
|
||||
let workflow_name = workflow.model().display_name();
|
||||
// Use workflow content for hover details, with first few lines as preview
|
||||
let workflow_content = workflow.model().data.content();
|
||||
let content_lines: Vec<&str> = workflow_content.lines().take(3).collect();
|
||||
let content_preview = content_lines.join("\n");
|
||||
let workflow_description = if content_preview.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(if content_preview.len() > 200 {
|
||||
format!("{}...", &content_preview[..197])
|
||||
} else {
|
||||
content_preview
|
||||
})
|
||||
};
|
||||
let workflow_uid = workflow.id.uid();
|
||||
let recency_bonus = (30 * (index + 1) / total_workflows) as i64;
|
||||
|
||||
let (match_result, is_match_on_name) = if query_text.is_empty() {
|
||||
// Zero state: score encodes recency so the mixer orders newest items highest.
|
||||
(
|
||||
FuzzyMatchResult {
|
||||
score: ZERO_STATE_BASE_SCORE + recency_bonus,
|
||||
matched_indices: vec![],
|
||||
},
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
// Fuzzy match against workflow name
|
||||
let name_match =
|
||||
fuzzy_match::match_indices_case_insensitive(&workflow_name, query_text);
|
||||
|
||||
// Also try matching against description if available
|
||||
let description_match = workflow_description
|
||||
.as_deref()
|
||||
.and_then(|desc| fuzzy_match::match_indices_case_insensitive(desc, query_text));
|
||||
|
||||
// Use the best match, tracking whether it was on the name
|
||||
let (mut result, on_name) = match (name_match, description_match) {
|
||||
(Some(name), Some(desc)) if desc.score > name.score => (desc, false),
|
||||
(Some(name), _) => (name, true),
|
||||
(None, Some(desc)) => (desc, false),
|
||||
(None, None) => continue, // No match, skip this workflow
|
||||
};
|
||||
// Add a recency bonus (capped at 30) so more recently updated
|
||||
// items rank higher among results with similar fuzzy scores,
|
||||
// regardless of the total size of the workflows collection.
|
||||
result.score += recency_bonus;
|
||||
(result, on_name)
|
||||
};
|
||||
|
||||
let search_item = WorkflowSearchItem {
|
||||
workflow_name,
|
||||
workflow_description,
|
||||
workflow_uid,
|
||||
match_result,
|
||||
is_match_on_name,
|
||||
};
|
||||
|
||||
workflow_results.push(QueryResult::from(search_item));
|
||||
}
|
||||
|
||||
// Sort by score and take the top results
|
||||
workflow_results.sort_by_key(|b| std::cmp::Reverse(b.score()));
|
||||
workflow_results.truncate(MAX_RESULTS);
|
||||
|
||||
Ok(workflow_results)
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for WorkflowDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod data_source;
|
||||
pub mod search_item;
|
||||
@@ -0,0 +1,219 @@
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::ObjectType;
|
||||
use crate::search::ai_context_menu::styles;
|
||||
use crate::search::ai_context_menu::{mixer::AIContextMenuSearchableAction, safe_truncate};
|
||||
use crate::search::item::SearchItem;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, Icon, ParentElement, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
const MAX_COMBINED_LENGTH: usize = 55;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WorkflowSearchItem {
|
||||
pub workflow_name: String,
|
||||
pub workflow_description: Option<String>,
|
||||
pub workflow_uid: String,
|
||||
pub match_result: FuzzyMatchResult,
|
||||
/// True if match_result was computed against the workflow name (vs description)
|
||||
pub is_match_on_name: bool,
|
||||
}
|
||||
|
||||
impl SearchItem for WorkflowSearchItem {
|
||||
type Action = AIContextMenuSearchableAction;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
"bundled/svg/workflow.svg",
|
||||
highlight_state.icon_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(styles::ICON_SIZE)
|
||||
.with_height(styles::ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(styles::MARGIN_RIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let mut workflow_name = self.workflow_name.clone();
|
||||
let mut workflow_description = self
|
||||
.workflow_description
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Track if we truncated anything for highlight adjustment
|
||||
let mut name_truncated = false;
|
||||
|
||||
// Ensure combined length is reasonable
|
||||
let combined_length = workflow_name.len() + workflow_description.len();
|
||||
|
||||
if combined_length > MAX_COMBINED_LENGTH {
|
||||
// Prioritize showing the workflow name
|
||||
if workflow_name.len() >= MAX_COMBINED_LENGTH {
|
||||
safe_truncate(&mut workflow_name, MAX_COMBINED_LENGTH - 3);
|
||||
workflow_name.push_str("...");
|
||||
name_truncated = true;
|
||||
workflow_description.clear();
|
||||
} else {
|
||||
// Workflow name fits, truncate description
|
||||
let available_for_description = MAX_COMBINED_LENGTH - workflow_name.len();
|
||||
if workflow_description.len() > available_for_description {
|
||||
safe_truncate(
|
||||
&mut workflow_description,
|
||||
available_for_description.saturating_sub(3),
|
||||
);
|
||||
workflow_description.push_str("...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate highlight indices based on where match occurred
|
||||
let name_highlights = if !self.match_result.matched_indices.is_empty()
|
||||
&& !name_truncated
|
||||
&& self.is_match_on_name
|
||||
{
|
||||
self.match_result.matched_indices.clone()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let description_highlights = if !self.match_result.matched_indices.is_empty()
|
||||
&& !self.is_match_on_name
|
||||
&& !workflow_description.is_empty()
|
||||
{
|
||||
self.match_result.matched_indices.clone()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
// Create workflow name with match highlighting
|
||||
let mut name_text = Text::new(
|
||||
workflow_name,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
)
|
||||
.with_color(highlight_state.main_text_fill(appearance).into_solid());
|
||||
|
||||
if !name_highlights.is_empty() {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
name_highlights,
|
||||
);
|
||||
}
|
||||
|
||||
// Create description text with lighter color
|
||||
let description_text = if !workflow_description.is_empty() {
|
||||
let mut desc_text = Text::new(
|
||||
workflow_description,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.0,
|
||||
)
|
||||
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
|
||||
|
||||
if !description_highlights.is_empty() {
|
||||
desc_text = desc_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
description_highlights,
|
||||
);
|
||||
}
|
||||
|
||||
Some(desc_text)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Create row with workflow name and description
|
||||
let mut row = Flex::row()
|
||||
.with_child(name_text.finish())
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
if let Some(description) = description_text {
|
||||
row.add_child(
|
||||
Container::new(description.finish())
|
||||
.with_padding_left(6.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
OrderedFloat(self.match_result.score as f64)
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
AIContextMenuSearchableAction::InsertDriveObject {
|
||||
object_type: ObjectType::Workflow,
|
||||
object_uid: self.workflow_uid.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
if let Some(description) = &self.workflow_description {
|
||||
format!("Workflow: {} - {}", self.workflow_name, description)
|
||||
} else {
|
||||
format!("Workflow: {}", self.workflow_name)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_details(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
|
||||
let name_element = Text::new(
|
||||
self.workflow_name.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
)
|
||||
.with_color(appearance.theme().active_ui_text_color().into());
|
||||
|
||||
let details = if let Some(description) = &self.workflow_description {
|
||||
let content_element = Text::new(
|
||||
description.clone(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size() - 2.0,
|
||||
)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into());
|
||||
|
||||
Flex::column()
|
||||
.with_child(name_element.finish())
|
||||
.with_child(
|
||||
Container::new(content_element.finish())
|
||||
.with_padding_top(4.0)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
} else {
|
||||
Flex::column().with_child(name_element.finish()).finish()
|
||||
};
|
||||
|
||||
Some(details)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user