Vectrum
Sign In
197 lines · 7.7 KB · Rust
//! 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 }))
}