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 if self.id == *target_id {
148 return Some(Vec::new());
149 }
150
151 for child in &self.children {
152 if let Some(mut path) = child.ancestors(target_id) {
153 path.push(self.clone());
154 return Some(path);
155 }
156 }
157
158 None
159 }
160}
161
162#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
164pub struct TreeEntryState {
165 selected: bool,
166 right_clicked: bool,
167}
168
169impl TreeEntryState {
170 #[inline]
171 pub fn is_selected(self) -> bool {
172 self.selected
173 }
174
175 #[inline]
176 pub fn is_right_clicked(self) -> bool {
177 self.right_clicked
178 }
179}
180
181type RenderItem = dyn Fn(usize, &TreeEntry, TreeEntryState, &mut Window, &mut App) -> AnyElement;
182
183pub struct TreeState {
185 focus_handle: FocusHandle,
186 entries: Vec<TreeEntry>,
187 scroll_handle: UniformListScrollHandle,
188 selected_ix: Option<usize>,
189 right_clicked_ix: Option<usize>,
190 render_item: Rc<RenderItem>,
191 list_style: StyleRefinement,
192}
193
194impl EventEmitter<TreeEvent> for TreeState {}
195
196impl TreeState {
197 pub fn new(cx: &mut App) -> Self {
198 Self {
199 focus_handle: cx.focus_handle(),
200 entries: Vec::new(),
201 scroll_handle: UniformListScrollHandle::default(),
202 selected_ix: None,
203 right_clicked_ix: None,
204 render_item: Rc::new(|_, _, _, _, _| div().into_any_element()),
205 list_style: StyleRefinement::default(),
206 }
207 }
208
209 pub fn items(mut self, items: impl Into<Vec<TreeItem>>) -> Self {
210 self.replace_items(items.into());
211 self
212 }
213
214 pub fn set_items(&mut self, items: impl Into<Vec<TreeItem>>, cx: &mut Context<Self>) {
215 self.replace_items(items.into());
216 self.selected_ix = None;
217 self.right_clicked_ix = None;
218 cx.notify();
219 }
220
221 pub fn selected_index(&self) -> Option<usize> {
222 self.selected_ix
223 }
224
225 pub fn set_selected_index(&mut self, ix: Option<usize>, cx: &mut Context<Self>) {
226 self.selected_ix = ix;
227 cx.notify();
228 }
229
230 pub fn set_selected_item(&mut self, item: Option<&TreeItem>, cx: &mut Context<Self>) {
231 if let Some(item) = item {
232 self.selected_ix = self.index_of(&item.id);
233 if self.selected_ix.is_none() {
234 self.expand_ancestors(item.id.clone(), cx);
235 self.selected_ix = self.index_of(&item.id);
236 }
237 } else {
238 self.selected_ix = None;
239 }
240 cx.notify();
241 }
242
243 pub fn selected_item(&self) -> Option<&TreeItem> {
244 self.selected_ix
245 .and_then(|ix| self.entries.get(ix).map(TreeEntry::item))
246 }
247
248 pub fn selected_entry(&self) -> Option<&TreeEntry> {
249 self.selected_ix.and_then(|ix| self.entries.get(ix))
250 }
251
252 pub fn entry(&self, ix: usize) -> Option<&TreeEntry> {
253 self.entries.get(ix)
254 }
255
256 pub fn scroll_handle(&self) -> &UniformListScrollHandle {
257 &self.scroll_handle
258 }
259
260 pub fn scroll_to_item(&mut self, ix: usize, strategy: gpui::ScrollStrategy) {
261 self.scroll_handle.scroll_to_item(ix, strategy);
262 }
263
264 pub fn index_of(&self, id: &SharedString) -> Option<usize> {
265 self.entries.iter().position(|entry| &entry.item.id == id)
266 }
267
268 pub fn reveal_item(
269 &mut self,
270 id: &SharedString,
271 strategy: gpui::ScrollStrategy,
272 cx: &mut Context<Self>,
273 ) {
274 self.expand_ancestors(id.clone(), cx);
275 if let Some(ix) = self.index_of(id) {
276 self.scroll_to_item(ix, strategy);
277 }
278 }
279
280 pub fn focus(&mut self, window: &mut Window, cx: &mut App) {
281 self.focus_handle.focus(window, cx);
282 }
283
284 fn replace_items(&mut self, items: Vec<TreeItem>) {
285 self.entries.clear();
286 for item in items {
287 self.add_entry(item, 0);
288 }
289 }
290
291 fn expand_ancestors(&mut self, target_id: SharedString, cx: &mut Context<Self>) {
292 let ancestors = self
293 .entries
294 .iter()
295 .find_map(|entry| entry.item.ancestors(&target_id))
296 .unwrap_or_default();
297
298 if ancestors.is_empty() {
299 return;
300 }
301
302 for ancestor in ancestors.into_iter().rev() {
303 if !ancestor.is_expanded() {
304 ancestor.state.borrow_mut().expanded = true;
305 cx.emit(TreeEvent::Expanded(ancestor.id.clone()));
306 }
307 }
308 self.rebuild_entries();
309 }
310
311 fn add_entry(&mut self, item: TreeItem, depth: usize) {
312 self.entries.push(TreeEntry::new(item.clone(), depth));
313 if item.is_expanded() {
314 for child in &item.children {
315 self.add_entry(child.clone(), depth + 1);
316 }
317 }
318 }
319
320 fn toggle_expand(&mut self, ix: usize, cx: &mut Context<Self>) {
321 let Some(entry) = self.entries.get(ix) else {
322 return;
323 };
324 if !entry.is_folder() {
325 return;
326 }
327
328 let expanded = !entry.is_expanded();
329 let id = entry.item.id.clone();
330 entry.item.state.borrow_mut().expanded = expanded;
331 cx.emit(if expanded {
332 TreeEvent::Expanded(id)
333 } else {
334 TreeEvent::Collapsed(id)
335 });
336 self.right_clicked_ix = None;
337 self.rebuild_entries();
338 }
339
340 fn rebuild_entries(&mut self) {
341 let roots = self
342 .entries
343 .iter()
344 .filter(|entry| entry.is_root())
345 .map(|entry| entry.item.clone())
346 .collect::<Vec<_>>();
347 self.replace_items(roots);
348 }
349
350 fn on_action_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context<Self>) {
351 if self
352 .selected_ix
353 .and_then(|ix| self.entries.get(ix).map(|entry| (ix, entry.is_folder())))
354 .is_some_and(|(ix, is_folder)| {
355 if is_folder {
356 self.toggle_expand(ix, cx);
357 }
358 is_folder
359 })
360 {
361 cx.notify();
362 }
363 }
364
365 fn on_action_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
366 if let Some(ix) = self.selected_ix
367 && self
368 .entries
369 .get(ix)
370 .is_some_and(|entry| entry.is_folder() && entry.is_expanded())
371 {
372 self.toggle_expand(ix, cx);
373 cx.notify();
374 }
375 }
376
377 fn on_action_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
378 if let Some(ix) = self.selected_ix
379 && self
380 .entries
381 .get(ix)
382 .is_some_and(|entry| entry.is_folder() && !entry.is_expanded())
383 {
384 self.toggle_expand(ix, cx);
385 cx.notify();
386 }
387 }
388
389 fn on_action_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
390 let mut ix = self.selected_ix.unwrap_or(0);
391 ix = ix
392 .checked_sub(1)
393 .unwrap_or_else(|| self.entries.len().saturating_sub(1));
394 self.selected_ix = Some(ix);
395 self.scroll_handle
396 .scroll_to_item(ix, gpui::ScrollStrategy::Top);
397 cx.notify();
398 }
399
400 fn on_action_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
401 let mut ix = self.selected_ix.unwrap_or(0);
402 ix = if ix + 1 < self.entries.len() {
403 ix + 1
404 } else {
405 0
406 };
407 self.selected_ix = Some(ix);
408 self.scroll_handle
409 .scroll_to_item(ix, gpui::ScrollStrategy::Bottom);
410 cx.notify();
411 }
412
413 fn on_entry_click(&mut self, ix: usize, cx: &mut Context<Self>) {
414 self.selected_ix = Some(ix);
415 self.toggle_expand(ix, cx);
416 cx.notify();
417 }
418}
419
420impl Render for TreeState {
421 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
422 let render_item = self.render_item.clone();
423 uniform_list("entries", self.entries.len(), {
424 cx.processor(move |state, visible_range: Range<usize>, window, cx| {
425 visible_range
426 .map(|ix| {
427 let entry = &state.entries[ix];
428 let entry_state = TreeEntryState {
429 selected: Some(ix) == state.selected_ix,
430 right_clicked: Some(ix) == state.right_clicked_ix,
431 };
432 div()
433 .id(ix)
434 .test_support()
435 .role(gpui::Role::TreeItem)
436 .aria_label(entry.item().label.clone())
437 .aria_selected(entry_state.selected)
438 .when(entry.is_folder(), |this| {
439 this.aria_expanded(entry.is_expanded())
440 })
441 .child((render_item)(ix, entry, entry_state, window, cx))
442 .when(!entry.is_disabled(), |this| {
443 this.on_mouse_down(
444 MouseButton::Left,
445 cx.listener(move |state, _, _, cx| {
446 state.on_entry_click(ix, cx);
447 }),
448 )
449 .on_mouse_down(
450 MouseButton::Right,
451 cx.listener(move |state, _, _, cx| {
452 state.right_clicked_ix = Some(ix);
453 cx.notify();
454 }),
455 )
456 })
457 })
458 .collect()
459 })
460 })
461 .track_scroll(&self.scroll_handle)
462 .refine_style(&self.list_style)
463 }
464}
465
466#[derive(IntoElement)]
468pub struct Tree {
469 id: ElementId,
470 state: Entity<TreeState>,
471 style: StyleRefinement,
472 list_style: StyleRefinement,
473 render_item: Rc<RenderItem>,
474}
475
476impl Tree {
477 pub fn new(state: &Entity<TreeState>) -> Self {
478 Self {
479 id: ElementId::Name(format!("tree-{}", state.entity_id()).into()),
480 state: state.clone(),
481 style: StyleRefinement::default(),
482 list_style: StyleRefinement::default(),
483 render_item: Rc::new(|_, _, _, _, _| div().into_any_element()),
484 }
485 }
486
487 pub fn item<R>(mut self, render_item: R) -> Self
489 where
490 R: Fn(usize, &TreeEntry, TreeEntryState, &mut Window, &mut App) -> AnyElement + 'static,
491 {
492 self.render_item = Rc::new(render_item);
493 self
494 }
495
496 pub fn list_style(mut self, style: StyleRefinement) -> Self {
498 self.list_style = style;
499 self
500 }
501}
502
503impl Styled for Tree {
504 fn style(&mut self) -> &mut StyleRefinement {
505 &mut self.style
506 }
507}
508
509impl RenderOnce for Tree {
510 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
511 let focus_handle = self.state.read(cx).focus_handle.clone();
512 self.state.update(cx, |state, _| {
513 state.render_item = self.render_item;
514 state.list_style = self.list_style;
515 });
516
517 div()
518 .id(self.id)
519 .test_support()
520 .role(gpui::Role::Tree)
521 .key_context(CONTEXT)
522 .track_focus(&focus_handle)
523 .on_action(window.listener_for(&self.state, TreeState::on_action_confirm))
524 .on_action(window.listener_for(&self.state, TreeState::on_action_left))
525 .on_action(window.listener_for(&self.state, TreeState::on_action_right))
526 .on_action(window.listener_for(&self.state, TreeState::on_action_up))
527 .on_action(window.listener_for(&self.state, TreeState::on_action_down))
528 .child(self.state)
529 .refine_style(&self.style)
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use gpui::{AppContext as _, Subscription};
537
538 struct EventCollector {
539 events: Rc<RefCell<Vec<TreeEvent>>>,
540 _subscription: Subscription,
541 }
542
543 impl EventCollector {
544 fn new(state: &Entity<TreeState>, cx: &mut Context<Self>) -> Self {
545 let events = Rc::new(RefCell::new(Vec::new()));
546 let captured = events.clone();
547 let subscription = cx.subscribe(state, move |_, _, event, _| {
548 captured.borrow_mut().push(event.clone());
549 });
550 Self {
551 events,
552 _subscription: subscription,
553 }
554 }
555 }
556
557 impl Render for EventCollector {
558 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
559 div()
560 }
561 }
562
563 #[test]
564 fn clones_share_state_and_ancestors_keep_nearest_first_order() {
565 let leaf = TreeItem::new("leaf", "Leaf");
566 let branch = TreeItem::new("branch", "Branch").child(leaf.clone());
567 let root = TreeItem::new("root", "Root").child(branch.clone());
568
569 leaf.clone().disabled(true).expanded(true);
570 assert!(leaf.is_disabled());
571 assert!(leaf.is_expanded());
572
573 let ancestors = root.ancestors(&"leaf".into()).unwrap();
574 assert_eq!(
575 ancestors
576 .iter()
577 .map(|item| item.id.as_str())
578 .collect::<Vec<_>>(),
579 vec!["branch", "root"]
580 );
581 }
582
583 #[gpui::test]
584 fn state_flattens_expanded_items_and_resets_selection(cx: &mut gpui::TestAppContext) {
585 let items = vec![
586 TreeItem::new("src", "src")
587 .expanded(true)
588 .child(TreeItem::new("src/lib.rs", "lib.rs")),
589 TreeItem::new("README.md", "README.md"),
590 ];
591 let state = cx.new(|cx| TreeState::new(cx).items(items));
592
593 state.update(cx, |state, cx| {
594 assert_eq!(state.entries.len(), 3);
595 assert_eq!(state.entries[1].depth(), 1);
596 state.set_selected_index(Some(1), cx);
597 state.set_items(vec![TreeItem::new("Cargo.toml", "Cargo.toml")], cx);
598 assert_eq!(state.selected_index(), None);
599 assert_eq!(state.entries.len(), 1);
600 });
601 }
602
603 #[gpui::test]
604 fn selecting_hidden_item_expands_its_ancestors(cx: &mut gpui::TestAppContext) {
605 let target = TreeItem::new("src/ui/tree.rs", "tree.rs");
606 let root =
607 TreeItem::new("src", "src").child(TreeItem::new("src/ui", "ui").child(target.clone()));
608 let state = cx.new(|cx| TreeState::new(cx).items(vec![root]));
609
610 state.update(cx, |state, cx| {
611 state.set_selected_item(Some(&target), cx);
612 assert_eq!(state.entries.len(), 3);
613 assert_eq!(
614 state.selected_item().map(|item| item.id.as_str()),
615 Some("src/ui/tree.rs")
616 );
617 });
618 }
619
620 #[gpui::test]
621 fn toggling_folder_rebuilds_visible_entries(cx: &mut gpui::TestAppContext) {
622 let root = TreeItem::new("src", "src").child(TreeItem::new("src/lib.rs", "lib.rs"));
623 let state = cx.new(|cx| TreeState::new(cx).items(vec![root]));
624
625 state.update(cx, |state, cx| {
626 state.toggle_expand(0, cx);
627 assert_eq!(state.entries.len(), 2);
628 state.toggle_expand(0, cx);
629 assert_eq!(state.entries.len(), 1);
630 });
631 }
632
633 #[gpui::test]
634 fn expansion_events_preserve_ids_and_set_items_stays_silent(cx: &mut gpui::TestAppContext) {
635 let root = TreeItem::new("src", "src").child(TreeItem::new("src/lib.rs", "lib.rs"));
636 let state = cx.new(|cx| TreeState::new(cx).items(vec![root]));
637 let collector = cx.new(|cx| EventCollector::new(&state, cx));
638
639 state.update(cx, |state, cx| {
640 state.toggle_expand(0, cx);
641 state.toggle_expand(0, cx);
642 state.set_items(vec![TreeItem::new("README.md", "README.md")], cx);
643 });
644
645 let events = collector.read_with(cx, |collector, _| collector.events.borrow().clone());
646 assert_eq!(
647 events,
648 vec![
649 TreeEvent::Expanded("src".into()),
650 TreeEvent::Collapsed("src".into()),
651 ]
652 );
653 }
654}