//! 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<InputState>,
/// The transcript rows that match, in order.
pub matches: Vec<usize>,
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) -> Option<AnyElement> {
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<usize>, Option<usize>)> {
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 }))
}