From 03dd61b4b57e00fdbda023f1c66529a6e11d0a5c Mon Sep 17 00:00:00 2001 From: Andreas Johansson Date: Thu, 20 Nov 2025 18:07:49 +0100 Subject: [PATCH 1/3] tree: Add context menu support --- crates/story/src/tree_story.rs | 114 +++++---- crates/ui/src/tree.rs | 171 +++++++++---- docs/docs/components/menu.md | 2 +- docs/docs/components/tree.md | 430 +++++++++++++++++++++++---------- 4 files changed, 492 insertions(+), 225 deletions(-) diff --git a/crates/story/src/tree_story.rs b/crates/story/src/tree_story.rs index b1f2062ae..191f11a7b 100644 --- a/crates/story/src/tree_story.rs +++ b/crates/story/src/tree_story.rs @@ -12,7 +12,7 @@ use gpui_component::{ h_flex, label::Label, list::ListItem, - tree::{TreeItem, TreeState, tree}, + tree::{TreeDelegate, TreeEntry, TreeItem, TreeState, tree}, v_flex, }; @@ -20,6 +20,57 @@ use crate::{Story, section}; actions!(story, [Rename, SelectItem]); +struct FileTreeDelegate { + parent: Entity, +} + +impl TreeDelegate for FileTreeDelegate { + fn render_item( + &self, + ix: usize, + entry: &TreeEntry, + selected: bool, + window: &mut Window, + cx: &mut App, + ) -> ListItem { + let item = entry.item(); + let icon = if !entry.is_folder() { + IconName::File + } else if entry.is_expanded() { + IconName::FolderOpen + } else { + IconName::Folder + }; + + ListItem::new(ix) + .w_full() + .rounded(cx.theme().radius) + .px_3() + .pl(px(16.) * entry.depth() + px(12.)) + .child(h_flex().gap_2().child(icon).child(item.label.clone())) + .selected(selected) + .on_click(window.listener_for(&self.parent, { + let item = item.clone(); + move |this, _, _window, cx| { + this.selected_item = Some(item.clone()); + cx.notify(); + } + })) + } + + fn context_menu( + &self, + ix: usize, + menu: gpui_component::menu::PopupMenu, + _window: &mut Window, + _cx: &mut App, + ) -> gpui_component::menu::PopupMenu { + menu.label(format!("Selected Index: {}", ix)) + .separator() + .menu("Rename", Box::new(Rename)) + } +} + const CONTEXT: &str = "TreeStory"; pub(crate) fn init(cx: &mut App) { cx.bind_keys([ @@ -29,7 +80,7 @@ pub(crate) fn init(cx: &mut App) { } pub struct TreeStory { - tree_state: Entity, + tree_state: Entity>, selected_item: Option, } @@ -71,7 +122,7 @@ impl TreeStory { cx.new(|cx| Self::new(window, cx)) } - fn load_files(state: Entity, path: PathBuf, cx: &mut App) { + fn load_files(state: Entity>, path: PathBuf, cx: &mut App) { cx.spawn(async move |cx| { let ignorer = Ignorer::new(&path.to_string_lossy()); let items = build_file_items(&ignorer, &path, &path); @@ -83,7 +134,15 @@ impl TreeStory { } fn new(_: &mut Window, cx: &mut Context) -> Self { - let tree_state = cx.new(|cx| TreeState::new(cx)); + let parent_entity = cx.entity(); + let tree_state = cx.new(move |cx| { + TreeState::new( + FileTreeDelegate { + parent: parent_entity, + }, + cx, + ) + }); Self::load_files(tree_state.clone(), PathBuf::from("./"), cx); @@ -134,7 +193,6 @@ impl Render for TreeStory { _: &mut gpui::Window, cx: &mut gpui::Context, ) -> impl gpui::IntoElement { - let view = cx.entity(); v_flex() .id("tree-story") .key_context(CONTEXT) @@ -144,46 +202,18 @@ impl Render for TreeStory { .size_full() .child( section("File tree") - .sub_title("Press `space` to select, `enter` to rename.") + .sub_title( + "Press `space` to select, `enter` to rename, right-click for context menu.", + ) .v_flex() .max_w_md() .child( - tree( - &self.tree_state, - move |ix, entry, _selected, _window, cx| { - view.update(cx, |_, cx| { - let item = entry.item(); - let icon = if !entry.is_folder() { - IconName::File - } else if entry.is_expanded() { - IconName::FolderOpen - } else { - IconName::Folder - }; - - ListItem::new(ix) - .w_full() - .rounded(cx.theme().radius) - .px_3() - .pl(px(16.) * entry.depth() + px(12.)) - .child( - h_flex().gap_2().child(icon).child(item.label.clone()), - ) - .on_click(cx.listener({ - let item = item.clone(); - move |this, _, _window, cx| { - this.selected_item = Some(item.clone()); - cx.notify(); - } - })) - }) - }, - ) - .p_1() - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius) - .h(px(540.)), + tree(&self.tree_state) + .p_1() + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .h(px(540.)), ) .child( h_flex() diff --git a/crates/ui/src/tree.rs b/crates/ui/src/tree.rs index 0156f2fbc..6c80a01f1 100644 --- a/crates/ui/src/tree.rs +++ b/crates/ui/src/tree.rs @@ -10,6 +10,7 @@ use gpui::{ use crate::{ actions::{Confirm, SelectDown, SelectLeft, SelectRight, SelectUp}, list::ListItem, + menu::{ContextMenuExt, PopupMenu}, scroll::{Scrollbar, ScrollbarState}, StyledExt, }; @@ -28,28 +29,66 @@ pub(crate) fn init(cx: &mut App) { /// /// # Arguments /// -/// * `state` - The shared state managing the tree items. -/// * `render_item` - A closure to render each tree item. +/// * `state` - The shared state managing the tree items with a delegate. /// /// ```ignore -/// let state = cx.new(|_| { -/// TreeState::new().items(vec![ -/// TreeItem::new("src") -/// .child(TreeItem::new("lib.rs"), -/// TreeItem::new("Cargo.toml"), -/// TreeItem::new("README.md"), +/// struct MyTreeDelegate; +/// +/// impl TreeDelegate for MyTreeDelegate { +/// fn render_item( +/// &self, +/// ix: usize, +/// entry: &TreeEntry, +/// selected: bool, +/// window: &mut Window, +/// cx: &mut App, +/// ) -> ListItem { +/// ListItem::new(ix) +/// .selected(selected) +/// .px(px(16.) * entry.depth()) +/// .child(entry.item().label.clone()) +/// } +/// } +/// +/// let state = cx.new(|cx| { +/// TreeState::new(MyTreeDelegate, cx).items(vec![ +/// TreeItem::new("src", "src") +/// .expanded(true) +/// .child(TreeItem::new("src/lib.rs", "lib.rs")) +/// .child(TreeItem::new("src/main.rs", "main.rs")), +/// TreeItem::new("Cargo.toml", "Cargo.toml"), +/// TreeItem::new("README.md", "README.md"), /// ]) /// }); /// -/// tree(&state, |ix, entry, selected, window, cx| { -/// div().px(px(16.) * entry.depth()).child(item.label.clone()) -/// }) +/// tree(&state) /// ``` -pub fn tree(state: &Entity, render_item: R) -> Tree -where - R: Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem + 'static, -{ - Tree::new(state, render_item) +pub fn tree(state: &Entity>) -> Tree { + Tree::new(state) +} + +/// A delegate trait for providing tree data and rendering. +pub trait TreeDelegate: Sized + 'static { + /// Render the tree item at the given index. + fn render_item( + &self, + ix: usize, + entry: &TreeEntry, + selected: bool, + window: &mut Window, + cx: &mut App, + ) -> ListItem; + + /// Render the context menu for the tree item at the given index. + fn context_menu( + &self, + _ix: usize, + menu: PopupMenu, + _window: &mut Window, + _cx: &mut App, + ) -> PopupMenu { + menu + } } struct TreeItemState { @@ -175,25 +214,27 @@ impl TreeItem { } /// State for managing tree items. -pub struct TreeState { +pub struct TreeState { focus_handle: FocusHandle, entries: Vec, scrollbar_state: ScrollbarState, scroll_handle: UniformListScrollHandle, selected_ix: Option, - render_item: Rc ListItem>, + right_clicked_index: Option, + delegate: D, } -impl TreeState { +impl TreeState { /// Create a new empty tree state. - pub fn new(cx: &mut App) -> Self { + pub fn new(delegate: D, cx: &mut App) -> Self { Self { selected_ix: None, + right_clicked_index: None, focus_handle: cx.focus_handle(), scrollbar_state: ScrollbarState::default(), scroll_handle: UniformListScrollHandle::default(), entries: Vec::new(), - render_item: Rc::new(|_, _, _, _, _| ListItem::new(0)), + delegate, } } @@ -238,6 +279,11 @@ impl TreeState { self.selected_ix.and_then(|ix| self.entries.get(ix)) } + /// Get the delegate. + pub fn delegate(&self) -> &D { + &self.delegate + } + fn add_entry(&mut self, item: TreeItem, depth: usize) { self.entries.push(TreeEntry { item: item.clone(), @@ -344,14 +390,24 @@ impl TreeState { } } -impl Render for TreeState { +impl Render for TreeState { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let render_item = self.render_item.clone(); - div() .id("tree-state") .size_full() .relative() + .context_menu({ + let view = cx.entity().clone(); + move |this, window: &mut Window, cx: &mut Context| { + if let Some(ix) = view.read(cx).right_clicked_index { + view.update(cx, |menu, cx| { + menu.delegate().context_menu(ix, this, window, cx) + }) + } else { + this + } + } + }) .child( uniform_list("entries", self.entries.len(), { cx.processor(move |state, visible_range: Range, window, cx| { @@ -359,7 +415,8 @@ impl Render for TreeState { for ix in visible_range { let entry = &state.entries[ix]; let selected = Some(ix) == state.selected_ix; - let item = (render_item)(ix, entry, selected, window, cx); + + let item = state.delegate.render_item(ix, entry, selected, window, cx); let el = div() .id(ix) @@ -373,6 +430,15 @@ impl Render for TreeState { } }), ) + .on_mouse_down( + MouseButton::Right, + cx.listener({ + move |this, _, _window, cx| { + this.right_clicked_index = Some(ix); + cx.notify(); + } + }), + ) }); items.push(el) @@ -404,51 +470,41 @@ impl Render for TreeState { /// A tree view element that displays hierarchical data. #[derive(IntoElement)] -pub struct Tree { +pub struct Tree { id: ElementId, - state: Entity, + state: Entity>, style: StyleRefinement, - render_item: Rc ListItem>, } -impl Tree { - pub fn new(state: &Entity, render_item: R) -> Self - where - R: Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem + 'static, - { +impl Tree { + pub fn new(state: &Entity>) -> Self { Self { id: ElementId::Name(format!("tree-{}", state.entity_id()).into()), state: state.clone(), style: StyleRefinement::default(), - render_item: Rc::new(move |ix, item, selected, window, app| { - render_item(ix, item, selected, window, app) - }), } } } -impl Styled for Tree { +impl Styled for Tree { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } -impl RenderOnce for Tree { +impl RenderOnce for Tree { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let focus_handle = self.state.read(cx).focus_handle.clone(); - self.state - .update(cx, |state, _| state.render_item = self.render_item); - div() .id(self.id) .key_context(CONTEXT) .track_focus(&focus_handle) - .on_action(window.listener_for(&self.state, TreeState::on_action_confirm)) - .on_action(window.listener_for(&self.state, TreeState::on_action_left)) - .on_action(window.listener_for(&self.state, TreeState::on_action_right)) - .on_action(window.listener_for(&self.state, TreeState::on_action_up)) - .on_action(window.listener_for(&self.state, TreeState::on_action_down)) + .on_action(window.listener_for(&self.state, TreeState::::on_action_confirm)) + .on_action(window.listener_for(&self.state, TreeState::::on_action_left)) + .on_action(window.listener_for(&self.state, TreeState::::on_action_right)) + .on_action(window.listener_for(&self.state, TreeState::::on_action_up)) + .on_action(window.listener_for(&self.state, TreeState::::on_action_down)) .size_full() .child(self.state) .refine_style(&self.style) @@ -459,9 +515,28 @@ impl RenderOnce for Tree { mod tests { use indoc::indoc; - use super::TreeState; + use super::{TreeDelegate, TreeItem, TreeState}; + use crate::list::ListItem; + use crate::menu::PopupMenu; use gpui::AppContext as _; + struct TestDelegate; + + impl TreeDelegate for TestDelegate { + fn render_item( + &self, + ix: usize, + entry: &TreeEntry, + selected: bool, + window: &mut Window, + cx: &mut App, + ) -> ListItem { + ListItem::new(ix) + .selected(selected) + .child(entry.item().label.clone()) + } + } + fn assert_entries(entries: &Vec, expected: &str) { let actual: Vec = entries .iter() @@ -496,7 +571,7 @@ mod tests { TreeItem::new("README.md", "README.md"), ]; - let state = cx.new(|cx| TreeState::new(cx).items(items)); + let state = cx.new(|cx| TreeState::new(TestDelegate, cx).items(items)); state.update(cx, |state, _| { assert_entries( &state.entries, diff --git a/docs/docs/components/menu.md b/docs/docs/components/menu.md index 9dc37f8dd..bdbeb16da 100644 --- a/docs/docs/components/menu.md +++ b/docs/docs/components/menu.md @@ -24,7 +24,7 @@ use gpui::{actions, Action}; Context menus appear when right-clicking on an element: ```rust -use gpui_component::context_menu::ContextMenuExt; +use gpui_component::menu::ContextMenuExt; div() .child("Right click me") diff --git a/docs/docs/components/tree.md b/docs/docs/components/tree.md index 3d19481ed..eb2b170a1 100644 --- a/docs/docs/components/tree.md +++ b/docs/docs/components/tree.md @@ -5,12 +5,12 @@ description: A hierarchical tree view component for displaying and navigating tr # Tree -A versatile tree component for displaying hierarchical data with expand/collapse functionality, keyboard navigation, and custom item rendering. Perfect for file explorers, navigation menus, or any nested data structure. +A versatile tree component for displaying hierarchical data with expand/collapse functionality, keyboard navigation, custom item rendering, and built-in context menu support. Perfect for file explorers, navigation menus, or any nested data structure. ## Import ```rust -use gpui_component::tree::{tree, TreeState, TreeItem, TreeEntry}; +use gpui_component::tree::{tree, TreeState, TreeItem, TreeEntry, TreeDelegate}; ``` ## Usage @@ -18,9 +18,31 @@ use gpui_component::tree::{tree, TreeState, TreeItem, TreeEntry}; ### Basic Tree ```rust -// Create tree state +// Create a delegate +struct BasicTreeDelegate; + +impl TreeDelegate for BasicTreeDelegate { + fn render_item( + &self, + ix: usize, + entry: &TreeEntry, + selected: bool, + window: &mut Window, + cx: &mut App, + ) -> ListItem { + ListItem::new(ix) + .selected(selected) + .child( + h_flex() + .gap_2() + .child(entry.item().label.clone()) + ) + } +} + +// Create tree state with delegate let tree_state = cx.new(|cx| { - TreeState::new(cx).items(vec![ + TreeState::new(BasicTreeDelegate, cx).items(vec![ TreeItem::new("src", "src") .expanded(true) .child(TreeItem::new("src/lib.rs", "lib.rs")) @@ -31,49 +53,85 @@ let tree_state = cx.new(|cx| { }); // Render tree -tree(&tree_state, |ix, entry, selected, window, cx| { - ListItem::new(ix) - .child( - h_flex() - .gap_2() - .child(entry.item().label.clone()) - ) -}) +tree(&tree_state) ``` -### File Tree with Icons +### File Tree with Icons and Context Menu ```rust -use gpui_component::{ListItem, IconName, h_flex}; - -tree(&tree_state, |ix, entry, selected, window, cx| { - let item = entry.item(); - let icon = if !entry.is_folder() { - IconName::File - } else if entry.is_expanded() { - IconName::FolderOpen - } else { - IconName::Folder - }; - - ListItem::new(ix) - .selected(selected) - .pl(px(16.) * entry.depth() + px(12.)) // Indent based on depth - .child( - h_flex() - .gap_2() - .child(icon) - .child(item.label.clone()) - ) - .on_click(cx.listener(move |_, _, _, _| { - // Handle item click - })) -}) +use gpui_component::{ListItem, IconName, h_flex, Menu, MenuItem}; + +struct FileTreeDelegate; + +impl TreeDelegate for FileTreeDelegate { + fn render_item( + &self, + ix: usize, + entry: &TreeEntry, + selected: bool, + window: &mut Window, + cx: &mut App, + ) -> ListItem { + let item = entry.item(); + let icon = if !entry.is_folder() { + IconName::File + } else if entry.is_expanded() { + IconName::FolderOpen + } else { + IconName::Folder + }; + + ListItem::new(ix) + .selected(selected) + .pl(px(16.) * entry.depth() + px(12.)) // Indent based on depth + .child( + h_flex() + .gap_2() + .child(icon) + .child(item.label.clone()) + ) + } + + fn context_menu( + &self, + ix: usize, + menu: gpui_component::menu::PopupMenu, + _window: &mut Window, + _cx: &mut App, + ) -> gpui_component::menu::PopupMenu { + menu.label(format!("Selected Index: {}", ix)) + } +} + +// Create tree state with the delegate +let tree_state = cx.new(|cx| { + TreeState::new(FileTreeDelegate, cx).items(items) +}); + +// Render tree with context menu support +tree(&tree_state) ``` ### Dynamic Tree Loading ```rust +struct DynamicTreeDelegate; + +impl TreeDelegate for DynamicTreeDelegate { + fn render_item( + &self, + ix: usize, + entry: &TreeEntry, + selected: bool, + window: &mut Window, + cx: &mut App, + ) -> ListItem { + ListItem::new(ix) + .selected(selected) + .child(entry.item().label.clone()) + } +} + impl MyView { fn load_files(&mut self, path: PathBuf, cx: &mut Context) { let tree_state = self.tree_state.clone(); @@ -107,17 +165,60 @@ fn build_file_items(path: &Path) -> Vec { } items } + +// Create tree state +let tree_state = cx.new(|cx| { + TreeState::new(DynamicTreeDelegate, cx) +}); ``` ### Tree with Selection Handling ```rust struct MyTreeView { - tree_state: Entity, + tree_state: Entity>, selected_item: Option, } +struct SelectionTreeDelegate { + view: Entity, +} + +impl TreeDelegate for SelectionTreeDelegate { + fn render_item( + &self, + ix: usize, + entry: &TreeEntry, + selected: bool, + window: &mut Window, + cx: &mut App, + ) -> ListItem { + let item = entry.item(); + let view = self.view.clone(); + + ListItem::new(ix) + .selected(selected) + .child(item.label.clone()) + .on_click(cx.listener({ + let item = item.clone(); + move |this, _, _, cx| { + view.update(cx, |view, cx| { + view.handle_selection(item.clone(), cx); + }); + } + })) + } +} + impl MyTreeView { + fn new(cx: &mut Context) -> Self { + let tree_state = cx.new(|cx| { + TreeState::new(SelectionTreeDelegate { view: cx.entity() }, cx) + }); + + Self { tree_state, selected_item: None } + } + fn handle_selection(&mut self, item: TreeItem, cx: &mut Context) { self.selected_item = Some(item.clone()); println!("Selected: {} ({})", item.label, item.id); @@ -126,45 +227,7 @@ impl MyTreeView { } // In render method -tree(&self.tree_state, { - let view = cx.entity(); - move |ix, entry, selected, window, cx| { - view.update(cx, |this, cx| { - ListItem::new(ix) - .selected(selected) - .child(entry.item().label.clone()) - .on_click(cx.listener({ - let item = entry.item().clone(); - move |this, _, _, cx| { - this.handle_selection(item.clone(), cx); - } - })) - }) - } -}) -``` - -### Tree with Context Menu - -```rust -tree(&tree_state, |ix, entry, selected, window, cx| { - ListItem::new(ix) - .selected(selected) - .child(entry.item().label.clone()) - .on_secondary_mouse_down(MouseButton::Right, { - let item = entry.item().clone(); - move |_, _, cx| { - cx.show_context_menu( - ContextMenu::build(cx, |menu, cx| { - menu.action("Rename", Rename) - .action("Delete", Delete) - .separator() - .action("Copy Path", CopyPath) - }) - ); - } - }) -}) +tree(&self.tree_state) ``` ### Disabled Items @@ -201,17 +264,36 @@ tree_state.update(cx, |state, cx| { ## API Reference -### TreeState +### TreeDelegate + +The `TreeDelegate` trait defines how tree items are rendered and provides context menu functionality. -| Method | Description | -| ------------------------------ | ---------------------------- | -| `new(cx)` | Create a new tree state | -| `items(items)` | Set initial tree items | -| `set_items(items, cx)` | Update tree items and notify | -| `selected_index()` | Get currently selected index | -| `set_selected_index(ix, cx)` | Set selected index | -| `selected_entry()` | Get currently selected entry | -| `scroll_to_item(ix, strategy)` | Scroll to specific item | +| Method | Description | +| ---------------------------------------------- | ------------------------------------------------------------------------------- | +| `render_item(ix, entry, selected, window, cx)` | Render a tree item as a `ListItem` (required) | +| `context_menu(ix, menu, window, cx)` | Customize context menu for tree item (optional, default returns menu unchanged) | + +#### render_item Method Parameters + +- `ix: usize`: Item index in flattened tree +- `entry: &TreeEntry`: Tree entry with item and depth metadata +- `selected: bool`: Whether item is currently selected +- `window: &mut Window`: Current window context +- `cx: &mut App`: Application context +- Returns: `ListItem` for rendering + +### TreeState + +| Method | Description | +| ------------------------------ | ----------------------------------- | +| `new(delegate, cx)` | Create new tree state with delegate | +| `items(items)` | Set initial tree items | +| `set_items(items, cx)` | Update tree items and notify | +| `selected_index()` | Get currently selected index | +| `set_selected_index(ix, cx)` | Set selected index | +| `selected_entry()` | Get currently selected entry | +| `delegate()` | Get reference to the delegate | +| `scroll_to_item(ix, strategy)` | Scroll to specific item | ### TreeItem @@ -238,23 +320,9 @@ tree_state.update(cx, |state, cx| { ### tree() Function -| Parameter | Description | -| ------------- | ------------------------------------- | -| `state` | `Entity` for managing tree | -| `render_item` | Closure for rendering each item | - -#### Render Item Closure - -```rust -Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem -``` - -- `usize`: Item index in flattened tree -- `&TreeEntry`: Tree entry with item and metadata -- `bool`: Whether item is currently selected -- `&mut Window`: Current window context -- `&mut App`: Application context -- Returns: `ListItem` for rendering +| Parameter | Description | +| --------- | ------------------------------------------------------ | +| `state` | `Entity>` for managing tree with delegate | ## Examples @@ -262,11 +330,50 @@ Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem ```rust struct LazyTreeView { - tree_state: Entity, + tree_state: Entity>, loaded_paths: HashSet, } +struct LazyTreeDelegate { + view: Entity, +} + +impl TreeDelegate for LazyTreeDelegate { + fn render_item( + &self, + ix: usize, + entry: &TreeEntry, + selected: bool, + window: &mut Window, + cx: &mut App, + ) -> ListItem { + let item = entry.item(); + let view = self.view.clone(); + + ListItem::new(ix) + .selected(selected) + .pl(px(16.) * entry.depth() + px(12.)) + .child(item.label.clone()) + .on_click(window.listener_for(&self.view, { + let item_id = item.id.clone(); + move |this, _, _, cx| { + view.update(cx, |view, cx| { + view.load_children(&item_id, cx); + }); + } + })) + } +} + impl LazyTreeView { + fn new(cx: &mut Context) -> Self { + let tree_state = cx.new(|cx| { + TreeState::new(LazyTreeDelegate { view: cx.entity() }, cx) + }); + + Self { tree_state, loaded_paths: HashSet::new() } + } + fn load_children(&mut self, item_id: &str, cx: &mut Context) { if self.loaded_paths.contains(item_id) { return; @@ -280,8 +387,8 @@ impl LazyTreeView { cx.spawn(async move |cx| { let children = load_directory_children(&path).await; tree_state.update(cx, |state, cx| { - // Update specific item with loaded children - state.update_item_children(&item_id, children, cx); + // Update tree with new items - you'd need to implement this logic + state.set_items(children, cx); }) }).detach(); @@ -295,12 +402,42 @@ impl LazyTreeView { ```rust struct SearchableTree { - tree_state: Entity, + tree_state: Entity>, original_items: Vec, search_query: String, } +struct SearchableTreeDelegate; + +impl TreeDelegate for SearchableTreeDelegate { + fn render_item( + &self, + ix: usize, + entry: &TreeEntry, + selected: bool, + window: &mut Window, + cx: &mut App, + ) -> ListItem { + ListItem::new(ix) + .selected(selected) + .pl(px(16.) * entry.depth() + px(12.)) + .child(entry.item().label.clone()) + } +} + impl SearchableTree { + fn new(cx: &mut Context) -> Self { + let tree_state = cx.new(|cx| { + TreeState::new(SearchableTreeDelegate, cx) + }); + + Self { + tree_state, + original_items: Vec::new(), + search_query: String::new(), + } + } + fn filter_tree(&mut self, query: &str, cx: &mut Context) { self.search_query = query.to_string(); @@ -341,11 +478,59 @@ fn filter_tree_items(items: &[TreeItem], query: &str) -> Vec { ```rust struct MultiSelectTree { - tree_state: Entity, + tree_state: Entity>, selected_items: HashSet, } +struct MultiSelectTreeDelegate { + view: Entity, +} + +impl TreeDelegate for MultiSelectTreeDelegate { + fn render_item( + &self, + ix: usize, + entry: &TreeEntry, + selected: bool, + window: &mut Window, + cx: &mut App, + ) -> ListItem { + let item = entry.item(); + let view = self.view.clone(); + let is_multi_selected = view.read(cx).is_selected(&item.id); + + ListItem::new(ix) + .selected(is_multi_selected) + .pl(px(16.) * entry.depth() + px(12.)) + .child( + h_flex() + .gap_2() + .child(checkbox().checked(is_multi_selected)) + .child(item.label.clone()) + ) + .on_click(window.listener_for(&self.view, { + let item_id = item.id.clone(); + move |this, _, _, cx| { + view.update(cx, |view, cx| { + view.toggle_selection(&item_id, cx); + }); + } + })) + } +} + impl MultiSelectTree { + fn new(cx: &mut Context) -> Self { + let tree_state = cx.new(|cx| { + TreeState::new(MultiSelectTreeDelegate { view: cx.entity() }, cx) + }); + + Self { + tree_state, + selected_items: HashSet::new(), + } + } + fn toggle_selection(&mut self, item_id: &str, cx: &mut Context) { if self.selected_items.contains(item_id) { self.selected_items.remove(item_id); @@ -361,30 +546,7 @@ impl MultiSelectTree { } // In render method -tree(&self.tree_state, { - let view = cx.entity(); - move |ix, entry, _selected, window, cx| { - view.update(cx, |this, cx| { - let item = entry.item(); - let is_multi_selected = this.is_selected(&item.id); - - ListItem::new(ix) - .selected(is_multi_selected) - .child( - h_flex() - .gap_2() - .child(checkbox().checked(is_multi_selected)) - .child(item.label.clone()) - ) - .on_click(cx.listener({ - let item_id = item.id.clone(); - move |this, _, _, cx| { - this.toggle_selection(&item_id, cx); - } - })) - }) - } -}) +tree(&self.tree_state) ``` ## Keyboard Navigation From cf585bf2a356bb92193156237611fba613ffd328 Mon Sep 17 00:00:00 2001 From: Andreas Johansson Date: Thu, 20 Nov 2025 18:32:28 +0100 Subject: [PATCH 2/3] Fix tests and examples --- crates/story/examples/editor.rs | 110 +++++++++++++++++++------------- crates/ui/src/tree.rs | 11 ++-- 2 files changed, 69 insertions(+), 52 deletions(-) diff --git a/crates/story/examples/editor.rs b/crates/story/examples/editor.rs index 924a210b4..6884793d9 100644 --- a/crates/story/examples/editor.rs +++ b/crates/story/examples/editor.rs @@ -20,7 +20,7 @@ use gpui_component::{ }, list::ListItem, resizable::{h_resizable, resizable_panel}, - tree::{TreeItem, TreeState, tree}, + tree::{TreeDelegate, TreeEntry, TreeItem, TreeState, tree}, v_flex, }; use gpui_component_assets::Assets; @@ -44,9 +44,62 @@ fn init() { ); } +struct EditorTreeDelegate { + view: Entity, +} + +impl TreeDelegate for EditorTreeDelegate { + fn render_item( + &self, + ix: usize, + entry: &TreeEntry, + selected: bool, + window: &mut Window, + cx: &mut App, + ) -> ListItem { + let item = entry.item(); + + let icon = if !entry.is_folder() { + IconName::File + } else if entry.is_expanded() { + IconName::FolderOpen + } else { + IconName::Folder + }; + + ListItem::new(ix) + .w_full() + .rounded(cx.theme().radius) + .py_0p5() + .px_2() + .pl(px(16.) * entry.depth() + px(8.)) + .child(h_flex().gap_2().child(icon).child(item.label.clone())) + .selected(selected) + .on_click(window.listener_for(&self.view, { + let view_entity = self.view.clone(); + let item = item.clone(); + move |_this, _, _window, cx| { + if item.is_folder() { + return; + } + + Example::open_file( + view_entity.clone(), + PathBuf::from(item.id.as_str()), + _window, + cx, + ) + .ok(); + + cx.notify(); + } + })) + } +} + pub struct Example { editor: Entity, - tree_state: Entity, + tree_state: Entity>, go_to_line_state: Entity, language: Language, line_number: bool, @@ -657,7 +710,13 @@ impl Example { }); let go_to_line_state = cx.new(|cx| InputState::new(window, cx)); - let tree_state = cx.new(|cx| TreeState::new(cx)); + let view_entity = cx.entity(); + let tree_state = cx.new(move |cx| { + TreeState::new( + EditorTreeDelegate { view: view_entity }, + cx + ) + }); Self::load_files(tree_state.clone(), PathBuf::from("./"), cx); let _subscriptions = vec![cx.subscribe(&editor, |this, _, _: &InputEvent, cx| { @@ -678,7 +737,7 @@ impl Example { } } - fn load_files(state: Entity, path: PathBuf, cx: &mut App) { + fn load_files(state: Entity>, path: PathBuf, cx: &mut App) { cx.spawn(async move |cx| { let ignorer = Ignorer::new(&path.to_string_lossy()); let items = build_file_items(&ignorer, &path, &path); @@ -849,48 +908,7 @@ impl Example { } fn render_file_tree(&self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let view = cx.entity(); - tree( - &self.tree_state, - move |ix, entry, _selected, _window, cx| { - view.update(cx, |_, cx| { - let item = entry.item(); - let icon = if !entry.is_folder() { - IconName::File - } else if entry.is_expanded() { - IconName::FolderOpen - } else { - IconName::Folder - }; - - ListItem::new(ix) - .w_full() - .rounded(cx.theme().radius) - .py_0p5() - .px_2() - .pl(px(16.) * entry.depth() + px(8.)) - .child(h_flex().gap_2().child(icon).child(item.label.clone())) - .on_click(cx.listener({ - let item = item.clone(); - move |_, _, _window, cx| { - if item.is_folder() { - return; - } - - Self::open_file( - cx.entity(), - PathBuf::from(item.id.as_str()), - _window, - cx, - ) - .ok(); - - cx.notify(); - } - })) - }) - }, - ) + tree(&self.tree_state) .text_sm() .p_1() .bg(cx.theme().sidebar) diff --git a/crates/ui/src/tree.rs b/crates/ui/src/tree.rs index 6c80a01f1..22e5b3f18 100644 --- a/crates/ui/src/tree.rs +++ b/crates/ui/src/tree.rs @@ -515,10 +515,9 @@ impl RenderOnce for Tree { mod tests { use indoc::indoc; - use super::{TreeDelegate, TreeItem, TreeState}; + use super::{TreeDelegate, TreeEntry, TreeState}; use crate::list::ListItem; - use crate::menu::PopupMenu; - use gpui::AppContext as _; + use gpui::{App, AppContext as _, IntoElement, ParentElement, Window}; struct TestDelegate; @@ -528,12 +527,12 @@ mod tests { ix: usize, entry: &TreeEntry, selected: bool, - window: &mut Window, - cx: &mut App, + _window: &mut Window, + _cx: &mut App, ) -> ListItem { ListItem::new(ix) .selected(selected) - .child(entry.item().label.clone()) + .child(entry.item().label.clone().into_any_element()) } } From 5179858a9713e513b77aeca83e38b8603a1494a5 Mon Sep 17 00:00:00 2001 From: Andreas Johansson Date: Thu, 20 Nov 2025 18:40:06 +0100 Subject: [PATCH 3/3] Fix dead link --- docs/docs/components/settings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/components/settings.md b/docs/docs/components/settings.md index 10c465f99..67d3c9120 100644 --- a/docs/docs/components/settings.md +++ b/docs/docs/components/settings.md @@ -454,5 +454,5 @@ Settings::new("app-settings") [SettingItem]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.SettingItem.html [SettingField]: https://docs.rs/gpui-component/latest/gpui_component/setting/enum.SettingField.html [NumberFieldOptions]: https://docs.rs/gpui-component/latest/gpui_component/setting/struct.NumberFieldOptions.html -[GroupBox]: ./group_box.md +[GroupBox]: ./group-box.md [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html