163 lines
6.4 KiB
Rust
163 lines
6.4 KiB
Rust
use fuzzy_match::FuzzyMatchResult;
|
|
use warpui::{AppContext, SingletonEntity};
|
|
|
|
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;
|
|
|
|
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 galaxyui::Entity for NotebookDataSource {
|
|
type Event = ();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "data_source_tests.rs"]
|
|
mod tests;
|