Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions crates/tinyclient/src/gui/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,9 @@ pub struct Pane {
places: crate::gui::pane::Memory<Place>,
/// The next open starts from the page around this message: more is unread than a page holds.
around: Option<String>,
/// A search hit asked for this message before its conversation opened; it wins over where
/// reading stopped.
landing: Option<String>,
/// 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`.
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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() {
Expand All @@ -385,7 +392,7 @@ impl Pane {
root: Option<(Rc<Message>, u32)>,
cx: &mut Context<Self>,
) {
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);
Expand Down Expand Up @@ -746,14 +753,19 @@ 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<Self>) {
pub(in crate::gui) fn jump_to(&mut self, id: &str, cx: &mut Context<Self>) {
if self.window.position(id).is_some() {
self.reveal(id, cx);
} else {
self.load(Page::Around { id: id.to_owned() }, Purpose::Jump(id.to_owned()), cx);
}
}

/// 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<Self>) {
let Some(at) = self.items.iter().position(|item| item.message().is_some_and(|message| message.id == id)) else {
return;
Expand Down
173 changes: 169 additions & 4 deletions crates/tinyclient/src/gui/conversation/side.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,21 @@ use crate::gui::layout::{RightKind, RightPane};
use crate::gui::state::Shell;
use crate::gui::theme::{self, Type};
use crate::gui::time;
use gpui::{div, prelude::*, px, AnyElement, Context, Entity, MouseButton, Window};
use protocol::{ConversationKind, Key, Link, Message, Request, Response};
use crate::view::{age, now};
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;

/// 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<Rc<Message>>,
/// The message search's page: what was typed, and what the cache holds for it.
pub search: Option<SearchPage>,
/// The conversation the stack was last fitted to.
fitted: Option<Key>,
/// The kind this view last put in the layout's slot, so another view's change can be told
Expand All @@ -42,6 +46,40 @@ pub struct Side {
room: std::cell::Cell<f32>,
}

/// One message search's page: what was typed, and what the cache holds for it.
#[derive(Default)]
pub struct SearchPage {
pub words: String,
/// What the cache answered: `None` while the read is outstanding or failed.
pub hits: Option<Vec<MessageHit>>,
/// 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<Range<usize>> {
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 {
/// 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
Expand Down Expand Up @@ -315,6 +353,50 @@ 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<Self>) {
let side = &mut self.conversation_state.side;
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(
cx,
{
let asked = words.clone();
move || ask(&Request::SearchMessages { conversation: None, network: None, query: asked, limit: protocol::PAGE_LIMIT })
},
move |this, answer, cx| {
let Some(page) = this.conversation_state.side.search.as_mut().filter(|page| page.words == words) else {
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,
}
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<Self>) {
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<Self>) {
Expand Down Expand Up @@ -514,7 +596,7 @@ fn draw_side(shell: &Shell, window: &mut Window, cx: &mut Context<Shell>) -> 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()
Expand Down Expand Up @@ -559,6 +641,89 @@ fn crumb(base: &'static str, cx: &mut Context<Shell>) -> 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<Shell>) -> 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.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 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({
let highlights: Vec<(Range<usize>, 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")),
),
);
}
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<Shell>) -> AnyElement {
Expand Down
2 changes: 1 addition & 1 deletion crates/tinyclient/src/gui/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading