From 717b6371f8c5319f43d22731b8b7432efc3ab29e Mon Sep 17 00:00:00 2001 From: Benjamin Bouvier Date: Wed, 1 Apr 2026 09:53:10 +0200 Subject: [PATCH] feat(multiverse): add support for global search (ctrl+g) --- labs/multiverse/Cargo.toml | 2 +- labs/multiverse/src/main.rs | 107 +++++++++++------- .../src/widgets/search/searching.rs | 43 ++++--- 3 files changed, 96 insertions(+), 56 deletions(-) diff --git a/labs/multiverse/Cargo.toml b/labs/multiverse/Cargo.toml index 8cdda1418..82c3f0df0 100644 --- a/labs/multiverse/Cargo.toml +++ b/labs/multiverse/Cargo.toml @@ -24,7 +24,7 @@ itertools.workspace = true matrix-sdk = { path = "../../crates/matrix-sdk", features = ["sso-login", "experimental-search"] } matrix-sdk-base = { path = "../../crates/matrix-sdk-base" } matrix-sdk-common = { path = "../../crates/matrix-sdk-common" } -matrix-sdk-ui = { path = "../../crates/matrix-sdk-ui" } +matrix-sdk-ui = { path = "../../crates/matrix-sdk-ui", features = ["experimental-search"] } ratatui = { version = "0.29.0", features = ["unstable-widget-ref"] } rpassword = "7.4.0" serde_json.workspace = true diff --git a/labs/multiverse/src/main.rs b/labs/multiverse/src/main.rs index 9d6969eef..e9bfda935 100644 --- a/labs/multiverse/src/main.rs +++ b/labs/multiverse/src/main.rs @@ -20,16 +20,13 @@ use futures_util::{StreamExt as _, pin_mut}; use imbl::Vector; use layout::Flex; use matrix_sdk::{ - AuthSession, Client, Room, SqliteCryptoStore, SqliteEventCacheStore, SqliteStateStore, + AuthSession, Client, SqliteCryptoStore, SqliteEventCacheStore, SqliteStateStore, ThreadingSupport, authentication::matrix::MatrixSession, config::StoreConfig, - deserialized_responses::TimelineEvent, encryption::{BackupDownloadStrategy, EncryptionSettings}, reqwest::Url, - ruma::{ - OwnedEventId, OwnedRoomId, api::client::room::create_room::v3::Request as CreateRoomRequest, - }, + ruma::{OwnedRoomId, api::client::room::create_room::v3::Request as CreateRoomRequest}, search_index::{SearchIndexGuard, SearchIndexStoreKind}, }; use matrix_sdk_base::{RoomStateFilter, event_cache::store::EventCacheStoreLockGuard}; @@ -37,6 +34,7 @@ use matrix_sdk_common::{cross_process_lock::CrossProcessLockConfig, locks::Mutex use matrix_sdk_ui::{ Timeline as SdkTimeline, room_list_service::{self, State, filters::new_filter_non_left}, + search::{GlobalSearchIterator, RoomSearchIterator}, sync_service::SyncService, timeline::{RoomExt as _, TimelineFocus, TimelineItem}, }; @@ -110,7 +108,7 @@ pub enum GlobalMode { /// Mode where we have opened the create room screen CreateRoom { view: CreateRoomView }, /// Mode where we have opened the search screen - Searching { view: SearchingView }, + Searching { view: SearchingView, is_global: bool }, /// Mode where we have opened the indexing screen Indexing { view: IndexingView }, } @@ -616,9 +614,17 @@ impl App { self.set_global_mode(GlobalMode::CreateRoom { view: CreateRoomView::new() }) } - Event::Key(KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('s'), .. }) => { - self.set_global_mode(GlobalMode::Searching { view: SearchingView::new() }) - } + Event::Key(KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('s'), .. }) => self + .set_global_mode(GlobalMode::Searching { + view: SearchingView::new(false), + is_global: false, + }), + + Event::Key(KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('g'), .. }) => self + .set_global_mode(GlobalMode::Searching { + view: SearchingView::new(true), + is_global: true, + }), _ => self.room_view.handle_event(event).await, } @@ -732,33 +738,68 @@ impl App { } } } - GlobalMode::Searching { view } => { + GlobalMode::Searching { view, is_global } => { if let Event::Key(key) = event { match key.code { Enter => { if let Some(query) = view.get_text() { - if let Some(room) = self.room_view.room() { - if let Ok(results) = - room.search(&query, 100, None).await.inspect_err(|err| { - error!("error occurred while searching index: {err:?}"); - }) - { - let results = get_events_from_event_ids( - &room, - results, - ) - .await; + if *is_global { + let mut search = GlobalSearchIterator::builder( + self.client.clone(), + query, + ) + .build(); - view.results(results); + let mut all_results = HashMap::new(); + loop { + let Ok(results) = search.next_events(5).await + else { + continue; + }; + let Some(results) = results else { + break; + }; + for (room_id, event_id) in results { + all_results + .entry(room_id) + .or_insert_with(Vec::new) + .push(event_id); + } } + + view.set_results( + all_results + .into_iter() + .map(|(room_id, events)| { + (Some(room_id), events) + }) + .collect(), + ); } else { - warn!("No room in view.") + if let Some((query, room)) = + view.get_text().zip(self.room_view.room()) + { + let mut room_search = + RoomSearchIterator::new(room, query); + + let mut all_results = Vec::new(); + while let Some(results) = + room_search.next_events(5).await? + { + all_results.extend(results); + } + view.set_results(vec![(None, all_results)]); + } } } } + Esc => self.set_global_mode(GlobalMode::Default), + Up => view.list_state.previous(), + Down => view.list_state.next(), + _ => view.handle_key_press(key), } } @@ -849,7 +890,7 @@ impl Widget for &mut App { GlobalMode::CreateRoom { view } => { view.render(area, buf); } - GlobalMode::Searching { view } => { + GlobalMode::Searching { view, .. } => { view.render(room_view_area, buf); } GlobalMode::Indexing { view } => { @@ -957,21 +998,3 @@ async fn login_with_password(client: &Client) -> Result<()> { Ok(()) } - -async fn get_events_from_event_ids( - room: &Room, - event_ids: Vec, -) -> Vec { - futures_util::future::join_all(event_ids.iter().map(|event_id| async move { - room.load_or_fetch_event(event_id, None) - .await - .inspect_err(|err| { - debug!("Failed to find event {event_id} in event cache and server: {err}"); - }) - .ok() - })) - .await - .into_iter() - .flatten() - .collect::>() -} diff --git a/labs/multiverse/src/widgets/search/searching.rs b/labs/multiverse/src/widgets/search/searching.rs index 0e17017d3..43c4653c1 100644 --- a/labs/multiverse/src/widgets/search/searching.rs +++ b/labs/multiverse/src/widgets/search/searching.rs @@ -2,7 +2,7 @@ use crossterm::event::KeyEvent; use matrix_sdk::{ deserialized_responses::TimelineEvent, ruma::{ - OwnedUserId, + OwnedRoomId, OwnedUserId, events::{ AnySyncMessageLikeEvent, AnySyncTimelineEvent, room::message::{MessageType, SyncRoomMessageEvent}, @@ -30,16 +30,19 @@ const MESSAGE_PADDING_BOTTOM: u16 = 0; #[derive(Default)] pub struct SearchingView { input: PopupInput, - results: Option>, + #[allow(clippy::type_complexity)] + results: Option, OwnedUserId, String, String)>>, pub(crate) list_state: ListState, } impl SearchingView { - pub fn new() -> Self { + pub fn new(is_global: bool) -> Self { let border_set = Set { bottom_left: "╟", bottom_right: "╢", ..symbols::border::PLAIN }; + let title = if is_global { "Search across all rooms:" } else { "Search in room:" }; + Self { - input: PopupInputBuilder::new("", "(Enter search query)") + input: PopupInputBuilder::new(title, "(Enter search query)") .height_constraint(Constraint::Percentage(100)) .width_constraint(Constraint::Percentage(100)) .border_set(border_set) @@ -51,9 +54,16 @@ impl SearchingView { } } - pub fn results(&mut self, values: Vec) { - let values: Vec<(OwnedUserId, String, String)> = - values.iter().filter_map(get_message_from_timeline_event).collect(); + pub fn set_results(&mut self, values: Vec<(Option, Vec)>) { + let values: Vec<(Option, OwnedUserId, String, String)> = values + .iter() + .flat_map(|(room_id, events)| { + events.iter().filter_map(|ev| { + let (user_id, time, body) = get_message_from_timeline_event(ev)?; + Some((room_id.clone(), user_id, time, body)) + }) + }) + .collect(); self.results = Some(values); } @@ -65,6 +75,7 @@ impl SearchingView { pub fn handle_key_press(&mut self, key: KeyEvent) { self.input.handle_key_press(key); + self.results = None; } } @@ -88,8 +99,14 @@ impl Widget for &mut SearchingView { if !results.is_empty() { results .iter() - .map(|(sender, time, message)| { - MessageWidget::new(sender.to_string(), time.clone(), message.clone()) + .map(|(room_id, sender, time, message)| { + let title = if let Some(room_id) = room_id { + format!("{} - {}", room_id, sender) + } else { + sender.to_string() + }; + + MessageWidget::new(title, time.clone(), message.clone()) }) .collect() } else { @@ -137,13 +154,13 @@ impl Widget for &mut SearchingView { fn get_message_from_timeline_event(ev: &TimelineEvent) -> Option<(OwnedUserId, String, String)> { if let Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage( - SyncRoomMessageEvent::Original(ev), + SyncRoomMessageEvent::Original(msg_ev), ))) = ev.raw().deserialize() - && let MessageType::Text(content) = &ev.content.msgtype + && let MessageType::Text(content) = &msg_ev.content.msgtype { - let time = format!("{:?}", ev.origin_server_ts); + let time = format!("{:?}", ev.timestamp().unwrap()); - return Some((ev.sender.to_owned(), time, content.body.clone())); + return Some((msg_ev.sender.to_owned(), time, content.body.clone())); } None }