1use 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#[derive(Clone, Debug, PartialEq, Eq)]
23pub enum SessionPickerEvent {
24 New,
26 Load(String),
28 Refresh,
30}
31
32type SessionHandler = Rc<dyn Fn(SessionPickerEvent, &mut Window, &mut App)>;
33
34#[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 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 pub fn current(mut self, id: impl Into<String>) -> Self {
65 self.current = Some(id.into());
66 self
67 }
68
69 pub fn busy(mut self, busy: bool) -> Self {
71 self.busy = busy;
72 self
73 }
74
75 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}