Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ad491cb92 | ||
|
|
98903de1d0 | ||
|
|
2ccbfcd4a8 |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5"/><ellipse cx="12" cy="12" rx="3.5" ry="9.25" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 9.25H20.5" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 14.75H20.5" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M2.75 5.75V17.25C2.75 18.3546 3.64543 19.25 4.75 19.25H19.25C20.3546 19.25 21.25 18.3546 21.25 17.25V8.75C21.25 7.64543 20.3546 6.75 19.25 6.75H13.0704C12.4017 6.75 11.7772 6.4158 11.4063 5.8594L10.5937 4.6406C10.2228 4.0842 9.59834 3.75 8.92963 3.75H4.75C3.64543 3.75 2.75 4.64543 2.75 5.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M21.75 12C21.75 6.84375 17.9583 3.75 12 3.75C6.04167 3.75 2.25 6.84375 2.25 12C2.25 13.3368 3.17054 15.6055 3.3145 15.9522C3.32742 15.9833 3.34021 16.0117 3.3518 16.0433C3.45089 16.3136 3.85722 17.7527 2.25 19.8828C4.41667 20.914 6.71766 19.2188 6.71766 19.2188C8.30963 20.0597 10.2038 20.25 12 20.25C17.9583 20.25 21.75 17.1562 21.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 520 B |
+124
-59
@@ -24,6 +24,7 @@ use crate::menu::DropdownMenu as _;
|
||||
use crate::resizable::{resize_handle, resize_handle_appearance};
|
||||
use crate::tab::Tab;
|
||||
use crate::tab::tab_bar::TabBar;
|
||||
use crate::title_bar::{title_bar_drag_handlers, window_controls};
|
||||
use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex};
|
||||
|
||||
mod panel;
|
||||
@@ -31,12 +32,34 @@ pub use panel::*;
|
||||
|
||||
actions!(dock, [ToggleZoom, ClosePanel]);
|
||||
|
||||
pub type TitleBarRenderer = fn(&mut Window, &mut App) -> AnyElement;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TitleBarChrome {
|
||||
trailing: Cell<Option<TitleBarRenderer>>,
|
||||
}
|
||||
|
||||
impl TitleBarChrome {
|
||||
pub fn set_trailing(&self, renderer: TitleBarRenderer) {
|
||||
self.trailing.set(Some(renderer));
|
||||
}
|
||||
|
||||
fn trailing(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
|
||||
self.trailing.get().map(|render| render(window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dock_area(
|
||||
id: impl Into<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<DockArea> {
|
||||
let shared = Rc::new(SkinShared::default());
|
||||
) -> (Entity<DockArea>, Rc<TitleBarChrome>) {
|
||||
let chrome = Rc::new(TitleBarChrome::default());
|
||||
let shared = Rc::new(SkinShared {
|
||||
area: RefCell::new(None),
|
||||
resizing: Cell::new(None),
|
||||
chrome: chrome.clone(),
|
||||
});
|
||||
let area = cx.new(|cx| {
|
||||
DockArea::new(id, None, window, cx).with_renderer(Rc::new(DockSkin {
|
||||
shared: shared.clone(),
|
||||
@@ -44,7 +67,7 @@ pub fn dock_area(
|
||||
});
|
||||
|
||||
*shared.area.borrow_mut() = Some(area.downgrade());
|
||||
area
|
||||
(area, chrome)
|
||||
}
|
||||
|
||||
pub fn add_panel(
|
||||
@@ -167,6 +190,7 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
struct SkinShared {
|
||||
area: RefCell<Option<WeakEntity<DockArea>>>,
|
||||
resizing: Cell<Option<DockPlacement>>,
|
||||
chrome: Rc<TitleBarChrome>,
|
||||
}
|
||||
|
||||
impl SkinShared {
|
||||
@@ -394,6 +418,17 @@ impl TabGroupSkin {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_title_bar_group(&self, group: &TabGroupContext, cx: &App) -> bool {
|
||||
let Some(area) = self.shared.area() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
area.read(cx)
|
||||
.layout(DockPlacement::Center)
|
||||
.and_then(|tree| left_top_group(tree.root()))
|
||||
== Some(group.node())
|
||||
}
|
||||
|
||||
fn render_toolbar(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
@@ -473,16 +508,17 @@ impl TabGroupSkin {
|
||||
let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
||||
let has_leading = left_button.is_some() || bottom_button.is_some();
|
||||
let drag = tab_drag(group, ix, cx);
|
||||
let is_title_bar = self.is_title_bar_group(group, cx);
|
||||
let trailing_chrome = is_title_bar
|
||||
.then(|| self.shared.chrome.trailing(window, cx))
|
||||
.flatten();
|
||||
|
||||
h_flex()
|
||||
let bar = h_flex()
|
||||
.id("tab-title-bar")
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.line_height(rems(1.0))
|
||||
.h(TABBAR_HEIGHT)
|
||||
.py_2()
|
||||
.pl_3()
|
||||
.pr_2()
|
||||
.rounded_t(cx.theme().radius_lg)
|
||||
.bg(cx.theme().panel_background)
|
||||
.when(left_button.is_some(), |this| this.pl_2())
|
||||
.when(right_button.is_some(), |this| this.pr_2())
|
||||
@@ -499,9 +535,9 @@ impl TabGroupSkin {
|
||||
.child(
|
||||
div()
|
||||
.id("tab")
|
||||
.flex_1()
|
||||
.flex_initial()
|
||||
.min_w_0()
|
||||
.px_2()
|
||||
.min_w_16()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.child(
|
||||
@@ -524,6 +560,14 @@ impl TabGroupSkin {
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child({
|
||||
let space = div().id("tab-title-space").flex_1().h_full();
|
||||
if is_title_bar {
|
||||
title_bar_drag_handlers(space, window, cx).into_any_element()
|
||||
} else {
|
||||
space.into_any_element()
|
||||
}
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
@@ -532,7 +576,18 @@ impl TabGroupSkin {
|
||||
.child(self.render_toolbar(group, window, cx))
|
||||
.children(right_button),
|
||||
)
|
||||
.into_any_element()
|
||||
.when_some(trailing_chrome, |this, chrome| this.child(chrome));
|
||||
|
||||
if is_title_bar {
|
||||
h_flex()
|
||||
.h(TABBAR_HEIGHT)
|
||||
.bg(cx.theme().panel_background)
|
||||
.child(bar.flex_1())
|
||||
.child(window_controls())
|
||||
.into_any_element()
|
||||
} else {
|
||||
bar.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_tabs(
|
||||
@@ -556,13 +611,36 @@ impl TabGroupSkin {
|
||||
.iter()
|
||||
.position(|panel| panel.panel_id(cx) == displayed)
|
||||
});
|
||||
let is_title_bar = self.is_title_bar_group(group, cx);
|
||||
let trailing_chrome = is_title_bar
|
||||
.then(|| self.shared.chrome.trailing(window, cx))
|
||||
.flatten();
|
||||
let empty_space = div()
|
||||
.id("tab-bar-empty-space")
|
||||
.h_full()
|
||||
.flex_grow_1()
|
||||
.min_w_16()
|
||||
.when(droppable, |this| {
|
||||
this.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().surface_background))
|
||||
.on_drop({
|
||||
let group = TabGroupContext::clone(group);
|
||||
move |drag: &DragPanel, window, cx| {
|
||||
let ix = (drag.source() == group.node()).then(|| tabs_count - 1);
|
||||
group.drop_panel(drag.clone(), ix, false, window, cx);
|
||||
}
|
||||
})
|
||||
});
|
||||
let empty_space = if is_title_bar {
|
||||
title_bar_drag_handlers(empty_space, window, cx).into_any_element()
|
||||
} else {
|
||||
empty_space.into_any_element()
|
||||
};
|
||||
|
||||
TabBar::new("tab-bar")
|
||||
let bar = TabBar::new("tab-bar")
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.h(TABBAR_HEIGHT)
|
||||
.bg(cx.theme().panel_background)
|
||||
.rounded_t(cx.theme().radius_lg)
|
||||
.when(has_leading, |this| {
|
||||
.when(is_title_bar || has_leading, |this| {
|
||||
this.prefix(
|
||||
h_flex()
|
||||
.items_center()
|
||||
@@ -639,26 +717,7 @@ impl TabGroupSkin {
|
||||
})
|
||||
})
|
||||
}))
|
||||
.last_empty_space(
|
||||
// Empty space so a panel can be moved past the last tab.
|
||||
div()
|
||||
.id("tab-bar-empty-space")
|
||||
.h_full()
|
||||
.flex_grow_1()
|
||||
.min_w_16()
|
||||
.when(droppable, |this| {
|
||||
this.drag_over::<DragPanel>(|this, _, _, cx| {
|
||||
this.bg(cx.theme().surface_background)
|
||||
})
|
||||
.on_drop({
|
||||
let group = TabGroupContext::clone(group);
|
||||
move |drag: &DragPanel, window, cx| {
|
||||
let ix = (drag.source() == group.node()).then(|| tabs_count - 1);
|
||||
group.drop_panel(drag.clone(), ix, false, window, cx);
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
.last_empty_space(empty_space)
|
||||
.when(!collapsed, |this| {
|
||||
this.suffix(
|
||||
h_flex()
|
||||
@@ -669,10 +728,22 @@ impl TabGroupSkin {
|
||||
.px_0p5()
|
||||
.gap_1()
|
||||
.child(self.render_toolbar(group, window, cx))
|
||||
.children(right_button),
|
||||
.children(right_button)
|
||||
.children(trailing_chrome),
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
});
|
||||
|
||||
if is_title_bar {
|
||||
h_flex()
|
||||
.h(TABBAR_HEIGHT)
|
||||
.w_full()
|
||||
.bg(cx.theme().panel_background)
|
||||
.child(bar.flex_1())
|
||||
.child(window_controls())
|
||||
.into_any_element()
|
||||
} else {
|
||||
bar.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn dock_toggle_button(
|
||||
@@ -738,28 +809,23 @@ impl TabGroupSkin {
|
||||
}
|
||||
|
||||
impl TabGroupRenderer for TabGroupSkin {
|
||||
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
div()
|
||||
.id("tab-panel")
|
||||
.p_1()
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.when(cx.theme().shadow, |this| this.shadow_xs())
|
||||
.when(!group.is_collapsed(), |this| {
|
||||
this.on_action({
|
||||
let group = TabGroupContext::clone(group);
|
||||
move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx)
|
||||
})
|
||||
.on_action({
|
||||
let group = TabGroupContext::clone(group);
|
||||
move |_: &ClosePanel, window, cx| {
|
||||
let Some(panel) = group.active_panel() else {
|
||||
return;
|
||||
};
|
||||
let panel = panel.panel_id(cx);
|
||||
group.close(panel, window, cx);
|
||||
}
|
||||
})
|
||||
fn frame(&self, group: &TabGroupContext, _: &mut Window, _cx: &mut App) -> Stateful<Div> {
|
||||
div().id("tab-panel").when(!group.is_collapsed(), |this| {
|
||||
this.on_action({
|
||||
let group = TabGroupContext::clone(group);
|
||||
move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx)
|
||||
})
|
||||
.on_action({
|
||||
let group = TabGroupContext::clone(group);
|
||||
move |_: &ClosePanel, window, cx| {
|
||||
let Some(panel) = group.active_panel() else {
|
||||
return;
|
||||
};
|
||||
let panel = panel.panel_id(cx);
|
||||
group.close(panel, window, cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn render_tab_bar(
|
||||
@@ -811,7 +877,6 @@ impl TabGroupRenderer for TabGroupSkin {
|
||||
.child(
|
||||
div()
|
||||
.size_full()
|
||||
.rounded_b(cx.theme().radius_lg)
|
||||
.bg(cx.theme().panel_background)
|
||||
.overflow_hidden()
|
||||
.child(panel.cached(StyleRefinement::default().v_flex().size_full())),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,9 +2,10 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, Context, Decorations, Hsla, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, Pixels, Render, RenderOnce, StatefulInteractiveElement as _,
|
||||
StyleRefinement, Styled, TitlebarOptions, Window, WindowControlArea, div, px,
|
||||
AnyElement, App, ClickEvent, Context, Decorations, Div, Hsla, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, Pixels, Render, RenderOnce, Stateful,
|
||||
StatefulInteractiveElement as _, StyleRefinement, Styled, TitlebarOptions, Window,
|
||||
WindowControlArea, div, px,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::ActiveTheme;
|
||||
@@ -210,10 +211,61 @@ impl RenderOnce for ControlIcon {
|
||||
|
||||
#[derive(IntoElement)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
struct WindowControls {
|
||||
pub(crate) struct WindowControls {
|
||||
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
|
||||
}
|
||||
|
||||
pub(crate) fn window_controls() -> WindowControls {
|
||||
WindowControls {
|
||||
on_close_window: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title_bar_drag_handlers(
|
||||
this: Stateful<Div>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Stateful<Div> {
|
||||
let state = window.use_state(cx, |_, _| TitleBarState { should_move: false });
|
||||
|
||||
let this = if cfg!(target_family = "wasm") {
|
||||
this
|
||||
} else {
|
||||
this.window_control_area(WindowControlArea::Drag)
|
||||
};
|
||||
|
||||
this.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = false;
|
||||
}))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = true;
|
||||
}),
|
||||
)
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = false;
|
||||
}),
|
||||
)
|
||||
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
|
||||
if state.should_move {
|
||||
state.should_move = false;
|
||||
window.start_window_move();
|
||||
}
|
||||
}))
|
||||
.on_click(|event, window, _| {
|
||||
if event.click_count() == 2 {
|
||||
if cfg!(target_os = "macos") {
|
||||
window.titlebar_double_click();
|
||||
} else {
|
||||
window.zoom_window();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl RenderOnce for WindowControls {
|
||||
fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
if cfg!(target_os = "macos") || cfg!(target_family = "wasm") {
|
||||
|
||||
+36
-109
@@ -1,3 +1,4 @@
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ::settings::AppSettings;
|
||||
@@ -8,8 +9,8 @@ use common::download_dir;
|
||||
use device::{DeviceEvent, DeviceRegistry};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||
Action, AnyElement, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{PersonRegistry, shorten_pubkey};
|
||||
@@ -17,17 +18,18 @@ use serde::Deserialize;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{NostrRegistry, StateEvent};
|
||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{self, ClosePanel, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||
use ui::notification::{Notification, NotificationKind};
|
||||
use ui::{Icon, IconName, Root, Sizable, TitleBar, WindowExtension, h_flex, v_flex};
|
||||
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,12 +59,16 @@ enum Command {
|
||||
ShowSettings,
|
||||
ShowBackup,
|
||||
ShowContactList,
|
||||
ShowInbox,
|
||||
ShowBrowse,
|
||||
ShowSearch,
|
||||
}
|
||||
|
||||
pub struct Workspace {
|
||||
sidebar: Entity<Sidebar>,
|
||||
/// App's Dock Area
|
||||
dock: Entity<DockArea>,
|
||||
title_bar_chrome: Rc<dock::TitleBarChrome>,
|
||||
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
@@ -78,7 +84,7 @@ impl Workspace {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
|
||||
let dock = dock::dock_area("coop", window, cx);
|
||||
let (dock, title_bar_chrome) = dock::dock_area("coop", window, cx);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
@@ -225,6 +231,7 @@ impl Workspace {
|
||||
Self {
|
||||
sidebar,
|
||||
dock,
|
||||
title_bar_chrome,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -294,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);
|
||||
}
|
||||
@@ -518,95 +534,14 @@ impl Workspace {
|
||||
});
|
||||
}
|
||||
|
||||
fn titlebar_left(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let current_user = nostr.read(cx).current_user();
|
||||
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.gap_2()
|
||||
.when_none(¤t_user, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Import your identity to continue")),
|
||||
)
|
||||
})
|
||||
.when_some(current_user.as_ref(), |this, public_key| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(public_key, cx);
|
||||
let avatar = profile.avatar();
|
||||
let name = profile.name();
|
||||
|
||||
this.child(
|
||||
Button::new("current-user")
|
||||
.child(Avatar::new(avatar.clone()).xsmall())
|
||||
.small()
|
||||
.caret()
|
||||
.compact()
|
||||
.transparent()
|
||||
.dropdown_menu(move |this, _window, cx| {
|
||||
let avatar = avatar.clone();
|
||||
let name = name.clone();
|
||||
|
||||
this.min_w(px(256.))
|
||||
.item(PopupMenuItem::element(move |_window, cx| {
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(Avatar::new(avatar.clone()).xsmall())
|
||||
.child(name.clone())
|
||||
}))
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"Profile",
|
||||
IconName::Profile,
|
||||
Box::new(Command::ShowProfile),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Contact List",
|
||||
IconName::Book,
|
||||
Box::new(Command::ShowContactList),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Backup",
|
||||
IconName::UserKey,
|
||||
Box::new(Command::ShowBackup),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Themes",
|
||||
IconName::Sun,
|
||||
Box::new(Command::ToggleTheme),
|
||||
)
|
||||
// Only offer in-app updates when auto-update is
|
||||
// enabled (managed channels update themselves).
|
||||
.when(AutoUpdater::is_available(cx), |this| {
|
||||
this.separator().menu_with_icon(
|
||||
"Check for Updates",
|
||||
IconName::Device,
|
||||
Box::new(Command::Update),
|
||||
)
|
||||
})
|
||||
.menu_with_icon(
|
||||
"Settings",
|
||||
IconName::Settings,
|
||||
Box::new(Command::ShowSettings),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn titlebar_right(_window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
let auto_updater = AutoUpdater::try_global(cx);
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return div();
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
@@ -635,11 +570,11 @@ impl Workspace {
|
||||
.tooltip("Quit and relaunch into the installed update")
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click(cx.listener(|_this, _event, _window, cx| {
|
||||
.on_click(|_event, _window, cx| {
|
||||
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
||||
auto_updater.update(cx, |this, cx| this.restart(cx));
|
||||
}
|
||||
})),
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(nip4e_enabled, |this| {
|
||||
@@ -764,6 +699,7 @@ impl Workspace {
|
||||
)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,33 +708,24 @@ impl Render for Workspace {
|
||||
let modal_layer = Root::render_modal_layer(window, cx);
|
||||
let notification_layer = Root::render_notification_layer(window, cx);
|
||||
|
||||
self.title_bar_chrome.set_trailing(Self::titlebar_right);
|
||||
|
||||
div()
|
||||
.id("workspace")
|
||||
.on_action(cx.listener(Self::on_command))
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
h_flex()
|
||||
.size_full()
|
||||
// Title Bar
|
||||
.child(
|
||||
TitleBar::new()
|
||||
.child(self.titlebar_left(cx))
|
||||
.child(self.titlebar_right(cx)),
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.h_full()
|
||||
.w(SIDEBAR_WIDTH)
|
||||
.child(self.sidebar.clone()),
|
||||
)
|
||||
// Main
|
||||
.child(
|
||||
h_flex()
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.h_full()
|
||||
.w(SIDEBAR_WIDTH)
|
||||
.child(self.sidebar.clone()),
|
||||
)
|
||||
.child(self.dock.clone()),
|
||||
),
|
||||
.child(self.dock.clone()),
|
||||
)
|
||||
// Notifications
|
||||
.children(notification_layer)
|
||||
|
||||
@@ -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<BrowsePanel> {
|
||||
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<PanelEvent> 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<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(self.name.clone())
|
||||
}
|
||||
}
|
||||
@@ -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<InboxPanel> {
|
||||
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<PanelEvent> 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<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(self.name.clone())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SearchPanel> {
|
||||
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<PanelEvent> 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<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(self.name.clone())
|
||||
}
|
||||
}
|
||||
@@ -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<SharedString>,
|
||||
created_at: Option<SharedString>,
|
||||
kind: Option<RoomKind>,
|
||||
depth: u8,
|
||||
selected: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
trailing: Option<AnyElement>,
|
||||
}
|
||||
|
||||
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| {
|
||||
|
||||
@@ -2,30 +2,39 @@ use std::collections::HashSet;
|
||||
use std::ops::Range;
|
||||
|
||||
use anyhow::Error;
|
||||
use auto_update::AutoUpdater;
|
||||
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||
use common::{DebouncedDelay, TimestampExt};
|
||||
use entry::RoomEntry;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
|
||||
Window, div, retain_all, uniform_list,
|
||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task,
|
||||
UniformListScrollHandle, Window, div, px, retain_all, uniform_list,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{FIND_DELAY, NostrRegistry};
|
||||
use theme::{ActiveTheme, SIDEBAR_WIDTH};
|
||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||
use ui::notification::Notification;
|
||||
use ui::scroll::Scrollbar;
|
||||
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
use ui::{
|
||||
Icon, IconName, Selectable, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex,
|
||||
title_bar_drag_handlers, v_flex,
|
||||
};
|
||||
|
||||
use crate::Command;
|
||||
|
||||
mod entry;
|
||||
mod tree;
|
||||
|
||||
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
|
||||
|
||||
@@ -485,6 +494,97 @@ impl Sidebar {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_user(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let current_user = nostr.read(cx).current_user();
|
||||
|
||||
title_bar_drag_handlers(
|
||||
h_flex()
|
||||
.id("sidebar-user")
|
||||
.w_full()
|
||||
.h(TABBAR_HEIGHT)
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.when(cfg!(target_os = "macos"), |this| {
|
||||
this.pl(px(TRAFFIC_LIGHT_PADDING))
|
||||
})
|
||||
.when_none(¤t_user, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Import your identity to continue")),
|
||||
)
|
||||
})
|
||||
.when_some(current_user.as_ref(), |this, public_key| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(public_key, cx);
|
||||
let avatar = profile.avatar();
|
||||
let name = profile.name();
|
||||
|
||||
this.child(
|
||||
Button::new("current-user")
|
||||
.child(Avatar::new(avatar.clone()).xsmall())
|
||||
.small()
|
||||
.caret()
|
||||
.compact()
|
||||
.transparent()
|
||||
.dropdown_menu(move |this, _window, cx| {
|
||||
let avatar = avatar.clone();
|
||||
let name = name.clone();
|
||||
|
||||
this.min_w(px(256.))
|
||||
.item(PopupMenuItem::element(move |_window, cx| {
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(Avatar::new(avatar.clone()).xsmall())
|
||||
.child(name.clone())
|
||||
}))
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"Profile",
|
||||
IconName::Profile,
|
||||
Box::new(Command::ShowProfile),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Contact List",
|
||||
IconName::Book,
|
||||
Box::new(Command::ShowContactList),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Backup",
|
||||
IconName::UserKey,
|
||||
Box::new(Command::ShowBackup),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Themes",
|
||||
IconName::Sun,
|
||||
Box::new(Command::ToggleTheme),
|
||||
)
|
||||
.when(AutoUpdater::is_available(cx), |this| {
|
||||
this.separator().menu_with_icon(
|
||||
"Check for Updates",
|
||||
IconName::Device,
|
||||
Box::new(Command::Update),
|
||||
)
|
||||
})
|
||||
.menu_with_icon(
|
||||
"Settings",
|
||||
IconName::Settings,
|
||||
Box::new(Command::ShowSettings),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Sidebar {
|
||||
@@ -502,7 +602,7 @@ impl Focusable for Sidebar {
|
||||
}
|
||||
|
||||
impl Render for Sidebar {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let logged_in = nostr.read(cx).current_user().is_some();
|
||||
@@ -524,6 +624,7 @@ impl Render for Sidebar {
|
||||
.image_cache(retain_all("sidebar"))
|
||||
.size_full()
|
||||
.gap_2()
|
||||
.child(self.render_user(window, cx))
|
||||
.child(
|
||||
h_flex().px_2().py_1().child(
|
||||
Input::new(&self.find_input)
|
||||
|
||||
@@ -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<Room>,
|
||||
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<IconName>,
|
||||
icon: Option<IconName>,
|
||||
avatar: Option<SharedString>,
|
||||
label: SharedString,
|
||||
count: Option<usize>,
|
||||
dot: bool,
|
||||
selected: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl TreeRow {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
kind: TreeRowKind,
|
||||
label: impl Into<SharedString>,
|
||||
) -> 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<SharedString>) -> 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -9,6 +9,7 @@ use gpui::{
|
||||
use gpui_platform::application;
|
||||
use nostr_sdk::prelude::SecretKey;
|
||||
use state::{APP_ID, CLIENT_NAME};
|
||||
use theme::TABBAR_HEIGHT;
|
||||
use ui::Root;
|
||||
|
||||
actions!(coop, [Quit]);
|
||||
@@ -66,9 +67,13 @@ fn main() {
|
||||
app_id: Some(APP_ID.to_owned()),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(SharedString::new_static(CLIENT_NAME)),
|
||||
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
||||
traffic_light_position: Some(point(
|
||||
px(9.0),
|
||||
px(TABBAR_HEIGHT / px(2.) - 14. / 2.),
|
||||
)),
|
||||
appears_transparent: true,
|
||||
}),
|
||||
app_owns_titlebar_drag: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
# Sidebar tree redesign
|
||||
|
||||
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
|
||||
`crates/workspace/src/lib.rs`. New icons in `crates/ui/src/icon.rs` and
|
||||
`assets/icons/`.
|
||||
|
||||
Related: `docs/concord-usage.md` — Community is placeholder data until a
|
||||
`ConcordRegistry` exists (that document describes the backend shape; none of it
|
||||
is wired up yet).
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Replace the segmented filter (Inbox / Requests) plus flat room list with a
|
||||
collapsible tree, and give the sidebar a nav rail whose items open dock panels.
|
||||
|
||||
The sidebar body is always the tree. Search is not part of it: the find input,
|
||||
results, and contacts move to a Search panel (relocation lands here, step 5;
|
||||
the panel is owned separately afterwards).
|
||||
|
||||
Target layout:
|
||||
|
||||
```text
|
||||
┌ sidebar ───────────────────────────────┐
|
||||
│ [avatar] user menu │ render_user (unchanged)
|
||||
│ │
|
||||
│ Inbox │ opens Inbox panel
|
||||
│ Browse │ opens Browse panel
|
||||
│ Search │ opens Search panel
|
||||
│ │
|
||||
│ ▾ Pinned (2) │ hidden when empty
|
||||
│ ○ alice │
|
||||
│ ○ team chat │
|
||||
│ ▸ Requests │ collapsed by default
|
||||
│ ▾ Community │
|
||||
│ ○ Coop Contributors │ 1-3 dummy entries
|
||||
│ ○ Nostr Design │
|
||||
│ ▾ Messages │
|
||||
│ ○ bob │ RoomKind::Ongoing
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 2. Current state
|
||||
|
||||
| Piece | Where |
|
||||
| --- | --- |
|
||||
| Sidebar view, search, filters, room list | `crates/workspace/src/sidebar/mod.rs` |
|
||||
| Room row element | `crates/workspace/src/sidebar/entry.rs` (`RoomEntry`) |
|
||||
| Room kinds and lookup | `crates/chat/src/lib.rs` (`ChatRegistry::rooms/count/room`), `crates/chat/src/room.rs` (`RoomKind::{Request, Ongoing}`) |
|
||||
| User row, dropdown menu | `Sidebar::render_user` (keep as is) |
|
||||
| Dock panels | `crates/workspace/src/panels/` (`greeter`, `profile`, `contact_list`, ...) |
|
||||
| Panel opening + commands | `crates/workspace/src/lib.rs` (`Command`, `Workspace::on_command`, `add_panel_to_dock`) |
|
||||
| Panel dedupe/focus | `crates/ui/src/dock/mod.rs` (`add_panel` finds by `panel_id` and moves/focuses) |
|
||||
| Buttons, icons, tooltips | `crates/ui/src/button.rs`, `crates/ui/src/icon.rs` |
|
||||
| Split button / dropdown primitives | `crates/ui/src/menu/` (`DropdownMenu`, `PopupMenu`, `PopupMenuItem`) |
|
||||
| App settings persistence | `crates/settings/src/lib.rs` (`Settings`, `setting_accessors!`) |
|
||||
|
||||
Behavior to preserve:
|
||||
|
||||
- `RoomEntry` click emits `ChatEvent::OpenRoom` through
|
||||
`ChatRegistry::emit_room`, and shows the screening modal for non-ongoing
|
||||
rooms (`entry.rs`).
|
||||
- `ChatEvent::Ping` sets `new_requests = true`, drawn as a dot on Requests.
|
||||
- The dock-facing `Panel`/`Focusable` impls on `Sidebar` stay untouched.
|
||||
- Search behavior is preserved by moving it, not rewriting it (§7).
|
||||
|
||||
## 3. Decisions and assumptions
|
||||
|
||||
| # | Question | Decision |
|
||||
| --- | --- | --- |
|
||||
| 1 | Nav items | `Inbox` / `Browse` / `Search` dispatch new commands (`Command::{ShowInbox, ShowBrowse, ShowSearch}`); `Workspace::on_command` opens each panel with `DockPlacement::Center`. `ui::dock::add_panel` already focuses an open panel by `panel_id` instead of duplicating it. |
|
||||
| 2 | Search in the sidebar | No find input, no results/contacts sections. The existing search/select implementation moves to `panels/search.rs` as a mechanical relocation (§7, step 5). |
|
||||
| 3 | Panels in this change | `Inbox` and `Browse` render empty bodies for now (tab title only); `Search` gets its body from the search relocation in step 5. Real Inbox/Browse content is follow-up work. |
|
||||
| 4 | Pin storage | UI-local `Vec<u64>` of room ids, in memory first; persistence is step 8 (optional). |
|
||||
| 5 | Pinned rooms in Messages | Kept in both places; `Pinned` is a shortcut, not a move. |
|
||||
| 6 | Row height | Uniform `h_8` (32px) for every tree row, including `RoomEntry` (currently `h_9`). Required by `uniform_list`, which measures only the first row. |
|
||||
| 7 | Community data | 1-3 hardcoded `CommunityEntry` values with a `TODO(concord)` pointing at `docs/concord-usage.md`. |
|
||||
| 8 | Requests default | Collapsed; the folder row still shows the unread dot. Expanding clears `new_requests`. |
|
||||
|
||||
## 4. State model
|
||||
|
||||
`Sidebar` keeps only what the tree needs.
|
||||
|
||||
```rust
|
||||
/// Collapsible tree sections; declaration order is render order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
enum TreeSection {
|
||||
Pins,
|
||||
Requests,
|
||||
Community,
|
||||
Messages,
|
||||
}
|
||||
```
|
||||
|
||||
New fields:
|
||||
|
||||
```rust
|
||||
expanded: BTreeSet<TreeSection>,
|
||||
pinned_rooms: Vec<u64>, // room ids in pin order
|
||||
```
|
||||
|
||||
Defaults: `expanded = {Community, Messages}` (Requests intentionally absent;
|
||||
Pins only matters when non-empty and starts expanded).
|
||||
|
||||
New methods:
|
||||
|
||||
```rust
|
||||
fn toggle_section(&mut self, section: TreeSection, cx: &mut Context<Self>);
|
||||
fn is_expanded(&self, section: TreeSection) -> bool;
|
||||
fn pin_room(&mut self, room_id: u64, cx: &mut Context<Self>);
|
||||
fn unpin_room(&mut self, room_id: u64, cx: &mut Context<Self>);
|
||||
fn is_pinned(&self, room_id: u64) -> bool;
|
||||
fn tree_rows(&self, cx: &App) -> Vec<SidebarRow>; // see §5
|
||||
```
|
||||
|
||||
`toggle_section(Requests)` clears `new_requests`.
|
||||
|
||||
Removed from `Sidebar` (all search-related, carried to the Search panel in
|
||||
step 5): `filter: Entity<RoomKind>`, `current_filter`, `set_filter`,
|
||||
`show_find_panel`, `find_input`, `find_debouncer`, `finding`, `find_focused`,
|
||||
`find_results`, `find_task`, `has_search`, `contact_list`, `selected_pkeys`,
|
||||
and methods `get_contact_list`, `set_contact_list`, `debounced_search`,
|
||||
`search`, `set_results`, `set_finding`, `set_input_focus`, `reset`, `select`,
|
||||
`is_selected`, `get_selected`, `create_room`, `render_results`,
|
||||
`render_contacts`. Nothing is deleted from the codebase: step 5 moves it into
|
||||
the Search panel.
|
||||
|
||||
## 5. Row model and flattening
|
||||
|
||||
The tree body is one `uniform_list`, so all rows must be the same height
|
||||
(`h_8`) and the flattened order must be precomputed per frame.
|
||||
|
||||
New file `crates/workspace/src/sidebar/tree.rs`:
|
||||
|
||||
```rust
|
||||
/// One rendered tree row, in flattened order.
|
||||
enum SidebarRow {
|
||||
Section { section: TreeSection, count: usize },
|
||||
Room { room: Entity<Room>, depth: u8, pinned: bool },
|
||||
Community { entry: &'static CommunityEntry, depth: u8 },
|
||||
Hint { text: SharedString, depth: u8 },
|
||||
}
|
||||
|
||||
struct CommunityEntry {
|
||||
name: &'static str,
|
||||
// Rendered as a 20px circle with the first letter; no backend yet.
|
||||
}
|
||||
|
||||
fn dummy_communities() -> &'static [CommunityEntry]; // TODO(concord)
|
||||
|
||||
/// Folder/file row. One element for section headers, community rows and hints.
|
||||
#[derive(IntoElement)]
|
||||
struct TreeRow { /* id, depth, caret, icon, avatar, label, count, dot, selected, on_click */ }
|
||||
```
|
||||
|
||||
`Sidebar::tree_rows`:
|
||||
|
||||
```rust
|
||||
// Pins (only when a pinned id resolves to a live room), in pin order
|
||||
// Requests -> chat.rooms(&RoomKind::Request, cx)
|
||||
// Community -> dummy_communities()
|
||||
// Messages -> chat.rooms(&RoomKind::Ongoing, cx)
|
||||
//
|
||||
// Each section emits Section first, then children when expanded,
|
||||
// then a Hint row when expanded and empty.
|
||||
```
|
||||
|
||||
Render integration:
|
||||
|
||||
```rust
|
||||
let rows = Rc::new(self.tree_rows(cx));
|
||||
|
||||
uniform_list("sidebar-tree", rows.len(), cx.processor(move |this, range, _window, cx| {
|
||||
this.render_rows(range, &rows, cx)
|
||||
}))
|
||||
.track_scroll(&self.scroll_handle)
|
||||
```
|
||||
|
||||
`render_rows` matches on `SidebarRow` and builds either a `TreeRow` (sections,
|
||||
communities, hints) or a `RoomEntry` (rooms). Element ids: room rows use the
|
||||
flattened index (`RoomEntry::new(ix)`); non-room rows use
|
||||
`ElementId::NamedInteger("tree-row".into(), ix as u64)`.
|
||||
|
||||
Why a flattened list instead of `gpui_base::Tree` / `VirtualList`: sections are
|
||||
few and fixed, rows are heterogeneous, and the crate already uses
|
||||
`uniform_list` + `Scrollbar::vertical`. The base `Tree`/`VirtualList`
|
||||
primitives remain available if variable row heights are ever needed.
|
||||
|
||||
## 6. Rendering spec
|
||||
|
||||
### 6.1 Nav rail
|
||||
|
||||
Three full-width `Button`s, `ghost_alt`, `small`, dispatching actions:
|
||||
|
||||
```rust
|
||||
Button::new("nav-inbox")
|
||||
.icon(IconName::Inbox)
|
||||
.label("Inbox")
|
||||
.w_full()
|
||||
.justify_start()
|
||||
.on_click(|_ev, _window, cx| {
|
||||
cx.dispatch_action(&Command::ShowInbox);
|
||||
})
|
||||
```
|
||||
|
||||
Icons: `Inbox`, `Compass` (new, Browse), `Search`. `Command` is already
|
||||
imported in `sidebar/mod.rs`, and dispatching actions from a button listener is
|
||||
the existing `greeter.rs` pattern.
|
||||
|
||||
Nav rows carry no `selected` state: the sidebar does not know which panel the
|
||||
dock is showing. Highlighting the active destination is a follow-up if the dock
|
||||
API exposes it.
|
||||
|
||||
### 6.2 Section (folder) row
|
||||
|
||||
- `h_8`, `pl`/`pr` matching the list padding, `rounded(theme.radius)`, full
|
||||
width, hover `ghost_element_hover`.
|
||||
- Caret: `CaretDown` when expanded, `CaretRight` when collapsed.
|
||||
- Icon 16px (`small()`), `text_muted`.
|
||||
- Label: `text_xs`, `font_semibold`, `text_muted`; `flex_1`.
|
||||
- Trailing: count (`text_xs`, `text_placeholder`) for Requests/Pins, and the
|
||||
unread dot (`size_1().rounded_full().bg(theme.cursor)`) when
|
||||
`new_requests && section == Requests`.
|
||||
- Click toggles the section.
|
||||
|
||||
### 6.3 File rows
|
||||
|
||||
- Rooms reuse `RoomEntry` with two additions: `.depth(u8)` (left padding
|
||||
`px(6. + depth * 14.)`) and an optional `.trailing(AnyElement)` slot for the
|
||||
hover ellipsis; height becomes `h_8`.
|
||||
- Community rows use `TreeRow` with a 20px `element_background` circle and the
|
||||
first letter, `text_sm` label.
|
||||
- Indent guide (optional polish): 1px `border_variant` vertical line at the
|
||||
child indent, drawn by the child row.
|
||||
|
||||
### 6.4 Fixed chrome
|
||||
|
||||
`render_user` unchanged. The loading pill stays positioned as today. The
|
||||
"Create DM" floating button and the screening flow move with search to the
|
||||
Search panel. Only the tree body scrolls.
|
||||
|
||||
## 7. Search leaves the sidebar (hidden, not removed)
|
||||
|
||||
Search is now a panel, not a sidebar mode:
|
||||
|
||||
- `Command::ShowSearch` opens `panels/search.rs` in the dock center.
|
||||
- The current implementation moves there intact — same input, debounce
|
||||
(`DebouncedDelay` + `FIND_DELAY`), `NostrRegistry::search`, contact list,
|
||||
multi-select, create-DM flow, and the `RoomEntry` selection/screening
|
||||
behavior. The move is mechanical; no search logic is rewritten or dropped.
|
||||
- The sidebar renders no input and no results/contacts sections, and keeps no
|
||||
copy of the state (a dormant copy would be dead code).
|
||||
- The Search panel is a separate workstream from the tree: it owns the module
|
||||
after the relocation and evolves independently.
|
||||
|
||||
## 8. Pin folder
|
||||
|
||||
- Pin state: `pinned_rooms: Vec<u64>` in `Sidebar`, order = pin order.
|
||||
- UI: hover ellipsis (`IconName::Ellipsis`, `ghost_alt`, `xsmall`, `compact`)
|
||||
on each room row, opening a `DropdownMenu` with `Pin` / `Unpin`
|
||||
(`PopupMenuItem::new(...).on_click(...)`). Verify the trigger click does not
|
||||
also fire the row's `emit_room` click; if it does, `cx.stop_propagation()`
|
||||
in the menu trigger's `on_click`. (There is no right-click menu pattern in
|
||||
the codebase yet; a context menu is a follow-up.)
|
||||
- `Pinned` folder is hidden when no pinned room resolves to a live room;
|
||||
otherwise expanded by default, showing pinned rooms in pin order.
|
||||
- A pinned room remains listed under `Messages`.
|
||||
|
||||
## 9. Requests
|
||||
|
||||
- Folder always rendered, collapsed by default (`expanded` does not contain
|
||||
`Requests`).
|
||||
- Count badge = `chat.count(&RoomKind::Request, cx)`.
|
||||
- Expanding the folder clears `new_requests`.
|
||||
- Children are the same `RoomKind::Request` rooms the old Requests filter
|
||||
showed, with the same `RoomEntry` screening behavior.
|
||||
|
||||
## 10. Community (dummy data)
|
||||
|
||||
- `dummy_communities()` returns 2 entries for now (`Coop Contributors`,
|
||||
`Nostr Design`) so the folder has content; 1-3 is the range the sketch asks
|
||||
for.
|
||||
- Rows do not navigate anywhere yet; clicking is a no-op. Add
|
||||
`// TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md`.
|
||||
- Folder expanded by default.
|
||||
|
||||
## 11. Messages
|
||||
|
||||
- `RoomKind::Ongoing` rooms, using the existing `render_list_items` logic
|
||||
(display name/avatar/member pubkey/kind/created_at, `emit_room` on click).
|
||||
- Expanded by default.
|
||||
- When empty and expanded, show a `Hint` row ("No conversations yet") instead
|
||||
of the current large dashed card; the card is removed.
|
||||
|
||||
## 12. Implementation steps
|
||||
|
||||
Steps 1-4 are additive and compile on their own. Step 5 is one atomic change
|
||||
set: the search relocation and the sidebar render rewrite depend on each other,
|
||||
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.
|
||||
|
||||
- [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`.
|
||||
- [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`.
|
||||
- [x] **Step 3 — `RoomEntry`.** Add `.depth(u8)` and `.trailing(AnyElement)`;
|
||||
change `h_9` to `h_8`.
|
||||
- [x] **Step 4 — panel openers.** Add `Command::{ShowInbox, ShowBrowse,
|
||||
ShowSearch}` and `panels/{inbox,browse,search}.rs` shells (`init`, `Panel`,
|
||||
`Focusable`, `EventEmitter<PanelEvent>`, empty `Render`, following
|
||||
`greeter.rs`); register them in `panels/mod.rs`; handle the commands in
|
||||
`Workspace::on_command` with `add_panel_to_dock(..., DockPlacement::Center, ...)`.
|
||||
All three render empty bodies for now; the Search body is filled in step 5.
|
||||
- [ ] **Step 5 — relocation + render rewrite (atomic, separate workstream
|
||||
handoff).** Move the search/select implementation out of `Sidebar` into
|
||||
`panels/search.rs` (inventory in §7), wiring the input, results, contacts,
|
||||
selection, and create-DM button exactly as they are today; at the same time
|
||||
rewrite the sidebar render (nav rail dispatching the three commands, flattened
|
||||
tree list, scrollbar, `render_user`, loading pill), add
|
||||
`expanded`/`pinned_rooms`/`tree_rows`, and delete `filter`, `current_filter`,
|
||||
`set_filter`, and the sidebar's search state. The search workstream owns the
|
||||
relocated module afterwards.
|
||||
- [ ] **Step 6 — pin UI.** Build the per-row ellipsis dropdown, wire
|
||||
`pin_room`/`unpin_room`.
|
||||
- [ ] **Step 7 — community section.** Render dummy entries and hint; add the
|
||||
`TODO(concord)` marker.
|
||||
- [ ] **Step 8 (optional) — persistence.** Add
|
||||
`#[serde(default)] pinned_rooms: Vec<u64>` (and optionally
|
||||
`expanded_sections: Vec<String>`) to `settings::Settings`, register accessors
|
||||
in `setting_accessors!`, and load/save from `Sidebar`. The `#[serde(default)]`
|
||||
attribute is required: `Settings` has no defaults today, so a new field
|
||||
without it breaks parsing of existing `.settings` files.
|
||||
- [ ] **Step 9 — cleanup.** `cargo fmt`, remove dead imports/helpers, run
|
||||
clippy.
|
||||
|
||||
## 13. Files touched
|
||||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| `crates/workspace/src/sidebar/mod.rs` | State, flattening, render rewrite; search code moves out |
|
||||
| `crates/workspace/src/sidebar/tree.rs` | New: sections, rows, `TreeRow`, dummy data |
|
||||
| `crates/workspace/src/sidebar/entry.rs` | `depth`, `trailing`, height |
|
||||
| `crates/workspace/src/panels/{inbox,browse,search}.rs` | New panel modules |
|
||||
| `crates/workspace/src/panels/mod.rs` | Module registration |
|
||||
| `crates/workspace/src/lib.rs` | `Command` variants + `on_command` arms |
|
||||
| `crates/ui/src/icon.rs` | New icon variants |
|
||||
| `assets/icons/{folder,compass,message}.svg` | New assets |
|
||||
| `crates/settings/src/lib.rs` | Optional step 8 only |
|
||||
|
||||
## 14. Edge cases
|
||||
|
||||
- **Uniform height.** `uniform_list` measures the first row and reuses that
|
||||
height; every row must be `h_8`. If a section row ever needs a different
|
||||
height, switch to `gpui_base::VirtualList` instead of mixing.
|
||||
- **Panel dedupe.** Clicking a nav item whose panel is already open focuses and
|
||||
moves it (`ui::dock::add_panel` looks up `panel_id`); no duplicate tabs.
|
||||
- **Stale pins.** A pinned id whose room is gone is skipped at flatten time
|
||||
(and pruned on the next pin/unpin).
|
||||
- **Empty sections.** Expanded + empty renders a `Hint` row; collapsed sections
|
||||
render nothing.
|
||||
- **Logged out.** `NostrRegistry::current_user()` is `None`: `render_user`
|
||||
keeps its import-identity prompt; sections resolve to empty and show hints.
|
||||
- **Loading.** Sections may be empty; the loading pill stays.
|
||||
- **New requests while collapsed.** The dot shows on the collapsed Requests
|
||||
folder; expanding clears it.
|
||||
- **Image cache.** Keep `retain_all("sidebar")` on the root.
|
||||
- **Element ids.** Flattened index for room rows, `NamedInteger` for others, so
|
||||
expansion/collapse does not smuggle state between rows.
|
||||
|
||||
## 15. Validation
|
||||
|
||||
- `cargo fmt --check` (workspace `rustfmt.toml`).
|
||||
- `cargo check -p workspace` and `cargo clippy -p workspace --all-targets`.
|
||||
- Manual QA checklist:
|
||||
- Inbox/Browse/Search each open their panel; clicking the same nav item again
|
||||
focuses the existing panel instead of duplicating it;
|
||||
- the sidebar has no search input and no results/contacts sections;
|
||||
- the Search panel keeps the old behavior (debounced search, contacts,
|
||||
multi-select, create DM, `Enter` to search);
|
||||
- each folder toggles and keeps its state across re-renders and room updates;
|
||||
- Requests starts collapsed; the dot appears on `ChatEvent::Ping` and clears
|
||||
when expanded;
|
||||
- pin/unpin from the row menu updates the Pinned folder without opening the
|
||||
room; clicking a pinned row opens it;
|
||||
- Messages lists ongoing rooms and still opens the screening modal for
|
||||
non-ongoing rooms;
|
||||
- empty states at 0 ongoing and 0 requests.
|
||||
- There is no GPUI test infrastructure in the repo (no `#[gpui::test]`
|
||||
anywhere), so tests are limited to pure helpers (`TreeSection` defaults, pin
|
||||
ordering) if they are extracted as free functions; `cargo check` plus the
|
||||
manual checklist is the baseline.
|
||||
|
||||
## 16. Open questions
|
||||
|
||||
1. **Persistence.** Persist pins and folder state, or keep them session-local?
|
||||
2. **Row density.** `h_8` vs the current `h_9`; `SIDEBAR_WIDTH` stays 240px for
|
||||
now, one indent level fits.
|
||||
3. **Community entries.** Preferred dummy names/branding before the real
|
||||
registry lands.
|
||||
|
||||
## 17. Out of scope / follow-ups
|
||||
|
||||
- Inbox/Browse panel content (empty bodies in this change).
|
||||
- Search panel development beyond the relocation; any search UI changes happen
|
||||
in that workstream.
|
||||
- Real Concord integration (`ConcordRegistry`, subscriptions, member lists) —
|
||||
tracked in `docs/concord-usage.md`.
|
||||
- Drag-to-reorder pins, pin folders/groups beyond the single `Pinned` folder.
|
||||
- Unread counts per room, nav-item active highlighting.
|
||||
- Variable-height rows or nested subfolders.
|
||||
Reference in New Issue
Block a user