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
+ "#
+ },
+ );
+ })
+ }
+}