From 52f92c226fb1b968767d607c832fd541a7ead898 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 20:58:56 +0700 Subject: [PATCH] wip --- assets/icons/history.svg | 3 + crates/settings/src/lib.rs | 6 - crates/ui/src/icon.rs | 2 + crates/workspace/src/sidebar/mod.rs | 440 ++++++++++++--------------- crates/workspace/src/sidebar/tab.rs | 105 +++++++ crates/workspace/src/sidebar/tree.rs | 42 +-- 6 files changed, 310 insertions(+), 288 deletions(-) create mode 100644 assets/icons/history.svg create mode 100644 crates/workspace/src/sidebar/tab.rs diff --git a/assets/icons/history.svg b/assets/icons/history.svg new file mode 100644 index 00000000..e7c76e68 --- /dev/null +++ b/assets/icons/history.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 73ca64ae..2e3b53d8 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -46,7 +46,6 @@ setting_accessors! { pub nip4e: bool, pub trusted_relays: Vec, pub file_server: Url, - pub expanded_sections: Option>, } /// Signer kind @@ -131,10 +130,6 @@ pub struct Settings { /// Server for blossom media attachments pub file_server: Url, - - /// Expanded sidebar tree sections; `None` means the default sections - #[serde(default)] - pub expanded_sections: Option>, } impl Default for Settings { @@ -147,7 +142,6 @@ impl Default for Settings { nip4e: false, trusted_relays: vec![], file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(), - expanded_sections: None, } } } diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 5b9c17cb..9b935db8 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -66,6 +66,7 @@ pub enum IconName { Ship, Shield, Group, + History, UserKey, Upload, Usb, @@ -145,6 +146,7 @@ impl IconNamed for IconName { Self::Upload => "icons/upload.svg", Self::Usb => "icons/usb.svg", Self::Group => "icons/group.svg", + Self::History => "icons/history.svg", Self::PanelLeft => "icons/panel-left.svg", Self::PanelLeftOpen => "icons/panel-left-open.svg", Self::PanelRight => "icons/panel-right.svg", diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index db5342b2..5d1c05da 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -1,4 +1,3 @@ -use std::collections::BTreeSet; use std::ops::Range; use std::rc::Rc; @@ -13,44 +12,41 @@ use gpui::{ UniformListScrollHandle, Window, div, px, retain_all, uniform_list, }; use person::PersonRegistry; -use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; use theme::{ActiveTheme, TABBAR_HEIGHT}; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; -use ui::dock::{ClosePanel, Panel, PanelEvent}; +use ui::dock::{Panel, PanelEvent}; use ui::indicator::Indicator; use ui::menu::{DropdownMenu, PopupMenuItem}; -use ui::modal::ModalButtonProps; use ui::nav_item::NavItem; use ui::scroll::Scrollbar; use ui::{ - Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex, - title_bar_drag_handlers, v_flex, + Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, h_flex, title_bar_drag_handlers, + v_flex, }; use crate::Command; -use crate::dialogs::screening; +mod tab; mod tree; -use tree::{SidebarRow, TreeSection}; +use tab::{SidebarTab, TabBar}; +use tree::SidebarRow; pub(crate) use tree::{TreeRow, TreeRowKind}; pub struct Sidebar { focus_handle: FocusHandle, - scroll_handle: UniformListScrollHandle, + scroll_handles: [UniformListScrollHandle; 3], + active_tab: SidebarTab, /// Whether there are new chat requests new_requests: bool, - /// Expanded tree sections - expanded: BTreeSet, _subscriptions: SmallVec<[Subscription; 2]>, } impl Sidebar { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let settings = AppSettings::global(cx).read(cx).entity().clone(); let chat = ChatRegistry::global(cx); let communities = CommunityRegistry::global(cx); @@ -65,10 +61,6 @@ impl Sidebar { }), ); - subscriptions.push(cx.observe(&settings, move |this, _settings, cx| { - this.restore_state(cx); - })); - subscriptions.push( cx.subscribe(&communities, |_this, _communities, event, _cx| { if let CommunityEvent::Error(error) = event { @@ -79,192 +71,26 @@ impl Sidebar { Self { focus_handle: cx.focus_handle(), - scroll_handle: UniformListScrollHandle::new(), + scroll_handles: [ + UniformListScrollHandle::new(), + UniformListScrollHandle::new(), + UniformListScrollHandle::new(), + ], + active_tab: SidebarTab::Recents, new_requests: false, - expanded: load_expanded(cx), _subscriptions: subscriptions, } } - fn toggle_section(&mut self, section: TreeSection, cx: &mut Context) { - if !self.expanded.remove(§ion) { - self.expanded.insert(section); - } - - self.save_expanded(cx); - cx.notify(); - } - - fn is_expanded(&self, section: TreeSection) -> bool { - self.expanded.contains(§ion) - } - - fn restore_state(&mut self, cx: &mut Context) { - let expanded = load_expanded(cx); - if self.expanded == expanded { + fn select_tab(&mut self, tab: SidebarTab, cx: &mut Context) { + if self.active_tab == tab { return; } - self.expanded = expanded; + + self.active_tab = tab; cx.notify(); } - fn save_expanded(&self, cx: &mut App) { - let keys = self - .expanded - .iter() - .map(|section| section.key().to_string()) - .collect(); - AppSettings::update_expanded_sections(Some(keys), cx); - } - - fn tree_rows(&self, cx: &App) -> Vec { - let chat = ChatRegistry::global(cx); - let chat = chat.read(cx); - - let mut rows = Vec::new(); - - let registry = CommunityRegistry::global(cx); - let communities = registry.read(cx).communities(); - - rows.push(SidebarRow::Section { - section: TreeSection::Community, - count: communities.len(), - }); - - if self.is_expanded(TreeSection::Community) { - if communities.is_empty() { - rows.push(SidebarRow::Hint { - text: "No communities yet".into(), - }); - } else { - rows.extend( - communities - .iter() - .cloned() - .map(|community| SidebarRow::Community { community }), - ); - } - } - - let messages = chat.rooms(&RoomKind::Ongoing, cx); - rows.push(SidebarRow::Section { - section: TreeSection::Messages, - count: messages.len(), - }); - - if self.is_expanded(TreeSection::Messages) { - if messages.is_empty() { - rows.push(SidebarRow::Hint { - text: "No conversations yet".into(), - }); - } else { - rows.extend(messages.into_iter().map(|room| SidebarRow::Room { room })); - } - } - - rows - } - - fn render_rows( - &self, - range: Range, - rows: &[SidebarRow], - cx: &Context, - ) -> Vec { - rows.get(range.clone()) - .into_iter() - .flatten() - .enumerate() - .map(|(offset, row)| { - let index = range.start + offset; - - match row { - SidebarRow::Section { section, count } => { - let section = *section; - - TreeRow::new( - ElementId::NamedInteger("tree-row".into(), index as u64), - TreeRowKind::Section, - section.label(), - ) - .caret(if self.is_expanded(section) { - IconName::CaretDown - } else { - IconName::CaretRight - }) - .count(*count) - .on_click(cx.listener(move |this, _event, _window, cx| { - this.toggle_section(section, cx); - })) - .into_any_element() - } - SidebarRow::Room { room } => { - let public_key = room.read(cx).display_member(cx).public_key(); - let name = room.read(cx).display_name(cx); - let picture = room.read(cx).display_image(cx); - let seed = room.read(cx).display_image_seed(cx); - let kind = room.read(cx).kind; - let created_at = room.read(cx).created_at.to_ago(); - let room_clone = room.clone(); - - let handler = cx.listener(move |_this, _event, window, cx| { - ChatRegistry::global(cx).update(cx, |chat, cx| { - chat.emit_room(&room_clone, window, cx); - }); - - if kind != RoomKind::Ongoing && AppSettings::get_screening(cx) { - let screening = screening::init(public_key, window, cx); - - window.open_modal(cx, move |this, _window, _cx| { - this.confirm() - .child(screening.clone()) - .button_props( - ModalButtonProps::default() - .cancel_text("Ignore") - .ok_text("Response"), - ) - .on_cancel(move |_event, window, cx| { - window.dispatch_action(Box::new(ClosePanel), cx); - true - }) - }); - } - }); - - TreeRow::new( - ElementId::NamedInteger("tree-row".into(), index as u64), - TreeRowKind::Room, - name, - ) - .avatar(seed) - .picture(picture) - .created_at(created_at) - .on_click(handler) - .into_any_element() - } - SidebarRow::Community { community } => { - let community = community.read(cx); - - TreeRow::new( - ElementId::NamedInteger("tree-row".into(), index as u64), - TreeRowKind::Community, - community.name(), - ) - .avatar(community.id().to_hex()) - .picture(community.icon()) - .into_any_element() - } - SidebarRow::Hint { text } => TreeRow::new( - ElementId::NamedInteger("tree-row".into(), index as u64), - TreeRowKind::Hint, - text.clone(), - ) - .into_any_element(), - } - }) - .collect() - } - fn render_user(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let nostr = NostrRegistry::global(cx); let current_user = nostr.read(cx).current_user(); @@ -318,6 +144,16 @@ impl Sidebar { .child(name.clone()) })) .separator() + .menu_with_icon( + "Inbox", + IconName::Inbox, + Box::new(Command::ShowInbox), + ) + .menu_with_icon( + "Search", + IconName::Search, + Box::new(Command::ShowSearch), + ) .menu_with_icon( "Profile", IconName::Profile, @@ -359,13 +195,118 @@ impl Sidebar { } } -fn load_expanded(cx: &App) -> BTreeSet { - let Some(keys) = AppSettings::get_expanded_sections(cx) else { - return BTreeSet::from([TreeSection::Community, TreeSection::Messages]); - }; +fn rows_for(tab: SidebarTab, cx: &App) -> Vec { + match tab { + SidebarTab::Recents => vec![SidebarRow::Hint { + text: "Nothing recent yet".into(), + }], + SidebarTab::Chats => { + let chat = ChatRegistry::global(cx); + let chat = chat.read(cx); + let messages = chat.rooms(&RoomKind::Ongoing, cx); - keys.iter() - .filter_map(|key| TreeSection::from_key(key.as_str())) + let mut rows = vec![SidebarRow::Section { + label: "Chats".into(), + count: messages.len(), + }]; + + if messages.is_empty() { + rows.push(SidebarRow::Hint { + text: "No conversations yet".into(), + }); + } else { + rows.extend(messages.into_iter().map(|room| SidebarRow::Room { room })); + } + + rows + } + SidebarTab::Communities => { + let registry = CommunityRegistry::global(cx); + let communities = registry.read(cx).communities(); + + let mut rows = vec![SidebarRow::Section { + label: "Communities".into(), + count: communities.len(), + }]; + + if communities.is_empty() { + rows.push(SidebarRow::Hint { + text: "No communities yet".into(), + }); + } else { + rows.extend( + communities + .iter() + .cloned() + .map(|community| SidebarRow::Community { community }), + ); + } + + rows + } + } +} + +fn render_rows(range: Range, rows: &[SidebarRow], cx: &Context) -> Vec { + rows.get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(offset, row)| { + let index = range.start + offset; + + match row { + SidebarRow::Section { label, count } => TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Section, + label.clone(), + ) + .count(*count) + .into_any_element(), + SidebarRow::Room { room } => { + let name = room.read(cx).display_name(cx); + let picture = room.read(cx).display_image(cx); + let seed = room.read(cx).display_image_seed(cx); + let created_at = room.read(cx).created_at.to_ago(); + let room_clone = room.clone(); + + let handler = cx.listener(move |_this, _event, window, cx| { + ChatRegistry::global(cx).update(cx, |chat, cx| { + chat.emit_room(&room_clone, window, cx); + }); + }); + + TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Room, + name, + ) + .avatar(seed) + .picture(picture) + .created_at(created_at) + .on_click(handler) + .into_any_element() + } + SidebarRow::Community { community } => { + let community = community.read(cx); + + TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Community, + community.name(), + ) + .avatar(community.id().to_hex()) + .picture(community.icon()) + .into_any_element() + } + SidebarRow::Hint { text } => TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Hint, + text.clone(), + ) + .into_any_element(), + } + }) .collect() } @@ -404,86 +345,103 @@ impl Render for Sidebar { let loading = chat.read(cx).loading() && logged_in; let sidebar = cx.entity().downgrade(); - let rows = Rc::new(self.tree_rows(cx)); + let active_tab = self.active_tab; + let rows = Rc::new(rows_for(active_tab, cx)); + let scroll_handle = &self.scroll_handles[active_tab.index()]; v_flex() .image_cache(retain_all("sidebar")) .size_full() + .relative() .gap_2() .bg(cx.theme().surface_background) .border_r_1() .border_color(cx.theme().border_variant) .child(self.render_user(window, cx)) - .child( - v_flex() - .px_2() - .gap_1() - .child( - NavItem::new("nav-inbox", "Inbox", Icon::new(IconName::Inbox).small()) + .when(active_tab == SidebarTab::Chats, |this| { + this.child( + v_flex() + .px_2() + .gap_1() + .child( + NavItem::new( + "nav-contacts", + "Contacts", + Icon::new(IconName::Book).small(), + ) .on_click(|_event, window, cx| { - window.dispatch_action(Box::new(Command::ShowInbox), cx) + window.dispatch_action(Box::new(Command::ShowContactList), cx) }), - ) - .child( - NavItem::new( - "nav-requests", - "Requests", - Icon::new(IconName::Invite).small(), ) - .when(self.new_requests, |this| { - this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor)) - }) - .on_click({ - let sidebar = sidebar.clone(); - move |_event, window, cx| { - if let Err(error) = sidebar.update(cx, |this, cx| { - this.new_requests = false; - cx.notify(); - }) { - log::error!("Failed to clear new requests: {error}"); + .child( + NavItem::new( + "nav-requests", + "Requests", + Icon::new(IconName::Invite).small(), + ) + .when(self.new_requests, |this| { + this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor)) + }) + .on_click({ + let sidebar = sidebar.clone(); + move |_event, window, cx| { + if let Err(error) = sidebar.update(cx, |this, cx| { + this.new_requests = false; + cx.notify(); + }) { + log::error!("Failed to clear new requests: {error}"); + } + window.dispatch_action(Box::new(Command::ShowRequests), cx); } - window.dispatch_action(Box::new(Command::ShowRequests), cx); - } - }), - ) - .child( + }), + ), + ) + }) + .when(active_tab == SidebarTab::Communities, |this| { + this.child( + v_flex().px_2().gap_1().child( NavItem::new("nav-browse", "Browse", Icon::new(IconName::Compass).small()) .on_click(|_event, window, cx| { window.dispatch_action(Box::new(Command::ShowBrowse), cx) }), - ) - .child( - NavItem::new("nav-search", "Search", Icon::new(IconName::Search).small()) - .on_click(|_event, window, cx| { - window.dispatch_action(Box::new(Command::ShowSearch), cx) - }), ), - ) + ) + }) .child( v_flex() .size_full() .flex_1() + .min_h_0() .gap_1() + .pb_12() .child( uniform_list( - "sidebar-tree", + active_tab.list_id(), rows.len(), - cx.processor(move |this, range, _window, cx| { - this.render_rows(range, rows.as_slice(), cx) + cx.processor(move |_this, range, _window, cx| { + render_rows(range, rows.as_slice(), cx) }), ) - .track_scroll(&self.scroll_handle) + .track_scroll(scroll_handle) .flex_1() .h_full() .px_2(), ) - .child(Scrollbar::vertical(&self.scroll_handle)), + .child(Scrollbar::vertical(scroll_handle)), ) + .child(TabBar::new(active_tab).on_select({ + let sidebar = sidebar.clone(); + move |tab, _window, cx| { + if let Err(error) = sidebar.update(cx, |this, cx| this.select_tab(tab, cx)) { + log::error!("Failed to switch sidebar tab: {error}"); + } + } + })) .when(loading, |this| { this.child( div() .absolute() - .bottom_2() + .bottom_12() .left_0() .h_9() .w_full() diff --git a/crates/workspace/src/sidebar/tab.rs b/crates/workspace/src/sidebar/tab.rs new file mode 100644 index 00000000..1ce5ecc4 --- /dev/null +++ b/crates/workspace/src/sidebar/tab.rs @@ -0,0 +1,105 @@ +use std::rc::Rc; + +use gpui::prelude::FluentBuilder; +use gpui::{App, IntoElement, ParentElement, RenderOnce, Styled, Window, div}; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::{IconName, Selectable, h_flex}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SidebarTab { + Recents, + Chats, + Communities, +} + +impl SidebarTab { + pub const ALL: [SidebarTab; 3] = [Self::Recents, Self::Chats, Self::Communities]; + + pub fn label(self) -> &'static str { + match self { + Self::Recents => "Recents", + Self::Chats => "Chats", + Self::Communities => "Communities", + } + } + + pub fn icon(self) -> IconName { + match self { + Self::Recents => IconName::History, + Self::Chats => IconName::Message, + Self::Communities => IconName::Group, + } + } + + pub fn list_id(self) -> &'static str { + match self { + Self::Recents => "sidebar-recents", + Self::Chats => "sidebar-chats", + Self::Communities => "sidebar-communities", + } + } + + pub fn index(self) -> usize { + match self { + Self::Recents => 0, + Self::Chats => 1, + Self::Communities => 2, + } + } +} + +#[derive(IntoElement)] +#[allow(clippy::type_complexity)] +pub struct TabBar { + active: SidebarTab, + on_select: Option>, +} + +impl TabBar { + pub fn new(active: SidebarTab) -> Self { + Self { + active, + on_select: None, + } + } + + pub fn on_select( + mut self, + handler: impl Fn(SidebarTab, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_select = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for TabBar { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let Self { active, on_select } = self; + + div().absolute().bottom_2().left_0().w_full().px_2().child( + h_flex() + .w_full() + .p_1() + .gap_1() + .rounded(cx.theme().radius_lg) + .bg(cx.theme().elevated_surface_background) + .when(cx.theme().shadow, |this| this.shadow_md()) + .children(SidebarTab::ALL.into_iter().map(|tab| { + let on_select = on_select.clone(); + + Button::new(format!("tab-{}", tab.list_id())) + .icon(tab.icon()) + .ghost() + .flex_1() + .selected(tab == active) + .tooltip(tab.label()) + .on_click(move |_event, window, cx| { + if let Some(on_select) = on_select.as_ref() { + on_select(tab, window, cx); + } + }) + })), + ) + } +} diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index eef4b17c..7b8fc465 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -12,38 +12,8 @@ use theme::ActiveTheme; use ui::avatar::{Avatar, PixelAvatar}; use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex}; -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum TreeSection { - Community, - Messages, -} - -impl TreeSection { - pub fn label(self) -> &'static str { - match self { - Self::Community => "Community", - Self::Messages => "Messages", - } - } - - pub fn key(self) -> &'static str { - match self { - Self::Community => "community", - Self::Messages => "messages", - } - } - - pub fn from_key(key: &str) -> Option { - match key { - "community" => Some(Self::Community), - "messages" => Some(Self::Messages), - _ => None, - } - } -} - pub enum SidebarRow { - Section { section: TreeSection, count: usize }, + Section { label: SharedString, count: usize }, Room { room: Entity }, Community { community: Entity }, Hint { text: SharedString }, @@ -64,7 +34,6 @@ pub struct TreeRow { label: SharedString, avatar: Option, picture: Option, - caret: Option, count: Option, created_at: Option, selected: bool, @@ -84,7 +53,6 @@ impl TreeRow { label: label.into(), avatar: None, picture: None, - caret: None, count: None, created_at: None, selected: false, @@ -92,11 +60,6 @@ impl TreeRow { } } - pub fn caret(mut self, caret: IconName) -> Self { - self.caret = Some(caret); - self - } - /// Sets the seed for the row's generated avatar. pub fn avatar(mut self, seed: impl Into) -> Self { self.avatar = Some(seed.into()); @@ -221,9 +184,6 @@ impl RenderOnce for TreeRow { ) }), ) - .when_some(self.caret, |this, caret| { - this.child(Icon::new(caret).small().text_color(cx.theme().icon_muted)) - }) .when_some(self.on_click, |this, handler| { this.cursor_pointer() .when(!is_section, |this| {