//! Find in thread (Ctrl+F), like T3 Code's: a search field over the //! conversation's top right corner with the match count, previous and next, //! which scrolls to each message that matches. use gpui_kit::component::{ Icon, IconName, h_flex, input::{Input, InputEvent, InputState}, }; use gpui_kit::*; use super::ChatView; use crate::session::ChatItem; use crate::ui::metal::{self, MetalButton}; pub(super) struct Find { input: Entity, /// The transcript rows that match, in order. pub matches: Vec, pub current: usize, _subscription: Subscription, } impl ChatView { /// Opens the find bar, or with it open, selects what's typed in it. pub(super) fn open_find(&mut self, window: &mut Window, cx: &mut Context) { if self.find.is_none() { let input = cx.new(|cx| InputState::new(window, cx).placeholder("Find in thread")); let subscription = cx.subscribe_in(&input, window, |this, _, event: &InputEvent, _, cx| match event { InputEvent::Change => { this.update_find(cx); this.find_step(true, true, cx); } InputEvent::PressEnter { shift, .. } => this.find_step(!shift, false, cx), _ => {} }); self.find = Some(Find { input, matches: Vec::new(), current: 0, _subscription: subscription }); } if let Some(find) = &self.find { find.input.update(cx, |input, cx| { input.focus(window, cx); input.select_all(window, cx); }); } cx.notify(); } pub(super) fn close_find(&mut self, window: &mut Window, cx: &mut Context) { if self.find.take().is_some() { self.focus_composer(window, cx); cx.notify(); } } /// The rows whose messages contain what's typed, case aside. fn update_find(&mut self, cx: &mut Context) { let Some(find) = self.find.as_mut() else { return }; let query = find.input.read(cx).value().trim().to_lowercase(); find.matches = if query.is_empty() { Vec::new() } else { self.session .read(cx) .items .iter() .enumerate() .filter(|(_, item)| match item { ChatItem::User { text, .. } => text.to_lowercase().contains(&query), ChatItem::Assistant(answer) => answer.text.to_lowercase().contains(&query), ChatItem::Tool(tool) if tool.name == "ExitPlanMode" => { tool.input.get("plan").and_then(|plan| plan.as_str()).is_some_and(|plan| plan.to_lowercase().contains(&query)) } _ => false, }) .map(|(ix, _)| ix) .collect() }; find.current = find.current.min(find.matches.len().saturating_sub(1)); } /// Moves to the next (or previous) match, unfolding the turn it's in /// and scrolling to it. `stay` keeps the current one, for a new query. fn find_step(&mut self, forward: bool, stay: bool, cx: &mut Context) { let Some(find) = self.find.as_mut() else { return }; let count = find.matches.len(); if count == 0 { cx.notify(); return; } if !stay { find.current = if forward { (find.current + 1) % count } else { (find.current + count - 1) % count }; } let ix = find.matches[find.current]; let session = self.session.read(cx); let fold = crate::settings::get(|settings| settings.fold_turns); let hidden = session .finished_turn(ix) .filter(|turn| turn.answer != Some(ix) && session.turn_expanded(turn.end) != fold) .map(|turn| turn.end); if let Some(end) = hidden { self.session.update(cx, |session, cx| session.toggle_turn(end, cx)); } self.scroller.update(cx, |scroller, cx| { if hidden.is_some() { scroller.remeasure(cx); } scroller.scroll_to_item(ix, cx); }); cx.notify(); } /// The bar, floating over the conversation's top right corner. pub(super) fn render_find_bar(&self, cx: &mut Context) -> Option { let find = self.find.as_ref()?; let p = metal::palette(cx); let count = find.matches.len(); let searching = !find.input.read(cx).value().trim().is_empty(); let label = if count > 0 { format!("{}/{count}", find.current + 1) } else { "0/0".to_string() }; Some( h_flex() .id("find-bar") .absolute() .top(px(12.)) .right(px(12.)) .w(px(384.)) .h(px(36.)) .pl(px(12.)) .pr(px(4.)) .gap(px(4.)) .rounded(px(12.)) .bg(p.menu_bg) .shadow(p.raise.clone()) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { if event.keystroke.key == "escape" { cx.stop_propagation(); this.close_find(window, cx); } })) .child(Icon::new(IconName::Search).size(px(14.)).text_color(p.ink3)) .child(div().flex_1().min_w_0().text_size(px(13.)).child(Input::new(&find.input).appearance(false))) .child( div() .min_w(px(40.)) .text_center() .text_size(px(12.)) .text_color(if searching && count == 0 { p.red } else { p.ink3 }) .child(label), ) .child( MetalButton::new("find-previous") .ghost() .mini() .size(px(24.)) .icon(IconName::ChevronUp) .tooltip("Previous match") .disabled(count == 0) .on_click(cx.listener(|this, _, _, cx| this.find_step(false, false, cx))), ) .child( MetalButton::new("find-next") .ghost() .mini() .size(px(24.)) .icon(IconName::ChevronDown) .tooltip("Next match") .disabled(count == 0) .on_click(cx.listener(|this, _, _, cx| this.find_step(true, false, cx))), ) .child( MetalButton::new("find-close") .ghost() .mini() .size(px(24.)) .icon(IconName::Close) .tooltip("Close find") .on_click(cx.listener(|this, _, window, cx| this.close_find(window, cx))), ) .into_any_element(), ) } /// The match the find bar is on, and the others, for the timeline to /// mark. pub(super) fn find_marks(&self) -> Option<(Vec, Option)> { let find = self.find.as_ref()?; Some((find.matches.clone(), find.matches.get(find.current).copied())) } } /// How a matching message is marked: an amber outline, stronger on the /// current one. pub(super) fn mark(element: Div, current: bool, p: &metal::Palette) -> Div { element .rounded(px(12.)) .border_1() .border_color(p.amber.opacity(if current { 0.9 } else { 0.35 })) .bg(p.amber.opacity(if current { 0.08 } else { 0.03 })) }