Skip to main content

ragrig_gpui/
session_picker.rs

1//! Conversation picker.
2
3use std::rc::Rc;
4
5use gpui_kit::{
6    Anchor, App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce,
7    SharedString, Styled as _, Window,
8    assets::IconName,
9    component::{
10        ActiveTheme as _, Disableable as _, Selectable as _, Sizable as _,
11        button::{Button, ButtonVariants as _},
12        popover::Popover,
13        scroll::ScrollableElement as _,
14        v_flex,
15    },
16    div, px,
17};
18
19use crate::model::SessionEntry;
20
21/// What the user asked the picker to do.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub enum SessionPickerEvent {
24    /// Start a fresh conversation.
25    New,
26    /// Load the conversation with this id.
27    Load(String),
28    /// Re-read the session store.
29    Refresh,
30}
31
32type SessionHandler = Rc<dyn Fn(SessionPickerEvent, &mut Window, &mut App)>;
33
34/// A dropdown of saved conversations.
35///
36/// The host owns the store: it passes [`SessionEntry`] rows and reacts to
37/// [`SessionPickerEvent`]. The current entry is highlighted; selecting one
38/// emits [`SessionPickerEvent::Load`] and closes the popover.
39#[derive(IntoElement)]
40pub struct SessionPicker {
41    id: SharedString,
42    entries: Vec<SessionEntry>,
43    current: Option<String>,
44    busy: bool,
45    on_event: Option<SessionHandler>,
46}
47
48impl SessionPicker {
49    /// A picker with a unique element-ID prefix.
50    pub fn new(
51        id: impl Into<SharedString>,
52        entries: impl IntoIterator<Item = SessionEntry>,
53    ) -> Self {
54        Self {
55            id: id.into(),
56            entries: entries.into_iter().collect(),
57            current: None,
58            busy: false,
59            on_event: None,
60        }
61    }
62
63    /// Mark the active conversation.
64    pub fn current(mut self, id: impl Into<String>) -> Self {
65        self.current = Some(id.into());
66        self
67    }
68
69    /// Disable creating and refreshing while the host is busy.
70    pub fn busy(mut self, busy: bool) -> Self {
71        self.busy = busy;
72        self
73    }
74
75    /// Receive picker actions.
76    pub fn on_event(
77        mut self,
78        handler: impl Fn(SessionPickerEvent, &mut Window, &mut App) + 'static,
79    ) -> Self {
80        self.on_event = Some(Rc::new(handler));
81        self
82    }
83}
84
85impl RenderOnce for SessionPicker {
86    fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement {
87        let current_label = self
88            .entries
89            .iter()
90            .find(|entry| Some(entry.id.as_str()) == self.current.as_deref())
91            .map_or_else(|| "Conversation".to_owned(), |entry| entry.label.clone());
92
93        let trigger_id = ElementId::Name(format!("{}-trigger", self.id).into());
94        let popover_id = ElementId::Name(format!("{}-popover", self.id).into());
95        let list_id = ElementId::Name(format!("{}-list", self.id).into());
96
97        let entries = self.entries;
98        let current = self.current;
99        let busy = self.busy;
100        let handler = self.on_event;
101        let prefix = self.id;
102
103        Popover::new(popover_id)
104            .anchor(Anchor::TopLeft)
105            .trigger(
106                Button::new(trigger_id)
107                    .icon(IconName::MessagesSquare)
108                    .label(current_label)
109                    .outline()
110                    .small()
111                    .tooltip("Switch conversation"),
112            )
113            .content(move |_, _, cx| {
114                let popover = cx.entity();
115                let mut list = v_flex().w(px(320.)).p_1().gap_1();
116
117                let new_handler = handler.clone();
118                let new_popover = popover.clone();
119                let new_id = ElementId::Name(format!("{prefix}-new").into());
120                list = list.child(
121                    Button::new(new_id)
122                        .icon(IconName::Plus)
123                        .label("New conversation")
124                        .ghost()
125                        .small()
126                        .w_full()
127                        .disabled(busy)
128                        .on_click(move |_, window, cx| {
129                            if let Some(handler) = &new_handler {
130                                handler(SessionPickerEvent::New, window, cx);
131                            }
132                            new_popover.update(cx, |state, cx| state.dismiss(window, cx));
133                        }),
134                );
135
136                let mut entries_list = v_flex().id(list_id.clone()).gap_1();
137                if entries.is_empty() {
138                    entries_list = entries_list.child(
139                        div()
140                            .px_2()
141                            .py_2()
142                            .text_sm()
143                            .text_color(cx.theme().muted_foreground)
144                            .child("No saved conversations yet."),
145                    );
146                } else {
147                    for (index, entry) in entries.iter().cloned().enumerate() {
148                        let is_current = Some(entry.id.as_str()) == current.as_deref();
149                        let entry_handler = handler.clone();
150                        let entry_popover = popover.clone();
151                        let entry_id = entry.id.clone();
152                        let button_id = ElementId::Name(format!("{prefix}-entry-{index}").into());
153                        let label = entry.label.clone();
154                        let detail = entry.detail.clone();
155
156                        let label: SharedString = if let Some(detail) = detail {
157                            format!("{label} ยท {detail}").into()
158                        } else {
159                            label.into()
160                        };
161
162                        let mut button = Button::new(button_id)
163                            .icon(if is_current {
164                                IconName::Check
165                            } else {
166                                IconName::MessageSquare
167                            })
168                            .label(label)
169                            .ghost()
170                            .small()
171                            .selected(is_current)
172                            .w_full()
173                            .on_click(move |_, window, cx| {
174                                if let Some(handler) = &entry_handler {
175                                    handler(SessionPickerEvent::Load(entry_id.clone()), window, cx);
176                                }
177                                entry_popover.update(cx, |state, cx| state.dismiss(window, cx));
178                            });
179                        if is_current {
180                            button = button.primary();
181                        }
182                        entries_list = entries_list.child(button);
183                    }
184                }
185                list = list.child(entries_list.max_h(px(360.)).overflow_y_scrollbar());
186
187                let refresh_handler = handler.clone();
188                let refresh_popover = popover.clone();
189                let refresh_id = ElementId::Name(format!("{prefix}-refresh").into());
190                list.child(
191                    Button::new(refresh_id)
192                        .icon(IconName::RefreshCw)
193                        .label("Refresh list")
194                        .ghost()
195                        .small()
196                        .w_full()
197                        .disabled(busy)
198                        .on_click(move |_, window, cx| {
199                            if let Some(handler) = &refresh_handler {
200                                handler(SessionPickerEvent::Refresh, window, cx);
201                            }
202                            refresh_popover.update(cx, |state, cx| state.dismiss(window, cx));
203                        }),
204                )
205            })
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn entries_are_collected_and_current_is_optional() {
215        let picker = SessionPicker::new(
216            "sessions",
217            [
218                SessionEntry::new("a", "First"),
219                SessionEntry::new("b", "Second"),
220            ],
221        )
222        .current("b");
223
224        assert_eq!(picker.entries.len(), 2);
225        assert_eq!(picker.current.as_deref(), Some("b"));
226    }
227}