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#[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#[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#[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 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 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#[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
190pub 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 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 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 this.on_mouse_down(
472 MouseButton::Left,
473 cx.listener(move |state, _, _, cx| {
474 state.on_entry_click(ix, cx);
475 }),
476 )
477 .on_mouse_down(
478 MouseButton::Right,
479 cx.listener(move |state, _, _, cx| {
480 state.right_clicked_ix = Some(ix);
481 cx.notify();
482 }),
483 )
484 })
485 })
486 .collect()
487 })
488 })
489 .track_scroll(&self.scroll_handle)
490 .refine_style(&self.list_style)
491 }
492}
493
494#[derive(IntoElement)]
496pub struct Tree {
497 id: ElementId,
498 state: Entity<TreeState>,
499 style: StyleRefinement,
500 list_style: StyleRefinement,
501 render_item: Rc<RenderItem>,
502}
503
504impl Tree {
505 pub fn new(state: &Entity<TreeState>) -> Self {
506 Self {
507 id: ElementId::Name(format!("tree-{}", state.entity_id()).into()),
508 state: state.clone(),
509 style: StyleRefinement::default(),
510 list_style: StyleRefinement::default(),
511 render_item: Rc::new(|_, _, _, _, _| div().into_any_element()),
512 }
513 }
514
515 pub fn item<R>(mut self, render_item: R) -> Self
517 where
518 R: Fn(usize, &TreeEntry, TreeEntryState, &mut Window, &mut App) -> AnyElement + 'static,
519 {
520 self.render_item = Rc::new(render_item);
521 self
522 }
523
524 pub fn list_style(mut self, style: StyleRefinement) -> Self {
526 self.list_style = style;
527 self
528 }
529}
530
531impl Styled for Tree {
532 fn style(&mut self) -> &mut StyleRefinement {
533 &mut self.style
534 }
535}
536
537impl RenderOnce for Tree {
538 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
539 let focus_handle = self.state.read(cx).focus_handle.clone();
540 self.state.update(cx, |state, _| {
541 state.render_item = self.render_item;
542 state.list_style = self.list_style;
543 });
544
545 div()
546 .id(self.id)
547 .test_support()
548 .role(gpui::Role::Tree)
549 .key_context(CONTEXT)
550 .track_focus(&focus_handle)
551 .on_action(window.listener_for(&self.state, TreeState::on_action_confirm))
552 .on_action(window.listener_for(&self.state, TreeState::on_action_left))
553 .on_action(window.listener_for(&self.state, TreeState::on_action_right))
554 .on_action(window.listener_for(&self.state, TreeState::on_action_up))
555 .on_action(window.listener_for(&self.state, TreeState::on_action_down))
556 .child(self.state)
557 .refine_style(&self.style)
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use gpui::{AppContext as _, Subscription};
565
566 struct EventCollector {
567 events: Rc<RefCell<Vec<TreeEvent>>>,
568 _subscription: Subscription,
569 }
570
571 impl EventCollector {
572 fn new(state: &Entity<TreeState>, cx: &mut Context<Self>) -> Self {
573 let events = Rc::new(RefCell::new(Vec::new()));
574 let captured = events.clone();
575 let subscription = cx.subscribe(state, move |_, _, event, _| {
576 captured.borrow_mut().push(event.clone());
577 });
578 Self {
579 events,
580 _subscription: subscription,
581 }
582 }
583 }
584
585 impl Render for EventCollector {
586 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
587 div()
588 }
589 }
590
591 #[test]
592 fn clones_share_state_and_ancestors_keep_nearest_first_order() {
593 let leaf = TreeItem::new("leaf", "Leaf");
594 let branch = TreeItem::new("branch", "Branch").child(leaf.clone());
595 let root = TreeItem::new("root", "Root").child(branch.clone());
596
597 leaf.clone().disabled(true).expanded(true);
598 assert!(leaf.is_disabled());
599 assert!(leaf.is_expanded());
600
601 let ancestors = root.ancestors(&"leaf".into()).unwrap();
602 assert_eq!(
603 ancestors
604 .iter()
605 .map(|item| item.id.as_str())
606 .collect::<Vec<_>>(),
607 vec!["branch", "root"]
608 );
609 }
610
611 #[gpui::test]
612 fn state_flattens_expanded_items_and_resets_selection(cx: &mut gpui::TestAppContext) {
613 let items = vec![
614 TreeItem::new("src", "src")
615 .expanded(true)
616 .child(TreeItem::new("src/lib.rs", "lib.rs")),
617 TreeItem::new("README.md", "README.md"),
618 ];
619 let state = cx.new(|cx| TreeState::new(cx).items(items));
620
621 state.update(cx, |state, cx| {
622 assert_eq!(state.entries.len(), 3);
623 assert_eq!(state.entries[1].depth(), 1);
624 state.set_selected_index(Some(1), cx);
625 state.set_items(vec![TreeItem::new("Cargo.toml", "Cargo.toml")], cx);
626 assert_eq!(state.selected_index(), None);
627 assert_eq!(state.entries.len(), 1);
628 });
629 }
630
631 #[gpui::test]
632 fn selecting_hidden_item_expands_its_ancestors(cx: &mut gpui::TestAppContext) {
633 let target = TreeItem::new("src/ui/tree.rs", "tree.rs");
634 let root =
635 TreeItem::new("src", "src").child(TreeItem::new("src/ui", "ui").child(target.clone()));
636 let state = cx.new(|cx| TreeState::new(cx).items(vec![root]));
637
638 state.update(cx, |state, cx| {
639 state.set_selected_item(Some(&target), cx);
640 assert_eq!(state.entries.len(), 3);
641 assert_eq!(
642 state.selected_item().map(|item| item.id.as_str()),
643 Some("src/ui/tree.rs")
644 );
645 });
646 }
647
648 #[gpui::test]
649 fn revealing_item_under_later_root_keeps_other_subtrees(cx: &mut gpui::TestAppContext) {
650 let guide = TreeItem::new("docs/guide", "guide").child(TreeItem::new("docs/guide/a", "a"));
651 let docs = TreeItem::new("docs", "docs").expanded(true).child(guide);
652 let ui = TreeItem::new("src/ui", "ui").child(TreeItem::new("src/ui/tree.rs", "tree.rs"));
653 let src = TreeItem::new("src", "src").child(ui);
654 let state = cx.new(|cx| TreeState::new(cx).items(vec![docs, src]));
655
656 state.update(cx, |state, cx| {
657 assert_eq!(state.entries.len(), 3);
658 state.reveal_item(&"src/ui/tree.rs".into(), gpui::ScrollStrategy::Top, cx);
659 assert_eq!(
660 state
661 .entries
662 .iter()
663 .map(|entry| (entry.item().id.as_str(), entry.depth()))
664 .collect::<Vec<_>>(),
665 vec![
666 ("docs", 0),
667 ("docs/guide", 1),
668 ("src", 0),
669 ("src/ui", 1),
670 ("src/ui/tree.rs", 2),
671 ]
672 );
673 assert!(state.entries[0].is_folder());
674 assert_eq!(state.entries[0].item().children.len(), 1);
675
676 state.toggle_expand(1, cx);
677 assert_eq!(state.index_of(&"docs/guide/a".into()), Some(2));
678 });
679 }
680
681 #[gpui::test]
682 fn toggling_folder_rebuilds_visible_entries(cx: &mut gpui::TestAppContext) {
683 let root = TreeItem::new("src", "src").child(TreeItem::new("src/lib.rs", "lib.rs"));
684 let state = cx.new(|cx| TreeState::new(cx).items(vec![root]));
685
686 state.update(cx, |state, cx| {
687 state.toggle_expand(0, cx);
688 assert_eq!(state.entries.len(), 2);
689 state.toggle_expand(0, cx);
690 assert_eq!(state.entries.len(), 1);
691 });
692 }
693
694 #[gpui::test]
695 fn expansion_events_preserve_ids_and_set_items_stays_silent(cx: &mut gpui::TestAppContext) {
696 let root = TreeItem::new("src", "src").child(TreeItem::new("src/lib.rs", "lib.rs"));
697 let state = cx.new(|cx| TreeState::new(cx).items(vec![root]));
698 let collector = cx.new(|cx| EventCollector::new(&state, cx));
699
700 state.update(cx, |state, cx| {
701 state.toggle_expand(0, cx);
702 state.toggle_expand(0, cx);
703 state.set_items(vec![TreeItem::new("README.md", "README.md")], cx);
704 });
705
706 let events = collector.read_with(cx, |collector, _| collector.events.borrow().clone());
707 assert_eq!(
708 events,
709 vec![
710 TreeEvent::Expanded("src".into()),
711 TreeEvent::Collapsed("src".into()),
712 ]
713 );
714 }
715}