diff --git a/Cargo.lock b/Cargo.lock index b693aaf4..f9b1777f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2657,6 +2657,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "git2" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110" +dependencies = [ + "bitflags 2.9.1", + "libc", + "libgit2-sys", + "log", + "url", +] + [[package]] name = "glib" version = "0.18.5" @@ -2809,6 +2822,7 @@ dependencies = [ "as-raw-xcb-connection", "ashpd 0.11.0", "async-task", + "backtrace", "bindgen 0.71.1", "blade-graphics", "blade-macros", @@ -3928,6 +3942,18 @@ dependencies = [ "cc", ] +[[package]] +name = "libgit2-sys" +version = "0.18.2+1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c42fe03df2bd3c53a3a9c7317ad91d80c81cd1fb0caec8d7cc4cd2bfa10c222" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.8" @@ -3955,6 +3981,18 @@ dependencies = [ "redox_syscall 0.5.17", ] +[[package]] +name = "libz-sys" +version = "1.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -9823,11 +9861,13 @@ dependencies = [ "dunce", "futures", "futures-lite 1.13.0", + "git2", "globset", "itertools 0.14.0", "libc", "log", "nix 0.29.0", + "rand 0.9.2", "regex", "rust-embed", "schemars", @@ -9844,6 +9884,7 @@ dependencies = [ "which", "workspace-hack", "zed-collections", + "zed-util-macros", ] [[package]] diff --git a/assets/icons/file.svg b/assets/icons/file.svg new file mode 100644 index 00000000..32c6b4a3 --- /dev/null +++ b/assets/icons/file.svg @@ -0,0 +1,14 @@ + diff --git a/crates/story/src/drawer_story.rs b/crates/story/src/drawer_story.rs index 31fb90f4..ea367c6e 100644 --- a/crates/story/src/drawer_story.rs +++ b/crates/story/src/drawer_story.rs @@ -124,16 +124,14 @@ impl ListDelegate for ListItemDeletegate { }); } - fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { - _ = self.story.update(cx, |this, cx| { + fn confirm(&mut self, _secondary: bool, _: &mut Window, cx: &mut Context>) { + _ = self.story.update(cx, |this, _| { self.confirmed_index = self.selected_index; if let Some(ix) = self.confirmed_index { if let Some(item) = self.matches.get(ix) { this.selected_value = Some(SharedString::from(item.to_string())); } } - - window.close_drawer(cx); }); } diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index e9deccc6..4b430e3e 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -45,6 +45,7 @@ mod themes; mod title_bar; mod toggle_story; mod tooltip_story; +mod tree_story; mod virtual_list_story; mod webview_story; mod welcome_story; @@ -103,7 +104,7 @@ pub use textarea_story::TextareaStory; pub use title_bar::AppTitleBar; pub use toggle_story::ToggleStory; pub use tooltip_story::TooltipStory; -use tracing_subscriber::{layer::SubscriberExt as _, util::SubscriberInitExt as _}; +pub use tree_story::TreeStory; pub use virtual_list_story::VirtualListStory; pub use webview_story::WebViewStory; pub use welcome_story::WelcomeStory; @@ -120,6 +121,7 @@ use gpui_component::{ scroll::ScrollbarShow, v_flex, }; +use tracing_subscriber::{layer::SubscriberExt as _, util::SubscriberInitExt as _}; #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = story, no_json)] @@ -300,6 +302,7 @@ pub fn init(cx: &mut App) { webview_story::init(cx); tooltip_story::init(cx); otp_input_story::init(cx); + tree_story::init(cx); let http_client = std::sync::Arc::new( reqwest_client::ReqwestClient::user_agent("gpui-component/story").unwrap(), diff --git a/crates/story/src/main.rs b/crates/story/src/main.rs index eb9ecadb..081b4417 100644 --- a/crates/story/src/main.rs +++ b/crates/story/src/main.rs @@ -76,6 +76,7 @@ impl Gallery { StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), + StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), diff --git a/crates/story/src/tree_story.rs b/crates/story/src/tree_story.rs new file mode 100644 index 00000000..85e65123 --- /dev/null +++ b/crates/story/src/tree_story.rs @@ -0,0 +1,201 @@ +use std::path::PathBuf; + +use autocorrect::ignorer::Ignorer; +use gpui::{ + App, AppContext, Context, Entity, InteractiveElement, KeyBinding, ParentElement, Render, + Styled, Window, actions, px, +}; + +use gpui_component::{ + ActiveTheme as _, IconName, ListItem, StyledExt as _, TreeItem, TreeState, dock::PanelControl, + h_flex, label::Label, tree, v_flex, +}; + +use crate::{Story, section}; + +actions!(story, [Rename, SelectItem]); + +const CONTEXT: &str = "TreeStory"; +pub(crate) fn init(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("enter", Rename, Some(CONTEXT)), + KeyBinding::new("space", SelectItem, Some(CONTEXT)), + ]); +} + +pub struct TreeStory { + tree_state: Entity, + selected_item: Option, +} + +fn build_file_items(ignorer: &Ignorer, root: &PathBuf, path: &PathBuf) -> Vec { + let mut items = Vec::new(); + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + let path = entry.path(); + let relative_path = path.strip_prefix(root).unwrap_or(&path); + if ignorer.is_ignored(&relative_path.to_string_lossy()) + || relative_path.ends_with(".git") + { + continue; + } + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown") + .to_string(); + let id = path.to_string_lossy().to_string(); + if path.is_dir() { + let children = build_file_items(ignorer, &root, &path); + items.push(TreeItem::new(id, file_name).children(children)); + } else { + items.push(TreeItem::new(id, file_name)); + } + } + } + items.sort_by(|a, b| { + b.is_folder() + .cmp(&a.is_folder()) + .then(a.label.cmp(&b.label)) + }); + items +} + +impl TreeStory { + pub fn view(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Self::new(window, cx)) + } + + 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); + _ = state.update(cx, |state, cx| { + state.set_items(items, cx); + }); + }) + .detach(); + } + + fn new(_: &mut Window, cx: &mut Context) -> Self { + let tree_state = cx.new(|cx| TreeState::new(cx).items(vec![])); + + Self::load_files(tree_state.clone(), PathBuf::from("./"), cx); + + Self { + tree_state, + selected_item: None, + } + } + + fn on_action_select_item( + &mut self, + _: &SelectItem, + _: &mut Window, + cx: &mut gpui::Context, + ) { + if let Some(entry) = self.tree_state.read(cx).selected_entry() { + self.selected_item = Some(entry.item().clone()); + cx.notify(); + } + } + + fn on_action_rename(&mut self, _: &Rename, _: &mut Window, cx: &mut gpui::Context) { + if let Some(entry) = self.tree_state.read(cx).selected_entry() { + let item = entry.item(); + println!("Renaming item: {} ({})", item.label, item.id); + // Here you could implement actual renaming logic + } + } +} + +impl Story for TreeStory { + fn title() -> &'static str { + "Tree" + } + + fn new_view(window: &mut Window, cx: &mut App) -> Entity { + Self::view(window, cx) + } + + fn zoomable() -> Option { + None + } +} + +impl Render for TreeStory { + fn render( + &mut self, + _: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + let view = cx.entity(); + v_flex() + .id("tree-story") + .key_context(CONTEXT) + .on_action(cx.listener(Self::on_action_rename)) + .on_action(cx.listener(Self::on_action_select_item)) + .gap_5() + .child( + section("File tree") + .sub_title("Press `space` to select, `enter` to rename.") + .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.)), + ) + .child( + h_flex() + .w_full() + .justify_between() + .gap_3() + .children( + self.tree_state + .read(cx) + .selected_index() + .map(|ix| format!("Selected Index: {}", ix)), + ) + .children( + self.selected_item + .as_ref() + .map(|item| Label::new("Selected:").secondary(item.id.clone())), + ), + ), + ) + } +} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 6ebbfcb1..ccdb3272 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -50,7 +50,7 @@ tree-sitter-languages = [ [dependencies] anyhow.workspace = true -gpui = { workspace = true } +gpui = { workspace = true, features = ["test-support"] } gpui-component-macros = { workspace = true } gpui-macros.workspace = true notify.workspace = true diff --git a/crates/ui/src/actions.rs b/crates/ui/src/actions.rs index 79b65856..e73b5136 100644 --- a/crates/ui/src/actions.rs +++ b/crates/ui/src/actions.rs @@ -8,7 +8,4 @@ pub struct Confirm { pub secondary: bool, } -actions!( - list, - [Cancel, SelectPrev, SelectNext, SelectLeft, SelectRight] -); +actions!(ui, [Cancel, SelectUp, SelectDown, SelectLeft, SelectRight]); diff --git a/crates/ui/src/dropdown.rs b/crates/ui/src/dropdown.rs index 69dcd72e..bc7f0482 100644 --- a/crates/ui/src/dropdown.rs +++ b/crates/ui/src/dropdown.rs @@ -8,7 +8,7 @@ use gpui::{ use rust_i18n::t; use crate::{ - actions::{Cancel, Confirm, SelectNext, SelectPrev}, + actions::{Cancel, Confirm, SelectDown, SelectUp}, h_flex, input::clear_button, list::{List, ListDelegate}, @@ -29,8 +29,8 @@ pub enum ListEvent { const CONTEXT: &str = "Dropdown"; pub(crate) fn init(cx: &mut App) { cx.bind_keys([ - KeyBinding::new("up", SelectPrev, Some(CONTEXT)), - KeyBinding::new("down", SelectNext, Some(CONTEXT)), + KeyBinding::new("up", SelectUp, Some(CONTEXT)), + KeyBinding::new("down", SelectDown, Some(CONTEXT)), KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)), KeyBinding::new( "secondary-enter", @@ -603,7 +603,7 @@ where cx.notify(); } - fn up(&mut self, _: &SelectPrev, window: &mut Window, cx: &mut Context) { + fn up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { if !self.open { self.open = true; } @@ -612,7 +612,7 @@ where cx.propagate(); } - fn down(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { + fn down(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context) { if !self.open { self.open = true; } diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 0e6e4b70..da8c3294 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -38,6 +38,7 @@ pub enum IconName { ExternalLink, Eye, EyeOff, + File, Folder, FolderClosed, FolderOpen, @@ -124,6 +125,7 @@ impl IconName { Self::ExternalLink => "icons/external-link.svg", Self::Eye => "icons/eye.svg", Self::EyeOff => "icons/eye-off.svg", + Self::File => "icons/file.svg", Self::Folder => "icons/folder.svg", Self::FolderClosed => "icons/folder-closed.svg", Self::FolderOpen => "icons/folder-open.svg", diff --git a/crates/ui/src/input/popovers/code_action_menu.rs b/crates/ui/src/input/popovers/code_action_menu.rs index cf710a06..e1e31178 100644 --- a/crates/ui/src/input/popovers/code_action_menu.rs +++ b/crates/ui/src/input/popovers/code_action_menu.rs @@ -254,13 +254,13 @@ impl CodeActionMenu { fn on_action_up(&mut self, window: &mut Window, cx: &mut Context) { self.list.update(cx, |this, cx| { - this.on_action_select_prev(&actions::SelectPrev, window, cx) + this.on_action_select_prev(&actions::SelectUp, window, cx) }); } fn on_action_down(&mut self, window: &mut Window, cx: &mut Context) { self.list.update(cx, |this, cx| { - this.on_action_select_next(&actions::SelectNext, window, cx) + this.on_action_select_next(&actions::SelectDown, window, cx) }); } diff --git a/crates/ui/src/input/popovers/completion_menu.rs b/crates/ui/src/input/popovers/completion_menu.rs index b72f6959..74c95c9b 100644 --- a/crates/ui/src/input/popovers/completion_menu.rs +++ b/crates/ui/src/input/popovers/completion_menu.rs @@ -317,13 +317,13 @@ impl CompletionMenu { fn on_action_up(&mut self, window: &mut Window, cx: &mut Context) { self.list.update(cx, |this, cx| { - this.on_action_select_prev(&actions::SelectPrev, window, cx) + this.on_action_select_prev(&actions::SelectUp, window, cx) }); } fn on_action_down(&mut self, window: &mut Window, cx: &mut Context) { self.list.update(cx, |this, cx| { - this.on_action_select_next(&actions::SelectNext, window, cx) + this.on_action_select_next(&actions::SelectDown, window, cx) }); } diff --git a/crates/ui/src/input/search.rs b/crates/ui/src/input/search.rs index 52754064..8aae27c8 100644 --- a/crates/ui/src/input/search.rs +++ b/crates/ui/src/input/search.rs @@ -10,7 +10,7 @@ use gpui::{ use ropey::Rope; use crate::{ - actions::SelectPrev, + actions::SelectUp, button::{Button, ButtonVariants}, h_flex, input::{Enter, Escape, IndentInline, InputEvent, InputState, RopeExt as _, Search, TextInput}, @@ -25,7 +25,7 @@ actions!(input, [Tab]); pub(super) fn init(cx: &mut App) { cx.bind_keys(vec![KeyBinding::new( "shift-enter", - SelectPrev, + SelectUp, Some(KEY_CONTEXT), )]); } @@ -290,7 +290,7 @@ impl SearchPanel { cx.notify(); } - fn on_action_prev(&mut self, _: &SelectPrev, window: &mut Window, cx: &mut Context) { + fn on_action_prev(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { self.prev(window, cx); } diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 456bdbee..b0ae778b 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -27,6 +27,7 @@ use super::{ number_input, text_wrapper::TextWrapper, }; +use crate::actions::{SelectDown, SelectLeft, SelectRight, SelectUp}; use crate::input::{ element::RIGHT_MARGIN, popovers::{ContextMenu, DiagnosticPopover, HoverPopover, MouseContextMenu}, @@ -66,10 +67,6 @@ actions!( MoveEnd, MovePageUp, MovePageDown, - SelectUp, - SelectDown, - SelectLeft, - SelectRight, SelectAll, SelectToStartOfLine, SelectToEndOfLine, diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 0452990c..4d803ff7 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -9,6 +9,7 @@ mod root; mod styled; mod time; mod title_bar; +mod tree; mod virtual_list; mod window_border; @@ -73,11 +74,13 @@ pub use index_path::IndexPath; pub use input::{Rope, RopeExt, RopeLines}; #[cfg(any(feature = "inspector", debug_assertions))] pub use inspector::*; +pub use list::*; pub use menu::{context_menu, popup_menu}; pub use root::{ContextModal, Root}; pub use styled::*; pub use time::*; pub use title_bar::*; +pub use tree::*; pub use virtual_list::{h_virtual_list, v_virtual_list, VirtualList, VirtualListScrollHandle}; pub use window_border::{window_border, window_paddings, WindowBorder}; @@ -110,6 +113,7 @@ pub fn init(cx: &mut App) { menu::init(cx); table::init(cx); text::init(cx); + tree::init(cx); } #[inline] diff --git a/crates/ui/src/list/list.rs b/crates/ui/src/list/list.rs index 23cc4628..50ba6615 100644 --- a/crates/ui/src/list/list.rs +++ b/crates/ui/src/list/list.rs @@ -1,7 +1,7 @@ use std::ops::Range; use std::time::Duration; -use crate::actions::{Cancel, Confirm, SelectNext, SelectPrev}; +use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; use crate::input::InputState; use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache}; use crate::list::ListDelegate; @@ -30,8 +30,8 @@ pub(crate) fn init(cx: &mut App) { KeyBinding::new("escape", Cancel, context), KeyBinding::new("enter", Confirm { secondary: false }, context), KeyBinding::new("secondary-enter", Confirm { secondary: true }, context), - KeyBinding::new("up", SelectPrev, context), - KeyBinding::new("down", SelectNext, context), + KeyBinding::new("up", SelectUp, context), + KeyBinding::new("down", SelectDown, context), ]); } @@ -381,7 +381,7 @@ where pub(crate) fn on_action_select_prev( &mut self, - _: &SelectPrev, + _: &SelectUp, window: &mut Window, cx: &mut Context, ) { @@ -397,7 +397,7 @@ where pub(crate) fn on_action_select_next( &mut self, - _: &SelectNext, + _: &SelectDown, window: &mut Window, cx: &mut Context, ) { diff --git a/crates/ui/src/list/list_item.rs b/crates/ui/src/list/list_item.rs index afc71aff..d03179cf 100644 --- a/crates/ui/src/list/list_item.rs +++ b/crates/ui/src/list/list_item.rs @@ -1,8 +1,8 @@ use crate::{h_flex, ActiveTheme, Disableable, Icon, Selectable, Sizable as _, StyledExt}; use gpui::{ div, prelude::FluentBuilder as _, AnyElement, App, ClickEvent, Div, ElementId, - InteractiveElement, IntoElement, MouseButton, MouseMoveEvent, ParentElement, RenderOnce, - Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, + InteractiveElement, IntoElement, MouseMoveEvent, ParentElement, RenderOnce, Stateful, + StatefulInteractiveElement as _, StyleRefinement, Styled, Window, }; use smallvec::SmallVec; @@ -171,18 +171,13 @@ impl RenderOnce for ListItem { .justify_between() .refine_style(&self.style) .when(is_selectable, |this| { - this.when_some(self.on_click, |this, on_click| { - this.on_mouse_down(MouseButton::Left, move |_, _, cx| { - cx.stop_propagation(); + this.when_some(self.on_click, |this, on_click| this.on_click(on_click)) + .when_some(self.on_mouse_enter, |this, on_mouse_enter| { + this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx)) + }) + .when(!is_active, |this| { + this.hover(|this| this.bg(cx.theme().list_hover)) }) - .on_click(on_click) - }) - .when_some(self.on_mouse_enter, |this, on_mouse_enter| { - this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx)) - }) - .when(!is_active, |this| { - this.hover(|this| this.bg(cx.theme().list_hover)) - }) }) .when(!is_selectable, |this| { this.text_color(cx.theme().muted_foreground) diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index 32911656..9c89b9ea 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -1,5 +1,5 @@ -use crate::actions::{Cancel, Confirm, SelectNext, SelectPrev}; -use crate::input::{SelectLeft, SelectRight}; +use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; +use crate::actions::{SelectLeft, SelectRight}; use crate::menu::menu_item::MenuItemElement; use crate::scroll::{Scrollbar, ScrollbarState}; use crate::{ @@ -22,8 +22,8 @@ pub fn init(cx: &mut App) { cx.bind_keys([ KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)), KeyBinding::new("escape", Cancel, Some(CONTEXT)), - KeyBinding::new("up", SelectPrev, Some(CONTEXT)), - KeyBinding::new("down", SelectNext, Some(CONTEXT)), + KeyBinding::new("up", SelectUp, Some(CONTEXT)), + KeyBinding::new("down", SelectDown, Some(CONTEXT)), KeyBinding::new("left", SelectLeft, Some(CONTEXT)), KeyBinding::new("right", SelectRight, Some(CONTEXT)), ]); @@ -672,7 +672,7 @@ impl PopupMenu { } } - fn select_up(&mut self, _: &SelectPrev, _: &mut Window, cx: &mut Context) { + fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context) { cx.stop_propagation(); let ix = self.selected_index.unwrap_or(0); @@ -691,7 +691,7 @@ impl PopupMenu { self.set_selected_index(last_clickable_ix.unwrap_or(0), cx); } - fn select_down(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context) { + fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context) { cx.stop_propagation(); let Some(ix) = self.selected_index else { self.set_selected_index(0, cx); diff --git a/crates/ui/src/table/mod.rs b/crates/ui/src/table/mod.rs index f9cf75ab..971ba032 100644 --- a/crates/ui/src/table/mod.rs +++ b/crates/ui/src/table/mod.rs @@ -1,7 +1,7 @@ use std::{ops::Range, rc::Rc, time::Duration}; use crate::{ - actions::{Cancel, SelectNext, SelectPrev}, + actions::{Cancel, SelectDown, SelectUp}, context_menu::ContextMenuExt, h_flex, popup_menu::PopupMenu, @@ -30,8 +30,8 @@ pub(crate) fn init(cx: &mut App) { let context = Some("Table"); cx.bind_keys([ KeyBinding::new("escape", Cancel, context), - KeyBinding::new("up", SelectPrev, context), - KeyBinding::new("down", SelectNext, context), + KeyBinding::new("up", SelectUp, context), + KeyBinding::new("down", SelectDown, context), KeyBinding::new("left", SelectPrevColumn, context), KeyBinding::new("right", SelectNextColumn, context), ]); @@ -390,7 +390,7 @@ where cx.propagate(); } - fn action_select_prev(&mut self, _: &SelectPrev, _: &mut Window, cx: &mut Context) { + fn action_select_prev(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context) { let rows_count = self.delegate.rows_count(cx); if rows_count < 1 { return; @@ -408,7 +408,7 @@ where self.set_selected_row(selected_row, cx); } - fn action_select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context) { + fn action_select_next(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context) { let rows_count = self.delegate.rows_count(cx); if rows_count < 1 { return; diff --git a/crates/ui/src/tree.rs b/crates/ui/src/tree.rs new file mode 100644 index 00000000..eef038d0 --- /dev/null +++ b/crates/ui/src/tree.rs @@ -0,0 +1,540 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + div, prelude::FluentBuilder as _, uniform_list, App, Context, ElementId, Entity, FocusHandle, + InteractiveElement as _, IntoElement, KeyBinding, ListSizingBehavior, MouseButton, + ParentElement, Render, RenderOnce, SharedString, StyleRefinement, Styled, + UniformListScrollHandle, Window, +}; + +use crate::{ + actions::{Confirm, SelectDown, SelectLeft, SelectRight, SelectUp}, + scroll::{Scrollbar, ScrollbarState}, + ListItem, StyledExt, +}; + +const CONTEXT: &str = "Tree"; +pub(crate) fn init(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("up", SelectUp, Some(CONTEXT)), + KeyBinding::new("down", SelectDown, Some(CONTEXT)), + KeyBinding::new("left", SelectLeft, Some(CONTEXT)), + KeyBinding::new("right", SelectRight, Some(CONTEXT)), + ]); +} + +/// Create a [`Tree`]. +/// +/// # Arguments +/// +/// * `state` - The shared state managing the tree items. +/// * `render_item` - A closure to render each tree item. +/// +/// ```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"), +/// ]) +/// }); +/// +/// tree(&state, |ix, entry, selected, window, cx| { +/// div().px(px(16.) * entry.depth()).child(item.label.clone()) +/// }) +/// ``` +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) +} + +struct TreeItemState { + expanded: bool, + disabled: bool, +} + +/// A tree item with a label, children, and an expanded state. +#[derive(Clone)] +pub struct TreeItem { + pub id: SharedString, + pub label: SharedString, + pub children: Vec, + state: Rc>, +} + +/// A flat representation of a tree item with its depth. +#[derive(Clone)] +pub struct TreeEntry { + item: TreeItem, + depth: usize, +} + +impl TreeEntry { + /// Get the source tree item. + #[inline] + pub fn item(&self) -> &TreeItem { + &self.item + } + + /// The depth of this item in the tree. + #[inline] + pub fn depth(&self) -> usize { + self.depth + } + + #[inline] + fn is_root(&self) -> bool { + self.depth == 0 + } + + /// Whether this item is a folder (has children). + #[inline] + pub fn is_folder(&self) -> bool { + self.item.is_folder() + } + + /// Return true if the item is expanded. + #[inline] + pub fn is_expanded(&self) -> bool { + self.item.is_expanded() + } + + #[inline] + pub fn is_disabled(&self) -> bool { + self.item.is_disabled() + } +} + +impl TreeItem { + /// Create a new tree item with the given label. + /// + /// - The `id` for you to uniquely identify this item, then later you can use it for selection or other purposes. + /// - The `label` is the text to display for this item. + /// + /// For example, the `id` is the full file path, and the `label` is the file name. + /// + /// ```ignore + /// TreeItem::new("src/ui/button.rs", "button.rs") + /// ``` + pub fn new(id: impl Into, label: impl Into) -> Self { + Self { + id: id.into(), + label: label.into(), + children: Vec::new(), + state: Rc::new(RefCell::new(TreeItemState { + expanded: false, + disabled: false, + })), + } + } + + /// Add a child item to this tree item. + pub fn child(mut self, child: TreeItem) -> Self { + self.children.push(child); + self + } + + /// Add multiple child items to this tree item. + pub fn children(mut self, children: impl Into>) -> Self { + self.children.extend(children.into()); + self + } + + /// Set expanded state for this tree item. + pub fn expanded(self, expanded: bool) -> Self { + self.state.borrow_mut().expanded = expanded; + self + } + + /// Set disabled state for this tree item. + pub fn disabled(self, disabled: bool) -> Self { + self.state.borrow_mut().disabled = disabled; + self + } + + /// Whether this item is a folder (has children). + #[inline] + pub fn is_folder(&self) -> bool { + self.children.len() > 0 + } + + /// Return true if the item is disabled. + pub fn is_disabled(&self) -> bool { + self.state.borrow().disabled + } + + /// Return true if the item is expanded. + #[inline] + pub fn is_expanded(&self) -> bool { + self.state.borrow().expanded + } +} + +/// State for managing tree items. +pub struct TreeState { + focus_handle: FocusHandle, + entries: Vec, + scrollbar_state: ScrollbarState, + scroll_handle: UniformListScrollHandle, + selected_ix: Option, +} + +impl TreeState { + /// Create a new empty tree state. + pub fn new(cx: &mut App) -> Self { + Self { + selected_ix: None, + focus_handle: cx.focus_handle(), + scrollbar_state: ScrollbarState::default(), + scroll_handle: UniformListScrollHandle::default(), + entries: Vec::new(), + } + } + + pub fn items(mut self, items: impl Into>) -> Self { + let items = items.into(); + self.entries.clear(); + for item in items.into_iter() { + self.add_entry(item, 0); + } + self + } + + pub fn set_items(&mut self, items: impl Into>, cx: &mut Context) { + let items = items.into(); + self.entries.clear(); + for item in items.into_iter() { + self.add_entry(item, 0); + } + self.selected_ix = None; + cx.notify(); + } + + /// Get the currently selected index, if any. + pub fn selected_index(&self) -> Option { + self.selected_ix + } + + /// Set the selected index, or `None` to clear selection. + pub fn set_selected_index(&mut self, ix: Option, cx: &mut Context) { + self.selected_ix = ix; + cx.notify(); + } + + pub fn scroll_to_item(&mut self, ix: usize, strategy: gpui::ScrollStrategy) { + self.scroll_handle.scroll_to_item(ix, strategy); + } + + /// Get the currently selected entry, if any. + pub fn selected_entry(&self) -> Option<&TreeEntry> { + self.selected_ix.and_then(|ix| self.entries.get(ix)) + } + + fn add_entry(&mut self, item: TreeItem, depth: usize) { + self.entries.push(TreeEntry { + item: item.clone(), + depth, + }); + if item.is_expanded() { + for child in &item.children { + self.add_entry(child.clone(), depth + 1); + } + } + } + + fn toggle_expand(&mut self, ix: usize) { + let Some(entry) = self.entries.get_mut(ix) else { + return; + }; + if !entry.is_folder() { + return; + } + + entry.item.state.borrow_mut().expanded = !entry.is_expanded(); + self.rebuild_entries(); + } + + fn rebuild_entries(&mut self) { + let root_items: Vec = self + .entries + .iter() + .filter(|e| e.is_root()) + .map(|e| e.item.clone()) + .collect(); + self.entries.clear(); + for item in root_items.into_iter() { + self.add_entry(item, 0); + } + } + + fn on_action_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context) { + if let Some(selected_ix) = self.selected_ix { + if let Some(entry) = self.entries.get(selected_ix) { + if entry.is_folder() { + self.toggle_expand(selected_ix); + cx.notify(); + } + } + } + } + + fn on_action_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { + if let Some(selected_ix) = self.selected_ix { + if let Some(entry) = self.entries.get(selected_ix) { + if entry.is_folder() && entry.is_expanded() { + self.toggle_expand(selected_ix); + cx.notify(); + } + } + } + } + + fn on_action_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { + if let Some(selected_ix) = self.selected_ix { + if let Some(entry) = self.entries.get(selected_ix) { + if entry.is_folder() && !entry.is_expanded() { + self.toggle_expand(selected_ix); + cx.notify(); + } + } + } + } + + fn on_action_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context) { + let mut selected_ix = self.selected_ix.unwrap_or(0); + + if selected_ix > 0 { + selected_ix = selected_ix - 1; + } else { + selected_ix = self.entries.len().saturating_sub(1); + } + + self.selected_ix = Some(selected_ix); + self.scroll_handle + .scroll_to_item(selected_ix, gpui::ScrollStrategy::Top); + cx.notify(); + } + + fn on_action_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context) { + let mut selected_ix = self.selected_ix.unwrap_or(0); + if selected_ix + 1 < self.entries.len() { + selected_ix = selected_ix + 1; + } else { + selected_ix = 0; + } + + self.selected_ix = Some(selected_ix); + self.scroll_handle + .scroll_to_item(selected_ix, gpui::ScrollStrategy::Bottom); + cx.notify(); + } +} + +impl Render for TreeState { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } +} + +/// A tree view element that displays hierarchical data. +#[derive(IntoElement)] +pub struct Tree { + id: ElementId, + 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, + { + 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) + }), + } + } + + fn on_entry_click(state: &Entity, ix: usize, _: &mut Window, cx: &mut App) { + state.update(cx, |state, cx| { + state.selected_ix = Some(ix); + state.toggle_expand(ix); + cx.notify(); + }) + } +} + +impl Styled for Tree { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for Tree { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let tree_state = self.state.read(cx); + let render_item = self.render_item.clone(); + + div() + .id(self.id) + .key_context(CONTEXT) + .track_focus(&tree_state.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)) + .size_full() + .child( + uniform_list("entries", tree_state.entries.len(), { + let selected_ix = tree_state.selected_ix; + let entries = tree_state.entries.clone(); + let state = self.state.clone(); + move |visible_range, window, cx| { + let mut items = Vec::with_capacity(visible_range.len()); + for ix in visible_range { + let entry = &entries[ix]; + let selected = Some(ix) == selected_ix; + let item = (render_item)(ix, entry, selected, window, cx); + + let el = div() + .id(ix) + .child(item.disabled(entry.item().is_disabled()).selected(selected)) + .when(!entry.item().is_disabled(), |this| { + this.on_mouse_down(MouseButton::Left, { + let state = state.clone(); + move |_, window, cx| { + Self::on_entry_click(&state, ix, window, cx); + } + }) + }); + + items.push(el) + } + + items + } + }) + .flex_grow() + .size_full() + .track_scroll(tree_state.scroll_handle.clone()) + .with_sizing_behavior(ListSizingBehavior::Auto) + .into_any_element(), + ) + .refine_style(&self.style) + .relative() + .child( + div() + .absolute() + .top_0() + .right_0() + .bottom_0() + .w(Scrollbar::width()) + .child(Scrollbar::vertical( + &tree_state.scrollbar_state, + &tree_state.scroll_handle, + )), + ) + } +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + + use crate::TreeState; + use gpui::AppContext as _; + + fn assert_entries(entries: &Vec, expected: &str) { + let actual: Vec = entries + .iter() + .map(|e| { + let mut s = String::new(); + s.push_str(&" ".repeat(e.depth)); + s.push_str(e.item().label.as_str()); + s + }) + .collect(); + let actual = actual.join("\n"); + assert_eq!(actual.trim(), expected.trim()); + } + + #[gpui::test] + fn test_tree_entry(cx: &mut gpui::TestAppContext) { + use super::TreeItem; + + let items = vec![ + TreeItem::new("src", "src") + .expanded(true) + .child( + TreeItem::new("src/ui", "ui") + .expanded(true) + .child(TreeItem::new("src/ui/button.rs", "button.rs")) + .child(TreeItem::new("src/ui/icon.rs", "icon.rs")) + .child(TreeItem::new("src/ui/mod.rs", "mod.rs")), + ) + .child(TreeItem::new("src/lib.rs", "lib.rs")), + TreeItem::new("Cargo.toml", "Cargo.toml"), + TreeItem::new("Cargo.lock", "Cargo.lock").disabled(true), + TreeItem::new("README.md", "README.md"), + ]; + + let state = cx.new(|cx| TreeState::new(cx).items(items)); + state.update(cx, |state, _| { + assert_entries( + &state.entries, + indoc! { + r#" + src + ui + button.rs + icon.rs + mod.rs + lib.rs + Cargo.toml + Cargo.lock + README.md + "# + }, + ); + + let entry = state.entries.get(0).unwrap(); + assert_eq!(entry.depth(), 0); + assert_eq!(entry.is_root(), true); + assert_eq!(entry.is_folder(), true); + assert_eq!(entry.is_expanded(), true); + + let entry = state.entries.get(1).unwrap(); + assert_eq!(entry.depth(), 1); + assert_eq!(entry.is_root(), false); + assert_eq!(entry.is_folder(), true); + assert_eq!(entry.is_expanded(), true); + assert_eq!(entry.item().label.as_str(), "ui"); + + state.toggle_expand(1); + let entry = state.entries.get(1).unwrap(); + assert_eq!(entry.is_expanded(), false); + assert_entries( + &state.entries, + indoc! { + r#" + src + ui + lib.rs + Cargo.toml + Cargo.lock + README.md + "# + }, + ); + }) + } +}