1use crate::cues::{self, Cue};
20use crate::scrolling::PolicyScrollbarExt as _;
21use crate::{
22 control::composed_button,
23 resolved_layout::ResolvedLayoutKey,
24 surface::{eyebrow, icon_button},
25 theme::SemanticStyledExt as _,
26};
27use gpui::{
28 Anchor, AnyElement, App, AppContext as _, ClickEvent, Context, DismissEvent, ElementId, Entity,
29 EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyDownEvent,
30 ListAlignment, ListOffset, ListState, ParentElement as _, Pixels, Render, RenderOnce, Role,
31 SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, WeakEntity, Window,
32 div, list, prelude::FluentBuilder as _,
33};
34use gpui_base::StyledExt as _;
35use gpui_component::{
36 ActiveTheme as _, IconName, h_flex,
37 input::{Input, InputEvent, InputState},
38 menu::{PopupMenu, PopupMenuItem},
39 popover::Popover,
40 v_flex,
41};
42use std::{rc::Rc, sync::Arc};
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ThreadItem {
47 id: SharedString,
48 title: SharedString,
49 subtitle: Option<SharedString>,
50 archived: bool,
51}
52
53impl ThreadItem {
54 pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
56 Self {
57 id: id.into(),
58 title: title.into(),
59 subtitle: None,
60 archived: false,
61 }
62 }
63
64 pub fn subtitle(mut self, subtitle: impl Into<SharedString>) -> Self {
66 self.subtitle = Some(subtitle.into());
67 self
68 }
69
70 pub fn archived(mut self, archived: bool) -> Self {
72 self.archived = archived;
73 self
74 }
75
76 pub fn id(&self) -> &SharedString {
78 &self.id
79 }
80
81 pub fn title(&self) -> &SharedString {
83 &self.title
84 }
85
86 pub fn subtitle_text(&self) -> Option<&SharedString> {
88 self.subtitle.as_ref()
89 }
90
91 pub fn is_archived(&self) -> bool {
93 self.archived
94 }
95
96 fn matches(&self, query: &str) -> bool {
97 if query.is_empty() {
98 return true;
99 }
100 let needle = query.to_lowercase();
101 self.title.to_lowercase().contains(&needle)
102 || self
103 .subtitle
104 .as_ref()
105 .is_some_and(|subtitle| subtitle.to_lowercase().contains(&needle))
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct ThreadSection {
112 id: SharedString,
113 label: SharedString,
114 items: Vec<ThreadItem>,
115}
116
117impl ThreadSection {
118 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
120 Self {
121 id: id.into(),
122 label: label.into(),
123 items: Vec::new(),
124 }
125 }
126
127 pub fn items(mut self, items: impl IntoIterator<Item = ThreadItem>) -> Self {
129 self.items = items.into_iter().collect();
130 self
131 }
132
133 pub fn id(&self) -> &SharedString {
135 &self.id
136 }
137
138 pub fn label(&self) -> &SharedString {
140 &self.label
141 }
142
143 pub fn thread_items(&self) -> &[ThreadItem] {
145 &self.items
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum ThreadListEvent {
152 Selected {
154 id: SharedString,
156 },
157 NewRequested,
159 RenameRequested {
161 id: SharedString,
163 },
164 ArchiveRequested {
166 id: SharedString,
168 },
169 UnarchiveRequested {
171 id: SharedString,
173 },
174 DeleteRequested {
176 id: SharedString,
178 },
179 QueryChanged {
181 query: SharedString,
183 },
184}
185
186fn sections_are_well_formed(sections: &[ThreadSection]) -> bool {
187 let mut section_ids = std::collections::HashSet::new();
188 let mut item_ids = std::collections::HashSet::new();
189 sections.iter().all(|section| {
190 section_ids.insert(§ion.id)
191 && section.items.iter().all(|item| item_ids.insert(&item.id))
192 })
193}
194
195#[derive(Clone, Debug, PartialEq, Eq)]
202enum ThreadRow {
203 Header {
205 section_id: SharedString,
207 label: SharedString,
209 },
210 Thread {
212 item: ThreadItem,
214 position: usize,
216 },
217}
218
219impl ThreadRow {
220 fn thread_id(&self) -> Option<&SharedString> {
222 match self {
223 Self::Header { .. } => None,
224 Self::Thread { item, .. } => Some(&item.id),
225 }
226 }
227
228 fn anchor(&self) -> (bool, SharedString) {
233 match self {
234 Self::Header { section_id, .. } => (true, section_id.clone()),
235 Self::Thread { item, .. } => (false, item.id.clone()),
236 }
237 }
238}
239
240#[cfg(test)]
246#[derive(Default, Clone)]
247struct ThreadConstructionCounts {
248 rows: std::rc::Rc<std::cell::Cell<usize>>,
249 threads: std::rc::Rc<std::cell::Cell<usize>>,
250}
251
252#[cfg(test)]
253impl ThreadConstructionCounts {
254 fn start_draw(&self) {
256 self.rows.set(0);
257 self.threads.set(0);
258 }
259
260 fn count_row(&self) {
261 self.rows.set(self.rows.get() + 1);
262 }
263
264 fn count_thread(&self) {
265 self.threads.set(self.threads.get() + 1);
266 }
267}
268
269#[derive(Clone)]
276struct RowRenderer {
277 component: SharedString,
278 owner: WeakEntity<ThreadList>,
279 list_focus: FocusHandle,
281 active: Option<SharedString>,
282 focused: Option<SharedString>,
283 visible_threads: usize,
284 glide: Option<gpui::Entity<crate::glide::GlideHover>>,
287 #[cfg(test)]
288 construction: ThreadConstructionCounts,
289}
290
291fn row_gutter(cx: &App) -> gpui::Pixels {
294 cx.theme().semantic_tokens().spacing.sm
295}
296
297fn row_text_inset(cx: &App) -> gpui::Pixels {
301 cx.theme().semantic_tokens().spacing.xs
302}
303
304impl RowRenderer {
305 fn render(&self, row: &ThreadRow, window: &mut Window, cx: &mut App) -> AnyElement {
306 #[cfg(test)]
307 self.construction.count_row();
308 match row {
309 ThreadRow::Header { section_id, label } => self.render_header(section_id, label, cx),
310 ThreadRow::Thread { item, position } => self.render_thread(item, *position, window, cx),
311 }
312 }
313
314 fn render_header(
315 &self,
316 section_id: &SharedString,
317 label: &SharedString,
318 cx: &mut App,
319 ) -> AnyElement {
320 let tokens = cx.theme().semantic_tokens();
321 let header_key = section_id.clone();
325 let header_label_key = section_id.clone();
326 let section_id =
327 ElementId::from((ElementId::from(self.component.clone()), section_id.clone()));
328 div()
329 .id((section_id, "header"))
330 .debug_selector(move || format!("thread-section-{header_key}"))
331 .role(Role::Label)
335 .aria_label(label.clone())
336 .w_full()
337 .min_w_0()
338 .border_1()
339 .border_color(cx.theme().transparent)
340 .px(row_text_inset(cx))
341 .pt(tokens.spacing.sm)
342 .pb(tokens.spacing.xxs)
343 .child({
344 div()
345 .debug_selector(move || format!("thread-section-label-{header_label_key}"))
346 .child(eyebrow(label.clone(), cx))
347 })
348 .into_any_element()
349 }
350
351 fn render_thread(
352 &self,
353 item: &ThreadItem,
354 position: usize,
355 window: &mut Window,
356 cx: &mut App,
357 ) -> AnyElement {
358 #[cfg(test)]
359 self.construction.count_thread();
360
361 let tokens = cx.theme().semantic_tokens();
362 let root_id = ElementId::from(self.component.clone());
363 let item_id = ElementId::from((root_id.clone(), item.id.clone()));
364 let selected = self.active.as_ref() == Some(&item.id);
365 let acknowledged = crate::motion::acknowledged_state(
370 ElementId::from((item_id.clone(), "active-ack")),
371 selected as u64,
372 window,
373 cx,
374 );
375 let focused = self.focused.as_ref() == Some(&item.id);
376 let accessibility_label: SharedString = if item.archived {
377 format!("{}, archived", item.title).into()
378 } else {
379 item.title.clone()
380 };
381 let debug_id = item.id.clone();
382 let select_id = item.id.clone();
383 let owner = self.owner.clone();
384
385 let row_debug_id = item.id.clone();
393 let base = div()
394 .id((item_id.clone(), "row"))
395 .debug_selector(move || format!("thread-row-{row_debug_id}"));
396 crate::surface::selection_surface(
397 base,
398 selected,
399 tokens.radius.lg,
400 tokens.spacing.xs,
401 self.glide.as_ref().map(|glide| (&item.id, glide)),
402 cx,
403 )
404 .when(selected, |row| {
408 row.bg(cx.theme().list_active.opacity(acknowledged))
409 })
410 .border_1()
415 .border_color(if focused {
416 cx.theme().ring
417 } else {
418 cx.theme().transparent
419 })
420 .w_full()
421 .min_w_0()
422 .flex()
423 .items_center()
424 .gap(tokens.spacing.xxs)
425 .mb(tokens.spacing.xxs)
426 .pr(tokens.spacing.xxs)
427 .child(
428 composed_button((item_id.clone(), "select"), accessibility_label)
429 .debug_selector(move || format!("thread-{debug_id}"))
430 .role(Role::ListBoxOption)
431 .selected(selected)
432 .aria_selected(selected)
433 .aria_position_in_set(position)
434 .aria_size_of_set(self.visible_threads)
435 .when(focused, |button| button.aria_active_descendant())
439 .tab_stop(false)
440 .flex_1()
441 .min_w_0()
442 .justify_start()
443 .px(row_text_inset(cx))
444 .py(tokens.spacing.xs)
445 .child(
446 v_flex()
447 .w_full()
448 .min_w_0()
449 .items_start()
450 .child({
451 let title_debug = item.id.clone();
452 div()
453 .debug_selector(move || format!("thread-title-{title_debug}"))
454 .w_full()
455 .truncate()
456 .text_token(tokens.typography.sm)
457 .text_color(cx.theme().foreground)
458 .child(item.title.clone())
459 })
460 .when_some(item.subtitle.clone(), |this, subtitle| {
461 this.child(crate::surface::hint(subtitle, cx).w_full().truncate())
462 }),
463 )
464 .on_click(move |_, window, cx| {
465 let _ = owner.update(cx, |this, cx| {
466 this.focus_thread(select_id.clone(), window, cx);
467 cx.emit(ThreadListEvent::Selected {
468 id: select_id.clone(),
469 });
470 cues::emit(cx, Cue::ThreadSelected);
471 });
472 }),
473 )
474 .child(self.render_actions_menu(item, item_id, window, cx))
475 .into_any_element()
476 }
477
478 fn render_actions_menu(
485 &self,
486 item: &ThreadItem,
487 item_id: ElementId,
488 window: &mut Window,
489 cx: &mut App,
490 ) -> impl IntoElement {
491 let tokens = cx.theme().semantic_tokens();
492 let more_debug_id = item.id.clone();
493 let list_focus = self.list_focus.clone();
494 let owner = self.owner.clone();
495 let action_id = item.id.clone();
496 let archived = item.archived;
497 ThreadActionsMenu {
500 id: ElementId::from((item_id.clone(), "more-menu")),
501 trigger: icon_button(
502 (item_id, "more"),
503 IconName::Ellipsis,
504 format!("More actions for {}", item.title),
505 window,
506 cx,
507 )
508 .debug_selector(move || format!("thread-more-{more_debug_id}"))
509 .rounded(tokens.radius.sm),
510 build: Rc::new(move |menu, _, _| {
511 let rename_id = action_id.clone();
512 let archive_id = action_id.clone();
513 let delete_id = action_id.clone();
514 let rename_owner = owner.clone();
515 let archive_owner = owner.clone();
516 let delete_owner = owner.clone();
517 menu.action_context(list_focus.clone())
518 .item(PopupMenuItem::new("Rename").on_click(move |_, _, cx| {
519 let _ = rename_owner.update(cx, |_, cx| {
520 cx.emit(ThreadListEvent::RenameRequested {
521 id: rename_id.clone(),
522 });
523 });
524 }))
525 .item(
526 PopupMenuItem::new(if archived { "Unarchive" } else { "Archive" })
527 .on_click(move |_, _, cx| {
528 let _ = archive_owner.update(cx, |_, cx| {
529 cx.emit(if archived {
530 ThreadListEvent::UnarchiveRequested {
531 id: archive_id.clone(),
532 }
533 } else {
534 ThreadListEvent::ArchiveRequested {
535 id: archive_id.clone(),
536 }
537 });
538 });
539 }),
540 )
541 .item(PopupMenuItem::new("Delete").on_click(move |_, _, cx| {
542 let _ = delete_owner.update(cx, |_, cx| {
543 cx.emit(ThreadListEvent::DeleteRequested {
544 id: delete_id.clone(),
545 });
546 });
547 }))
548 }),
549 }
550 }
551}
552
553#[derive(Default)]
556struct ThreadActionsMenuState {
557 open: bool,
558 menu: Option<Entity<PopupMenu>>,
559}
560
561type ThreadMenuBuilder = dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu;
567
568#[derive(IntoElement)]
577struct ThreadActionsMenu {
578 id: ElementId,
579 trigger: gpui_base::Button,
580 build: Rc<ThreadMenuBuilder>,
581}
582
583impl RenderOnce for ThreadActionsMenu {
584 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
585 let build = self.build.clone();
586 let state = window.use_keyed_state((self.id.clone(), "menu"), cx, |_, _| {
587 ThreadActionsMenuState::default()
588 });
589 let open = state.read(cx).open;
590 let trigger_state = state.clone();
591 let trigger = self.trigger.on_click(move |event, _, cx| {
592 if matches!(event, ClickEvent::Keyboard(_)) {
593 trigger_state.update(cx, |state, cx| {
594 state.open = !state.open;
595 if !state.open {
596 state.menu = None;
597 }
598 cx.notify();
599 });
600 }
601 });
602 let open_state = state.clone();
603
604 Popover::new(self.id)
605 .appearance(false)
608 .overlay_closable(false)
609 .anchor(Anchor::TopRight)
612 .open(open)
613 .on_open_change(move |open, _, cx| {
614 open_state.update(cx, |state, cx| {
615 state.open = *open;
616 if !open {
617 state.menu = None;
618 }
619 cx.notify();
620 });
621 })
622 .trigger(trigger)
623 .content(move |_, window, cx| {
624 if let Some(menu) = state.read(cx).menu.clone() {
625 return div()
626 .debug_selector(|| "thread-actions-menu".into())
627 .child(menu);
628 }
629 let build = build.clone();
630 let menu =
631 PopupMenu::build(window, cx, move |menu, window, cx| build(menu, window, cx));
632 state.update(cx, |this, _| this.menu = Some(menu.clone()));
633 menu.focus_handle(cx).focus(window, cx);
634
635 window
636 .subscribe(&menu, cx, {
637 let state = state.clone();
638 move |_, _: &DismissEvent, _, cx| {
639 state.update(cx, |state, cx| {
640 state.open = false;
641 state.menu = None;
642 cx.notify();
643 });
644 }
645 })
646 .detach();
647 div()
648 .debug_selector(|| "thread-actions-menu".into())
649 .child(menu)
650 })
651 }
652}
653
654pub struct ThreadList {
675 style: gpui::StyleRefinement,
682 id: SharedString,
683 sections: Arc<[ThreadSection]>,
684 active: Option<SharedString>,
685 query: SharedString,
686 input: Entity<InputState>,
687 show_archived: bool,
688 rows: Arc<[ThreadRow]>,
690 visible_threads: usize,
692 candidates: usize,
695 focused_thread: Option<SharedString>,
698 list_focus: FocusHandle,
700 list_state: ListState,
701 hover_glide: bool,
704 resolved_layout: ResolvedLayoutKey,
706 #[cfg(test)]
707 construction: ThreadConstructionCounts,
708 _input_subscription: Subscription,
709}
710
711impl ThreadList {
712 pub fn set_hover_glide(&mut self, enabled: bool, cx: &mut Context<Self>) {
715 if self.hover_glide != enabled {
716 self.hover_glide = enabled;
717 cx.notify();
718 }
719 }
720
721 pub fn new(id: impl Into<SharedString>, window: &mut Window, cx: &mut Context<Self>) -> Self {
723 let input = cx.new(|cx| InputState::new(window, cx).placeholder("Search conversations"));
724 let subscription =
725 cx.subscribe_in(&input, window, |this, input, event: &InputEvent, _, cx| {
726 if let InputEvent::Change = event {
727 let query: SharedString = input.read(cx).value().to_string().into();
728 this.apply_query(query, cx);
729 }
730 });
731 Self {
732 style: gpui::StyleRefinement::default(),
733 id: id.into(),
734 sections: Arc::from([]),
735 active: None,
736 query: "".into(),
737 input,
738 show_archived: false,
739 rows: Arc::from([]),
740 visible_threads: 0,
741 candidates: 0,
742 focused_thread: None,
743 list_focus: cx.focus_handle(),
744 list_state: ListState::new(0, ListAlignment::Top, Pixels::ZERO),
745 hover_glide: true,
746 resolved_layout: ResolvedLayoutKey::default(),
747 #[cfg(test)]
748 construction: ThreadConstructionCounts::default(),
749 _input_subscription: subscription,
750 }
751 }
752
753 pub fn set_sections(
756 &mut self,
757 sections: impl IntoIterator<Item = ThreadSection>,
758 cx: &mut Context<Self>,
759 ) {
760 let sections: Arc<[ThreadSection]> = sections.into_iter().collect();
761 if !sections_are_well_formed(§ions) || self.sections == sections {
762 return;
763 }
764 self.sections = sections;
765 self.rebuild_rows();
766 cx.notify();
767 }
768
769 pub fn set_active(&mut self, id: Option<impl Into<SharedString>>, cx: &mut Context<Self>) {
771 let id = id.map(Into::into);
772 if self.active != id {
773 self.active = id;
774 cx.notify();
777 }
778 }
779
780 pub fn active_id(&self) -> Option<&SharedString> {
782 self.active.as_ref()
783 }
784
785 pub fn set_show_archived(&mut self, show: bool, cx: &mut Context<Self>) {
787 if self.show_archived != show {
788 self.show_archived = show;
789 self.rebuild_rows();
790 cx.notify();
791 }
792 }
793
794 pub fn shows_archived(&self) -> bool {
796 self.show_archived
797 }
798
799 pub fn set_query(
801 &mut self,
802 query: impl Into<SharedString>,
803 window: &mut Window,
804 cx: &mut Context<Self>,
805 ) {
806 let query = query.into();
807 if self.query == query {
808 return;
809 }
810 self.input.update(cx, |input, cx| {
811 input.set_value(query.to_string(), window, cx)
812 });
813 self.apply_query(query, cx);
815 }
816
817 pub fn query(&self) -> &SharedString {
819 &self.query
820 }
821
822 pub fn focus_search(&self, window: &mut Window, cx: &mut App) {
824 self.input.read(cx).focus_handle(cx).focus(window, cx);
825 }
826
827 pub fn scroll_to_end(&mut self, cx: &mut Context<Self>) {
829 self.list_state.scroll_to_end();
830 cx.notify();
831 }
832
833 fn apply_query(&mut self, query: SharedString, cx: &mut Context<Self>) {
834 if self.query == query {
835 return;
836 }
837 self.query = query.clone();
838 self.rebuild_rows();
839 cx.emit(ThreadListEvent::QueryChanged { query });
840 cx.notify();
841 }
842
843 fn rebuild_rows(&mut self) {
849 let sections = self.sections.clone();
850 let mut rows: Vec<ThreadRow> = Vec::new();
851 let mut visible_threads = 0usize;
852 let mut candidates = 0usize;
853 for section in sections.iter() {
854 let mut header_written = false;
855 for item in section.items.iter() {
856 if !self.show_archived && item.archived {
857 continue;
858 }
859 candidates += 1;
860 if !item.matches(&self.query) {
861 continue;
862 }
863 if !header_written {
864 rows.push(ThreadRow::Header {
865 section_id: section.id.clone(),
866 label: section.label.clone(),
867 });
868 header_written = true;
869 }
870 visible_threads += 1;
871 rows.push(ThreadRow::Thread {
872 item: item.clone(),
873 position: visible_threads,
874 });
875 }
876 }
877 self.visible_threads = visible_threads;
878 self.candidates = candidates;
879 if !self
882 .focused_thread
883 .as_ref()
884 .is_some_and(|id| rows.iter().any(|row| row.thread_id() == Some(id)))
885 {
886 self.focused_thread = None;
887 }
888 self.rows = rows.into();
889 self.list_state.reset(self.rows.len());
890 }
891
892 fn resolve_layout(&mut self, rem_size: Pixels, cx: &mut Context<Self>) {
898 if !self.resolved_layout.observe(rem_size) {
899 return;
900 }
901 let offset = self.list_state.logical_scroll_top();
902 let anchor = self
903 .rows
904 .get(offset.item_ix)
905 .map(|row| (row.anchor(), offset.offset_in_item));
906
907 self.list_state.remeasure();
908 if let Some((anchor, offset_in_item)) = anchor
909 && let Some(item_ix) = self.rows.iter().position(|row| row.anchor() == anchor)
910 {
911 self.list_state.scroll_to(ListOffset {
912 item_ix,
913 offset_in_item,
914 });
915 }
916 cx.notify();
917 }
918
919 fn row_index_of(&self, id: &SharedString) -> Option<usize> {
921 self.rows.iter().position(|row| row.thread_id() == Some(id))
922 }
923
924 fn focus_thread(&mut self, id: SharedString, window: &mut Window, cx: &mut Context<Self>) {
926 if let Some(index) = self.row_index_of(&id) {
927 self.list_state.scroll_to_reveal_item(index);
928 }
929 self.focused_thread = Some(id);
930 self.list_focus.focus(window, cx);
931 cx.notify();
932 }
933
934 fn focusable_ids(&self) -> Vec<SharedString> {
936 self.rows
937 .iter()
938 .filter_map(|row| row.thread_id().cloned())
939 .collect()
940 }
941
942 fn move_focus(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
949 let ids = self.focusable_ids();
950 if ids.is_empty() {
951 return;
952 }
953 let last = ids.len() - 1;
954 let next = match self
955 .focused_thread
956 .as_ref()
957 .and_then(|id| ids.iter().position(|candidate| candidate == id))
958 {
959 Some(current) => current.saturating_add_signed(delta).min(last),
960 None if delta < 0 => last,
961 None => 0,
962 };
963 let id = ids[next].clone();
964 self.focus_thread(id, window, cx);
965 }
966
967 fn focus_bound(&mut self, last: bool, window: &mut Window, cx: &mut Context<Self>) {
969 let ids = self.focusable_ids();
970 let Some(id) = (if last { ids.last() } else { ids.first() }) else {
971 return;
972 };
973 let id = id.clone();
974 self.focus_thread(id, window, cx);
975 }
976
977 fn activate_focused(&mut self, cx: &mut Context<Self>) {
979 let Some(id) = self.focused_thread.clone() else {
980 return;
981 };
982 cx.emit(ThreadListEvent::Selected { id });
983 cues::emit(cx, Cue::ThreadSelected);
984 }
985
986 fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
993 match event.keystroke.key.as_str() {
994 "up" => self.move_focus(-1, window, cx),
995 "down" => self.move_focus(1, window, cx),
996 "home" => self.focus_bound(false, window, cx),
997 "end" => self.focus_bound(true, window, cx),
998 "enter" | "space" => self.activate_focused(cx),
999 _ => return,
1000 }
1001 cx.stop_propagation();
1002 }
1003}
1004
1005impl EventEmitter<ThreadListEvent> for ThreadList {}
1006
1007impl Focusable for ThreadList {
1008 fn focus_handle(&self, cx: &App) -> FocusHandle {
1009 self.input.read(cx).focus_handle(cx)
1010 }
1011}
1012
1013impl gpui::Styled for ThreadList {
1014 fn style(&mut self) -> &mut gpui::StyleRefinement {
1015 &mut self.style
1016 }
1017}
1018
1019impl Render for ThreadList {
1020 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1021 let rem_size = window.rem_size();
1025 if !self.resolved_layout.matches(rem_size) {
1026 cx.defer_in(window, move |this, _, cx| {
1027 this.resolve_layout(rem_size, cx);
1028 });
1029 }
1030 #[cfg(test)]
1031 self.construction.start_draw();
1032
1033 let tokens = cx.theme().semantic_tokens();
1034 let root_id = ElementId::from(self.id.clone());
1035 let show_archived = self.show_archived;
1036 let empty_message: Option<SharedString> = if self.candidates == 0 {
1037 Some("No conversations yet".into())
1038 } else if self.rows.is_empty() {
1039 Some(format!("No conversations match “{}”", self.query).into())
1040 } else {
1041 None
1042 };
1043 let rows = self.rows.clone();
1044 let glide = self
1045 .hover_glide
1046 .then(|| crate::glide::glide_hover_state(root_id.clone(), window, cx));
1047 let renderer = RowRenderer {
1048 component: self.id.clone(),
1049 owner: cx.weak_entity(),
1050 list_focus: self.list_focus.clone(),
1051 active: self.active.clone(),
1052 focused: self.focused_thread.clone(),
1053 visible_threads: self.visible_threads,
1054 glide: glide.clone(),
1055 #[cfg(test)]
1056 construction: self.construction.clone(),
1057 };
1058 let list_state = self.list_state.clone();
1059
1060 v_flex()
1061 .id(root_id.clone())
1062 .role(Role::Group)
1063 .aria_label("Conversations")
1064 .size_full()
1065 .min_h_0()
1066 .min_w_0()
1067 .gap(tokens.spacing.sm)
1068 .child(
1069 h_flex()
1070 .w_full()
1071 .items_center()
1072 .px(row_gutter(cx))
1073 .gap(tokens.spacing.xs)
1074 .child(
1075 div().debug_selector(|| "thread-list-new".into()).child(
1076 crate::control::outlined_control_with_icon(
1077 (root_id.clone(), "new"),
1078 "New chat",
1079 IconName::Plus,
1080 "New chat",
1081 window,
1082 cx,
1083 )
1084 .accessibility_id(format!("{}-new", self.id))
1085 .on_click(cx.listener(|_, _, _, cx| {
1086 cx.emit(ThreadListEvent::NewRequested);
1087 })),
1088 ),
1089 )
1090 .child(div().flex_1())
1091 .child(
1092 icon_button(
1093 (root_id.clone(), "archived"),
1094 IconName::Inbox,
1095 if show_archived {
1096 "Hide archived conversations"
1097 } else {
1098 "Show archived conversations"
1099 },
1100 window,
1101 cx,
1102 )
1103 .debug_selector(|| "thread-list-archived-toggle".into())
1104 .selected(show_archived)
1105 .aria_expanded(show_archived)
1106 .on_click(cx.listener(|this, _, _, cx| {
1107 let show = !this.show_archived;
1108 this.set_show_archived(show, cx);
1109 })),
1110 ),
1111 )
1112 .child(
1113 h_flex()
1114 .debug_selector(|| "thread-list-search".into())
1115 .w_full()
1116 .items_center()
1117 .px(row_gutter(cx))
1118 .child(
1119 div()
1120 .debug_selector(|| "thread-list-field".into())
1121 .flex_1()
1122 .min_w_0()
1123 .child(Input::new(&self.input).prefix(IconName::Search)),
1124 ),
1125 )
1126 .child(
1127 {
1131 let frame = div()
1132 .id((root_id.clone(), "list-frame"))
1133 .debug_selector(|| "thread-list-scroll".into())
1134 .relative()
1135 .flex_1()
1136 .min_h_0()
1137 .w_full()
1138 .overflow_hidden()
1139 .policy_vertical_scrollbar(&list_state, cx);
1140 match &glide {
1143 Some(glide) => crate::glide::glide_frame(frame, glide).child(
1144 crate::glide::glide_highlight(
1145 root_id.clone(),
1146 glide,
1147 crate::surface::nested_radius(
1148 tokens.radius.lg,
1149 tokens.spacing.xs,
1150 tokens.radius.sm,
1151 ),
1152 "thread-list-glide",
1153 window,
1154 cx,
1155 ),
1156 ),
1157 None => frame,
1158 }
1159 }
1160 .child(
1161 div()
1162 .id((root_id.clone(), "list"))
1163 .role(Role::ListBox)
1164 .aria_label("Conversation list")
1165 .pl(row_gutter(cx))
1169 .pr(row_gutter(cx).max(crate::scrolling::control_lane(cx)))
1173 .track_focus(&self.list_focus)
1174 .tab_stop(true)
1175 .on_key_down(cx.listener(Self::on_key_down))
1176 .size_full()
1177 .min_h_0()
1178 .when(!rows.is_empty(), |this| {
1179 this.child(
1180 list(list_state, move |index, window, cx| {
1181 rows.get(index)
1182 .map(|row| renderer.render(row, window, cx))
1183 .unwrap_or_else(|| div().hidden().into_any_element())
1184 })
1185 .size_full(),
1186 )
1187 })
1188 .when_some(empty_message, |this, message| {
1189 this.child(
1190 div()
1191 .id((root_id, "empty"))
1192 .debug_selector(|| "thread-list-empty".into())
1193 .role(Role::Status)
1194 .aria_label(message.clone())
1195 .child(crate::surface::empty_state(
1196 IconName::Inbox,
1197 message.clone(),
1198 Some("Start a chat to see it here".into()),
1199 cx,
1200 )),
1201 )
1202 }),
1203 ),
1204 )
1205 .refine_style(&self.style)
1206 }
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211 use super::*;
1212 use gpui::{
1213 Bounds, KeyUpEvent, Keystroke, Modifiers, Pixels, TestAppContext, VisualTestContext, px,
1214 size,
1215 };
1216 use std::cell::Cell;
1217
1218 struct ThreadActionsMenuProbe {
1219 activations: Rc<Cell<usize>>,
1220 trigger_focus: FocusHandle,
1221 }
1222
1223 impl Render for ThreadActionsMenuProbe {
1224 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1225 let activations = self.activations.clone();
1226 div().tab_group().child(ThreadActionsMenu {
1227 id: "keyboard-actions".into(),
1228 trigger: icon_button(
1229 "keyboard-actions-trigger",
1230 IconName::Ellipsis,
1231 "More actions",
1232 window,
1233 cx,
1234 )
1235 .track_focus(&self.trigger_focus)
1236 .debug_selector(|| "keyboard-actions-trigger".into()),
1237 build: Rc::new(move |menu, _, _| {
1238 let activations = activations.clone();
1239 menu.item(PopupMenuItem::new("Rename").on_click(move |_, _, _| {
1240 activations.set(activations.get() + 1);
1241 }))
1242 }),
1243 })
1244 }
1245 }
1246
1247 #[test]
1248 fn matching_is_case_insensitive_over_title_and_subtitle() {
1249 let item = ThreadItem::new("t", "Supplier pricing").subtitle("Compared Alpenrose");
1250 assert!(item.matches(""));
1251 assert!(item.matches("PRICING"));
1252 assert!(item.matches("alpenrose"));
1253 assert!(!item.matches("delivery"));
1254 }
1255
1256 #[test]
1257 fn malformed_snapshots_are_rejected() {
1258 let duplicate_items = [ThreadSection::new("a", "A")
1259 .items([ThreadItem::new("t", "One"), ThreadItem::new("t", "Two")])];
1260 assert!(!sections_are_well_formed(&duplicate_items));
1261 let duplicate_sections = [
1262 ThreadSection::new("a", "A"),
1263 ThreadSection::new("a", "Again"),
1264 ];
1265 assert!(!sections_are_well_formed(&duplicate_sections));
1266 let fine = [
1267 ThreadSection::new("a", "A").items([ThreadItem::new("t-1", "One")]),
1268 ThreadSection::new("b", "B").items([ThreadItem::new("t-2", "Two")]),
1269 ];
1270 assert!(sections_are_well_formed(&fine));
1271 }
1272
1273 #[gpui::test]
1274 fn row_action_menu_opens_and_activates_from_the_keyboard(cx: &mut TestAppContext) {
1275 cx.update(crate::init);
1276 let activations = Rc::new(Cell::new(0));
1277 let (view, cx) = cx.add_window_view({
1278 let activations = activations.clone();
1279 move |_, cx| ThreadActionsMenuProbe {
1280 activations,
1281 trigger_focus: cx.focus_handle(),
1282 }
1283 });
1284 let cx: &mut VisualTestContext = cx;
1285 cx.update(|window, cx| window.draw(cx).clear(cx));
1286 cx.update(|window, cx| {
1287 let trigger_focus = view.read(cx).trigger_focus.clone();
1288 trigger_focus.focus(window, cx);
1289 window.draw(cx).clear(cx);
1290 });
1291
1292 let enter = Keystroke::parse("enter").expect("Enter is a valid keystroke");
1293 cx.simulate_event(KeyDownEvent {
1294 keystroke: enter.clone(),
1295 is_held: false,
1296 prefer_character_input: false,
1297 });
1298 cx.simulate_event(KeyUpEvent { keystroke: enter });
1299 cx.update(|window, cx| window.draw(cx).clear(cx));
1300 assert!(
1301 cx.debug_bounds("thread-actions-menu").is_some(),
1302 "Enter on the named ellipsis button must open the popup"
1303 );
1304
1305 cx.simulate_keystrokes("down enter");
1306 cx.update(|window, cx| window.draw(cx).clear(cx));
1307 assert_eq!(activations.get(), 1);
1308 assert!(cx.debug_bounds("thread-actions-menu").is_none());
1309 }
1310
1311 const MEASURED_VIEWPORT: (f32, f32) = (320., 480.);
1314
1315 const LARGE_SECTION_SIZE: usize = 1_000;
1317 const LARGE_SECTIONS: usize = 10;
1318
1319 #[gpui::test]
1320 fn activating_a_thread_acknowledges_once_and_a_mounted_marker_is_settled(
1321 cx: &mut TestAppContext,
1322 ) {
1323 let (threads, cx) = measured_list(cx);
1324 cx.update(|_, cx| {
1325 threads.update(cx, |threads, cx| {
1326 threads.set_sections(
1327 [ThreadSection::new("today", "Today")
1328 .items([ThreadItem::new("t-1", "One"), ThreadItem::new("t-2", "Two")])],
1329 cx,
1330 );
1331 threads.set_active(Some("t-1"), cx);
1332 });
1333 });
1334 redraw(&threads, cx);
1335 cx.executor()
1336 .advance_clock(std::time::Duration::from_secs(2));
1337 redraw(&threads, cx);
1338 crate::motion::take_reveal_frame_requests();
1339 redraw(&threads, cx);
1340 assert_eq!(
1341 crate::motion::take_reveal_frame_requests(),
1342 0,
1343 "the marker the list mounts with is placed settled"
1344 );
1345
1346 cx.update(|_, cx| {
1347 threads.update(cx, |threads, cx| threads.set_active(Some("t-2"), cx));
1348 });
1349 redraw(&threads, cx);
1350 assert!(
1351 crate::motion::take_reveal_frame_requests() > 0,
1352 "activating a thread must acknowledge the new marker"
1353 );
1354 }
1355
1356 fn large_sections() -> Vec<ThreadSection> {
1357 (0..LARGE_SECTIONS)
1358 .map(|section| {
1359 ThreadSection::new(format!("s-{section}"), format!("Section {section}")).items(
1360 (0..LARGE_SECTION_SIZE).map(|row| {
1361 let index = section * LARGE_SECTION_SIZE + row;
1362 ThreadItem::new(format!("t-{index:05}"), format!("Conversation {index}"))
1363 .subtitle(format!("{index} min ago"))
1364 }),
1365 )
1366 })
1367 .collect()
1368 }
1369
1370 #[gpui::test]
1374 fn the_list_stands_on_one_left_edge(cx: &mut TestAppContext) {
1375 let (threads, cx) = measured_list(cx);
1376 cx.update(|_, cx| {
1377 threads.update(cx, |threads, cx| threads.set_sections(glide_sections(), cx));
1378 });
1379 let _ = redraw(&threads, cx);
1380 let _ = redraw(&threads, cx);
1381
1382 let new_chat = bounds_of(cx, "thread-list-new".to_owned()).expect("new chat renders");
1383 let field = bounds_of(cx, "thread-list-field".to_owned()).expect("search renders");
1384 let row = bounds_of(cx, "thread-row-one".to_owned()).expect("a row renders");
1385 assert_eq!(
1386 (new_chat.left(), row.left()),
1387 (field.left(), field.left()),
1388 "the control, the field, and the rows share one gutter"
1389 );
1390
1391 let header = bounds_of(cx, "thread-section-label-recent".to_owned())
1392 .expect("the section label renders");
1393 let title = bounds_of(cx, "thread-title-one".to_owned()).expect("a row title renders");
1394 assert_eq!(
1395 header.left(),
1396 title.left(),
1397 "a section label and the titles it names share one text edge"
1398 );
1399 }
1400
1401 #[gpui::test]
1405 fn selection_focus_and_hover_describe_one_row_rect(cx: &mut TestAppContext) {
1406 let (threads, cx) = measured_list(cx);
1407 cx.update(|_, cx| {
1408 threads.update(cx, |threads, cx| {
1409 threads.set_sections(glide_sections(), cx);
1410 threads.set_active(Some(SharedString::from("one")), cx);
1411 });
1412 });
1413 let _ = redraw(&threads, cx);
1414 let _ = redraw(&threads, cx);
1415
1416 let row = bounds_of(cx, "thread-row-one".to_owned()).expect("the row renders");
1417 let select = bounds_of(cx, "thread-one".to_owned()).expect("the select control renders");
1418 assert!(
1419 row.right() > select.right(),
1420 "the row rect reaches past its label into the trailing actions: row {row:?}, label {select:?}"
1421 );
1422
1423 cx.simulate_mouse_move(row.center(), None, Modifiers::default());
1424 cx.update(|window, cx| window.draw(cx).clear(cx));
1425 let highlight =
1426 bounds_of(cx, "thread-list-glide".to_owned()).expect("hovering highlights the row");
1427 assert_eq!(
1428 highlight.size.width,
1429 row.size.width - px(2.),
1430 "the hover highlight covers the row inside its own border"
1431 );
1432 }
1433
1434 fn glide_sections() -> Vec<ThreadSection> {
1435 vec![ThreadSection::new("recent", "Recent").items([
1436 ThreadItem::new("one", "One"),
1437 ThreadItem::new("two", "Two"),
1438 ThreadItem::new("three", "Three"),
1439 ])]
1440 }
1441
1442 #[gpui::test]
1454 fn a_row_keeps_its_controls_clear_of_the_scrollbar(cx: &mut TestAppContext) {
1455 let (threads, cx) = measured_list(cx);
1456 cx.update(|_, cx| {
1457 threads.update(cx, |threads, cx| threads.set_sections(glide_sections(), cx));
1458 });
1459 let _ = redraw(&threads, cx);
1460 let _ = redraw(&threads, cx);
1461
1462 let frame = bounds_of(cx, "thread-list-scroll".to_owned()).expect("the list scrolls");
1463 let row = bounds_of(cx, "thread-row-one".to_owned()).expect("a row renders");
1464 let bar = gpui_base::Scrollbar::width();
1465 let gutter = cx.update(|_, cx| super::row_gutter(cx));
1466
1467 assert!(
1468 frame.right() - row.right() >= bar,
1469 "the bar covers this row's trailing edge: row ends {:?} from the frame, bar is {bar:?}",
1470 frame.right() - row.right()
1471 );
1472 assert_eq!(
1475 row.left() - frame.left(),
1476 gutter,
1477 "the leading gutter is unchanged"
1478 );
1479 }
1480
1481 #[gpui::test]
1486 fn glide_highlight_appears_on_hover_and_chases_the_next_row(cx: &mut TestAppContext) {
1487 let (threads, cx) = measured_list(cx);
1488 cx.update(|_, cx| {
1489 threads.update(cx, |threads, cx| threads.set_sections(glide_sections(), cx));
1490 });
1491 let _ = redraw(&threads, cx);
1492 let _ = redraw(&threads, cx);
1493
1494 assert!(
1495 bounds_of(cx, "thread-list-glide".to_owned()).is_none(),
1496 "no highlight before anything is hovered"
1497 );
1498
1499 let row_one = bounds_of(cx, "thread-row-one".to_owned()).expect("row one renders");
1500 cx.simulate_mouse_move(row_one.center(), None, Modifiers::default());
1501 cx.update(|window, cx| window.draw(cx).clear(cx));
1502 let highlight = bounds_of(cx, "thread-list-glide".to_owned())
1503 .expect("hovering a row mounts the highlight");
1504 assert_eq!(
1505 highlight.size.height,
1506 row_one.size.height - px(2.),
1507 "a fresh hover seats the highlight on its row, inside its border"
1508 );
1509 assert_eq!(highlight.origin.y, row_one.origin.y + px(1.));
1510
1511 let row_three = bounds_of(cx, "thread-row-three".to_owned()).expect("row three renders");
1512 cx.simulate_mouse_move(row_three.center(), None, Modifiers::default());
1513 cx.update(|window, cx| window.draw(cx).clear(cx));
1514 let start = bounds_of(cx, "thread-list-glide".to_owned()).expect("highlight persists");
1515 assert_eq!(
1516 start.origin.y,
1517 row_one.origin.y + px(1.),
1518 "the glide departs from the previously hovered row"
1519 );
1520
1521 cx.executor()
1522 .advance_clock(std::time::Duration::from_millis(10));
1523 cx.update(|window, cx| window.draw(cx).clear(cx));
1524 let moving = bounds_of(cx, "thread-list-glide".to_owned()).expect("highlight persists");
1525 assert!(
1526 moving.top() > row_one.top() && moving.top() < row_three.top(),
1527 "mid-glide the highlight sits between the rows: {moving:?}"
1528 );
1529
1530 cx.executor()
1531 .advance_clock(std::time::Duration::from_secs(1));
1532 cx.update(|window, cx| window.draw(cx).clear(cx));
1533 let settled = bounds_of(cx, "thread-list-glide".to_owned()).expect("highlight persists");
1534 assert_eq!(
1535 settled.origin.y,
1536 row_three.origin.y + px(1.),
1537 "the highlight arrives on the hovered row"
1538 );
1539
1540 cx.update(|window, cx| window.draw(cx).clear(cx));
1542 assert_eq!(
1543 bounds_of(cx, "thread-list-glide".to_owned()),
1544 Some(settled),
1545 "a settled highlight holds its row"
1546 );
1547 }
1548
1549 #[gpui::test]
1556 fn a_filtered_away_row_takes_its_highlight_with_it(cx: &mut TestAppContext) {
1557 let (threads, cx) = measured_list(cx);
1558 cx.update(|_, cx| {
1559 threads.update(cx, |threads, cx| threads.set_sections(glide_sections(), cx));
1560 });
1561 let _ = redraw(&threads, cx);
1562 let _ = redraw(&threads, cx);
1563
1564 let row = bounds_of(cx, "thread-row-three".to_owned()).expect("row three renders");
1565 cx.simulate_mouse_move(row.center(), None, Modifiers::default());
1566 cx.update(|window, cx| window.draw(cx).clear(cx));
1567 assert!(
1568 bounds_of(cx, "thread-list-glide".to_owned()).is_some(),
1569 "the hovered row is highlighted"
1570 );
1571
1572 cx.update(|window, cx| {
1574 threads.update(cx, |threads, cx| threads.set_query("One", window, cx));
1575 window.draw(cx).clear(cx);
1576 });
1577 cx.update(|window, cx| window.draw(cx).clear(cx));
1578 assert!(
1579 bounds_of(cx, "thread-row-three".to_owned()).is_none(),
1580 "the filter really removed the row"
1581 );
1582 assert!(
1583 bounds_of(cx, "thread-list-glide".to_owned()).is_none(),
1584 "and its highlight left with it, rather than painting over what took its place"
1585 );
1586 }
1587
1588 #[gpui::test]
1591 fn glide_snaps_between_rows_under_a_reduced_preference(cx: &mut TestAppContext) {
1592 cx.update(|cx| {
1593 crate::motion::MotionTokens::default()
1594 .with_preference(crate::motion::MotionPreference::Crossfade)
1595 .set(cx);
1596 crate::init(cx);
1597 });
1598 let (threads, cx) =
1599 cx.add_window_view(|window, cx| ThreadList::new("measured", window, cx));
1600 let cx: &mut VisualTestContext = cx;
1601 cx.simulate_resize(size(px(MEASURED_VIEWPORT.0), px(MEASURED_VIEWPORT.1)));
1602 cx.update(|_, cx| {
1603 threads.update(cx, |threads, cx| threads.set_sections(glide_sections(), cx));
1604 });
1605 let _ = redraw(&threads, cx);
1606 let _ = redraw(&threads, cx);
1607
1608 let row_one = bounds_of(cx, "thread-row-one".to_owned()).expect("row one renders");
1609 cx.simulate_mouse_move(row_one.center(), None, Modifiers::default());
1610 cx.update(|window, cx| window.draw(cx).clear(cx));
1611 let row_three = bounds_of(cx, "thread-row-three".to_owned()).expect("row three renders");
1612 cx.simulate_mouse_move(row_three.center(), None, Modifiers::default());
1613 cx.update(|window, cx| window.draw(cx).clear(cx));
1614 let snapped = bounds_of(cx, "thread-list-glide".to_owned())
1615 .expect("a reduced preference still highlights the hovered row");
1616 assert_eq!(
1617 snapped.origin.y,
1618 row_three.origin.y + px(1.),
1619 "a reduced preference snaps the highlight to the hovered row"
1620 );
1621 }
1622
1623 #[gpui::test]
1626 fn disabling_the_glide_restores_per_row_hover(cx: &mut TestAppContext) {
1627 let (threads, cx) = measured_list(cx);
1628 cx.update(|_, cx| {
1629 threads.update(cx, |threads, cx| {
1630 threads.set_sections(glide_sections(), cx);
1631 threads.set_hover_glide(false, cx);
1632 });
1633 });
1634 let _ = redraw(&threads, cx);
1635 let row_one = bounds_of(cx, "thread-row-one".to_owned()).expect("row one renders");
1636 cx.simulate_mouse_move(row_one.center(), None, Modifiers::default());
1637 cx.update(|window, cx| window.draw(cx).clear(cx));
1638 assert!(
1639 bounds_of(cx, "thread-list-glide".to_owned()).is_none(),
1640 "no gliding highlight when the builder disables it"
1641 );
1642 }
1643
1644 fn measured_list(cx: &mut TestAppContext) -> (Entity<ThreadList>, &mut VisualTestContext) {
1645 cx.update(crate::init);
1646 let (threads, cx) =
1647 cx.add_window_view(|window, cx| ThreadList::new("measured", window, cx));
1648 let cx: &mut VisualTestContext = cx;
1649 cx.simulate_resize(size(px(MEASURED_VIEWPORT.0), px(MEASURED_VIEWPORT.1)));
1650 (threads, cx)
1651 }
1652
1653 fn redraw(threads: &Entity<ThreadList>, cx: &mut VisualTestContext) -> (usize, usize) {
1655 cx.update(|window, cx| {
1656 threads.update(cx, |_, cx| cx.notify());
1657 window.draw(cx).clear(cx);
1658 });
1659 threads.read_with(cx, |threads, _| {
1660 (
1661 threads.construction.rows.get(),
1662 threads.construction.threads.get(),
1663 )
1664 })
1665 }
1666
1667 fn bounds_of(cx: &mut VisualTestContext, selector: String) -> Option<Bounds<Pixels>> {
1668 cx.debug_bounds(Box::leak(selector.into_boxed_str()))
1669 }
1670
1671 #[gpui::test]
1677 fn ten_thousand_threads_build_only_the_visible_window(cx: &mut TestAppContext) {
1678 let (threads, cx) = measured_list(cx);
1679 cx.update(|_, cx| {
1680 threads.update(cx, |threads, cx| threads.set_sections(large_sections(), cx));
1681 });
1682
1683 let total = LARGE_SECTIONS * LARGE_SECTION_SIZE;
1684 let (rows, built) = redraw(&threads, cx);
1685 assert!(built > 0, "the first screen must build some threads");
1686 assert!(
1687 rows <= 32,
1688 "a {total}-thread snapshot built {rows} rows on the first draw"
1689 );
1690 assert!(
1691 bounds_of(cx, "thread-t-00000".to_owned()).is_some(),
1692 "the first thread should be on screen"
1693 );
1694 for far in ["thread-t-00500", "thread-t-05000", "thread-t-09999"] {
1695 assert!(
1696 bounds_of(cx, far.to_owned()).is_none(),
1697 "{far} is far outside the window and must not be built"
1698 );
1699 }
1700
1701 let page = threads.read_with(cx, |threads, _| {
1703 threads.list_state.logical_scroll_top().item_ix + rows
1704 });
1705 cx.update(|_, cx| {
1706 threads.update(cx, |threads, _| {
1707 threads.list_state.scroll_to(ListOffset {
1708 item_ix: page,
1709 offset_in_item: Pixels::ZERO,
1710 });
1711 });
1712 });
1713 let (scrolled_rows, scrolled_threads) = redraw(&threads, cx);
1714 assert!(scrolled_threads > 0, "the second page must build threads");
1715 assert!(
1716 scrolled_rows <= 32,
1717 "one page down built {scrolled_rows} rows of {total}"
1718 );
1719 assert!(
1720 bounds_of(cx, "thread-t-00000".to_owned()).is_none(),
1721 "the first thread scrolled out and must no longer be built"
1722 );
1723
1724 let (again, _) = redraw(&threads, cx);
1726 assert_eq!(again, scrolled_rows, "construction must not accumulate");
1727 }
1728
1729 #[gpui::test]
1733 fn the_flattened_snapshot_carries_headers_and_listbox_positions(cx: &mut TestAppContext) {
1734 let (threads, cx) = measured_list(cx);
1735 cx.update(|_, cx| {
1736 threads.update(cx, |threads, cx| {
1737 threads.set_sections(
1738 [
1739 ThreadSection::new("today", "Today").items([
1740 ThreadItem::new("a", "Alpha"),
1741 ThreadItem::new("b", "Beta").archived(true),
1742 ]),
1743 ThreadSection::new("earlier", "Earlier")
1744 .items([ThreadItem::new("c", "Gamma")]),
1745 ThreadSection::new("empty", "Empty")
1746 .items([ThreadItem::new("d", "Delta").archived(true)]),
1747 ],
1748 cx,
1749 );
1750 });
1751 });
1752 threads.read_with(cx, |threads, _| {
1753 assert_eq!(
1754 threads.rows.as_ref(),
1755 &[
1756 ThreadRow::Header {
1757 section_id: "today".into(),
1758 label: "Today".into()
1759 },
1760 ThreadRow::Thread {
1761 item: ThreadItem::new("a", "Alpha"),
1762 position: 1
1763 },
1764 ThreadRow::Header {
1765 section_id: "earlier".into(),
1766 label: "Earlier".into()
1767 },
1768 ThreadRow::Thread {
1769 item: ThreadItem::new("c", "Gamma"),
1770 position: 2
1771 },
1772 ],
1773 "a section whose every thread is archived contributes no header"
1774 );
1775 assert_eq!(threads.visible_threads, 2);
1776 assert_eq!(threads.candidates, 2);
1777 });
1778 }
1779
1780 #[gpui::test]
1783 fn a_changed_rem_remeasures_and_keeps_the_first_visible_row(cx: &mut TestAppContext) {
1784 let (threads, cx) = measured_list(cx);
1785 cx.update(|_, cx| {
1786 threads.update(cx, |threads, cx| threads.set_sections(large_sections(), cx));
1787 });
1788 redraw(&threads, cx);
1789 cx.update(|_, cx| {
1790 threads.update(cx, |threads, _| {
1791 threads.list_state.scroll_to(ListOffset {
1792 item_ix: 24,
1793 offset_in_item: Pixels::ZERO,
1794 });
1795 });
1796 });
1797 redraw(&threads, cx);
1798 let before = threads.read_with(cx, |threads, _| {
1799 threads.rows[threads.list_state.logical_scroll_top().item_ix].anchor()
1800 });
1801
1802 cx.update(|window, cx| {
1803 window.set_rem_size(px(24.));
1804 window.draw(cx).clear(cx);
1805 });
1806 cx.update(|window, cx| window.draw(cx).clear(cx));
1807
1808 let after = threads.read_with(cx, |threads, _| {
1809 threads.rows[threads.list_state.logical_scroll_top().item_ix].anchor()
1810 });
1811 assert_eq!(before, after, "zooming must not move the reader");
1812 assert!(
1813 threads.read_with(cx, |threads, _| threads.resolved_layout.matches(px(24.))),
1814 "the new rem must be recorded so the next draw does not remeasure again"
1815 );
1816 }
1817
1818 #[gpui::test]
1821 fn clicking_a_row_moves_the_roving_focus(cx: &mut TestAppContext) {
1822 let (threads, cx) = measured_list(cx);
1823 cx.update(|_, cx| {
1824 threads.update(cx, |threads, cx| {
1825 threads.set_sections(
1826 [ThreadSection::new("today", "Today")
1827 .items([ThreadItem::new("a", "Alpha"), ThreadItem::new("b", "Beta")])],
1828 cx,
1829 );
1830 });
1831 });
1832 redraw(&threads, cx);
1833 let row = bounds_of(cx, "thread-b".to_owned()).expect("the row should render");
1834 cx.simulate_click(row.center(), Modifiers::default());
1835 cx.update(|window, cx| window.draw(cx).clear(cx));
1836 threads.read_with(cx, |threads, _| {
1837 assert_eq!(threads.focused_thread.as_deref(), Some("b"));
1838 });
1839 }
1840}