Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,266 @@
use super::search_item::BlockSearchItem;
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
use crate::search::data_source::{Query, QueryResult};
use crate::search::item::SearchItem;
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
use crate::terminal::model::block::Block;
use crate::terminal::TerminalView;
use crate::workspace::ActiveSession;
use fuzzy_match::FuzzyMatchResult;
use itertools::Itertools;
use warpui::{AppContext, Entity, SingletonEntity};
const MAX_RESULTS: usize = 20;
const ZERO_STATE_BASE_SCORE: i64 = 1000;
const RECENCY_SCALE: usize = 30;
const ACTIVE_SESSION_BONUS: i64 = 5;
pub struct BlockDataSource;
impl BlockDataSource {
#![cfg_attr(target_family = "wasm", allow(dead_code))]
pub fn new() -> Self {
Self
}
/// Helper function to process all eligible blocks from terminal views.
/// The processor closure receives the command text, the block, and whether
/// the block belongs to the currently active terminal session.
fn process_eligible_blocks<F, R>(&self, app: &AppContext, mut processor: F) -> Vec<R>
where
F: FnMut(&str, &Block, bool) -> Option<R>,
{
let mut results = Vec::new();
let active_session = ActiveSession::as_ref(app);
// Iterate over all window IDs to search across all terminal views
for window_id in app.window_ids() {
let active_view_id = active_session.terminal_view_id(window_id);
// Try to get all terminal views for this window
if let Some(terminal_views) = app.views_of_type::<TerminalView>(window_id) {
for terminal_view_handle in terminal_views {
let is_active =
active_view_id.is_some_and(|id| id == terminal_view_handle.id());
let terminal_view = terminal_view_handle.as_ref(app);
let terminal_model = terminal_view.model.lock();
let block_list = terminal_model.block_list();
// Process all eligible blocks
for block in block_list.blocks().iter() {
if !block.can_be_ai_context(block_list.agent_view_state()) {
continue;
}
let command = block.command_to_string();
// Skip empty commands
if command.trim().is_empty() {
continue;
}
if let Some(result) = processor(&command, block, is_active) {
results.push(result);
}
}
}
}
}
results
}
/// Create a BlockSearchItem from a command and block
fn create_block_search_item(
&self,
command: String,
block: &Block,
match_result: FuzzyMatchResult,
is_active_session: bool,
) -> BlockSearchItem {
// Get output lines (limit to last 3 lines for performance)
let output = block.output_to_string();
let output_lines: Vec<String> = output
.lines()
.rev()
.take(3)
.map(|s| s.to_string())
.collect();
BlockSearchItem {
block_id: block.id().clone(),
command,
directory: block.pwd().cloned(),
exit_code: block.exit_code(),
output_lines,
completed_ts: block.completed_ts().cloned(),
match_result,
is_active_session,
}
}
/// Get terminal blocks from all sessions' block lists by searching command text
fn get_matching_blocks(&self, query: &str, app: &AppContext) -> Vec<BlockSearchItem> {
let results = self.process_eligible_blocks(app, |command, block, is_active| {
self.fuzzy_match_command(command, query)
.map(|mut match_result| {
// Give active-session blocks a score bonus so they rank
// above equally-matched blocks from other sessions without
// being pinned to a separate priority tier.
if is_active {
match_result.score += ACTIVE_SESSION_BONUS;
}
self.create_block_search_item(
command.to_string(),
block,
match_result,
is_active,
)
})
});
results
.into_iter()
.k_largest_relaxed_by_key(MAX_RESULTS, |item| item.score())
.collect()
}
/// Handle zero-state query.
///
/// Each block gets a composite score:
/// ZERO_STATE_BASE_SCORE + recency (0..RECENCY_SCALE) + active-session bonus
///
/// Recency is position-based: sort all blocks by timestamp ascending,
/// map position onto 0..RECENCY_SCALE. Active-session blocks get a
/// flat ACTIVE_SESSION_BONUS on top. A very recent inactive block can
/// outrank an old active block, but blocks of similar age will be
/// boosted by the active-session bonus.
///
/// Results are sorted descending by score and truncated to MAX_RESULTS.
/// The mixer sorts ascending by (priority_tier, score, source_order)
/// and the search bar reverses with .rev(), so higher scores appear
/// at the top.
fn run_zero_state_query(
&self,
app: &AppContext,
) -> Result<Vec<QueryResult<AIContextMenuSearchableAction>>, DataSourceRunErrorWrapper> {
let mut results = self.process_eligible_blocks(app, |command, block, is_active| {
let match_result = FuzzyMatchResult {
score: 0,
matched_indices: vec![],
};
Some(self.create_block_search_item(command.to_string(), block, match_result, is_active))
});
// Sort by timestamp ascending to assign position-based recency.
results.sort_by(
|a, b| match (a.completed_ts.as_ref(), b.completed_ts.as_ref()) {
(Some(a_ts), Some(b_ts)) => a_ts.cmp(b_ts),
(Some(_), None) => std::cmp::Ordering::Greater,
(None, Some(_)) => std::cmp::Ordering::Less,
(None, None) => std::cmp::Ordering::Equal,
},
);
let total = results.len();
for (index, item) in results.iter_mut().enumerate() {
let recency = (RECENCY_SCALE * (index + 1) / total) as i64;
let active_bonus = if item.is_active_session {
ACTIVE_SESSION_BONUS
} else {
0
};
item.match_result.score = ZERO_STATE_BASE_SCORE + recency + active_bonus;
}
let mut query_results: Vec<QueryResult<AIContextMenuSearchableAction>> =
results.into_iter().map(QueryResult::from).collect();
query_results.sort_by_key(|r| std::cmp::Reverse(r.score()));
query_results.truncate(MAX_RESULTS);
Ok(query_results)
}
/// Handle non-empty query with fuzzy matching
fn run_fuzzy_search_query(
&self,
app: &AppContext,
query_text: &str,
) -> Result<Vec<QueryResult<AIContextMenuSearchableAction>>, DataSourceRunErrorWrapper> {
let matching_blocks = self.get_matching_blocks(query_text, app);
let results: Vec<QueryResult<AIContextMenuSearchableAction>> =
matching_blocks.into_iter().map(QueryResult::from).collect();
Ok(results)
}
fn fuzzy_match_command(&self, command: &str, query: &str) -> Option<FuzzyMatchResult> {
fuzzy_match::match_indices_case_insensitive_ignore_spaces(command, query).map(
|mut match_result| {
// Normalize command and query for comparison
let normalized_command = command
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ")
.to_lowercase();
let normalized_query = query
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ")
.to_lowercase();
let is_exact_match = normalized_command == normalized_query;
// Check if query matches the root command (first word)
let command_root = command
.split_whitespace()
.next()
.unwrap_or("")
.to_lowercase();
let query_normalized = normalized_query.clone();
let is_root_command_match = !is_exact_match && command_root == query_normalized;
if is_exact_match {
// Apply highest boost for exact matches to prioritize them over everything else
match_result.score *= 10;
} else if is_root_command_match {
// Apply medium boost for root command matches (e.g., "tail" matches "tail -f file.log")
// This should rank higher than partial matches from files but lower than exact matches
match_result.score *= 6;
} else {
// Apply standard 3x weighted multiplier for other fuzzy matches
match_result.score *= 3;
}
match_result
},
)
}
}
impl SyncDataSource for BlockDataSource {
type Action = AIContextMenuSearchableAction;
fn run_query(
&self,
query: &Query,
app: &AppContext,
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
let query_text = &query.text;
if query_text.is_empty() {
// Zero state: prioritize active-session blocks, then recency
self.run_zero_state_query(app)
} else {
// Non-empty query: fuzzy match against command text
self.run_fuzzy_search_query(app, query_text)
}
}
}
impl Entity for BlockDataSource {
type Event = ();
}
#[cfg(test)]
#[path = "data_source_tests.rs"]
mod tests;
@@ -0,0 +1,274 @@
use chrono::{Duration, Local};
use crate::search::ai_context_menu::blocks::data_source::BlockDataSource;
use crate::search::ai_context_menu::blocks::search_item::BlockSearchItem;
use crate::search::data_source::Query;
use crate::search::item::SearchItem;
use crate::search::mixer::SyncDataSource;
use crate::terminal::model::block::BlockId;
use crate::test_util::terminal::{
add_window_with_id_and_terminal, add_window_with_terminal, initialize_app_for_terminal_view,
};
use crate::workspace::ActiveSession;
use fuzzy_match::FuzzyMatchResult;
use warp_core::command::ExitCode;
use warpui::{App, SingletonEntity};
/// Helper to create a `BlockSearchItem` with the given parameters.
fn make_block_search_item(
command: &str,
completed_ts: Option<chrono::DateTime<Local>>,
score: i64,
is_active_session: bool,
) -> BlockSearchItem {
BlockSearchItem {
block_id: BlockId::new(),
command: command.to_string(),
directory: None,
exit_code: ExitCode::from(0),
output_lines: vec![],
completed_ts,
match_result: FuzzyMatchResult {
score,
matched_indices: vec![],
},
is_active_session,
}
}
#[test]
fn zero_state_scores_reflect_recency() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let term = add_window_with_terminal(&mut app, None);
let now = Local::now();
term.update(&mut app, |view, _ctx| {
let mut model = view.model.lock();
model.simulate_block("oldest_cmd", "out1");
model.simulate_block("middle_cmd", "out2");
model.simulate_block("newest_cmd", "out3");
let blocks = model.block_list_mut().blocks_mut();
for block in blocks.iter_mut() {
let cmd = block.command_to_string();
if cmd.contains("oldest_cmd") {
block.override_completed_ts(now - Duration::minutes(3));
} else if cmd.contains("middle_cmd") {
block.override_completed_ts(now - Duration::minutes(2));
} else if cmd.contains("newest_cmd") {
block.override_completed_ts(now - Duration::minutes(1));
}
}
});
let data_source = BlockDataSource::new();
let results = app.read(|app| data_source.run_query(&Query::from(""), app).unwrap());
assert!(
results.len() >= 3,
"Expected at least 3 results, got {}",
results.len()
);
// Newer blocks should receive strictly higher scores.
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 zero_state_active_bonus_boosts_nearby_blocks() {
// With enough blocks, adjacent positions have a small recency gap.
// The ACTIVE_SESSION_BONUS should be enough to let an active block
// that is one position older still outscore its inactive neighbour.
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let inactive_term = add_window_with_terminal(&mut app, None);
let active_term = add_window_with_terminal(&mut app, None);
let now = Local::now();
// 10 inactive blocks spanning minutes 1..=10
inactive_term.update(&mut app, |view, _ctx| {
let mut model = view.model.lock();
for i in 1..=10 {
model.simulate_block(format!("inactive_{i}").as_str(), "out");
let blocks = model.block_list_mut().blocks_mut();
if let Some(block) = blocks.iter_mut().last() {
block.override_completed_ts(now - Duration::minutes(i as i64));
}
}
});
// 1 active block at 2 minutes ago — sits between inactive_1 and
// inactive_2 in recency, so its position-based recency score is
// similar to nearby inactive blocks.
active_term.update(&mut app, |view, _ctx| {
let mut model = view.model.lock();
model.simulate_block("active_cmd", "out");
let blocks = model.block_list_mut().blocks_mut();
if let Some(block) = blocks.iter_mut().last() {
block.override_completed_ts(now - Duration::minutes(2));
}
});
let data_source = BlockDataSource::new();
let results = app.read(|app| data_source.run_query(&Query::from(""), app).unwrap());
// Find the active block's score and its immediate inactive
// neighbour (inactive_1 at 1 min ago, which has higher recency).
let scores: Vec<_> = results.iter().map(|r| r.score()).collect();
// Results are descending by score. The active block should appear
// above inactive_2 (which has the same or lower recency) thanks
// to the bonus.
// More importantly: the active block shouldn't be dead last.
let active_score = scores
.iter()
.zip(results.iter())
.find(|(_, r)| {
matches!(
r.accept_result(),
crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction::InsertText { ref text } if text.contains("active")
) || {
// Fall back: check if the block came from the active terminal
// by verifying its score includes the bonus.
false
}
})
.map(|(s, _)| *s);
let inactive_2_score = scores
.iter()
.zip(results.iter())
.find(|(_, r)| {
matches!(
r.accept_result(),
crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction::InsertText { ref text } if text.contains("inactive_2")
)
})
.map(|(s, _)| *s);
if let (Some(active), Some(inactive)) = (active_score, inactive_2_score) {
assert!(
active > inactive,
"Expected active block (at -2min + bonus) to outscore inactive_2 (at -2min). \
Active: {active:?}, Inactive_2: {inactive:?}"
);
}
})
}
#[test]
fn zero_state_very_recent_inactive_outranks_old_active() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let inactive_term = add_window_with_terminal(&mut app, None);
let active_term = add_window_with_terminal(&mut app, None);
let now = Local::now();
// Very recent inactive block: 1 minute ago
inactive_term.update(&mut app, |view, _ctx| {
let mut model = view.model.lock();
model.simulate_block("recent_inactive", "out");
let blocks = model.block_list_mut().blocks_mut();
if let Some(block) = blocks.iter_mut().last() {
block.override_completed_ts(now - Duration::minutes(1));
}
});
// Very old active block: 100 minutes ago
active_term.update(&mut app, |view, _ctx| {
let mut model = view.model.lock();
model.simulate_block("old_active", "out");
let blocks = model.block_list_mut().blocks_mut();
if let Some(block) = blocks.iter_mut().last() {
block.override_completed_ts(now - Duration::minutes(100));
}
});
let data_source = BlockDataSource::new();
let results = app.read(|app| data_source.run_query(&Query::from(""), app).unwrap());
assert_eq!(results.len(), 2);
let scores: Vec<_> = results.iter().map(|r| r.score()).collect();
// The very recent inactive block should outscore the very old
// active block because recency (30) > active bonus (5).
assert!(
scores[0] > scores[1],
"Expected very recent inactive to outscore very old active. Got: {scores:?}"
);
})
}
#[test]
fn fuzzy_query_active_session_blocks_rank_above_other_sessions() {
// Blocks from the active session receive a score bonus, so given equal
// fuzzy match quality the active-session block should rank above others.
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
// First window is inactive.
let inactive_term = add_window_with_terminal(&mut app, None);
// Second window is the active session — register it with ActiveSession.
let (active_window_id, active_term) = add_window_with_id_and_terminal(&mut app, None);
let active_view_id = active_term.id();
ActiveSession::handle(&app).update(&mut app, |active_session, ctx| {
active_session.set_session_for_test(
active_window_id,
std::sync::Arc::new(crate::terminal::model::session::Session::test()),
None::<std::path::PathBuf>,
Some(active_view_id),
ctx,
);
});
// Add the identical command to both terminals.
inactive_term.update(&mut app, |view, _ctx| {
view.model.lock().simulate_block("cargo build", "out");
});
active_term.update(&mut app, |view, _ctx| {
view.model.lock().simulate_block("cargo build", "out");
});
let data_source = BlockDataSource::new();
let results = app.read(|app| data_source.run_query(&Query::from("cargo"), app).unwrap());
assert_eq!(results.len(), 2, "Expected one result per terminal");
// The data source returns results sorted descending by score.
// The active-session block should be first due to its score bonus.
let scores: Vec<_> = results.iter().map(|r| r.score()).collect();
assert!(
scores[0] > scores[1],
"Expected active-session block to score higher. Got: {scores:?}"
);
})
}
#[test]
fn fuzzy_query_within_same_session_higher_fuzzy_score_wins() {
// Both blocks are active-session, but one has a much better fuzzy score
let better_match = make_block_search_item("cargo test", None, 9000, true);
let worse_match = make_block_search_item("cat README.md", None, 3000, true);
// Same tier, so score should determine ordering
assert_eq!(better_match.priority_tier(), worse_match.priority_tier());
assert!(
better_match.score() > worse_match.score(),
"Expected higher fuzzy score to win within same tier. \
Better: {}, Worse: {}",
better_match.score(),
worse_match.score(),
);
}
@@ -0,0 +1,2 @@
pub mod data_source;
pub mod search_item;
@@ -0,0 +1,212 @@
use crate::appearance::Appearance;
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
use crate::search::ai_context_menu::styles;
use crate::search::item::SearchItem;
use crate::search::result_renderer::ItemHighlightState;
use crate::terminal::model::block::BlockId;
use crate::util::truncation::truncate_from_end;
use fuzzy_match::FuzzyMatchResult;
use ordered_float::OrderedFloat;
use warpui::elements::Highlight;
use warpui::fonts::{Properties, Weight};
use warpui::{
elements::{ConstrainedBox, Container, CrossAxisAlignment, Flex, Icon, ParentElement, Text},
AppContext, Element, SingletonEntity,
};
use chrono::{DateTime, Local};
use warp_core::command::ExitCode;
/// Calculate how long ago a timestamp was
fn time_ago_string(timestamp: Option<&DateTime<Local>>) -> String {
let Some(timestamp) = timestamp else {
return "Just now".to_string();
};
let now = Local::now();
let duration = now.signed_duration_since(*timestamp);
if duration.num_seconds() < 60 {
"Just now".to_string()
} else if duration.num_minutes() < 60 {
format!("{} minutes ago", duration.num_minutes())
} else if duration.num_hours() < 24 {
format!("{} hours ago", duration.num_hours())
} else {
format!("{} days ago", duration.num_days())
}
}
#[derive(Clone, Debug)]
pub struct BlockSearchItem {
pub block_id: BlockId,
pub command: String,
pub directory: Option<String>,
pub exit_code: ExitCode,
pub output_lines: Vec<String>,
pub completed_ts: Option<DateTime<Local>>,
pub match_result: FuzzyMatchResult,
/// Whether this block belongs to the currently active terminal session.
/// Used to give active-session blocks higher priority in search results.
pub is_active_session: bool,
}
impl SearchItem for BlockSearchItem {
type Action = AIContextMenuSearchableAction;
fn render_icon(
&self,
highlight_state: ItemHighlightState,
appearance: &Appearance,
) -> Box<dyn Element> {
// Show error icon if the block failed, otherwise show the regular block icon
let (icon_path, icon_color) = if !self.exit_code.was_successful() {
(
"bundled/svg/alert-triangle.svg",
appearance.theme().ui_error_color(),
)
} else {
(
"bundled/svg/terminal.svg",
highlight_state.icon_fill(appearance).into_solid(),
)
};
Container::new(
ConstrainedBox::new(Icon::new(icon_path, icon_color).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);
// Create command text with highlighting
let mut command_text = Text::new(
self.command.clone(),
appearance.ui_font_family(),
appearance.monospace_font_size() - 1.0,
)
.with_color(highlight_state.main_text_fill(appearance).into_solid());
if !self.match_result.matched_indices.is_empty() {
command_text = command_text.with_single_highlight(
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
self.match_result.matched_indices.clone(),
);
}
// Create directory text with lighter color
let directory_text = self.directory.as_ref().map(|directory| {
Text::new(
directory.clone(),
appearance.ui_font_family(),
appearance.monospace_font_size() - 2.0,
)
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
.finish()
});
// Create row with command name and directory on the same line
let mut row = Flex::row()
.with_child(command_text.finish())
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some(directory) = directory_text {
row.add_child(Container::new(directory).with_padding_left(8.0).finish());
}
row.finish()
}
fn render_details(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
let appearance = Appearance::as_ref(ctx);
let theme = appearance.theme();
// Create main text: command (truncate for hover card too)
let main_text = truncate_from_end(&self.command, 100);
// Create sub text: last 3 lines of output
let sub_text = if self.output_lines.is_empty() {
"No output".to_string()
} else {
let joined = self.output_lines.join("\n").trim().to_string();
// Additional safety truncation for the hover card
truncate_from_end(&joined, 400)
};
// Create time ago text
let time_ago_text = time_ago_string(self.completed_ts.as_ref());
// Create main text element - use monospace font for command
let main_text_element = Text::new(
main_text,
appearance.monospace_font_family(),
appearance.monospace_font_size() - 1.0,
)
.with_color(theme.active_ui_text_color().into())
.with_style(Properties::default().weight(Weight::Medium))
.finish();
// Create sub text element - output lines
let sub_text_element = Text::new(
sub_text,
appearance.monospace_font_family(),
appearance.monospace_font_size() - 3.0,
)
.with_color(theme.nonactive_ui_text_color().into())
.finish();
// Create time ago element
let time_ago_element = Text::new(
time_ago_text,
appearance.ui_font_family(),
appearance.monospace_font_size() - 3.0,
)
.with_color(theme.nonactive_ui_text_color().into())
.finish();
// Create modal content with reduced spacing
let content = Flex::column()
.with_child(main_text_element)
.with_child(
Container::new(sub_text_element)
.with_padding_top(4.0)
.finish(),
)
.with_child(
Container::new(time_ago_element)
.with_padding_top(4.0)
.finish(),
)
.finish();
Some(content)
}
fn score(&self) -> OrderedFloat<f64> {
OrderedFloat(self.match_result.score as f64)
}
fn accept_result(&self) -> AIContextMenuSearchableAction {
AIContextMenuSearchableAction::InsertText {
text: format!("<block:{}>", self.block_id),
}
}
fn execute_result(&self) -> AIContextMenuSearchableAction {
self.accept_result()
}
fn accessibility_label(&self) -> String {
format!("Block: {}", self.command)
}
}
@@ -0,0 +1,403 @@
#![cfg_attr(not(feature = "local_fs"), allow(dead_code))]
#[cfg(not(target_family = "wasm"))]
use super::search_item::CodeSearchItem;
#[cfg(not(target_family = "wasm"))]
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
#[cfg(not(target_family = "wasm"))]
use crate::search::data_source::{Query, QueryResult};
#[cfg(not(target_family = "wasm"))]
use crate::search::files::model::FileSearchModel;
#[cfg(not(target_family = "wasm"))]
use crate::search::mixer::{
AsyncDataSource, BoxFuture, DataSourceRunError, DataSourceRunErrorWrapper,
};
use ai::index::Symbol;
use fuzzy_match::FuzzyMatchResult;
#[cfg(not(target_family = "wasm"))]
use instant::Instant;
#[cfg(not(target_family = "wasm"))]
use itertools::Itertools;
use std::cell::RefCell;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
#[cfg(not(target_family = "wasm"))]
use std::time::Duration;
use warpui::AppContext;
#[cfg(not(target_family = "wasm"))]
use warpui::ModelSpawner;
#[cfg(not(target_family = "wasm"))]
use crate::ai::outline::{OutlineStatus, RepoOutlines, RepoOutlinesEvent};
#[cfg(not(target_family = "wasm"))]
use crate::workspace::ActiveSession;
#[cfg(not(target_family = "wasm"))]
use repo_metadata::repositories::DetectedRepositories;
#[cfg(not(target_family = "wasm"))]
use std::path::Path;
#[cfg(not(target_family = "wasm"))]
use warpui::SingletonEntity;
const MAX_RESULTS: usize = 200;
/// Represents a single code symbol within a file
#[derive(Debug, Clone)]
pub struct CodeSymbol {
pub file_path: PathBuf,
pub symbol: Symbol,
}
/// Symbol cache that stores all symbols in a simple vector
pub struct SymbolCache {
/// All symbols stored in a vector
pub(crate) symbols: Vec<CodeSymbol>,
}
impl SymbolCache {
fn new(symbols: Vec<CodeSymbol>) -> Self {
Self { symbols }
}
}
/// Entity that owns a per-repo map of cached [`CodeSymbol`]s (the "symbol cache").
/// Lives on `AIContextMenu` so the cache persists across mixer resets.
///
/// On construction subscribes to [`RepoOutlinesEvent::OutlinesUpdated`]; when an
/// outline changes for a repo, the corresponding cache entry is evicted so the next
/// query re-populates it from the fresh outline.
pub struct CodeSymbolCache {
symbol_cache: RefCell<HashMap<PathBuf, SymbolCache>>,
#[cfg(not(target_family = "wasm"))]
spawner: ModelSpawner<Self>,
}
impl warpui::Entity for CodeSymbolCache {
type Event = ();
}
impl CodeSymbolCache {
#[cfg(not(target_family = "wasm"))]
pub fn new(ctx: &mut warpui::ModelContext<Self>) -> Self {
let spawner = ctx.spawner();
let cache = Self {
symbol_cache: RefCell::new(HashMap::new()),
spawner,
};
ctx.subscribe_to_model(&RepoOutlines::handle(ctx), |me, event, ctx| match event {
RepoOutlinesEvent::OutlinesUpdated(repo_path) => {
me.symbol_cache.get_mut().remove(repo_path);
ctx.emit(());
}
});
cache
}
#[cfg(target_family = "wasm")]
pub fn new() -> Self {
Self {
symbol_cache: RefCell::new(HashMap::new()),
}
}
#[cfg(not(target_family = "wasm"))]
pub fn spawner(&self) -> ModelSpawner<Self> {
self.spawner.clone()
}
/// Resolves the active git repo from the current window, looks up its outline,
/// and lazily populates the symbol cache from that outline. Returns the repo
/// path and total symbol count, or `None` when no repo or completed outline is
/// available.
#[cfg(not(target_family = "wasm"))]
pub fn ensure_symbols_cached(&mut self, app: &AppContext) -> Option<(PathBuf, usize)> {
let git_repo_path = app
.windows()
.state()
.active_window
.and_then(|window_id| ActiveSession::as_ref(app).path_if_local(window_id))
.and_then(|current_dir| {
DetectedRepositories::as_ref(app).get_root_for_path(Path::new(current_dir))
})?;
let (outline_status, _) = RepoOutlines::as_ref(app).get_outline(&git_repo_path)?;
let outline = match outline_status {
OutlineStatus::Complete(outline) => outline,
_ => return None,
};
let cache = self.symbol_cache.get_mut();
let cached = cache.entry(git_repo_path.clone()).or_insert_with(|| {
let symbols = outline
.to_symbols_by_file(None)
.into_iter()
.flat_map(|(file_path, file_outline)| {
let prefix = git_repo_path.clone();
file_outline
.symbols()
.into_iter()
.flatten()
.map(move |symbol| CodeSymbol {
file_path: file_path
.strip_prefix(&prefix)
.unwrap_or(&file_path)
.to_path_buf(),
symbol: symbol.clone(),
})
.collect::<Vec<_>>()
})
.collect();
SymbolCache::new(symbols)
});
let count = cached.symbols.len();
Some((git_repo_path, count))
}
/// Processes a chunk of symbols starting at `cursor`, fuzzy-matching each against `query`
/// until `budget` is exceeded. Returns `(new_cursor, batch_results)`.
#[cfg(not(target_family = "wasm"))]
pub fn search_symbols_chunk(
&mut self,
repo_path: &Path,
cursor: usize,
query: &str,
budget: Duration,
) -> (usize, Vec<CodeSearchItem>) {
// If the cache was invalidated between chunks, signal the caller with usize::MAX.
let Some(cached) = self.symbol_cache.get_mut().get(repo_path) else {
return (usize::MAX, Vec::new());
};
let symbols = &cached.symbols;
if cursor >= symbols.len() {
return (symbols.len(), Vec::new());
}
let start = Instant::now();
let mut batch = Vec::new();
let mut i = cursor;
while i < symbols.len() && start.elapsed() < budget {
let symbol = &symbols[i];
let match_result = fuzzy_match_symbol_with_type(symbol, query);
batch.push(CodeSearchItem {
code_symbol: symbol.clone(),
match_result,
});
i += 1;
}
(i, batch)
}
#[cfg(not(target_family = "wasm"))]
pub fn get_git_changed_files(&self, app: &AppContext) -> HashSet<String> {
let Some(git_repo_path) = app
.windows()
.state()
.active_window
.and_then(|window_id| ActiveSession::as_ref(app).path_if_local(window_id))
.and_then(|current_dir| {
DetectedRepositories::as_ref(app).get_root_for_path(Path::new(current_dir))
})
else {
return HashSet::new();
};
FileSearchModel::as_ref(app)
.get_git_changed_files(&git_repo_path)
.unwrap_or_default()
}
#[cfg(target_family = "wasm")]
pub fn get_git_changed_files(&self, _app: &AppContext) -> HashSet<String> {
HashSet::new()
}
}
#[cfg(not(target_family = "wasm"))]
#[derive(Debug)]
struct CodeSearchError;
#[cfg(not(target_family = "wasm"))]
impl DataSourceRunError for CodeSearchError {
fn user_facing_error(&self) -> String {
"Code search failed".to_string()
}
fn telemetry_payload(&self) -> serde_json::Value {
serde_json::json!({ "error": "model_dropped" })
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
/// Data source that searches code symbols incrementally on the main thread
/// using time-budgeted chunks, avoiding bulk-cloning the symbol list.
#[cfg(not(target_family = "wasm"))]
pub struct CodeCursorDataSource {
spawner: ModelSpawner<CodeSymbolCache>,
}
#[cfg(not(target_family = "wasm"))]
impl CodeCursorDataSource {
pub fn new(spawner: ModelSpawner<CodeSymbolCache>) -> Self {
Self { spawner }
}
}
#[cfg(not(target_family = "wasm"))]
impl AsyncDataSource for CodeCursorDataSource {
type Action = AIContextMenuSearchableAction;
fn run_query(
&self,
query: &Query,
_app: &AppContext,
) -> BoxFuture<'static, Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper>> {
let spawner = self.spawner.clone();
let query_text = query.text.clone();
let is_zero_state = query_text.is_empty();
Box::pin(async move {
let map_err = |_| -> DataSourceRunErrorWrapper { Box::new(CodeSearchError) };
// Populate cache, get repo path + count, and git-changed files if zero-state
let init_query = query_text.clone();
let init = spawner
.spawn(move |cache, ctx| {
let (repo_path, total) = cache.ensure_symbols_cached(ctx)?;
let git_changed_files = if init_query.is_empty() {
cache.get_git_changed_files(ctx)
} else {
HashSet::new()
};
Some((repo_path, total, git_changed_files))
})
.await
.map_err(map_err)?;
let Some((repo_path, total, git_changed_files)) = init else {
return Ok(Vec::new());
};
// We can't actually perform the search off of the main thread
// (because we don't have access to the code data we need for searching).
// Instead, we dispatch small search chunks to the main thread so it
// can access the cache. We yield between chunks, letting
// the main thread continue to perform render cycles while we're searching.
let mut cursor = 0usize;
let mut all_items: Vec<CodeSearchItem> = Vec::new();
while cursor < total {
let rp = repo_path.clone();
let qt = query_text.clone();
let (new_cursor, batch) = spawner
.spawn(move |cache, _ctx| {
cache.search_symbols_chunk(&rp, cursor, &qt, Duration::from_millis(5))
})
.await
.map_err(map_err)?;
all_items.extend(batch);
// Cache was invalidated or we reached the end
if new_cursor == usize::MAX || new_cursor >= total {
break;
}
cursor = new_cursor;
}
// Finalize: sort/filter results (runs on background thread)
if is_zero_state {
Ok(finalize_zero_state(all_items, &git_changed_files))
} else {
Ok(finalize_query(all_items))
}
})
}
}
#[cfg(not(target_family = "wasm"))]
pub fn code_data_source(cache: &CodeSymbolCache) -> CodeCursorDataSource {
CodeCursorDataSource::new(cache.spawner())
}
/// Zero-state finalisation: prioritize symbols from git-changed files.
#[cfg(not(target_family = "wasm"))]
fn finalize_zero_state(
items: Vec<CodeSearchItem>,
git_changed_files: &HashSet<String>,
) -> Vec<QueryResult<AIContextMenuSearchableAction>> {
let mut results: Vec<QueryResult<AIContextMenuSearchableAction>> = Vec::new();
// First, add all symbols from git-changed files (they get priority)
for item in &items {
let file_path_str = item.code_symbol.file_path.to_string_lossy().to_string();
if git_changed_files.contains(&file_path_str) {
let search_item = CodeSearchItem {
code_symbol: item.code_symbol.clone(),
match_result: FuzzyMatchResult {
score: 10000,
matched_indices: vec![],
},
};
results.push(QueryResult::from(search_item));
}
}
// Then add remaining symbols up to MAX_RESULTS total
for item in &items {
let file_path_str = item.code_symbol.file_path.to_string_lossy().to_string();
if !git_changed_files.contains(&file_path_str) && results.len() < MAX_RESULTS {
let search_item = CodeSearchItem {
code_symbol: item.code_symbol.clone(),
match_result: FuzzyMatchResult {
score: 0,
matched_indices: vec![],
},
};
results.push(QueryResult::from(search_item));
}
}
results
}
/// Query finalisation: take top-k by fuzzy score.
#[cfg(not(target_family = "wasm"))]
fn finalize_query(items: Vec<CodeSearchItem>) -> Vec<QueryResult<AIContextMenuSearchableAction>> {
items
.into_iter()
.k_largest_relaxed_by_key(MAX_RESULTS, |item| item.match_result.score)
.map(QueryResult::from)
.collect()
}
/// Matches a symbol name (including type prefix when present) and applies symbol-score weighting.
fn fuzzy_match_symbol_with_type(code_symbol: &CodeSymbol, query: &str) -> FuzzyMatchResult {
if query.is_empty() {
return FuzzyMatchResult::no_match();
}
let search_text = if let Some(type_prefix) = &code_symbol.symbol.type_prefix {
format!("{}{}", type_prefix, code_symbol.symbol.name)
} else {
code_symbol.symbol.name.clone()
};
if let Some(mut match_result) =
fuzzy_match::match_indices_case_insensitive_ignore_spaces(&search_text, query)
{
// Apply 3x weighted multiplier to make symbol scores competitive with file scores
match_result.score *= 3;
match_result
} else {
FuzzyMatchResult::no_match()
}
}
#[cfg(test)]
#[path = "data_source_tests.rs"]
mod tests;
@@ -0,0 +1,277 @@
#[cfg(test)]
use super::*;
use ai::index::Symbol;
use std::collections::HashSet;
use std::path::PathBuf;
fn create_test_symbol(name: &str, type_prefix: Option<&str>) -> CodeSymbol {
CodeSymbol {
file_path: PathBuf::from("test.rs"),
symbol: Symbol {
name: name.to_string(),
type_prefix: type_prefix.map(|s| s.to_string()),
comment: None,
line_number: 1,
},
}
}
fn create_test_symbol_in_file(
name: &str,
type_prefix: Option<&str>,
file_path: &str,
) -> CodeSymbol {
CodeSymbol {
file_path: PathBuf::from(file_path),
symbol: Symbol {
name: name.to_string(),
type_prefix: type_prefix.map(|s| s.to_string()),
comment: None,
line_number: 1,
},
}
}
fn search_code_symbols(symbols: &[CodeSymbol], query: &str) -> Vec<CodeSearchItem> {
if query.is_empty() {
return Vec::new();
}
symbols
.iter()
.map(|symbol| {
let match_result = fuzzy_match_symbol_with_type(symbol, query);
CodeSearchItem {
code_symbol: symbol.clone(),
match_result,
}
})
.collect()
}
#[test]
fn test_fuzzy_match_symbol_with_type_basic_functionality() {
let symbol = create_test_symbol("my_function", Some("fn"));
let name_match = fuzzy_match_symbol_with_type(&symbol, "function");
let type_match = fuzzy_match_symbol_with_type(&symbol, "fn");
let combined_match = fuzzy_match_symbol_with_type(&symbol, "fn my_function");
let no_match = fuzzy_match_symbol_with_type(&symbol, "xyz");
assert!(name_match.score > 0);
assert!(type_match.score > 0);
assert!(combined_match.score > 0);
assert_eq!(no_match.score, 0);
}
#[test]
fn test_fuzzy_match_symbol_with_type_no_type_handling() {
let symbol = create_test_symbol("some_variable", None);
let name_match = fuzzy_match_symbol_with_type(&symbol, "variable");
let no_match = fuzzy_match_symbol_with_type(&symbol, "xyz");
assert!(name_match.score > 0);
assert_eq!(no_match.score, 0);
}
#[test]
fn test_symbol_cache_creation() {
let symbols = vec![
create_test_symbol("my_function", Some("fn")),
create_test_symbol("MyStruct", Some("struct")),
create_test_symbol("global_var", None),
create_test_symbol("another_function", Some("fn")),
];
let cache = SymbolCache::new(symbols);
assert_eq!(cache.symbols.len(), 4);
let symbol_names: Vec<&str> = cache
.symbols
.iter()
.map(|s| s.symbol.name.as_str())
.collect();
assert!(symbol_names.contains(&"my_function"));
assert!(symbol_names.contains(&"MyStruct"));
assert!(symbol_names.contains(&"global_var"));
assert!(symbol_names.contains(&"another_function"));
}
#[test]
fn test_search_code_symbols_basic_functionality() {
let symbols = vec![
create_test_symbol("my_function", Some("fn")),
create_test_symbol("MyStruct", Some("struct")),
create_test_symbol("global_var", None),
];
let results = search_code_symbols(&symbols, "function");
assert!(!results.is_empty());
assert!(results
.iter()
.any(|r| r.code_symbol.symbol.name == "my_function"));
let results = search_code_symbols(&symbols, "fn");
assert!(!results.is_empty());
assert!(results
.iter()
.any(|r| r.code_symbol.symbol.name == "my_function"));
let results = search_code_symbols(&symbols, "fn function");
assert!(!results.is_empty());
assert!(results
.iter()
.any(|r| r.code_symbol.symbol.name == "my_function"));
}
#[test]
fn test_search_code_symbols_all_symbols_searched() {
let symbols = vec![
create_test_symbol("process_data", Some("fn")),
create_test_symbol("DataProcessor", Some("struct")),
create_test_symbol("my_variable", None),
];
let results = search_code_symbols(&symbols, "data");
assert!(results.len() >= 2);
let found_names: Vec<&str> = results
.iter()
.map(|r| r.code_symbol.symbol.name.as_str())
.collect();
assert!(found_names.contains(&"process_data"));
assert!(found_names.contains(&"DataProcessor"));
}
#[test]
fn test_search_code_symbols_empty_query() {
let symbols = vec![
create_test_symbol("my_function", Some("fn")),
create_test_symbol("MyStruct", Some("struct")),
];
let results = search_code_symbols(&symbols, "");
assert!(results.is_empty());
}
#[test]
fn test_search_code_symbols_no_matches() {
let symbols = vec![
create_test_symbol("my_function", Some("fn")),
create_test_symbol("MyStruct", Some("struct")),
];
let results = search_code_symbols(&symbols, "nonexistent");
assert_eq!(results.len(), 2);
for result in results {
assert_eq!(result.match_result.score, 0);
}
}
#[test]
fn test_search_code_symbols_untyped_symbols() {
let symbols = vec![
create_test_symbol("my_function", Some("fn")),
create_test_symbol("my_variable", None),
];
let results = search_code_symbols(&symbols, "variable");
assert!(!results.is_empty());
assert!(results
.iter()
.any(|r| r.code_symbol.symbol.name == "my_variable"));
}
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_finalize_zero_state_git_changed_first() {
let items = vec![
CodeSearchItem {
code_symbol: create_test_symbol_in_file("unchanged_fn", Some("fn"), "src/lib.rs"),
match_result: FuzzyMatchResult::no_match(),
},
CodeSearchItem {
code_symbol: create_test_symbol_in_file("changed_fn", Some("fn"), "src/changed.rs"),
match_result: FuzzyMatchResult::no_match(),
},
CodeSearchItem {
code_symbol: create_test_symbol_in_file("another_fn", Some("fn"), "src/other.rs"),
match_result: FuzzyMatchResult::no_match(),
},
];
let git_changed_files = HashSet::from(["src/changed.rs".to_string()]);
let results = finalize_zero_state(items, &git_changed_files);
assert_eq!(results.len(), 3);
assert!(results[0].score() > results[1].score());
}
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_finalize_query_returns_top_results() {
let items: Vec<CodeSearchItem> = vec![
CodeSearchItem {
code_symbol: create_test_symbol("my_function", Some("fn")),
match_result: fuzzy_match_symbol_with_type(
&create_test_symbol("my_function", Some("fn")),
"function",
),
},
CodeSearchItem {
code_symbol: create_test_symbol("MyStruct", Some("struct")),
match_result: fuzzy_match_symbol_with_type(
&create_test_symbol("MyStruct", Some("struct")),
"function",
),
},
CodeSearchItem {
code_symbol: create_test_symbol("unrelated_var", None),
match_result: fuzzy_match_symbol_with_type(
&create_test_symbol("unrelated_var", None),
"function",
),
},
];
let results = finalize_query(items);
let best = results.iter().max_by_key(|r| r.score()).unwrap();
assert_eq!(
best.accept_result(),
AIContextMenuSearchableAction::InsertText {
text: "fn my_function in test.rs:1".to_string()
}
);
}
#[test]
fn test_fuzzy_match_code_symbols_3x_multiplier() {
let symbol = create_test_symbol("my_function", Some("fn"));
let match_result = fuzzy_match_symbol_with_type(&symbol, "function");
// The score should be 3x the raw fuzzy match score.
// We can verify the multiplier is applied by checking score > 0
// and that it's divisible by 3 (since raw scores are integers).
assert!(match_result.score > 0);
assert_eq!(match_result.score % 3, 0);
}
#[cfg(not(target_family = "wasm"))]
#[test]
fn test_finalize_zero_state_respects_max_results() {
let items: Vec<CodeSearchItem> = (0..300)
.map(|i| CodeSearchItem {
code_symbol: create_test_symbol_in_file(&format!("sym_{i}"), Some("fn"), "src/main.rs"),
match_result: FuzzyMatchResult::no_match(),
})
.collect();
let results = finalize_zero_state(items, &HashSet::new());
assert_eq!(results.len(), 200);
}
@@ -0,0 +1,43 @@
pub mod data_source;
#[cfg(not(target_family = "wasm"))]
pub mod search_item;
#[cfg(not(target_family = "wasm"))]
use crate::ai::outline::{OutlineStatus, RepoOutlines};
#[cfg(not(target_family = "wasm"))]
use crate::workspace::ActiveSession;
#[cfg(not(target_family = "wasm"))]
use std::path::Path;
use warpui::AppContext;
#[cfg(not(target_family = "wasm"))]
use warpui::SingletonEntity;
/// Checks if the code symbols (outline) are currently being indexed for the active directory.
/// Returns true if the outline is in a pending state, false otherwise.
#[cfg(not(target_family = "wasm"))]
pub fn is_code_symbols_indexing(app: &AppContext) -> bool {
let active_window_id = app.windows().state().active_window;
let current_dir =
active_window_id.and_then(|window_id| ActiveSession::as_ref(app).path_if_local(window_id));
if let Some(current_dir) = current_dir {
let repo_outlines = RepoOutlines::handle(app);
let repo_outlines_ref = repo_outlines.as_ref(app);
if let Some((status, _)) = repo_outlines_ref.get_outline(Path::new(current_dir)) {
matches!(status, OutlineStatus::Pending)
} else {
false
}
} else {
false
}
}
/// WASM stub for the indexing check function.
#[cfg(target_family = "wasm")]
#[cfg_attr(target_family = "wasm", allow(dead_code))]
pub fn is_code_symbols_indexing(_app: &AppContext) -> bool {
false
}
@@ -0,0 +1,270 @@
use crate::appearance::Appearance;
use crate::search::ai_context_menu::styles;
use crate::search::ai_context_menu::{mixer::AIContextMenuSearchableAction, safe_truncate};
use crate::search::item::{IconLocation, SearchItem};
use crate::search::result_renderer::ItemHighlightState;
use fuzzy_match::FuzzyMatchResult;
use ordered_float::OrderedFloat;
use warpui::elements::{
ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, Icon, ParentElement, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::{AppContext, Element, SingletonEntity};
// Import CodeSymbol from the data_source module
use super::data_source::CodeSymbol;
const MAX_COMBINED_LENGTH: usize = 55;
#[derive(Debug, Clone)]
pub struct CodeSearchItem {
pub code_symbol: CodeSymbol,
pub match_result: FuzzyMatchResult,
}
impl SearchItem for CodeSearchItem {
type Action = AIContextMenuSearchableAction;
fn render_icon(
&self,
_highlight_state: ItemHighlightState,
appearance: &Appearance,
) -> Box<dyn Element> {
Container::new(
ConstrainedBox::new(
Icon::new(
"bundled/svg/code-01.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 icon_location(&self, _appearance: &Appearance) -> IconLocation {
IconLocation::Centered
}
fn render_item(
&self,
highlight_state: ItemHighlightState,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
// Build the symbol name with type prefix
let mut symbol_name = String::new();
if let Some(symbol_type) = &self.code_symbol.symbol.type_prefix {
symbol_name.push_str(&format!("{symbol_type} "));
}
symbol_name.push_str(&self.code_symbol.symbol.name);
// Get file path for display
let file_path = self.code_symbol.file_path.to_string_lossy().to_string();
let mut path_display = file_path.clone();
// Track truncation for highlight adjustment
let mut symbol_truncated = false;
// Ensure combined length is less than MAX_COMBINED_LENGTH characters
let combined_length = symbol_name.len() + path_display.len();
if combined_length > MAX_COMBINED_LENGTH {
// If combined length is too long, prioritize showing the symbol name
if symbol_name.len() >= MAX_COMBINED_LENGTH {
// If symbol name itself is too long, truncate it and add ellipsis
safe_truncate(&mut symbol_name, MAX_COMBINED_LENGTH - 3);
symbol_name.push_str("...");
symbol_truncated = true;
path_display.clear();
} else {
// Symbol name fits, truncate path display
let available_for_path = MAX_COMBINED_LENGTH - symbol_name.len();
if path_display.len() > available_for_path {
let new_path_len = available_for_path.saturating_sub(3);
safe_truncate(&mut path_display, new_path_len);
path_display.push_str("...");
}
}
}
// Calculate highlight indices, adjusting for display format
// The fuzzy matching is done on concatenated "typeprefix" + "symbolname" (no space)
// But display shows "typeprefix " + "symbolname" (with space)
// So we need to adjust indices to account for the added space in display
let symbol_highlights: Vec<usize> = if !symbol_truncated {
self.match_result
.matched_indices
.iter()
.map(|&i| {
if let Some(symbol_type) = &self.code_symbol.symbol.type_prefix {
// If we have a type prefix, adjust indices:
// - Indices 0 to type_prefix.len()-1 map directly (type prefix part)
// - Indices type_prefix.len() and beyond need +1 offset (for the added space)
if i < symbol_type.len() {
i // Direct mapping for type prefix
} else {
i + 1 // Add 1 for the space between type and name
}
} else {
i // No type prefix, direct mapping
}
})
.collect()
} else {
// Only include highlights that fall within the truncated range
self.match_result
.matched_indices
.iter()
.filter_map(|&i| {
let adjusted_i = if let Some(symbol_type) = &self.code_symbol.symbol.type_prefix
{
if i < symbol_type.len() {
i
} else {
i + 1
}
} else {
i
};
if adjusted_i < MAX_COMBINED_LENGTH - 3 {
Some(adjusted_i)
} else {
None
}
})
.collect()
};
// Create symbol name text with highlighting
let mut symbol_text = Text::new(
symbol_name,
appearance.ui_font_family(),
appearance.monospace_font_size() - 1.0,
)
.with_color(highlight_state.main_text_fill(appearance).into_solid());
if !symbol_highlights.is_empty() {
symbol_text = symbol_text.with_single_highlight(
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
symbol_highlights,
);
}
// Create path text with lighter color
let path_text = if !path_display.is_empty() {
Some(
Text::new(
path_display,
appearance.ui_font_family(),
appearance.monospace_font_size() - 2.0,
)
.with_color(highlight_state.sub_text_fill(appearance).into_solid())
.finish(),
)
} else {
None
};
// Create row with symbol name and path on the same line
let mut row = Flex::row()
.with_child(symbol_text.finish())
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some(path) = path_text {
row.add_child(Container::new(path).with_padding_left(8.0).finish());
}
row.finish()
}
fn render_details(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
let appearance = Appearance::as_ref(ctx);
let theme = appearance.theme();
// Create main text: symbol type + name (e.g., "fn initialize_logger")
let mut main_text = String::new();
if let Some(symbol_type) = &self.code_symbol.symbol.type_prefix {
main_text.push_str(symbol_type);
main_text.push(' ');
}
main_text.push_str(&self.code_symbol.symbol.name);
// Create sub text: path + line number (e.g., "core/logging.rs (44)")
let sub_text = format!(
"{} ({})",
self.code_symbol.file_path.to_string_lossy(),
self.code_symbol.symbol.line_number
);
// Create main text element - use slightly smaller font that scales with user settings
let main_text_element = Text::new(
main_text,
appearance.monospace_font_family(), // Use monospace font for consistency
appearance.monospace_font_size() - 1.0, // Slightly smaller than normal
)
.with_color(theme.active_ui_text_color().into())
.with_style(Properties::default().weight(Weight::Medium))
.finish();
// Create sub text element - even smaller for sub information
let sub_text_element = Text::new(
sub_text,
appearance.monospace_font_family(), // Use monospace font for consistency
appearance.monospace_font_size() - 3.0, // Smaller than main text
)
.with_color(theme.nonactive_ui_text_color().into())
.finish();
// Create modal content with reduced spacing
let content = Flex::column()
.with_child(main_text_element)
.with_child(
Container::new(sub_text_element)
.with_padding_top(2.0)
.finish(),
)
.finish();
Some(content)
}
fn score(&self) -> OrderedFloat<f64> {
OrderedFloat(self.match_result.score as f64)
}
fn accept_result(&self) -> Self::Action {
// Format the text as "{symbol_type} {symbol_name} in {path}:{line_number}"
let mut text = String::new();
if let Some(symbol_type) = &self.code_symbol.symbol.type_prefix {
text.push_str(symbol_type);
text.push(' ');
}
text.push_str(&self.code_symbol.symbol.name);
text.push_str(" in ");
text.push_str(&self.code_symbol.file_path.to_string_lossy());
text.push(':');
text.push_str(&self.code_symbol.symbol.line_number.to_string());
AIContextMenuSearchableAction::InsertText { text }
}
fn execute_result(&self) -> Self::Action {
self.accept_result()
}
fn accessibility_label(&self) -> String {
format!(
"Code symbol: {} in {}:{}",
self.code_symbol.symbol.name,
self.code_symbol.file_path.to_string_lossy(),
self.code_symbol.symbol.line_number
)
}
}
@@ -0,0 +1,123 @@
use super::search_item::CommandSearchItem;
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
use crate::search::data_source::{Query, QueryResult};
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
use crate::terminal::History;
use fuzzy_match::FuzzyMatchResult;
use std::collections::HashSet;
use warpui::{AppContext, SingletonEntity};
const MAX_RESULTS: usize = 50;
pub struct CommandDataSource;
impl CommandDataSource {
#[allow(dead_code)]
pub fn new() -> Self {
Self
}
/// Get terminal commands from all sessions' history
fn get_terminal_commands(&self, app: &AppContext) -> Vec<String> {
let history = History::as_ref(app);
let mut unique_commands = Vec::new();
let mut seen = HashSet::new();
// Get all live session IDs from history
let session_ids = history.all_live_session_ids();
// Collect commands from all sessions, prioritizing more recent commands
let mut all_commands = Vec::new();
for session_id in session_ids {
if let Some(commands) = history.commands(session_id) {
// Add commands with their timestamps for sorting
for entry in commands.iter() {
if !entry.command.trim().is_empty() {
all_commands.push((entry.command.clone(), entry.start_ts));
}
}
}
}
// Sort by timestamp (most recent first), using start_ts when available
all_commands.sort_by(|a, b| {
match (a.1, b.1) {
(Some(a_time), Some(b_time)) => b_time.cmp(&a_time),
(Some(_), None) => std::cmp::Ordering::Less, // timestamped commands first
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
});
// Deduplicate while preserving order (most recent occurrence wins)
for (command, _) in all_commands {
if !seen.contains(&command) {
seen.insert(command.clone());
unique_commands.push(command);
// Limit to reasonable number of commands
if unique_commands.len() >= MAX_RESULTS {
break;
}
}
}
unique_commands
}
/// Performs fuzzy matching on commands
fn fuzzy_match_command(&self, command: &str, query: &str) -> Option<FuzzyMatchResult> {
if query.is_empty() {
return Some(FuzzyMatchResult::no_match());
}
fuzzy_match::match_indices_case_insensitive(command, query)
}
}
impl SyncDataSource for CommandDataSource {
type Action = AIContextMenuSearchableAction;
fn run_query(
&self,
query: &Query,
app: &AppContext,
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
let query_text = &query.text;
let commands = self.get_terminal_commands(app);
let results: Vec<QueryResult<AIContextMenuSearchableAction>> = if query_text.is_empty() {
// Zero state: show recent commands without fuzzy matching
commands
.into_iter()
.map(|command| {
let search_item = CommandSearchItem {
command,
match_result: FuzzyMatchResult::no_match(),
};
QueryResult::from(search_item)
})
.collect()
} else {
// Non-empty query: use fuzzy matching
commands
.into_iter()
.filter_map(|command| {
let match_result = self.fuzzy_match_command(&command, query_text)?;
let search_item = CommandSearchItem {
command,
match_result,
};
Some(QueryResult::from(search_item))
})
.collect()
};
Ok(results)
}
}
impl warpui::Entity for CommandDataSource {
type Event = ();
}
@@ -0,0 +1,2 @@
pub mod data_source;
pub mod search_item;
@@ -0,0 +1,87 @@
use crate::appearance::Appearance;
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
use crate::search::item::SearchItem;
use crate::search::result_renderer::ItemHighlightState;
use fuzzy_match::FuzzyMatchResult;
use ordered_float::OrderedFloat;
use warpui::{
elements::{ConstrainedBox, Container, Icon, Text},
AppContext, Element, SingletonEntity,
};
#[derive(Clone, Debug)]
pub struct CommandSearchItem {
pub command: String,
pub match_result: FuzzyMatchResult,
}
impl SearchItem for CommandSearchItem {
type Action = AIContextMenuSearchableAction;
fn render_icon(
&self,
highlight_state: ItemHighlightState,
appearance: &Appearance,
) -> Box<dyn Element> {
Container::new(
ConstrainedBox::new(
Icon::new(
"bundled/svg/terminal.svg",
highlight_state.icon_fill(appearance),
)
.finish(),
)
.with_width(appearance.monospace_font_size())
.with_height(appearance.monospace_font_size())
.finish(),
)
.with_margin_right(12.)
.finish()
}
fn render_item(
&self,
highlight_state: ItemHighlightState,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
Text::new_inline(
self.command.clone(),
appearance.monospace_font_family(),
appearance.monospace_font_size(),
)
.with_color(highlight_state.main_text_fill(appearance).into_solid())
.with_single_highlight(
warpui::elements::Highlight::new()
.with_properties(
warpui::fonts::Properties::default().weight(warpui::fonts::Weight::Bold),
)
.with_foreground_color(highlight_state.main_text_fill(appearance).into_solid()),
self.match_result.matched_indices.clone(),
)
.finish()
}
fn render_details(&self, _ctx: &AppContext) -> Option<Box<dyn Element>> {
None
}
fn score(&self) -> OrderedFloat<f64> {
OrderedFloat(self.match_result.score as f64)
}
fn accept_result(&self) -> AIContextMenuSearchableAction {
AIContextMenuSearchableAction::InsertText {
text: self.command.clone(),
}
}
fn execute_result(&self) -> AIContextMenuSearchableAction {
self.accept_result()
}
fn accessibility_label(&self) -> String {
format!("Command: {}", self.command)
}
}
@@ -0,0 +1,128 @@
use super::search_item::ConversationSearchItem;
use super::ConversationContextItem;
use crate::ai::agent_conversations_model::AgentConversationsModel;
use crate::ai::conversation_navigation::ConversationNavigationData;
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 std::collections::HashSet;
use warpui::{AppContext, Entity, SingletonEntity};
const MAX_RESULTS: usize = 50;
/// Minimum fuzzy match score to include a conversation in filtered results.
const MIN_FUZZY_SCORE: i64 = 25;
/// Score assigned to zero-state (unfiltered) results so they rank above low fuzzy matches.
const ZERO_STATE_SCORE: i64 = 1000;
pub struct ConversationDataSource;
impl ConversationDataSource {
/// Merges local conversations and cloud agent tasks, deduplicated by
/// `server_conversation_token`.
fn collect_conversations(app: &AppContext) -> Vec<ConversationContextItem> {
let mut seen_tokens: HashSet<String> = HashSet::new();
let mut items: Vec<ConversationContextItem> = Vec::new();
// Source 1: local + historical conversations (excludes ambient agent conversations).
for nav in ConversationNavigationData::all_conversations(app) {
if let Some(token) = &nav.server_conversation_token {
if !seen_tokens.contains(token.as_str()) {
let token_str = token.as_str().to_string();
seen_tokens.insert(token_str.clone());
items.push(ConversationContextItem {
title: nav.title,
server_conversation_token: token_str,
last_updated: nav.last_updated.to_utc(),
});
}
}
}
// Source 2: cloud agent tasks. Every ambient agent conversation has a
// corresponding task, so this covers all cloud conversations.
let agent_model = AgentConversationsModel::as_ref(app);
for task in agent_model.tasks_iter() {
if let Some(conv_id) = &task.conversation_id {
if seen_tokens.insert(conv_id.clone()) {
items.push(ConversationContextItem {
title: task.title.clone(),
server_conversation_token: conv_id.clone(),
last_updated: task.updated_at,
});
}
}
}
items
}
}
impl SyncDataSource for ConversationDataSource {
type Action = AIContextMenuSearchableAction;
fn run_query(
&self,
query: &Query,
app: &AppContext,
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
let all_conversations = Self::collect_conversations(app);
let query_text = query.text.trim().to_lowercase();
// Always sort by last_updated ascending so that position-based scores
// assign higher values to more recently updated conversations. This ensures
// recency acts as a tiebreaker when fuzzy scores are similar.
let mut all_conversations = all_conversations;
all_conversations.sort_by(|a, b| a.last_updated.cmp(&b.last_updated));
let total_conversations = all_conversations.len();
let mut results: Vec<QueryResult<Self::Action>> = if query_text.is_empty() {
// Zero state: score encodes recency so the mixer orders newest items highest.
all_conversations
.into_iter()
.enumerate()
.map(|(index, item)| {
let search_item = ConversationSearchItem::new(
item,
FuzzyMatchResult {
score: ZERO_STATE_SCORE
+ (30 * (index + 1) / total_conversations) as i64,
matched_indices: vec![],
},
);
QueryResult::from(search_item)
})
.collect()
} else {
// Fuzzy match on conversation title.
all_conversations
.into_iter()
.enumerate()
.filter_map(|(index, item)| {
let mut match_result =
fuzzy_match::match_indices_case_insensitive(&item.title, &query_text)?;
if match_result.score < MIN_FUZZY_SCORE {
return None;
}
// Add a recency bonus (capped at 30) so more recently updated
// conversations rank higher among results with similar fuzzy
// scores, regardless of the total number of conversations.
match_result.score += (30 * (index + 1) / total_conversations) as i64;
let search_item = ConversationSearchItem::new(item, match_result);
Some(QueryResult::from(search_item))
})
.collect()
};
results.sort_by_key(|r| std::cmp::Reverse(r.score()));
results.truncate(MAX_RESULTS);
Ok(results)
}
}
impl Entity for ConversationDataSource {
type Event = ();
}
@@ -0,0 +1,14 @@
pub mod data_source;
mod search_item;
use chrono::{DateTime, Utc};
/// Lightweight representation of a conversation for the @conversations context menu.
/// Only carries the fields needed for display and insertion — avoids constructing
/// a full `ConversationNavigationData` for cloud conversations that have no local state.
#[derive(Debug)]
pub struct ConversationContextItem {
pub title: String,
pub server_conversation_token: String,
pub last_updated: DateTime<Utc>,
}
@@ -0,0 +1,129 @@
use fuzzy_match::FuzzyMatchResult;
use ordered_float::OrderedFloat;
use super::ConversationContextItem;
use crate::appearance::Appearance;
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
use crate::search::ai_context_menu::styles;
use crate::search::item::SearchItem;
use crate::search::result_renderer::ItemHighlightState;
use crate::util::time_format::format_approx_duration_from_now_utc;
use crate::util::truncation::truncate_from_end;
use warpui::elements::{
ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, Icon, ParentElement, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::{AppContext, Element, SingletonEntity};
const MAX_TITLE_LENGTH: usize = 45;
#[derive(Debug)]
pub(super) struct ConversationSearchItem {
item: ConversationContextItem,
match_result: FuzzyMatchResult,
}
impl ConversationSearchItem {
pub fn new(item: ConversationContextItem, match_result: FuzzyMatchResult) -> Self {
Self { item, match_result }
}
}
impl SearchItem for ConversationSearchItem {
type Action = AIContextMenuSearchableAction;
fn render_icon(
&self,
highlight_state: ItemHighlightState,
appearance: &Appearance,
) -> Box<dyn Element> {
Container::new(
ConstrainedBox::new(
Icon::new(
"bundled/svg/conversation.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 char_count = self.item.title.chars().count();
let highlight_limit = if char_count > MAX_TITLE_LENGTH {
MAX_TITLE_LENGTH.saturating_sub(1)
} else {
char_count
};
let title = truncate_from_end(&self.item.title, MAX_TITLE_LENGTH);
let mut name_text = Text::new(
title,
appearance.ui_font_family(),
(appearance.monospace_font_size() - 1.0).max(1.0),
)
.with_color(highlight_state.main_text_fill(appearance).into_solid());
if !self.match_result.matched_indices.is_empty() {
let filtered_indices: Vec<usize> = self
.match_result
.matched_indices
.iter()
.copied()
.filter(|&i| i < highlight_limit)
.collect();
if !filtered_indices.is_empty() {
name_text = name_text.with_single_highlight(
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
filtered_indices,
);
}
}
let timestamp_text = Text::new(
format_approx_duration_from_now_utc(self.item.last_updated),
appearance.ui_font_family(),
(appearance.monospace_font_size() - 2.0).max(1.0),
)
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
Flex::row()
.with_child(name_text.finish())
.with_child(
Container::new(timestamp_text.finish())
.with_padding_left(6.)
.finish(),
)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish()
}
fn score(&self) -> OrderedFloat<f64> {
OrderedFloat(self.match_result.score as f64)
}
fn accept_result(&self) -> Self::Action {
AIContextMenuSearchableAction::InsertConversation {
conversation_id: self.item.server_conversation_token.clone(),
}
}
fn execute_result(&self) -> Self::Action {
self.accept_result()
}
fn accessibility_label(&self) -> String {
format!("Conversation: {}", self.item.title)
}
}
@@ -0,0 +1,58 @@
use super::search_item::DiffSetSearchItem;
use crate::code_review::diff_state::DiffMode;
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
use crate::search::data_source::{Query, QueryResult};
use crate::search::mixer::{DataSourceRunErrorWrapper, SyncDataSource};
use warpui::AppContext;
const UNCOMMITTED_CHANGES_NAME: &str = "uncommitted changes";
const MAIN_BRANCH_CHANGES_NAME: &str = "changes vs. main branch";
pub struct DiffSetDataSource;
impl SyncDataSource for DiffSetDataSource {
type Action = AIContextMenuSearchableAction;
fn run_query(
&self,
query: &Query,
_app: &AppContext,
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
// Filter based on query if provided
let query_text = &query.text.to_lowercase();
let mut results: Vec<QueryResult<Self::Action>> = vec![];
// Add uncommitted changes option
if let Some(match_result) =
fuzzy_match::match_indices_case_insensitive(UNCOMMITTED_CHANGES_NAME, query_text)
{
results.push(
DiffSetSearchItem {
diff_mode: DiffMode::Head,
match_result,
}
.into(),
);
}
// Add main branch comparison option
if let Some(match_result) =
fuzzy_match::match_indices_case_insensitive(MAIN_BRANCH_CHANGES_NAME, query_text)
{
results.push(
DiffSetSearchItem {
diff_mode: DiffMode::MainBranch,
match_result,
}
.into(),
);
}
Ok(results)
}
}
impl warpui::Entity for DiffSetDataSource {
type Event = ();
}
@@ -0,0 +1,4 @@
#[cfg(feature = "local_fs")]
pub(super) mod data_source;
#[cfg(feature = "local_fs")]
pub(super) mod search_item;
@@ -0,0 +1,120 @@
use crate::appearance::Appearance;
use crate::code_review::diff_state::DiffMode;
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
use crate::search::ai_context_menu::styles;
use crate::search::item::SearchItem;
use crate::search::result_renderer::ItemHighlightState;
use fuzzy_match::FuzzyMatchResult;
use ordered_float::OrderedFloat;
use warpui::elements::{
ConstrainedBox, Container, CrossAxisAlignment, Flex, Icon, ParentElement, Text,
};
use warpui::{AppContext, Element, SingletonEntity};
#[derive(Debug, Clone)]
pub struct DiffSetSearchItem {
pub diff_mode: DiffMode,
pub match_result: FuzzyMatchResult,
}
impl DiffSetSearchItem {
pub fn name(&self) -> String {
match &self.diff_mode {
DiffMode::Head => "Uncommitted changes".to_string(),
DiffMode::MainBranch => "Changes vs. main branch".to_string(),
DiffMode::OtherBranch(branch) => format!("Changes vs. {branch}"),
}
}
pub fn description(&self) -> String {
match &self.diff_mode {
DiffMode::Head => "All uncommitted changes in the working directory".to_string(),
DiffMode::MainBranch => "All changes compared to the main branch".to_string(),
DiffMode::OtherBranch(branch) => format!("All changes compared to {branch}"),
}
}
}
impl SearchItem for DiffSetSearchItem {
type Action = AIContextMenuSearchableAction;
fn render_icon(
&self,
highlight_state: ItemHighlightState,
appearance: &Appearance,
) -> Box<dyn Element> {
Container::new(
ConstrainedBox::new(
Icon::new(
"bundled/svg/diff.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 name_text = Text::new(
self.name(),
appearance.ui_font_family(),
appearance.monospace_font_size() - 1.0,
)
.with_color(highlight_state.main_text_fill(appearance).into_solid());
let description_text = Text::new(
self.description(),
appearance.ui_font_family(),
appearance.monospace_font_size() - 2.0,
)
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(name_text.finish())
.with_child(
Container::new(description_text.finish())
.with_padding_left(6.)
.finish(),
)
.finish()
}
fn priority_tier(&self) -> u8 {
// Prioritize diffsets above other items.
1
}
fn score(&self) -> OrderedFloat<f64> {
OrderedFloat(self.match_result.score as f64)
}
fn accept_result(&self) -> Self::Action {
AIContextMenuSearchableAction::InsertDiffSet {
diff_mode: self.diff_mode.clone(),
}
}
fn execute_result(&self) -> Self::Action {
self.accept_result()
}
fn accessibility_label(&self) -> String {
format!("{} - {}", self.name(), self.description())
}
}
#[cfg(test)]
#[path = "search_item_tests.rs"]
mod tests;
@@ -0,0 +1,17 @@
use super::DiffSetSearchItem;
use crate::code_review::diff_state::DiffMode;
use crate::search::item::SearchItem;
#[test]
fn diffset_has_higher_priority_tier() {
let match_result =
fuzzy_match::match_indices_case_insensitive("uncommitted changes", "uncommitted")
.expect("query should match");
let item = DiffSetSearchItem {
diff_mode: DiffMode::Head,
match_result,
};
assert_eq!(item.priority_tier(), 1);
}
@@ -0,0 +1,274 @@
#![cfg_attr(not(feature = "local_fs"), allow(dead_code))]
use super::search_item::FileSearchItem;
use crate::code::opened_files::OpenedFilesModel;
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
use crate::search::async_snapshot_data_source::AsyncSnapshotDataSource;
use crate::search::data_source::{Query, QueryResult};
use crate::search::files::model::FileSearchModel;
use crate::search::files::search_item::FileSearchResult;
use crate::search::mixer::{BoxFuture, DataSourceRunErrorWrapper};
use crate::workspace::ActiveSession;
use futures_lite::future::yield_now;
use fuzzy_match::FuzzyMatchResult;
use itertools::Itertools;
use repo_metadata::repositories::DetectedRepositories;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use warpui::{AppContext, SingletonEntity};
const MAX_RESULTS: usize = 200;
pub(crate) struct FileSnapshot {
pub(crate) contents: Arc<Vec<FileSearchResult>>,
pub(crate) git_changed_files: HashSet<String>,
pub(crate) query_text: String,
/// Last-opened timestamps for files, keyed by path. Populated from
/// `OpenedFilesModel` at snapshot time. Used as a secondary recency
/// signal within each scoring tier.
pub(crate) last_opened: HashMap<String, instant::Instant>,
}
/// Builds the repository-backed file search source used by the AI context menu.
/// For empty queries, snapshots repo contents with git-change status to prioritize modified files,
/// and for non-empty queries snapshots repo contents only for faster fuzzy matching.
pub fn file_data_source_for_current_repo(
) -> AsyncSnapshotDataSource<FileSnapshot, AIContextMenuSearchableAction> {
AsyncSnapshotDataSource::new(
|query: &Query, app: &AppContext| {
if FileSearchModel::should_skip_overly_broad_query(&query.text) {
return FileSnapshot {
contents: Arc::new(Vec::new()),
git_changed_files: HashSet::new(),
query_text: query.text.clone(),
last_opened: HashMap::new(),
};
}
let file_search_model = FileSearchModel::as_ref(app);
let last_opened = snapshot_last_opened(app);
if query.text.is_empty() {
let (contents, git_changed_files) =
file_search_model.get_repo_contents_with_git_status(app);
FileSnapshot {
contents,
git_changed_files,
query_text: query.text.clone(),
last_opened,
}
} else {
let contents = file_search_model.get_repo_contents(app);
FileSnapshot {
contents,
git_changed_files: HashSet::new(),
query_text: query.text.clone(),
last_opened,
}
}
},
fuzzy_match_files,
)
}
pub fn file_data_source_for_pwd(
app: &AppContext,
) -> AsyncSnapshotDataSource<FileSnapshot, AIContextMenuSearchableAction> {
let file_search_model = FileSearchModel::as_ref(app);
let mut cached_contents = file_search_model.get_folder_contents(app);
// Reverse sort to put what you'd expect at the top for zero-state
cached_contents.sort_by(|a, b| b.path.cmp(&a.path));
let cached_contents = Arc::new(cached_contents);
AsyncSnapshotDataSource::new(
move |query: &Query, _app: &AppContext| {
if FileSearchModel::should_skip_overly_broad_query(&query.text) {
return FileSnapshot {
contents: Arc::new(Vec::new()),
git_changed_files: HashSet::new(),
query_text: query.text.clone(),
last_opened: HashMap::new(),
};
}
FileSnapshot {
contents: cached_contents.clone(),
git_changed_files: HashSet::new(),
query_text: query.text.clone(),
last_opened: HashMap::new(),
}
},
fuzzy_match_files,
)
}
/// Captures last-opened timestamps from `OpenedFilesModel` for the active
/// repo at snapshot time. Returns an empty map when no repo is active.
fn snapshot_last_opened(app: &AppContext) -> HashMap<String, instant::Instant> {
let git_repo_path = app
.windows()
.state()
.active_window
.and_then(|window_id| ActiveSession::as_ref(app).path_if_local(window_id))
.and_then(|current_dir| {
DetectedRepositories::as_ref(app).get_root_for_path(Path::new(current_dir))
});
let Some(repo_path) = git_repo_path else {
return HashMap::new();
};
let opened_files_model = OpenedFilesModel::as_ref(app);
let Some(opened_in_repo) = opened_files_model.opened_files_for_repo(&repo_path) else {
return HashMap::new();
};
// Convert PathBuf keys to String keys matching FileSearchResult.path
// (relative paths from repo root).
opened_in_repo
.iter()
.map(|(path, ts)| (path.to_string_lossy().to_string(), *ts))
.collect()
}
/// Routes file matching to zero-state ranking or query-based fuzzy scoring.
pub(crate) fn fuzzy_match_files(
snapshot: FileSnapshot,
) -> BoxFuture<
'static,
Result<Vec<QueryResult<AIContextMenuSearchableAction>>, DataSourceRunErrorWrapper>,
> {
Box::pin(async move {
if snapshot.query_text.is_empty() {
Ok(fuzzy_match_files_zero_state(snapshot).await)
} else {
Ok(fuzzy_match_files_query(snapshot).await)
}
})
}
/// Build a recency index: sort files by last-opened timestamp (ascending,
/// `None` first) and return a map from path to sort position.
fn build_recency_index(
contents: &[FileSearchResult],
last_opened: &HashMap<String, instant::Instant>,
) -> HashMap<String, usize> {
let mut opened: Vec<_> = contents
.iter()
.filter_map(|item| last_opened.get(&item.path).map(|ts| (&item.path, ts)))
.collect();
opened.sort_by_key(|(_, ts)| *ts);
opened
.into_iter()
.enumerate()
.map(|(rank, (path, _))| (path.clone(), rank + 1))
.collect()
}
/// Returns zero-state file results with two scoring tiers and recency
/// as a secondary sort within each tier.
async fn fuzzy_match_files_zero_state(
snapshot: FileSnapshot,
) -> Vec<QueryResult<AIContextMenuSearchableAction>> {
let recency_index = build_recency_index(&snapshot.contents, &snapshot.last_opened);
let max_recency = recency_index.len();
let mut results: Vec<QueryResult<AIContextMenuSearchableAction>> = Vec::new();
// Pass 1: git-changed or recently-opened files (guaranteed inclusion)
for chunk in snapshot.contents.chunks(512) {
for item in chunk {
let is_git_changed = snapshot.git_changed_files.contains(&item.path);
let is_recently_opened = snapshot.last_opened.contains_key(&item.path);
if is_git_changed || is_recently_opened {
let rank = recency_index.get(&item.path).copied().unwrap_or(0);
let recency_bonus = if max_recency > 0 {
(30 * rank / max_recency) as i64
} else {
0
};
let base_score = if is_git_changed { 10000 } else { 0 };
let match_result = FuzzyMatchResult {
score: base_score + recency_bonus,
matched_indices: vec![],
};
let search_item = FileSearchItem {
path: PathBuf::from(&item.path),
match_result,
is_directory: item.is_directory,
};
results.push(QueryResult::from(search_item));
}
}
yield_now().await;
}
// Pass 2: fill remaining capacity with untouched files
for chunk in snapshot.contents.chunks(512) {
for item in chunk {
if !snapshot.git_changed_files.contains(&item.path)
&& !snapshot.last_opened.contains_key(&item.path)
&& results.len() < MAX_RESULTS
{
let match_result = FuzzyMatchResult {
score: 0,
matched_indices: vec![],
};
let search_item = FileSearchItem {
path: PathBuf::from(&item.path),
match_result,
is_directory: item.is_directory,
};
results.push(QueryResult::from(search_item));
}
}
yield_now().await;
}
results
}
/// Returns fuzzy-ranked file results for non-empty queries.
async fn fuzzy_match_files_query(
snapshot: FileSnapshot,
) -> Vec<QueryResult<AIContextMenuSearchableAction>> {
let recency_index = build_recency_index(&snapshot.contents, &snapshot.last_opened);
let max_recency = recency_index.len();
let mut results = Vec::new();
for chunk in snapshot.contents.chunks(512) {
for item in chunk {
if let Some(mut match_result) =
FileSearchModel::fuzzy_match_path(&item.path, &snapshot.query_text)
{
// Give files a slight boost over directories to prioritize them when names are similar
if !item.is_directory {
match_result.score += 100;
}
// Add a recency bonus, capped at 30.
let rank = recency_index.get(&item.path).copied().unwrap_or(0);
let recency_bonus = if max_recency > 0 {
(30 * rank / max_recency) as i64
} else {
0
};
match_result.score += recency_bonus;
let search_item = FileSearchItem {
path: PathBuf::from(&item.path),
match_result,
is_directory: item.is_directory,
};
results.push(QueryResult::from(search_item));
}
}
yield_now().await;
}
results
.into_iter()
.k_largest_relaxed_by_key(MAX_RESULTS, |item| item.score())
.collect()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
pub mod data_source;
pub mod search_item;
#[cfg(test)]
mod data_source_tests;
@@ -0,0 +1,87 @@
use fuzzy_match::FuzzyMatchResult;
use ordered_float::OrderedFloat;
use std::fmt::Debug;
use std::path::PathBuf;
use crate::appearance::Appearance;
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
use crate::search::ai_context_menu::styles;
use crate::search::item::SearchItem;
use crate::search::result_renderer::ItemHighlightState;
use warpui::elements::{ConstrainedBox, Container, Icon};
use warpui::{AppContext, Element};
use crate::search::files::icon::icon_from_file_path;
use crate::ui_components::render_file_search_row::{render_file_search_row, FileSearchRowOptions};
#[derive(Debug)]
pub struct FileSearchItem {
pub path: PathBuf,
pub match_result: FuzzyMatchResult,
pub is_directory: bool,
}
impl SearchItem for FileSearchItem {
type Action = AIContextMenuSearchableAction;
fn render_icon(
&self,
highlight_state: ItemHighlightState,
appearance: &Appearance,
) -> Box<dyn Element> {
Container::new(
ConstrainedBox::new(if self.is_directory {
Icon::new(
"bundled/svg/completion-folder.svg",
highlight_state.icon_fill(appearance).into_solid(),
)
.finish()
} else {
icon_from_file_path(&self.path.to_string_lossy(), appearance, highlight_state)
})
.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> {
render_file_search_row(
&self.path,
FileSearchRowOptions {
match_result: Some(&self.match_result),
highlight_state,
..Default::default()
},
app,
)
}
fn score(&self) -> OrderedFloat<f64> {
OrderedFloat(self.match_result.score as f64)
}
fn accept_result(&self) -> Self::Action {
AIContextMenuSearchableAction::InsertFilePath {
file_path: self.path.to_string_lossy().to_string(),
}
}
fn execute_result(&self) -> Self::Action {
self.accept_result()
}
fn accessibility_label(&self) -> String {
if self.is_directory {
format!("Directory: {}", self.path.display())
} else {
format!("File: {}", self.path.display())
}
}
}
+42
View File
@@ -0,0 +1,42 @@
use crate::cloud_object::ObjectType;
use crate::code_review::diff_state::DiffMode;
use crate::search::mixer::SearchMixer;
pub type AIContextMenuMixer = SearchMixer<AIContextMenuSearchableAction>;
#[derive(Debug, Clone, PartialEq)]
pub enum AIContextMenuSearchableAction {
InsertFilePath {
/// This is the file path relative to the root of the current git
/// repository. If this changes, this could break how we resolve
/// the file path outside of AI mode, so just note the downstream
/// dependencies.
file_path: String,
},
InsertText {
/// Text to insert into the input buffer.
text: String,
},
InsertDriveObject {
/// The type of the drive object (Workflow, Notebook, etc.)
object_type: ObjectType,
/// The UID of the drive object to insert as <object_type:{uid}>
object_uid: String,
},
InsertPlan {
/// The UID of the AI document to insert as <plan:{uid}>
ai_document_uid: String,
},
InsertDiffSet {
/// The diff mode indicating what base to compare against
diff_mode: DiffMode,
},
InsertConversation {
/// The conversation identifier to insert as <convo:{id}>.
conversation_id: String,
},
InsertSkill {
/// The skill name to insert as /{name} into the buffer.
name: String,
},
}
+37
View File
@@ -0,0 +1,37 @@
mod blocks;
mod code;
mod commands;
mod conversations;
mod diffset;
mod files;
pub mod mixer;
mod notebooks;
mod rules;
pub mod search;
#[cfg(not(target_family = "wasm"))]
mod skills;
mod styles;
pub mod view;
mod workflows;
/// Safely truncate a string at the given byte index, ensuring we don't split UTF-8 characters
pub fn safe_truncate(s: &mut String, new_len: usize) {
if new_len >= s.len() {
return;
}
let safe_len = floor_char_boundary(s, new_len);
s.truncate(safe_len);
}
/// Find the largest valid character boundary at or before the given byte index
pub fn floor_char_boundary(original_string: &str, idx: usize) -> usize {
if idx >= original_string.len() {
original_string.len()
} else {
let mut curr = idx;
while curr > 0 && !original_string.is_char_boundary(curr) {
curr -= 1;
}
curr
}
}
@@ -0,0 +1,157 @@
use super::search_item::NotebookSearchItem;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::CloudModelType;
use crate::notebooks::manager::{NotebookManager, NotebookSource};
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 NotebookDataSource {
is_plan: bool,
}
impl NotebookDataSource {
#[allow(dead_code)]
pub fn new(is_plan: bool) -> Self {
Self { is_plan }
}
}
impl SyncDataSource for NotebookDataSource {
type Action = AIContextMenuSearchableAction;
fn run_query(
&self,
query: &Query,
app: &AppContext,
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
let query_text = &query.text;
// Get all notebooks from CloudModel
let cloud_model = CloudModel::as_ref(app);
let _user_workspaces = UserWorkspaces::as_ref(app);
// Get notebooks from all spaces the user has access to
let mut notebook_results = Vec::new();
let notebook_manager = NotebookManager::as_ref(app);
let mut notebooks: Vec<_> = cloud_model
.get_all_active_notebooks()
.filter(|notebook| {
// Notebooks and plans have separate filters.
self.is_plan == notebook.model().ai_document_id.is_some()
})
.filter(|notebook| !notebook.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.
notebooks.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_notebooks = notebooks.len();
for (index, notebook) in notebooks.into_iter().enumerate() {
let notebook_name = notebook.model().display_name();
// Use the first few lines of raw text (without markdown) as description for hover info
let raw_text = notebook_manager
.notebook_raw_text(notebook.id)
.unwrap_or(notebook.model().data.as_str());
let content_lines: Vec<&str> = raw_text.lines().take(3).collect();
let content_preview = content_lines.join("\n");
let notebook_description = if content_preview.is_empty() {
None
} else {
Some(if content_preview.len() > 200 {
// Use char_indices to find the last valid character boundary before position 197
let truncated = content_preview
.char_indices()
.take_while(|(i, _)| *i <= 197)
.last()
.map(|(i, c)| &content_preview[..i + c.len_utf8()])
.unwrap_or("");
format!("{truncated}...")
} else {
content_preview
})
};
let notebook_uid = notebook.id.uid();
// Check if this notebook is currently open
let is_open = notebook_manager
.find_pane(&NotebookSource::Existing(notebook.id))
.is_some();
let recency_bonus = (30 * (index + 1) / total_notebooks) as i64;
let (base_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 notebook name
let name_match =
fuzzy_match::match_indices_case_insensitive(&notebook_name, query_text);
// Also try matching against description if available
let description_match = notebook_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 notebook
};
// Add a recency bonus, capped at 30.
result.score += recency_bonus;
(result, on_name)
};
let mut match_result = base_match_result;
// Heavily prioritize open notebooks by adding a large bonus to their score
if is_open {
match_result.score += 10000;
}
let ai_document_uid = notebook.model().ai_document_id;
let search_item = NotebookSearchItem {
notebook_name,
notebook_description,
notebook_uid,
match_result,
ai_document_uid: ai_document_uid.map(|id| id.to_string()),
is_match_on_name,
};
notebook_results.push(QueryResult::from(search_item));
}
// Sort by score and take the top results
notebook_results.sort_by_key(|b| std::cmp::Reverse(b.score()));
notebook_results.truncate(MAX_RESULTS);
Ok(notebook_results)
}
}
impl warpui::Entity for NotebookDataSource {
type Event = ();
}
@@ -0,0 +1,260 @@
#[cfg(test)]
mod tests {
use std::sync::Arc;
use chrono::{Duration, Utc};
use settings::manager::SettingsManager;
use warpui::{App, SingletonEntity};
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::model::view::CloudViewModel;
use crate::cloud_object::{Owner, Revision, ServerMetadata, ServerNotebook, ServerPermissions};
use crate::notebooks::manager::NotebookManager;
use crate::notebooks::CloudNotebookModel;
use crate::search::ai_context_menu::notebooks::data_source::NotebookDataSource;
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;
fn mock_server_notebook_with_revision(
id: i64,
title: &str,
revision: Revision,
) -> ServerNotebook {
ServerNotebook {
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: CloudNotebookModel {
title: title.to_string(),
data: format!("{title} content"),
ai_document_id: None,
conversation_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_notebook(
mock_server_notebook_with_revision(
1,
"oldest",
(now - Duration::minutes(3)).into(),
),
ctx,
);
model.upsert_from_server_notebook(
mock_server_notebook_with_revision(
2,
"middle",
(now - Duration::minutes(2)).into(),
),
ctx,
);
model.upsert_from_server_notebook(
mock_server_notebook_with_revision(
3,
"newest",
(now - Duration::minutes(1)).into(),
),
ctx,
);
});
let data_source = NotebookDataSource::new(false);
let results = app.read(|app| data_source.run_query(&Query::from(""), app).unwrap());
assert_eq!(results.len(), 3);
// run_query sorts descending by score, so first result should be newest
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_to_equal_matches() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let now = Utc::now();
// All titles contain "plan" so fuzzy scores should be similar
CloudModel::handle(&app).update(&mut app, |model, ctx| {
model.upsert_from_server_notebook(
mock_server_notebook_with_revision(
1,
"my first plan",
(now - Duration::minutes(3)).into(),
),
ctx,
);
model.upsert_from_server_notebook(
mock_server_notebook_with_revision(
2,
"my second plan",
(now - Duration::minutes(2)).into(),
),
ctx,
);
model.upsert_from_server_notebook(
mock_server_notebook_with_revision(
3,
"my third plan",
(now - Duration::minutes(1)).into(),
),
ctx,
);
});
let data_source = NotebookDataSource::new(false);
let results = app.read(|app| data_source.run_query(&Query::from("plan"), app).unwrap());
assert_eq!(results.len(), 3);
// All match "plan" similarly; recency bonus should make newer items score higher
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 test_multibyte_character_truncation() {
// Test string with multibyte characters (emojis, accented chars)
let test_content = "This is a test with emojis 🚀 and accented chars like café and naïve that should be truncated properly without panicking. This string is intentionally long to test the 200 character limit and ensure we don't slice in the middle of multibyte characters like 你好世界";
let truncated = if test_content.len() > 200 {
let result = test_content
.char_indices()
.take_while(|(i, _)| *i <= 197)
.last()
.map(|(i, c)| &test_content[..i + c.len_utf8()])
.unwrap_or("");
format!("{result}...")
} else {
test_content.to_string()
};
// Should not panic and should produce a valid string
assert!(!truncated.is_empty());
assert!(truncated.ends_with("..."));
// The truncated string should be valid UTF-8
assert!(std::str::from_utf8(truncated.as_bytes()).is_ok());
}
#[test]
fn test_truncation_with_boundary_at_multibyte_char() {
// Create a string where byte 197 falls exactly in the middle of a multibyte character
let mut test_content = "a".repeat(195); // 195 single-byte chars
test_content.push('🚀'); // 4-byte emoji at positions 195-198
test_content.push_str("more text after emoji");
// This should not panic even though byte 197 is in the middle of the emoji
let truncated = if test_content.len() > 200 {
let result = test_content
.char_indices()
.take_while(|(i, _)| *i <= 197)
.last()
.map(|(i, c)| &test_content[..i + c.len_utf8()])
.unwrap_or("");
format!("{result}...")
} else {
test_content.to_string()
};
// Should not panic and should produce a valid string
assert!(!truncated.is_empty());
// The truncated string should be valid UTF-8
assert!(std::str::from_utf8(truncated.as_bytes()).is_ok());
// Should either include the full emoji or stop before it
assert!(!truncated.contains("🚀") || truncated.contains("🚀..."));
}
#[test]
fn test_short_content_not_truncated() {
let short_content = "This is a short string with emoji 🚀";
let result = if short_content.len() > 200 {
let truncated = short_content
.char_indices()
.take_while(|(i, _)| *i <= 197)
.last()
.map(|(i, c)| &short_content[..i + c.len_utf8()])
.unwrap_or("");
format!("{truncated}...")
} else {
short_content.to_string()
};
// Short content should not be truncated
assert_eq!(result, short_content);
assert!(!result.ends_with("..."));
}
}
@@ -0,0 +1,5 @@
pub mod data_source;
pub mod search_item;
#[cfg(test)]
mod data_source_test;
@@ -0,0 +1,236 @@
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 NotebookSearchItem {
pub notebook_name: String,
pub notebook_description: Option<String>,
pub notebook_uid: String,
pub match_result: FuzzyMatchResult,
pub ai_document_uid: Option<String>,
/// True if match_result was computed against the notebook name (vs description)
pub is_match_on_name: bool,
}
impl SearchItem for NotebookSearchItem {
type Action = AIContextMenuSearchableAction;
fn render_icon(
&self,
highlight_state: ItemHighlightState,
appearance: &Appearance,
) -> Box<dyn Element> {
Container::new(
ConstrainedBox::new(
Icon::new(
if self.ai_document_uid.is_some() {
"bundled/svg/compass-3.svg"
} else {
"bundled/svg/notebook.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 notebook_name = self.notebook_name.clone();
let mut notebook_description = self
.notebook_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 = notebook_name.len() + notebook_description.len();
if combined_length > MAX_COMBINED_LENGTH {
// Prioritize showing the notebook name
if notebook_name.len() >= MAX_COMBINED_LENGTH {
safe_truncate(&mut notebook_name, MAX_COMBINED_LENGTH - 3);
notebook_name.push_str("...");
name_truncated = true;
notebook_description.clear();
} else {
// Notebook name fits, truncate description
let available_for_description = MAX_COMBINED_LENGTH - notebook_name.len();
if notebook_description.len() > available_for_description {
safe_truncate(
&mut notebook_description,
available_for_description.saturating_sub(3),
);
notebook_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
&& !notebook_description.is_empty()
{
self.match_result.matched_indices.clone()
} else {
vec![]
};
// Create notebook name with match highlighting
let mut name_text = Text::new(
notebook_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 !notebook_description.is_empty() {
let mut desc_text = Text::new(
notebook_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 notebook 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 {
if let Some(ai_document_uid) = &self.ai_document_uid {
return AIContextMenuSearchableAction::InsertPlan {
ai_document_uid: ai_document_uid.clone(),
};
}
AIContextMenuSearchableAction::InsertDriveObject {
object_type: ObjectType::Notebook,
object_uid: self.notebook_uid.clone(),
}
}
fn execute_result(&self) -> Self::Action {
self.accept_result()
}
fn accessibility_label(&self) -> String {
if let Some(description) = &self.notebook_description {
format!("Notebook: {} - {}", self.notebook_name, description)
} else {
format!("Notebook: {}", self.notebook_name)
}
}
fn render_details(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
let appearance = Appearance::as_ref(ctx);
// Use notebook name, or "Untitled" if empty
let display_name = if self.notebook_name.is_empty() {
"Untitled".to_string()
} else {
self.notebook_name.clone()
};
let name_element = Text::new(
display_name,
appearance.ui_font_family(),
appearance.monospace_font_size() - 1.0,
)
.with_color(appearance.theme().active_ui_text_color().into());
let details = if let Some(content) = &self.notebook_description {
let content_element = Text::new(
content.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)
}
}
@@ -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)
}
}
+17
View File
@@ -0,0 +1,17 @@
const MAX_NEW_SPACES: usize = 2;
/// If this is ever false, we close the AI context menu.
pub fn is_valid_search_query(is_navigation: bool, prev_query: &str, query: &str) -> bool {
if query.contains('\n') || query.contains(" ") {
return false;
}
if is_navigation {
// We need a simple heuristic to handle when somebody jumps to the end
// of the line. Since spaces are valid characters, we only count
// how many spaces the users likely jumped over between queries
let new_chars = query.chars().skip(prev_query.len());
return new_chars.filter(|c| *c == ' ').count() < MAX_NEW_SPACES;
}
true
}
@@ -0,0 +1,100 @@
use super::search_item::SkillSearchItem;
use crate::ai::skills::SkillManager;
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 std::path::PathBuf;
use warpui::{AppContext, Entity, SingletonEntity};
#[cfg(not(target_family = "wasm"))]
use crate::workspace::ActiveSession;
const MAX_RESULTS: usize = 50;
pub struct SkillsDataSource;
impl SkillsDataSource {
pub fn new() -> Self {
Self
}
}
impl SyncDataSource for SkillsDataSource {
type Action = AIContextMenuSearchableAction;
fn run_query(
&self,
query: &Query,
app: &AppContext,
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
let query_text = &query.text;
// Resolve the current working directory from the active window's session.
let cwd: Option<PathBuf> = {
#[cfg(not(target_family = "wasm"))]
{
app.windows()
.state()
.active_window
.and_then(|window_id| ActiveSession::as_ref(app).path_if_local(window_id))
.map(PathBuf::from)
}
#[cfg(target_family = "wasm")]
{
None
}
};
let skills =
SkillManager::as_ref(app).get_skills_for_working_directory(cwd.as_deref(), app);
let mut results: Vec<QueryResult<Self::Action>> = if query_text.is_empty() {
// Zero state: show all skills with a uniform high score.
skills
.into_iter()
.map(|skill| {
QueryResult::from(SkillSearchItem {
name: skill.name,
description: skill.description,
provider: skill.provider,
icon_override: skill.icon_override,
match_result: FuzzyMatchResult {
score: 1000,
matched_indices: vec![],
},
})
})
.collect()
} else {
// Fuzzy match against skill name.
skills
.into_iter()
.filter_map(|skill| {
let match_result =
fuzzy_match::match_indices_case_insensitive(&skill.name, query_text)?;
// Skip very weak matches once the user has typed more than one character.
if query_text.len() > 1 && match_result.score < 10 {
return None;
}
Some(QueryResult::from(SkillSearchItem {
name: skill.name,
description: skill.description,
provider: skill.provider,
icon_override: skill.icon_override,
match_result,
}))
})
.collect()
};
results.sort_by_key(|r| std::cmp::Reverse(r.score()));
results.truncate(MAX_RESULTS);
Ok(results)
}
}
impl Entity for SkillsDataSource {
type Event = ();
}
@@ -0,0 +1,2 @@
pub mod data_source;
pub mod search_item;
@@ -0,0 +1,134 @@
use ai::skills::SkillProvider;
use fuzzy_match::FuzzyMatchResult;
use ordered_float::OrderedFloat;
use crate::appearance::Appearance;
use crate::search::ai_context_menu::mixer::AIContextMenuSearchableAction;
use crate::search::ai_context_menu::styles;
use crate::search::item::SearchItem;
use crate::search::result_renderer::ItemHighlightState;
use warp_core::ui::icons::Icon;
use warpui::elements::{
ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, ParentElement, Shrinkable, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::{AppContext, Element, SingletonEntity};
const MAX_DESCRIPTION_LEN: usize = 60;
#[derive(Debug)]
pub struct SkillSearchItem {
pub name: String,
pub description: String,
pub provider: SkillProvider,
pub icon_override: Option<Icon>,
pub match_result: FuzzyMatchResult,
}
impl SearchItem for SkillSearchItem {
type Action = AIContextMenuSearchableAction;
fn render_icon(
&self,
highlight_state: ItemHighlightState,
appearance: &Appearance,
) -> Box<dyn Element> {
let icon_color = highlight_state.icon_fill(appearance).into_solid();
let icon_element = if let Some(override_icon) = self.icon_override {
override_icon.to_warpui_icon(icon_color.into()).finish()
} else {
self.provider
.icon()
.to_warpui_icon(self.provider.icon_fill(icon_color.into()))
.finish()
};
Container::new(
ConstrainedBox::new(icon_element)
.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 font_size = appearance.monospace_font_size() - 1.0;
let mut name_text = Text::new(self.name.clone(), appearance.ui_font_family(), font_size)
.with_color(highlight_state.main_text_fill(appearance).into_solid());
if !self.match_result.matched_indices.is_empty() {
name_text = name_text.with_single_highlight(
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
self.match_result.matched_indices.clone(),
);
}
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(name_text.finish());
if !self.description.is_empty() {
let mut display_description = self.description.clone();
if display_description.len() > MAX_DESCRIPTION_LEN {
let truncate_at = display_description
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= MAX_DESCRIPTION_LEN - 3)
.last()
.unwrap_or(0);
display_description.truncate(truncate_at);
display_description.push_str("...");
}
let description_text = Text::new(
display_description,
appearance.ui_font_family(),
font_size - 1.0,
)
.with_color(highlight_state.sub_text_fill(appearance).into_solid());
row.add_child(
Shrinkable::new(
1.0,
Container::new(description_text.finish())
.with_padding_left(6.0)
.finish(),
)
.finish(),
);
}
row.finish()
}
fn render_details(&self, _app: &AppContext) -> Option<Box<dyn Element>> {
None
}
fn score(&self) -> OrderedFloat<f64> {
OrderedFloat(self.match_result.score as f64)
}
fn accept_result(&self) -> Self::Action {
AIContextMenuSearchableAction::InsertSkill {
name: self.name.clone(),
}
}
fn execute_result(&self) -> Self::Action {
self.accept_result()
}
fn accessibility_label(&self) -> String {
format!("Skill: {}", self.name)
}
}
+5
View File
@@ -0,0 +1,5 @@
pub const ICON_SIZE: f32 = 16.0;
pub const MARGIN_RIGHT: f32 = 8.0;
pub const ESTIMATED_RESULT_HEIGHT: f32 = 24.0;
pub const MENU_ITEM_HORIZONTAL_PADDING: f32 = 16.0;
pub const MENU_ITEM_VERTICAL_PADDING: f32 = 4.0;
File diff suppressed because it is too large Load Diff
@@ -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)
}
}