Skip to main content

gpui_base/
tree.rs

1use crate::TestSupportExt as _;
2use gpui::StatefulInteractiveElement as _;
3use std::{cell::RefCell, ops::Range, rc::Rc};
4
5use gpui::{
6    AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, InteractiveElement,
7    IntoElement, KeyBinding, MouseButton, ParentElement, Render, RenderOnce, SharedString,
8    StyleRefinement, Styled, UniformListScrollHandle, Window, div, prelude::FluentBuilder as _,
9    uniform_list,
10};
11
12use crate::{
13    actions::{Confirm, SelectDown, SelectLeft, SelectRight, SelectUp},
14    styled::StyledExt as _,
15};
16
17const CONTEXT: &str = "Tree";
18
19#[doc(hidden)]
20pub fn init(cx: &mut App) {
21    cx.bind_keys([
22        KeyBinding::new("up", SelectUp, Some(CONTEXT)),
23        KeyBinding::new("down", SelectDown, Some(CONTEXT)),
24        KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
25        KeyBinding::new("right", SelectRight, Some(CONTEXT)),
26    ]);
27}
28
29#[doc(hidden)]
30pub const fn key_context() -> &'static str {
31    CONTEXT
32}
33
34struct TreeItemState {
35    expanded: bool,
36    disabled: bool,
37}
38
39/// A tree item with a stable id, display label, children, and shared state.
40#[derive(Clone)]
41pub struct TreeItem {
42    pub id: SharedString,
43    pub label: SharedString,
44    pub children: Vec<TreeItem>,
45    state: Rc<RefCell<TreeItemState>>,
46}
47
48/// A flat representation of a tree item with its depth.
49#[derive(Clone)]
50pub struct TreeEntry {
51    item: TreeItem,
52    depth: usize,
53}
54
55impl TreeEntry {
56    pub fn new(item: TreeItem, depth: usize) -> Self {
57        Self { item, depth }
58    }
59
60    #[inline]
61    pub fn item(&self) -> &TreeItem {
62        &self.item
63    }
64
65    #[inline]
66    pub fn depth(&self) -> usize {
67        self.depth
68    }
69
70    #[inline]
71    pub fn is_root(&self) -> bool {
72        self.depth == 0
73    }
74
75    #[inline]
76    pub fn is_folder(&self) -> bool {
77        self.item.is_folder()
78    }
79
80    #[inline]
81    pub fn is_expanded(&self) -> bool {
82        self.item.is_expanded()
83    }
84
85    #[inline]
86    pub fn is_disabled(&self) -> bool {
87        self.item.is_disabled()
88    }
89}
90
91/// Event emitted by a tree when user-visible expansion state changes.
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub enum TreeEvent {
94    Expanded(SharedString),
95    Collapsed(SharedString),
96}
97
98impl TreeItem {
99    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
100        Self {
101            id: id.into(),
102            label: label.into(),
103            children: Vec::new(),
104            state: Rc::new(RefCell::new(TreeItemState {
105                expanded: false,
106                disabled: false,
107            })),
108        }
109    }
110
111    pub fn child(mut self, child: TreeItem) -> Self {
112        self.children.push(child);
113        self
114    }
115
116    pub fn children(mut self, children: impl IntoIterator<Item = TreeItem>) -> Self {
117        self.children.extend(children);
118        self
119    }
120
121    pub fn expanded(self, expanded: bool) -> Self {
122        self.state.borrow_mut().expanded = expanded;
123        self
124    }
125
126    pub fn disabled(self, disabled: bool) -> Self {
127        self.state.borrow_mut().disabled = disabled;
128        self
129    }
130
131    #[inline]
132    pub fn is_folder(&self) -> bool {
133        !self.children.is_empty()
134    }
135
136    pub fn is_disabled(&self) -> bool {
137        self.state.borrow().disabled
138    }
139
140    #[inline]
141    pub fn is_expanded(&self) -> bool {
142        self.state.borrow().expanded
143    }
144
145    /// Returns the target's ancestors from nearest parent to root.
146    pub fn ancestors(&self, target_id: &SharedString) -> Option<Vec<TreeItem>> {
147        self.ancestor_refs(target_id)
148            .map(|path| path.into_iter().cloned().collect())
149    }
150
151    /// Like [`Self::ancestors`], but borrows the ancestors instead of
152    /// deep-cloning each one's subtree.
153    fn ancestor_refs(&self, target_id: &SharedString) -> Option<Vec<&TreeItem>> {
154        if self.id == *target_id {
155            return Some(Vec::new());
156        }
157
158        for child in &self.children {
159            if let Some(mut path) = child.ancestor_refs(target_id) {
160                path.push(self);
161                return Some(path);
162            }
163        }
164
165        None
166    }
167}
168
169/// The interaction state supplied while rendering a visible tree entry.
170#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
171pub struct TreeEntryState {
172    selected: bool,
173    right_clicked: bool,
174}
175
176impl TreeEntryState {
177    #[inline]
178    pub fn is_selected(self) -> bool {
179        self.selected
180    }
181
182    #[inline]
183    pub fn is_right_clicked(self) -> bool {
184        self.right_clicked
185    }
186}
187
188type RenderItem = dyn Fn(usize, &TreeEntry, TreeEntryState, &mut Window, &mut App) -> AnyElement;
189
190/// Behavior and interaction state for a virtualized tree.
191pub struct TreeState {
192    focus_handle: FocusHandle,
193    entries: Vec<TreeEntry>,
194    scroll_handle: UniformListScrollHandle,
195    selected_ix: Option<usize>,
196    right_clicked_ix: Option<usize>,
197    render_item: Rc<RenderItem>,
198    list_style: StyleRefinement,
199}
200
201impl EventEmitter<TreeEvent> for TreeState {}
202
203impl TreeState {
204    pub fn new(cx: &mut App) -> Self {
205        Self {
206            focus_handle: cx.focus_handle(),
207            entries: Vec::new(),
208            scroll_handle: UniformListScrollHandle::default(),
209            selected_ix: None,
210            right_clicked_ix: None,
211            render_item: Rc::new(|_, _, _, _, _| div().into_any_element()),
212            list_style: StyleRefinement::default(),
213        }
214    }
215
216    pub fn items(mut self, items: impl Into<Vec<TreeItem>>) -> Self {
217        self.replace_items(items.into());
218        self
219    }
220
221    pub fn set_items(&mut self, items: impl Into<Vec<TreeItem>>, cx: &mut Context<Self>) {
222        self.replace_items(items.into());
223        self.selected_ix = None;
224        self.right_clicked_ix = None;
225        cx.notify();
226    }
227
228    pub fn selected_index(&self) -> Option<usize> {
229        self.selected_ix
230    }
231
232    pub fn set_selected_index(&mut self, ix: Option<usize>, cx: &mut Context<Self>) {
233        self.selected_ix = ix;
234        cx.notify();
235    }
236
237    pub fn set_selected_item(&mut self, item: Option<&TreeItem>, cx: &mut Context<Self>) {
238        if let Some(item) = item {
239            self.selected_ix = self.index_of(&item.id);
240            if self.selected_ix.is_none() {
241                self.expand_ancestors(item.id.clone(), cx);
242                self.selected_ix = self.index_of(&item.id);
243            }
244        } else {
245            self.selected_ix = None;
246        }
247        cx.notify();
248    }
249
250    pub fn selected_item(&self) -> Option<&TreeItem> {
251        self.selected_ix
252            .and_then(|ix| self.entries.get(ix).map(TreeEntry::item))
253    }
254
255    pub fn selected_entry(&self) -> Option<&TreeEntry> {
256        self.selected_ix.and_then(|ix| self.entries.get(ix))
257    }
258
259    pub fn entry(&self, ix: usize) -> Option<&TreeEntry> {
260        self.entries.get(ix)
261    }
262
263    pub fn scroll_handle(&self) -> &UniformListScrollHandle {
264        &self.scroll_handle
265    }
266
267    pub fn scroll_to_item(&mut self, ix: usize, strategy: gpui::ScrollStrategy) {
268        self.scroll_handle.scroll_to_item(ix, strategy);
269    }
270
271    pub fn index_of(&self, id: &SharedString) -> Option<usize> {
272        self.entries.iter().position(|entry| &entry.item.id == id)
273    }
274
275    pub fn reveal_item(
276        &mut self,
277        id: &SharedString,
278        strategy: gpui::ScrollStrategy,
279        cx: &mut Context<Self>,
280    ) {
281        self.expand_ancestors(id.clone(), cx);
282        if let Some(ix) = self.index_of(id) {
283            self.scroll_to_item(ix, strategy);
284        }
285    }
286
287    pub fn focus(&mut self, window: &mut Window, cx: &mut App) {
288        self.focus_handle.focus(window, cx);
289    }
290
291    fn replace_items(&mut self, items: Vec<TreeItem>) {
292        self.entries.clear();
293        for item in items {
294            self.push_root(item);
295        }
296    }
297
298    fn expand_ancestors(&mut self, target_id: SharedString, cx: &mut Context<Self>) {
299        // Entries are in depth-first order, so the root containing the target
300        // precedes every descendant entry whose subtree could also contain it.
301        let ancestors = self
302            .entries
303            .iter()
304            .filter(|entry| entry.is_root())
305            .find_map(|entry| entry.item.ancestor_refs(&target_id))
306            .unwrap_or_default();
307
308        if ancestors.is_empty() {
309            return;
310        }
311
312        for ancestor in ancestors.into_iter().rev() {
313            if !ancestor.is_expanded() {
314                ancestor.state.borrow_mut().expanded = true;
315                cx.emit(TreeEvent::Expanded(ancestor.id.clone()));
316            }
317        }
318        self.rebuild_entries();
319    }
320
321    /// Moves an owned root item into the entries, flattening its visible
322    /// descendants by reference so the root's subtree is not cloned.
323    fn push_root(&mut self, mut item: TreeItem) {
324        let children = if item.is_expanded() {
325            std::mem::take(&mut item.children)
326        } else {
327            Vec::new()
328        };
329
330        let ix = self.entries.len();
331        self.entries.push(TreeEntry::new(item, 0));
332        if !children.is_empty() {
333            for child in &children {
334                self.add_entry(child, 1);
335            }
336            self.entries[ix].item.children = children;
337        }
338    }
339
340    fn add_entry(&mut self, item: &TreeItem, depth: usize) {
341        self.entries.push(TreeEntry::new(item.clone(), depth));
342        if item.is_expanded() {
343            for child in &item.children {
344                self.add_entry(child, depth + 1);
345            }
346        }
347    }
348
349    fn toggle_expand(&mut self, ix: usize, cx: &mut Context<Self>) {
350        let Some(entry) = self.entries.get(ix) else {
351            return;
352        };
353        if !entry.is_folder() {
354            return;
355        }
356
357        let expanded = !entry.is_expanded();
358        let id = entry.item.id.clone();
359        entry.item.state.borrow_mut().expanded = expanded;
360        cx.emit(if expanded {
361            TreeEvent::Expanded(id)
362        } else {
363            TreeEvent::Collapsed(id)
364        });
365        self.right_clicked_ix = None;
366        self.rebuild_entries();
367    }
368
369    fn rebuild_entries(&mut self) {
370        let roots = std::mem::take(&mut self.entries)
371            .into_iter()
372            .filter(TreeEntry::is_root)
373            .map(|entry| entry.item)
374            .collect::<Vec<_>>();
375        self.replace_items(roots);
376    }
377
378    fn on_action_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context<Self>) {
379        if self
380            .selected_ix
381            .and_then(|ix| self.entries.get(ix).map(|entry| (ix, entry.is_folder())))
382            .is_some_and(|(ix, is_folder)| {
383                if is_folder {
384                    self.toggle_expand(ix, cx);
385                }
386                is_folder
387            })
388        {
389            cx.notify();
390        }
391    }
392
393    fn on_action_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
394        if let Some(ix) = self.selected_ix
395            && self
396                .entries
397                .get(ix)
398                .is_some_and(|entry| entry.is_folder() && entry.is_expanded())
399        {
400            self.toggle_expand(ix, cx);
401            cx.notify();
402        }
403    }
404
405    fn on_action_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
406        if let Some(ix) = self.selected_ix
407            && self
408                .entries
409                .get(ix)
410                .is_some_and(|entry| entry.is_folder() && !entry.is_expanded())
411        {
412            self.toggle_expand(ix, cx);
413            cx.notify();
414        }
415    }
416
417    fn on_action_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
418        let mut ix = self.selected_ix.unwrap_or(0);
419        ix = ix
420            .checked_sub(1)
421            .unwrap_or_else(|| self.entries.len().saturating_sub(1));
422        self.selected_ix = Some(ix);
423        self.scroll_handle
424            .scroll_to_item(ix, gpui::ScrollStrategy::Top);
425        cx.notify();
426    }
427
428    fn on_action_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
429        let mut ix = self.selected_ix.unwrap_or(0);
430        ix = if ix + 1 < self.entries.len() {
431            ix + 1
432        } else {
433            0
434        };
435        self.selected_ix = Some(ix);
436        self.scroll_handle
437            .scroll_to_item(ix, gpui::ScrollStrategy::Bottom);
438        cx.notify();
439    }
440
441    fn on_entry_click(&mut self, ix: usize, cx: &mut Context<Self>) {
442        self.selected_ix = Some(ix);
443        self.toggle_expand(ix, cx);
444        cx.notify();
445    }
446}
447
448impl Render for TreeState {
449    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
450        let render_item = self.render_item.clone();
451        uniform_list("entries", self.entries.len(), {
452            cx.processor(move |state, visible_range: Range<usize>, window, cx| {
453                visible_range
454                    .map(|ix| {
455                        let entry = &state.entries[ix];
456                        let entry_state = TreeEntryState {
457                            selected: Some(ix) == state.selected_ix,
458                            right_clicked: Some(ix) == state.right_clicked_ix,
459                        };
460                        div()
461                            .id(ix)
462                            .test_support()
463                            .role(gpui::Role::TreeItem)
464                            .aria_label(entry.item().label.clone())
465                            .aria_selected(entry_state.selected)
466                            .when(entry.is_folder(), |this| {
467                                this.aria_expanded(entry.is_expanded())
468                            })
469                            .child((render_item)(ix, entry, entry_state, window, cx))
470                            .when(!entry.is_disabled(), |this| {
471                                // Select and toggle on a full click, like `List` and `Table`
472                                // rows, so a press that is dragged away leaves the entry alone.
473                                this.on_click(cx.listener(move |state, _, _, cx| {
474                                    state.on_entry_click(ix, cx);
475                                }))
476                                .on_mouse_down(
477                                    MouseButton::Right,
478                                    cx.listener(move |state, _, _, cx| {
479                                        state.right_clicked_ix = Some(ix);
480                                        cx.notify();
481                                    }),
482                                )
483                            })
484                    })
485                    .collect()
486            })
487        })
488        .track_scroll(&self.scroll_handle)
489        .refine_style(&self.list_style)
490    }
491}
492
493/// An unstyled, virtualized tree element.
494///
495/// A click on an entry, a press and release on that same entry, selects it
496/// and expands or collapses it when it is a folder. Click handlers in the
497/// content from [`Tree::item`] run first, so they still see the previous
498/// selection. To drag entries, use `on_drag` on that content: a press that
499/// starts a drag is not a click.
500#[derive(IntoElement)]
501pub struct Tree {
502    id: ElementId,
503    state: Entity<TreeState>,
504    style: StyleRefinement,
505    list_style: StyleRefinement,
506    render_item: Rc<RenderItem>,
507}
508
509impl Tree {
510    pub fn new(state: &Entity<TreeState>) -> Self {
511        Self {
512            id: ElementId::Name(format!("tree-{}", state.entity_id()).into()),
513            state: state.clone(),
514            style: StyleRefinement::default(),
515            list_style: StyleRefinement::default(),
516            render_item: Rc::new(|_, _, _, _, _| div().into_any_element()),
517        }
518    }
519
520    /// Supplies the application-owned content for each visible entry.
521    pub fn item<R>(mut self, render_item: R) -> Self
522    where
523        R: Fn(usize, &TreeEntry, TreeEntryState, &mut Window, &mut App) -> AnyElement + 'static,
524    {
525        self.render_item = Rc::new(render_item);
526        self
527    }
528
529    /// Applies caller-owned presentation to the internal virtual list.
530    pub fn list_style(mut self, style: StyleRefinement) -> Self {
531        self.list_style = style;
532        self
533    }
534}
535
536impl Styled for Tree {
537    fn style(&mut self) -> &mut StyleRefinement {
538        &mut self.style
539    }
540}
541
542impl RenderOnce for Tree {
543    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
544        let focus_handle = self.state.read(cx).focus_handle.clone();
545        self.state.update(cx, |state, _| {
546            state.render_item = self.render_item;
547            state.list_style = self.list_style;
548        });
549
550        div()
551            .id(self.id)
552            .test_support()
553            .role(gpui::Role::Tree)
554            .key_context(CONTEXT)
555            .track_focus(&focus_handle)
556            .on_action(window.listener_for(&self.state, TreeState::on_action_confirm))
557            .on_action(window.listener_for(&self.state, TreeState::on_action_left))
558            .on_action(window.listener_for(&self.state, TreeState::on_action_right))
559            .on_action(window.listener_for(&self.state, TreeState::on_action_up))
560            .on_action(window.listener_for(&self.state, TreeState::on_action_down))
561            .child(self.state)
562            .refine_style(&self.style)
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use gpui::{
570        AppContext as _, Modifiers, MouseUpEvent, Pixels, Point, Subscription, TestAppContext,
571        VisualTestContext, point, px,
572    };
573
574    struct EventCollector {
575        events: Rc<RefCell<Vec<TreeEvent>>>,
576        _subscription: Subscription,
577    }
578
579    impl EventCollector {
580        fn new(state: &Entity<TreeState>, cx: &mut Context<Self>) -> Self {
581            let events = Rc::new(RefCell::new(Vec::new()));
582            let captured = events.clone();
583            let subscription = cx.subscribe(state, move |_, _, event, _| {
584                captured.borrow_mut().push(event.clone());
585            });
586            Self {
587                events,
588                _subscription: subscription,
589            }
590        }
591    }
592
593    impl Render for EventCollector {
594        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
595            div()
596        }
597    }
598
599    #[test]
600    fn clones_share_state_and_ancestors_keep_nearest_first_order() {
601        let leaf = TreeItem::new("leaf", "Leaf");
602        let branch = TreeItem::new("branch", "Branch").child(leaf.clone());
603        let root = TreeItem::new("root", "Root").child(branch.clone());
604
605        leaf.clone().disabled(true).expanded(true);
606        assert!(leaf.is_disabled());
607        assert!(leaf.is_expanded());
608
609        let ancestors = root.ancestors(&"leaf".into()).unwrap();
610        assert_eq!(
611            ancestors
612                .iter()
613                .map(|item| item.id.as_str())
614                .collect::<Vec<_>>(),
615            vec!["branch", "root"]
616        );
617    }
618
619    #[gpui::test]
620    fn state_flattens_expanded_items_and_resets_selection(cx: &mut gpui::TestAppContext) {
621        let items = vec![
622            TreeItem::new("src", "src")
623                .expanded(true)
624                .child(TreeItem::new("src/lib.rs", "lib.rs")),
625            TreeItem::new("README.md", "README.md"),
626        ];
627        let state = cx.new(|cx| TreeState::new(cx).items(items));
628
629        state.update(cx, |state, cx| {
630            assert_eq!(state.entries.len(), 3);
631            assert_eq!(state.entries[1].depth(), 1);
632            state.set_selected_index(Some(1), cx);
633            state.set_items(vec![TreeItem::new("Cargo.toml", "Cargo.toml")], cx);
634            assert_eq!(state.selected_index(), None);
635            assert_eq!(state.entries.len(), 1);
636        });
637    }
638
639    #[gpui::test]
640    fn selecting_hidden_item_expands_its_ancestors(cx: &mut gpui::TestAppContext) {
641        let target = TreeItem::new("src/ui/tree.rs", "tree.rs");
642        let root =
643            TreeItem::new("src", "src").child(TreeItem::new("src/ui", "ui").child(target.clone()));
644        let state = cx.new(|cx| TreeState::new(cx).items(vec![root]));
645
646        state.update(cx, |state, cx| {
647            state.set_selected_item(Some(&target), cx);
648            assert_eq!(state.entries.len(), 3);
649            assert_eq!(
650                state.selected_item().map(|item| item.id.as_str()),
651                Some("src/ui/tree.rs")
652            );
653        });
654    }
655
656    #[gpui::test]
657    fn revealing_item_under_later_root_keeps_other_subtrees(cx: &mut gpui::TestAppContext) {
658        let guide = TreeItem::new("docs/guide", "guide").child(TreeItem::new("docs/guide/a", "a"));
659        let docs = TreeItem::new("docs", "docs").expanded(true).child(guide);
660        let ui = TreeItem::new("src/ui", "ui").child(TreeItem::new("src/ui/tree.rs", "tree.rs"));
661        let src = TreeItem::new("src", "src").child(ui);
662        let state = cx.new(|cx| TreeState::new(cx).items(vec![docs, src]));
663
664        state.update(cx, |state, cx| {
665            assert_eq!(state.entries.len(), 3);
666            state.reveal_item(&"src/ui/tree.rs".into(), gpui::ScrollStrategy::Top, cx);
667            assert_eq!(
668                state
669                    .entries
670                    .iter()
671                    .map(|entry| (entry.item().id.as_str(), entry.depth()))
672                    .collect::<Vec<_>>(),
673                vec![
674                    ("docs", 0),
675                    ("docs/guide", 1),
676                    ("src", 0),
677                    ("src/ui", 1),
678                    ("src/ui/tree.rs", 2),
679                ]
680            );
681            assert!(state.entries[0].is_folder());
682            assert_eq!(state.entries[0].item().children.len(), 1);
683
684            state.toggle_expand(1, cx);
685            assert_eq!(state.index_of(&"docs/guide/a".into()), Some(2));
686        });
687    }
688
689    struct TreeHarness {
690        state: Entity<TreeState>,
691        draggable: bool,
692    }
693
694    struct DraggedEntry;
695
696    impl Render for DraggedEntry {
697        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
698            div()
699        }
700    }
701
702    impl Render for TreeHarness {
703        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
704            let draggable = self.draggable;
705            div().size_full().child(
706                Tree::new(&self.state)
707                    .size_full()
708                    .list_style(StyleRefinement::default().size_full())
709                    .item(move |ix, _, _, _, _| {
710                        div()
711                            .id(("tree-entry", ix))
712                            .debug_selector(move || format!("tree-entry-{ix}"))
713                            .h(px(20.))
714                            .w_full()
715                            .when(draggable, |this| {
716                                this.on_drag(ix, |_, _, _, cx| cx.new(|_| DraggedEntry))
717                            })
718                            .into_any_element()
719                    }),
720            )
721        }
722    }
723
724    /// Paints a tree of `items` in a test window and returns the center of
725    /// its first entry.
726    fn open_tree(
727        cx: &mut TestAppContext,
728        items: Vec<TreeItem>,
729        draggable: bool,
730    ) -> (Entity<TreeState>, &mut VisualTestContext, Point<Pixels>) {
731        let state = cx.new(|cx| TreeState::new(cx).items(items));
732        let (_, cx) = cx.add_window_view({
733            let state = state.clone();
734            move |_, _| TreeHarness { state, draggable }
735        });
736        cx.update(|window, cx| window.draw(cx).clear(cx));
737        let entry = cx
738            .debug_bounds("tree-entry-0")
739            .expect("the first entry is painted")
740            .center();
741        (state, cx, entry)
742    }
743
744    fn folder() -> TreeItem {
745        TreeItem::new("src", "src").child(TreeItem::new("src/lib.rs", "lib.rs"))
746    }
747
748    #[gpui::test]
749    fn entries_select_and_toggle_on_click_not_mouse_down(cx: &mut TestAppContext) {
750        let (state, cx, entry) = open_tree(cx, vec![folder()], false);
751
752        // A press alone (e.g. the start of a drag) must not select or expand.
753        cx.simulate_mouse_down(entry, MouseButton::Left, Modifiers::default());
754        state.read_with(cx, |state, _| {
755            assert_eq!(state.selected_index(), None);
756            assert_eq!(state.entries.len(), 1);
757        });
758        // Releasing away from the entry cancels the click.
759        cx.simulate_event(MouseUpEvent {
760            position: point(px(10.), px(500.)),
761            button: MouseButton::Left,
762            modifiers: Modifiers::default(),
763            click_count: 1,
764        });
765        cx.update(|window, cx| window.draw(cx).clear(cx));
766        state.read_with(cx, |state, _| assert_eq!(state.selected_index(), None));
767
768        cx.simulate_click(entry, Modifiers::default());
769        state.read_with(cx, |state, _| {
770            assert_eq!(state.selected_index(), Some(0));
771            assert_eq!(state.entries.len(), 2);
772        });
773    }
774
775    #[gpui::test]
776    fn dragging_an_entry_with_on_drag_does_not_select_or_toggle_it(cx: &mut TestAppContext) {
777        let (state, cx, entry) = open_tree(cx, vec![folder()], true);
778
779        // Start a drag, then drop it back on the entry it started from.
780        cx.simulate_mouse_down(entry, MouseButton::Left, Modifiers::default());
781        cx.simulate_mouse_move(
782            entry + point(px(8.), px(0.)),
783            MouseButton::Left,
784            Modifiers::default(),
785        );
786        cx.update(|window, cx| window.draw(cx).clear(cx));
787        cx.simulate_mouse_up(entry, MouseButton::Left, Modifiers::default());
788
789        state.read_with(cx, |state, _| {
790            assert_eq!(state.selected_index(), None);
791            assert_eq!(state.entries.len(), 1);
792        });
793    }
794
795    #[gpui::test]
796    fn right_click_neither_selects_nor_toggles(cx: &mut TestAppContext) {
797        let (state, cx, entry) = open_tree(cx, vec![folder()], false);
798
799        cx.simulate_mouse_down(entry, MouseButton::Right, Modifiers::default());
800        cx.simulate_mouse_up(entry, MouseButton::Right, Modifiers::default());
801
802        state.read_with(cx, |state, _| {
803            assert_eq!(state.right_clicked_ix, Some(0));
804            assert_eq!(state.selected_index(), None);
805            assert_eq!(state.entries.len(), 1);
806        });
807    }
808
809    #[gpui::test]
810    fn clicking_a_disabled_entry_does_nothing(cx: &mut TestAppContext) {
811        let (state, cx, entry) = open_tree(cx, vec![folder().disabled(true)], false);
812
813        cx.simulate_click(entry, Modifiers::default());
814
815        state.read_with(cx, |state, _| {
816            assert_eq!(state.selected_index(), None);
817            assert_eq!(state.entries.len(), 1);
818        });
819    }
820
821    #[gpui::test]
822    fn toggling_folder_rebuilds_visible_entries(cx: &mut gpui::TestAppContext) {
823        let root = TreeItem::new("src", "src").child(TreeItem::new("src/lib.rs", "lib.rs"));
824        let state = cx.new(|cx| TreeState::new(cx).items(vec![root]));
825
826        state.update(cx, |state, cx| {
827            state.toggle_expand(0, cx);
828            assert_eq!(state.entries.len(), 2);
829            state.toggle_expand(0, cx);
830            assert_eq!(state.entries.len(), 1);
831        });
832    }
833
834    #[gpui::test]
835    fn expansion_events_preserve_ids_and_set_items_stays_silent(cx: &mut gpui::TestAppContext) {
836        let root = TreeItem::new("src", "src").child(TreeItem::new("src/lib.rs", "lib.rs"));
837        let state = cx.new(|cx| TreeState::new(cx).items(vec![root]));
838        let collector = cx.new(|cx| EventCollector::new(&state, cx));
839
840        state.update(cx, |state, cx| {
841            state.toggle_expand(0, cx);
842            state.toggle_expand(0, cx);
843            state.set_items(vec![TreeItem::new("README.md", "README.md")], cx);
844        });
845
846        let events = collector.read_with(cx, |collector, _| collector.events.borrow().clone());
847        assert_eq!(
848            events,
849            vec![
850                TreeEvent::Expanded("src".into()),
851                TreeEvent::Collapsed("src".into()),
852            ]
853        );
854    }
855}