From 0ad491cb92bb0aae036707b48d06a2f34366841b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 15:04:57 +0700 Subject: [PATCH] refactor sidebar (wip) --- assets/icons/compass.svg | 3 + assets/icons/folder.svg | 3 + assets/icons/message.svg | 3 + crates/ui/src/icon.rs | 6 + crates/workspace/src/lib.rs | 16 +- crates/workspace/src/panels/browse.rs | 62 +++++++ crates/workspace/src/panels/inbox.rs | 62 +++++++ crates/workspace/src/panels/mod.rs | 3 + crates/workspace/src/panels/search.rs | 62 +++++++ crates/workspace/src/sidebar/entry.rs | 24 ++- crates/workspace/src/sidebar/mod.rs | 1 + crates/workspace/src/sidebar/tree.rs | 230 ++++++++++++++++++++++++++ docs/sidebar-tree-redesign.md | 11 +- 13 files changed, 476 insertions(+), 10 deletions(-) create mode 100644 assets/icons/compass.svg create mode 100644 assets/icons/folder.svg create mode 100644 assets/icons/message.svg create mode 100644 crates/workspace/src/panels/browse.rs create mode 100644 crates/workspace/src/panels/inbox.rs create mode 100644 crates/workspace/src/panels/search.rs create mode 100644 crates/workspace/src/sidebar/tree.rs diff --git a/assets/icons/compass.svg b/assets/icons/compass.svg new file mode 100644 index 00000000..6cd227dc --- /dev/null +++ b/assets/icons/compass.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/folder.svg b/assets/icons/folder.svg new file mode 100644 index 00000000..65967a9b --- /dev/null +++ b/assets/icons/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/message.svg b/assets/icons/message.svg new file mode 100644 index 00000000..87e04016 --- /dev/null +++ b/assets/icons/message.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 3ab51480..5b9c17cb 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -33,12 +33,14 @@ pub enum IconName { Close, CloseCircle, CloseCircleFill, + Compass, Copy, Device, Door, Ellipsis, Emoji, Eye, + Folder, Input, Info, Invite, @@ -47,6 +49,7 @@ pub enum IconName { Link, Loader, Lock, + Message, Moon, Plus, PlusCircle, @@ -106,12 +109,14 @@ impl IconNamed for IconName { Self::Close => "icons/close.svg", Self::CloseCircle => "icons/close-circle.svg", Self::CloseCircleFill => "icons/close-circle-fill.svg", + Self::Compass => "icons/compass.svg", Self::Copy => "icons/copy.svg", Self::Device => "icons/device.svg", Self::Door => "icons/door.svg", Self::Ellipsis => "icons/ellipsis.svg", Self::Emoji => "icons/emoji.svg", Self::Eye => "icons/eye.svg", + Self::Folder => "icons/folder.svg", Self::Input => "icons/input.svg", Self::Info => "icons/info.svg", Self::Invite => "icons/invite.svg", @@ -120,6 +125,7 @@ impl IconNamed for IconName { Self::Link => "icons/link.svg", Self::Loader => "icons/loader.svg", Self::Lock => "icons/lock.svg", + Self::Message => "icons/message.svg", Self::Moon => "icons/moon.svg", Self::Plus => "icons/plus.svg", Self::PlusCircle => "icons/plus-circle.svg", diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 85da8cd6..3e68ac3e 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -27,7 +27,9 @@ use ui::{Icon, IconName, Root, Sizable, WindowExtension, h_flex, v_flex}; use crate::dialogs::import::ImportIdentity; use crate::dialogs::restore::RestoreEncryption; use crate::dialogs::settings; -use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list}; +use crate::panels::{ + backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, search, +}; use crate::sidebar::Sidebar; mod dialogs; @@ -57,6 +59,9 @@ enum Command { ShowSettings, ShowBackup, ShowContactList, + ShowInbox, + ShowBrowse, + ShowSearch, } pub struct Workspace { @@ -296,6 +301,15 @@ impl Workspace { cx, ); } + Command::ShowInbox => { + self.add_panel_to_dock(inbox::init(window, cx), DockPlacement::Center, window, cx); + } + Command::ShowBrowse => { + self.add_panel_to_dock(browse::init(window, cx), DockPlacement::Center, window, cx); + } + Command::ShowSearch => { + self.add_panel_to_dock(search::init(window, cx), DockPlacement::Center, window, cx); + } Command::ShowBackup => { self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx); } diff --git a/crates/workspace/src/panels/browse.rs b/crates/workspace/src/panels/browse.rs new file mode 100644 index 00000000..163766b9 --- /dev/null +++ b/crates/workspace/src/panels/browse.rs @@ -0,0 +1,62 @@ +use gpui::{ + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, ParentElement, Render, SharedString, Styled, Window, +}; +use theme::ActiveTheme; +use ui::dock::{Panel, PanelEvent}; +use ui::{Icon, IconName, Sizable, h_flex}; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| BrowsePanel::new(window, cx)) +} + +pub struct BrowsePanel { + name: SharedString, + focus_handle: FocusHandle, +} + +impl BrowsePanel { + fn new(_window: &mut Window, cx: &mut App) -> Self { + Self { + name: "Browse".into(), + focus_handle: cx.focus_handle(), + } + } +} + +impl Panel for BrowsePanel { + fn panel_id(&self) -> SharedString { + self.name.clone() + } + + fn title(&self, cx: &App) -> AnyElement { + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::Compass) + .small() + .text_color(cx.theme().icon_muted), + ) + .child(self.name.clone()) + .into_any_element() + } +} + +impl EventEmitter for BrowsePanel {} + +impl Focusable for BrowsePanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for BrowsePanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .size_full() + .justify_center() + .text_sm() + .text_color(cx.theme().text_muted) + .child(self.name.clone()) + } +} diff --git a/crates/workspace/src/panels/inbox.rs b/crates/workspace/src/panels/inbox.rs new file mode 100644 index 00000000..84d83b8e --- /dev/null +++ b/crates/workspace/src/panels/inbox.rs @@ -0,0 +1,62 @@ +use gpui::{ + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, ParentElement, Render, SharedString, Styled, Window, +}; +use theme::ActiveTheme; +use ui::dock::{Panel, PanelEvent}; +use ui::{Icon, IconName, Sizable, h_flex}; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| InboxPanel::new(window, cx)) +} + +pub struct InboxPanel { + name: SharedString, + focus_handle: FocusHandle, +} + +impl InboxPanel { + fn new(_window: &mut Window, cx: &mut App) -> Self { + Self { + name: "Inbox".into(), + focus_handle: cx.focus_handle(), + } + } +} + +impl Panel for InboxPanel { + fn panel_id(&self) -> SharedString { + self.name.clone() + } + + fn title(&self, cx: &App) -> AnyElement { + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::Inbox) + .small() + .text_color(cx.theme().icon_muted), + ) + .child(self.name.clone()) + .into_any_element() + } +} + +impl EventEmitter for InboxPanel {} + +impl Focusable for InboxPanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for InboxPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .size_full() + .justify_center() + .text_sm() + .text_color(cx.theme().text_muted) + .child(self.name.clone()) + } +} diff --git a/crates/workspace/src/panels/mod.rs b/crates/workspace/src/panels/mod.rs index bb47e07b..88973725 100644 --- a/crates/workspace/src/panels/mod.rs +++ b/crates/workspace/src/panels/mod.rs @@ -1,6 +1,9 @@ pub mod backup; +pub mod browse; pub mod contact_list; pub mod greeter; +pub mod inbox; pub mod messaging_relays; pub mod profile; pub mod relay_list; +pub mod search; diff --git a/crates/workspace/src/panels/search.rs b/crates/workspace/src/panels/search.rs new file mode 100644 index 00000000..9dc087aa --- /dev/null +++ b/crates/workspace/src/panels/search.rs @@ -0,0 +1,62 @@ +use gpui::{ + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, ParentElement, Render, SharedString, Styled, Window, +}; +use theme::ActiveTheme; +use ui::dock::{Panel, PanelEvent}; +use ui::{Icon, IconName, Sizable, h_flex}; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| SearchPanel::new(window, cx)) +} + +pub struct SearchPanel { + name: SharedString, + focus_handle: FocusHandle, +} + +impl SearchPanel { + fn new(_window: &mut Window, cx: &mut App) -> Self { + Self { + name: "Search".into(), + focus_handle: cx.focus_handle(), + } + } +} + +impl Panel for SearchPanel { + fn panel_id(&self) -> SharedString { + self.name.clone() + } + + fn title(&self, cx: &App) -> AnyElement { + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::Search) + .small() + .text_color(cx.theme().icon_muted), + ) + .child(self.name.clone()) + .into_any_element() + } +} + +impl EventEmitter for SearchPanel {} + +impl Focusable for SearchPanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for SearchPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .size_full() + .justify_center() + .text_sm() + .text_color(cx.theme().text_muted) + .child(self.name.clone()) + } +} diff --git a/crates/workspace/src/sidebar/entry.rs b/crates/workspace/src/sidebar/entry.rs index 2c3a89d5..145996bb 100644 --- a/crates/workspace/src/sidebar/entry.rs +++ b/crates/workspace/src/sidebar/entry.rs @@ -3,8 +3,8 @@ use std::rc::Rc; use chat::RoomKind; use gpui::prelude::FluentBuilder; use gpui::{ - App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString, - StatefulInteractiveElement, Styled, Window, div, + AnyElement, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, div, px, }; use nostr_sdk::prelude::*; use settings::AppSettings; @@ -24,9 +24,11 @@ pub struct RoomEntry { avatar: Option, created_at: Option, kind: Option, + depth: u8, selected: bool, #[allow(clippy::type_complexity)] handler: Option>, + trailing: Option, } impl RoomEntry { @@ -38,8 +40,10 @@ impl RoomEntry { avatar: None, created_at: None, kind: None, + depth: 0, handler: None, selected: false, + trailing: None, } } @@ -68,6 +72,16 @@ impl RoomEntry { self } + pub fn depth(mut self, depth: u8) -> Self { + self.depth = depth; + self + } + + pub fn trailing(mut self, trailing: impl IntoElement) -> Self { + self.trailing = Some(trailing.into_any_element()); + self + } + pub fn on_click( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -98,9 +112,10 @@ impl RenderOnce for RoomEntry { h_flex() .id(self.ix) - .h_9() + .h_8() .w_full() - .px_1p5() + .pl(px(6. + self.depth as f32 * 14.)) + .pr_1p5() .gap_2() .text_sm() .rounded(cx.theme().radius) @@ -143,6 +158,7 @@ impl RenderOnce for RoomEntry { .when_some(self.created_at, |this, created_at| this.child(created_at)), ), ) + .when_some(self.trailing, |this, trailing| this.child(trailing)) .hover(|this| this.bg(cx.theme().elevated_surface_background)) .when_some(self.handler, |this, handler| { this.on_click(move |event, window, cx| { diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 9fd7f585..02129c91 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -34,6 +34,7 @@ use ui::{ use crate::Command; mod entry; +mod tree; const INPUT_PLACEHOLDER: &str = "Find or start a conversation"; diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs new file mode 100644 index 00000000..c972f464 --- /dev/null +++ b/crates/workspace/src/sidebar/tree.rs @@ -0,0 +1,230 @@ +use std::rc::Rc; + +use chat::Room; +use gpui::prelude::FluentBuilder; +use gpui::{ + App, ClickEvent, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, div, px, +}; +use theme::ActiveTheme; +use ui::{Icon, IconName, Sizable, StyledExt, h_flex}; + +/// Collapsible tree sections; declaration order is render order. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TreeSection { + Pins, + Requests, + Community, + Messages, +} + +/// One rendered tree row, in flattened order. +pub enum SidebarRow { + Section { + section: TreeSection, + count: usize, + }, + Room { + room: Entity, + depth: u8, + pinned: bool, + }, + Community { + entry: &'static CommunityEntry, + depth: u8, + }, + Hint { + text: SharedString, + depth: u8, + }, +} + +/// A community shown under the Community section. +pub struct CommunityEntry { + pub name: &'static str, +} + +/// Communities to show until the Concord backend is wired up. +pub fn dummy_communities() -> &'static [CommunityEntry] { + // TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md. + &[ + CommunityEntry { + name: "Coop Contributors", + }, + CommunityEntry { + name: "Nostr Design", + }, + ] +} + +/// Presentation differences between the rows [`TreeRow`] draws. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TreeRowKind { + Section, + Community, + Hint, +} + +/// Folder/file row. One element for section headers, community rows and hints. +#[derive(IntoElement)] +pub struct TreeRow { + id: ElementId, + kind: TreeRowKind, + depth: u8, + caret: Option, + icon: Option, + avatar: Option, + label: SharedString, + count: Option, + dot: bool, + selected: bool, + #[allow(clippy::type_complexity)] + on_click: Option>, +} + +impl TreeRow { + pub fn new( + id: impl Into, + kind: TreeRowKind, + label: impl Into, + ) -> Self { + Self { + id: id.into(), + kind, + depth: 0, + caret: None, + icon: None, + avatar: None, + label: label.into(), + count: None, + dot: false, + selected: false, + on_click: None, + } + } + + pub fn depth(mut self, depth: u8) -> Self { + self.depth = depth; + self + } + + pub fn caret(mut self, caret: IconName) -> Self { + self.caret = Some(caret); + self + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(icon); + self + } + + pub fn avatar(mut self, name: impl Into) -> Self { + self.avatar = Some(name.into()); + self + } + + pub fn count(mut self, count: usize) -> Self { + self.count = Some(count); + self + } + + pub fn dot(mut self) -> Self { + self.dot = true; + self + } + + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for TreeRow { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let indent = px(6. + self.depth as f32 * 14.); + let avatar_initial = self + .avatar + .as_ref() + .and_then(|name| name.chars().next()) + .map(|letter| SharedString::from(letter.to_uppercase().to_string())); + let is_section = self.kind == TreeRowKind::Section; + let is_community = self.kind == TreeRowKind::Community; + let is_hint = self.kind == TreeRowKind::Hint; + + h_flex() + .id(self.id) + .h_8() + .w_full() + .pl(indent) + .pr_1p5() + .gap_2() + .rounded(cx.theme().radius) + .when(is_section, |this| { + this.text_xs() + .font_semibold() + .text_color(cx.theme().text_muted) + }) + .when(is_community, |this| this.text_sm()) + .when(is_hint, |this| { + this.text_xs() + .font_normal() + .text_color(cx.theme().text_placeholder) + }) + .when(self.selected, |this| { + this.bg(cx.theme().ghost_element_selected) + }) + .when_some(self.caret, |this, caret| { + this.child(Icon::new(caret).xsmall().text_color(cx.theme().icon_muted)) + }) + .when_some(self.icon, |this, icon| { + this.child(Icon::new(icon).small().text_color(cx.theme().icon_muted)) + }) + .when_some(avatar_initial, |this, initial| { + this.child( + div() + .flex_shrink_0() + .size_5() + .rounded_full() + .bg(cx.theme().element_background) + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(cx.theme().text) + .child(initial), + ) + }) + .child(div().flex_1().truncate().child(self.label)) + .when_some(self.count, |this, count| { + this.child( + div() + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(count.to_string()), + ) + }) + .when(self.dot, |this| { + this.child( + div() + .flex_shrink_0() + .size_1() + .rounded_full() + .bg(cx.theme().cursor), + ) + }) + .when_some(self.on_click, |this, handler| { + this.cursor_pointer() + .hover(|this| this.bg(cx.theme().ghost_element_hover)) + .on_click(move |event, window, cx| handler(event, window, cx)) + }) + } +} diff --git a/docs/sidebar-tree-redesign.md b/docs/sidebar-tree-redesign.md index 63d66385..264dd551 100644 --- a/docs/sidebar-tree-redesign.md +++ b/docs/sidebar-tree-redesign.md @@ -1,6 +1,7 @@ # Sidebar tree redesign -Status: proposed, not implemented. +Status: steps 1-4 implemented (icons, tree primitives, `RoomEntry` extensions, +panel shells); step 5 (search relocation + sidebar render rewrite) not started. Scope: `crates/workspace/src/sidebar` (`mod.rs`, `entry.rs`, new `tree.rs`), new panel shells in `crates/workspace/src/panels/`, and the `Command` wiring in @@ -302,16 +303,16 @@ because removing the search fields breaks the old render and rewriting the render orphans the search code. Helpers added in earlier steps may warn as unused until step 5 consumes them. Run the checks in §15 after each step. -- [ ] **Step 1 — icons.** Add `assets/icons/folder.svg`, `compass.svg`, +- [x] **Step 1 — icons.** Add `assets/icons/folder.svg`, `compass.svg`, `message.svg` (24x24 viewBox, `stroke="currentColor"`, `stroke-width="1.5"`, matching existing files); add `Folder`, `Compass`, `Message` variants to `IconName` and its `path()` match in `crates/ui/src/icon.rs`. -- [ ] **Step 2 — tree primitives.** Add `crates/workspace/src/sidebar/tree.rs` +- [x] **Step 2 — tree primitives.** Add `crates/workspace/src/sidebar/tree.rs` with `TreeSection`, `SidebarRow`, `CommunityEntry`, `dummy_communities()`, and the `TreeRow` element; declare `mod tree;` in `sidebar/mod.rs`. -- [ ] **Step 3 — `RoomEntry`.** Add `.depth(u8)` and `.trailing(AnyElement)`; +- [x] **Step 3 — `RoomEntry`.** Add `.depth(u8)` and `.trailing(AnyElement)`; change `h_9` to `h_8`. -- [ ] **Step 4 — panel openers.** Add `Command::{ShowInbox, ShowBrowse, +- [x] **Step 4 — panel openers.** Add `Command::{ShowInbox, ShowBrowse, ShowSearch}` and `panels/{inbox,browse,search}.rs` shells (`init`, `Panel`, `Focusable`, `EventEmitter`, empty `Render`, following `greeter.rs`); register them in `panels/mod.rs`; handle the commands in