Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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(¬ebook_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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user