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,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)
}
}