From 000682ec4832ee392faf9cdf4acf7f0d920d41cc Mon Sep 17 00:00:00 2001 From: unRekable Date: Sat, 17 Oct 2026 18:09:58 +0200 Subject: [PATCH 1/2] Open the search pane from the switcher's row, with the cache's hits landing on their message. --- crates/tinyclient/src/gui/conversation.rs | 18 ++- .../tinyclient/src/gui/conversation/side.rs | 129 +++++++++++++++++- crates/tinyclient/src/gui/state.rs | 2 +- 3 files changed, 142 insertions(+), 7 deletions(-) diff --git a/crates/tinyclient/src/gui/conversation.rs b/crates/tinyclient/src/gui/conversation.rs index 5a78578..08d7171 100644 --- a/crates/tinyclient/src/gui/conversation.rs +++ b/crates/tinyclient/src/gui/conversation.rs @@ -262,6 +262,9 @@ pub struct Pane { places: crate::gui::pane::Memory, /// The next open starts from the page around this message: more is unread than a page holds. around: Option, + /// A search hit asked for this message before its conversation opened; it wins over where + /// reading stopped. + landing: Option, /// Nothing to type in: the messages take the focus on the next draw. wants_focus: bool, /// How far the files of each of my pending messages have gone, by `client_id`. @@ -333,6 +336,7 @@ impl Pane { land: false, places: crate::gui::pane::Memory::default(), around: None, + landing: None, wants_focus: false, uploads: HashMap::new(), pending_at: HashMap::new(), @@ -362,8 +366,11 @@ impl Pane { return; } // More unread than a page holds: the first page is the one around where reading stopped. + // A search hit's landing wins over it. let around = conversation.last_read.clone().filter(|_| conversation.unread > PAGE / 2); - self.around = around; + if self.landing.is_none() { + self.around = around; + } self.start(conversation.key.clone(), &conversation.title, conversation.kind, me, None, cx); self.wants_focus = !typing; if let Some(open) = self.open.as_mut() { @@ -385,7 +392,7 @@ impl Pane { root: Option<(Rc, u32)>, cx: &mut Context, ) { - let around = self.around.take(); + let around = self.landing.take().or(self.around.take()); // Where the one being left was, for coming back to it. if let Some((left, place)) = self.key().cloned().zip(self.place()) { self.places.put(left, place); @@ -746,7 +753,7 @@ impl Pane { } /// Scrolls to a message, reading the history around it first if it is not held. - fn jump_to(&mut self, id: &str, cx: &mut Context) { + pub(in crate::gui) fn jump_to(&mut self, id: &str, cx: &mut Context) { if self.window.position(id).is_some() { self.reveal(id, cx); } else { @@ -754,6 +761,11 @@ impl Pane { } } + /// The next open starts on this message: a search hit opening its conversation. + pub(in crate::gui) fn land_next(&mut self, id: String) { + self.landing = Some(id); + } + fn reveal(&mut self, id: &str, cx: &mut Context) { let Some(at) = self.items.iter().position(|item| item.message().is_some_and(|message| message.id == id)) else { return; diff --git a/crates/tinyclient/src/gui/conversation/side.rs b/crates/tinyclient/src/gui/conversation/side.rs index 5c6a6a8..cb4e150 100644 --- a/crates/tinyclient/src/gui/conversation/side.rs +++ b/crates/tinyclient/src/gui/conversation/side.rs @@ -13,17 +13,20 @@ use crate::gui::layout::{RightKind, RightPane}; use crate::gui::state::Shell; use crate::gui::theme::{self, Type}; use crate::gui::time; +use crate::view::{age, now}; use gpui::{div, prelude::*, px, AnyElement, Context, Entity, MouseButton, Window}; -use protocol::{ConversationKind, Key, Link, Message, Request, Response}; +use protocol::{ConversationKind, Key, Link, Message, MessageHit, Request, Response}; use std::rc::Rc; -/// What the window keeps of the right pane. +/// The window keeps of the right pane. #[derive(Default)] pub struct Side { pub stack: Stack, pub switches: pane::Switches, /// Threads put away on leaving their conversation, by conversation. threads: pane::Memory>, + /// The message search's page: what was typed, and what the cache holds for it. + pub search: Option, /// The conversation the stack was last fitted to. fitted: Option, /// The kind this view last put in the layout's slot, so another view's change can be told @@ -42,6 +45,13 @@ pub struct Side { room: std::cell::Cell, } +/// One message search's page: what was typed, and what the cache holds for it. +#[derive(Default)] +pub struct SearchPage { + pub words: String, + pub hits: Vec, +} + impl Side { /// A Details or Members button pressed in a window too narrow for the pane beside the /// conversation: it opens once, in place of the conversation, and a second press puts it @@ -315,6 +325,51 @@ impl Shell { self.install(cx); } + /// The switcher's "Search messages" row: the pane opens at once and the cache is asked. + pub(in crate::gui) fn open_message_search(&mut self, words: String, cx: &mut Context) { + let side = &mut self.conversation_state.side; + side.search = Some(SearchPage { words: words.clone(), hits: Vec::new() }); + side.stack = pane::open(&side.stack, Layer::Search); + self.install(cx); + self.off_thread( + cx, + { + let asked = words.clone(); + move || ask(&Request::SearchMessages { conversation: None, network: None, query: asked, limit: protocol::PAGE_LIMIT }) + }, + move |this, answer, cx| { + let Ok(Response::MessageHits { hits }) = answer else { + return; + }; + let Some(page) = this.conversation_state.side.search.as_mut() else { + return; + }; + if page.words != words { + return; + } + page.hits = hits; + cx.notify(); + }, + ); + } + + /// A search hit was clicked: its conversation opens on the message, or, being open already, + /// jumps to it. + pub(in crate::gui) fn open_search_hit(&mut self, hit: &MessageHit, cx: &mut Context) { + let key = hit.conversation.clone(); + if self.open.as_ref() == Some(&key) { + if let Some(pane) = self.conversation_state.pane.get() { + pane.update(cx, |pane, cx| pane.jump_to(&hit.message_id, cx)); + } + return; + } + if let Some(pane) = self.conversation_state.pane.get() { + pane.update(cx, |pane, _| pane.land_next(hit.message_id.clone())); + } + let title = if hit.title.is_empty() { hit.conversation.id.clone() } else { hit.title.clone() }; + self.select(key, &title, cx); + } + /// For slice D's Details: the open conversation's pinned messages, over Details. #[allow(dead_code)] pub(in crate::gui) fn open_pins(&mut self, cx: &mut Context) { @@ -514,7 +569,7 @@ fn draw_side(shell: &Shell, window: &mut Window, cx: &mut Context) -> Any Layer::Members(_) => shell.pane_members(window, cx), Layer::Thread { conversation, .. } => draw_thread(shell, conversation, cx), Layer::Pinned(conversation) => draw_pins(shell, conversation, cx), - Layer::Search => div().into_any_element(), + Layer::Search => draw_search(shell, cx), }; let crumb = stack.push.as_ref().and(stack.base.as_ref()).map(|base| crumb(base.title(), cx)); div() @@ -559,6 +614,74 @@ fn crumb(base: &'static str, cx: &mut Context) -> AnyElement { .into_any_element() } +/// The message search's pane: what was asked, and the cache's hits, newest first. Every hit is +/// the cache's word for it, so a row says "cached"; the networks are a later ask. +fn draw_search(shell: &Shell, cx: &mut Context) -> AnyElement { + let Some(page) = &shell.conversation_state.side.search else { + return div().into_any_element(); + }; + let head = div() + .h(px(44.)) + .flex_none() + .px(px(12.)) + .flex() + .items_center() + .gap(px(8.)) + .border_b_1() + .border_color(theme::hairline()) + .child(glyph("icons/search.svg", 14., theme::text_soft())) + .child(div().flex_1().min_w_0().truncate().type_ui().text_color(theme::text()).child(page.words.clone())) + .child(div().type_small().text_color(theme::text_soft()).child("cached")); + let body = if page.hits.is_empty() { + empty_state("icons/search.svg", "Nothing in the cache yet.", "The networks are not asked yet.") + .into_any_element() + } else { + let mut rows = div().id("search-hits").flex_1().min_h_0().overflow_y_scroll().flex().flex_col().p(px(4.)); + for (ix, hit) in page.hits.iter().enumerate() { + let clicked = hit.clone(); + rows = rows.child( + div() + .id(("hit", ix)) + .flex_none() + .px(px(10.)) + .py(px(8.)) + .rounded(px(theme::R_CONTROL)) + .flex() + .flex_col() + .gap(px(2.)) + .cursor_pointer() + .hover(|style| style.bg(theme::hover())) + .on_click(cx.listener(move |this, _, _, cx| this.open_search_hit(&clicked, cx))) + .child( + div() + .flex() + .items_center() + .gap(px(6.)) + .child(div().flex_1().min_w_0().truncate().type_ui().text_color(theme::text()).child(hit.title.clone())) + .child(div().flex_none().type_small().text_color(theme::text_soft()).child(age(now(), hit.sent))), + ) + .child( + div() + .flex() + .items_center() + .gap(px(6.)) + .child(div().flex_1().min_w_0().truncate().type_small().text_color(theme::text_soft()).child(hit.snippet.clone())) + .child(div().flex_none().type_micro().text_color(theme::text_soft()).child("cached")), + ), + ); + } + rows.into_any_element() + }; + div() + .size_full() + .flex() + .flex_col() + .bg(theme::canvas()) + .child(head) + .child(body) + .into_any_element() +} + /// The thread and its composer. The composer posts to the thread's own key, and only while /// that thread belongs to the open conversation. fn draw_thread(shell: &Shell, conversation: &Key, cx: &mut Context) -> AnyElement { diff --git a/crates/tinyclient/src/gui/state.rs b/crates/tinyclient/src/gui/state.rs index 46deabe..a8497bf 100644 --- a/crates/tinyclient/src/gui/state.rs +++ b/crates/tinyclient/src/gui/state.rs @@ -272,7 +272,7 @@ impl Shell { presence: Some(people::presence_slot), typing: Some(people::typing_slot), call_dock: Some(call::dock), - search: None, + search: Some(Self::open_message_search), }, layout_state: layout::State::default(), sidebar_state: sidebar::State::new(cx), From 38acddb23a91d1def0e366f23b33f9b0180709d7 Mon Sep 17 00:00:00 2001 From: unRekable Date: Sat, 17 Oct 2026 19:09:04 +0200 Subject: [PATCH 2/2] Say what the search pane's read is doing, and rebase. --- .../tinyclient/src/gui/conversation/side.rs | 70 +++++++++++++++---- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/crates/tinyclient/src/gui/conversation/side.rs b/crates/tinyclient/src/gui/conversation/side.rs index cb4e150..1cfed63 100644 --- a/crates/tinyclient/src/gui/conversation/side.rs +++ b/crates/tinyclient/src/gui/conversation/side.rs @@ -14,8 +14,9 @@ use crate::gui::state::Shell; use crate::gui::theme::{self, Type}; use crate::gui::time; use crate::view::{age, now}; -use gpui::{div, prelude::*, px, AnyElement, Context, Entity, MouseButton, Window}; +use gpui::{div, prelude::*, px, AnyElement, Context, Entity, FontWeight, HighlightStyle, MouseButton, SharedString, StyledText, Window}; use protocol::{ConversationKind, Key, Link, Message, MessageHit, Request, Response}; +use std::ops::Range; use std::rc::Rc; /// The window keeps of the right pane. @@ -49,7 +50,34 @@ pub struct Side { #[derive(Default)] pub struct SearchPage { pub words: String, - pub hits: Vec, + /// What the cache answered: `None` while the read is outstanding or failed. + pub hits: Option>, + /// The cache could not be asked. + pub failed: bool, +} + +/// The letters of the query inside a hit's text, for the heavier drawing. +fn matched(text: &str, words: &str) -> Vec> { + let (lower, needle) = (text.to_lowercase(), words.to_lowercase()); + if needle.is_empty() { + return Vec::new(); + } + let mut out = Vec::new(); + let mut from = 0; + while let Some(at) = lower[from..].find(&needle) { + let (start, end) = (from + at, from + at + needle.len()); + // A lowercased index can sit inside a letter that shrank or grew; such a range is skipped. + if text.is_char_boundary(start) && text.is_char_boundary(end) { + out.push(start..end); + } + from = end; + } + out +} + +/// The heavier letters a match is drawn with, as the switcher's rows do. +fn lit() -> HighlightStyle { + HighlightStyle { color: Some(theme::text().into()), font_weight: Some(FontWeight::SEMIBOLD), ..Default::default() } } impl Side { @@ -328,7 +356,7 @@ impl Shell { /// The switcher's "Search messages" row: the pane opens at once and the cache is asked. pub(in crate::gui) fn open_message_search(&mut self, words: String, cx: &mut Context) { let side = &mut self.conversation_state.side; - side.search = Some(SearchPage { words: words.clone(), hits: Vec::new() }); + side.search = Some(SearchPage { words: words.clone(), hits: None, failed: false }); side.stack = pane::open(&side.stack, Layer::Search); self.install(cx); self.off_thread( @@ -338,16 +366,15 @@ impl Shell { move || ask(&Request::SearchMessages { conversation: None, network: None, query: asked, limit: protocol::PAGE_LIMIT }) }, move |this, answer, cx| { - let Ok(Response::MessageHits { hits }) = answer else { - return; - }; - let Some(page) = this.conversation_state.side.search.as_mut() else { + let Some(page) = this.conversation_state.side.search.as_mut().filter(|page| page.words == words) else { return; }; - if page.words != words { - return; + match answer { + Ok(Response::MessageHits { hits }) => page.hits = Some(hits), + // Another answer is not this request's; the read failing is the pane's word. + Ok(_) => {} + Err(_) => page.failed = true, } - page.hits = hits; cx.notify(); }, ); @@ -632,12 +659,17 @@ fn draw_search(shell: &Shell, cx: &mut Context) -> AnyElement { .child(glyph("icons/search.svg", 14., theme::text_soft())) .child(div().flex_1().min_w_0().truncate().type_ui().text_color(theme::text()).child(page.words.clone())) .child(div().type_small().text_color(theme::text_soft()).child("cached")); - let body = if page.hits.is_empty() { - empty_state("icons/search.svg", "Nothing in the cache yet.", "The networks are not asked yet.") + let body = if page.failed { + empty_state("icons/search.svg", "tinyclient isn't answering.", "The cache could not be asked. Try again in a moment.") .into_any_element() + } else if page.hits.is_none() { + empty_state("icons/search.svg", "Asking the cache…", "The hits land here as they are read.").into_any_element() + } else if page.hits.as_deref().is_none_or(|hits| hits.is_empty()) { + empty_state("icons/search.svg", "Nothing in the cache yet.", "The networks are not asked yet.").into_any_element() } else { + let hits = page.hits.as_deref().unwrap_or_default(); let mut rows = div().id("search-hits").flex_1().min_h_0().overflow_y_scroll().flex().flex_col().p(px(4.)); - for (ix, hit) in page.hits.iter().enumerate() { + for (ix, hit) in hits.iter().enumerate() { let clicked = hit.clone(); rows = rows.child( div() @@ -665,7 +697,17 @@ fn draw_search(shell: &Shell, cx: &mut Context) -> AnyElement { .flex() .items_center() .gap(px(6.)) - .child(div().flex_1().min_w_0().truncate().type_small().text_color(theme::text_soft()).child(hit.snippet.clone())) + .child({ + let highlights: Vec<(Range, HighlightStyle)> = + matched(&hit.snippet, &page.words).into_iter().map(|range| (range, lit())).collect(); + div() + .flex_1() + .min_w_0() + .truncate() + .type_small() + .text_color(theme::text_soft()) + .child(StyledText::new(SharedString::from(hit.snippet.clone())).with_highlights(highlights)) + }) .child(div().flex_none().type_micro().text_color(theme::text_soft()).child("cached")), ), );