update sidebar

This commit is contained in:
2026-09-18 15:53:40 +07:00
parent 41bd0ce345
commit 88005fbc41
4 changed files with 142 additions and 36 deletions
+19
View File
@@ -46,6 +46,8 @@ setting_accessors! {
pub nip4e: bool, pub nip4e: bool,
pub trusted_relays: Vec<String>, pub trusted_relays: Vec<String>,
pub file_server: Url, pub file_server: Url,
pub pinned_rooms: Vec<u64>,
pub expanded_sections: Option<Vec<String>>,
} }
/// Signer kind /// Signer kind
@@ -130,6 +132,14 @@ pub struct Settings {
/// Server for blossom media attachments /// Server for blossom media attachments
pub file_server: Url, pub file_server: Url,
/// Pinned sidebar room ids, in pin order
#[serde(default)]
pub pinned_rooms: Vec<u64>,
/// Expanded sidebar tree sections; `None` means the default sections
#[serde(default)]
pub expanded_sections: Option<Vec<String>>,
} }
impl Default for Settings { impl Default for Settings {
@@ -142,6 +152,8 @@ impl Default for Settings {
nip4e: false, nip4e: false,
trusted_relays: vec![], trusted_relays: vec![],
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(), file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
pinned_rooms: vec![],
expanded_sections: None,
} }
} }
} }
@@ -171,6 +183,13 @@ impl AppSettings {
cx.global::<GlobalAppSettings>().0.clone() cx.global::<GlobalAppSettings>().0.clone()
} }
/// The underlying settings entity, which notifies whenever any field changes.
/// Settings load asynchronously, so observers can watch it to pick up values
/// that arrive after construction.
pub fn entity(&self) -> &Entity<Settings> {
&self.inner
}
/// Set the global settings instance /// Set the global settings instance
fn set_global(state: Entity<Self>, cx: &mut App) { fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAppSettings(state)); cx.set_global(GlobalAppSettings(state));
+47 -4
View File
@@ -12,6 +12,7 @@ use gpui::{
UniformListScrollHandle, Window, div, px, retain_all, uniform_list, UniformListScrollHandle, Window, div, px, retain_all, uniform_list,
}; };
use person::PersonRegistry; use person::PersonRegistry;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::NostrRegistry; use state::NostrRegistry;
use theme::{ActiveTheme, TABBAR_HEIGHT}; use theme::{ActiveTheme, TABBAR_HEIGHT};
@@ -49,17 +50,17 @@ pub struct Sidebar {
pinned_rooms: Vec<u64>, pinned_rooms: Vec<u64>,
/// Event subscriptions /// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 2]>,
} }
impl Sidebar { impl Sidebar {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let settings = AppSettings::global(cx).read(cx).entity().clone();
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
subscriptions.push( subscriptions.push(
// Subscribe for registry new events
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| { cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
if event == &ChatEvent::Ping { if event == &ChatEvent::Ping {
this.new_requests = true; this.new_requests = true;
@@ -68,12 +69,16 @@ impl Sidebar {
}), }),
); );
subscriptions.push(cx.observe(&settings, move |this, _settings, cx| {
this.restore_state(cx);
}));
Self { Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
scroll_handle: UniformListScrollHandle::new(), scroll_handle: UniformListScrollHandle::new(),
new_requests: false, new_requests: false,
expanded: BTreeSet::from([TreeSection::Community, TreeSection::Messages]), expanded: load_expanded(cx),
pinned_rooms: Vec::new(), pinned_rooms: AppSettings::get_pinned_rooms(cx),
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -87,6 +92,7 @@ impl Sidebar {
self.new_requests = false; self.new_requests = false;
} }
self.save_expanded(cx);
cx.notify(); cx.notify();
} }
@@ -94,16 +100,43 @@ impl Sidebar {
self.expanded.contains(&section) self.expanded.contains(&section)
} }
fn restore_state(&mut self, cx: &mut Context<Self>) {
let pinned_rooms = AppSettings::get_pinned_rooms(cx);
let expanded = load_expanded(cx);
if self.pinned_rooms == pinned_rooms && self.expanded == expanded {
return;
}
self.pinned_rooms = pinned_rooms;
self.expanded = expanded;
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 pin_room(&mut self, room_id: u64, cx: &mut Context<Self>) { fn pin_room(&mut self, room_id: u64, cx: &mut Context<Self>) {
if !self.pinned_rooms.contains(&room_id) { if !self.pinned_rooms.contains(&room_id) {
self.pinned_rooms.push(room_id); self.pinned_rooms.push(room_id);
} }
self.expanded.insert(TreeSection::Pins); self.expanded.insert(TreeSection::Pins);
AppSettings::update_pinned_rooms(self.pinned_rooms.clone(), cx);
self.save_expanded(cx);
cx.notify(); cx.notify();
} }
fn unpin_room(&mut self, room_id: u64, cx: &mut Context<Self>) { fn unpin_room(&mut self, room_id: u64, cx: &mut Context<Self>) {
self.pinned_rooms.retain(|id| *id != room_id); self.pinned_rooms.retain(|id| *id != room_id);
AppSettings::update_pinned_rooms(self.pinned_rooms.clone(), cx);
cx.notify(); cx.notify();
} }
@@ -443,6 +476,16 @@ fn nav_item(id: &'static str, icon: IconName, label: &'static str, command: Comm
}) })
} }
fn load_expanded(cx: &App) -> BTreeSet<TreeSection> {
let Some(keys) = AppSettings::get_expanded_sections(cx) else {
return BTreeSet::from([TreeSection::Community, TreeSection::Messages]);
};
keys.iter()
.filter_map(|key| TreeSection::from_key(key.as_str()))
.collect()
}
impl Panel for Sidebar { impl Panel for Sidebar {
fn panel_id(&self) -> SharedString { fn panel_id(&self) -> SharedString {
"Sidebar".into() "Sidebar".into()
+19 -16
View File
@@ -9,7 +9,6 @@ use gpui::{
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::{Icon, IconName, Sizable, StyledExt, h_flex}; 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)] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TreeSection { pub enum TreeSection {
Pins, Pins,
@@ -34,9 +33,27 @@ impl TreeSection {
Self::Messages => IconName::Message, Self::Messages => IconName::Message,
} }
} }
pub fn key(self) -> &'static str {
match self {
Self::Pins => "pins",
Self::Requests => "requests",
Self::Community => "community",
Self::Messages => "messages",
}
}
pub fn from_key(key: &str) -> Option<Self> {
match key {
"pins" => Some(Self::Pins),
"requests" => Some(Self::Requests),
"community" => Some(Self::Community),
"messages" => Some(Self::Messages),
_ => None,
}
}
} }
/// One rendered tree row, in flattened order.
pub enum SidebarRow { pub enum SidebarRow {
Section { Section {
section: TreeSection, section: TreeSection,
@@ -57,12 +74,10 @@ pub enum SidebarRow {
}, },
} }
/// A community shown under the Community section.
pub struct CommunityEntry { pub struct CommunityEntry {
pub name: &'static str, pub name: &'static str,
} }
/// Communities to show until the Concord backend is wired up.
pub fn dummy_communities() -> &'static [CommunityEntry] { pub fn dummy_communities() -> &'static [CommunityEntry] {
// TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md. // TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md.
&[ &[
@@ -75,7 +90,6 @@ pub fn dummy_communities() -> &'static [CommunityEntry] {
] ]
} }
/// Presentation differences between the rows [`TreeRow`] draws.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TreeRowKind { pub enum TreeRowKind {
Section, Section,
@@ -83,7 +97,6 @@ pub enum TreeRowKind {
Hint, Hint,
} }
/// Folder/file row. One element for section headers, community rows and hints.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct TreeRow { pub struct TreeRow {
id: ElementId, id: ElementId,
@@ -95,7 +108,6 @@ pub struct TreeRow {
label: SharedString, label: SharedString,
count: Option<usize>, count: Option<usize>,
dot: bool, dot: bool,
selected: bool,
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>, on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
} }
@@ -116,7 +128,6 @@ impl TreeRow {
label: label.into(), label: label.into(),
count: None, count: None,
dot: false, dot: false,
selected: false,
on_click: None, on_click: None,
} }
} }
@@ -151,11 +162,6 @@ impl TreeRow {
self self
} }
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn on_click( pub fn on_click(
mut self, mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -196,9 +202,6 @@ impl RenderOnce for TreeRow {
.font_normal() .font_normal()
.text_color(cx.theme().text_placeholder) .text_color(cx.theme().text_placeholder)
}) })
.when(self.selected, |this| {
this.bg(cx.theme().ghost_element_selected)
})
.when_some(self.caret, |this, caret| { .when_some(self.caret, |this, caret| {
this.child(Icon::new(caret).xsmall().text_color(cx.theme().icon_muted)) this.child(Icon::new(caret).xsmall().text_color(cx.theme().icon_muted))
}) })
+57 -16
View File
@@ -1,10 +1,11 @@
# Sidebar tree redesign # Sidebar tree redesign
Status: steps 1-7 implemented. Search lives in `panels/search.rs`; the sidebar Status: steps 1-9 implemented. Search lives in `panels/search.rs`; the sidebar
renders the nav rail, the flattened tree, per-row pin/unpin menus, and the renders the nav rail, the flattened tree, per-row pin/unpin menus, and the
Community section from placeholder data (`TODO(concord)`). Remaining: optional Community section from placeholder data (`TODO(concord)`). Pins and expanded
step 8 (persistence), step 9 (remove the unused `TreeRow::selected` and run the sections persist through `settings::Settings`. `cargo check`, `cargo clippy
final cleanup). --workspace --all-targets` and `rustfmt --check` on the changed files are clean.
Remaining: the §15 manual QA checklist (needs the running app).
Scope: `crates/workspace/src/sidebar` (`mod.rs`, `entry.rs`, new `tree.rs`), 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 new panel shells in `crates/workspace/src/panels/`, and the `Command` wiring in
@@ -108,6 +109,20 @@ pinned_rooms: Vec<u64>, // room ids in pin order
Defaults: `expanded = {Community, Messages}` (Requests intentionally absent; Defaults: `expanded = {Community, Messages}` (Requests intentionally absent;
Pins only matters when non-empty and starts expanded). Pins only matters when non-empty and starts expanded).
Both fields persist through `settings::Settings`:
```rust
#[serde(default)] pinned_rooms: Vec<u64>,
#[serde(default)] expanded_sections: Option<Vec<String>>,
```
`expanded_sections` is an `Option` so that an empty list (the user collapsed
everything) is distinguishable from the field never having been written, which
keeps the `{Community, Messages}` default. `TreeSection::key()`/`from_key()` map
the sections to their stable string keys. Because settings load asynchronously,
`Sidebar` observes the settings entity and re-reads both fields in
`restore_state` instead of trusting the constructor's read.
New methods: New methods:
```rust ```rust
@@ -116,6 +131,7 @@ fn is_expanded(&self, section: TreeSection) -> bool;
fn pin_room(&mut self, room_id: u64, cx: &mut Context<Self>); 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 unpin_room(&mut self, room_id: u64, cx: &mut Context<Self>);
fn is_pinned(&self, room_id: u64) -> bool; fn is_pinned(&self, room_id: u64) -> bool;
fn restore_state(&mut self, cx: &mut Context<Self>); // step 8
fn tree_rows(&self, cx: &App) -> Vec<SidebarRow>; // see §5 fn tree_rows(&self, cx: &App) -> Vec<SidebarRow>; // see §5
``` ```
@@ -349,14 +365,34 @@ unused until step 5 consumes them. Run the checks in §15 after each step.
flattening and rendering landed with step 5 (`SidebarRow::Community` -> flattening and rendering landed with step 5 (`SidebarRow::Community` ->
`TreeRow`), so this step added the missing hint branch and confirmed the §10 `TreeRow`), so this step added the missing hint branch and confirmed the §10
placeholder names. placeholder names.
- [ ] **Step 8 (optional) — persistence.** Add - [x] **Step 8 — persistence.** `settings::Settings` gained
`#[serde(default)] pinned_rooms: Vec<u64>` (and optionally `#[serde(default)] pinned_rooms: Vec<u64>` and
`expanded_sections: Vec<String>`) to `settings::Settings`, register accessors `#[serde(default)] expanded_sections: Option<Vec<String>>`, both registered in
in `setting_accessors!`, and load/save from `Sidebar`. The `#[serde(default)]` `setting_accessors!` (so `AppSettings::get_*`/`update_*` exist). The
attribute is required: `Settings` has no defaults today, so a new field `#[serde(default)]` attribute is required: `Settings` has no serde defaults, so
without it breaks parsing of existing `.settings` files. a new field without it breaks parsing of existing `.settings` files. `Sidebar::new`
- [ ] **Step 9 — cleanup.** `cargo fmt`, remove dead imports/helpers, run loads both (falling back to the default sections when the setting is `None`),
clippy. and `toggle_section`/`pin_room`/`unpin_room` write back through
`AppSettings::update_*`; the settings observer already saves on every change, so
no explicit file I/O was added. `expanded_sections` is `Option` so that
collapsing every folder does not silently revert to the default on restart.
Stale pinned ids are still skipped at flatten time rather than pruned on load.
Settings load asynchronously (a deferred, background file read), so the
constructor's read always sees defaults on a cold start. To pick up the loaded
values, `AppSettings::entity()` now exposes the inner `Entity<Settings>` (it
notifies on every field change) and `Sidebar` observes it, re-reading through
`restore_state` and re-rendering only when the values actually differ. Without
this the sidebar would render with empty pins until the next unrelated change.
The observation is on the inner entity because `AppSettings` itself never
notifies its own observers.
- [x] **Step 9 — cleanup.** Removed `TreeRow::selected` (the field, the builder
method, and the `ghost_element_selected` render branch) — it was the only dead
code left after step 5. No other unused imports or helpers remained.
`cargo clippy --workspace --all-targets` reports zero warnings. Formatting is
checked per file with `rustfmt +nightly --check`; `cargo fmt --all` is **not**
run, because the repo's committed formatting does not match the installed
nightly rustfmt (many pre-existing diffs in unrelated files).
## 13. Files touched ## 13. Files touched
@@ -370,7 +406,7 @@ unused until step 5 consumes them. Run the checks in §15 after each step.
| `crates/workspace/src/lib.rs` | `Command` variants + `on_command` arms | | `crates/workspace/src/lib.rs` | `Command` variants + `on_command` arms |
| `crates/ui/src/icon.rs` | New icon variants | | `crates/ui/src/icon.rs` | New icon variants |
| `assets/icons/{folder,compass,message}.svg` | New assets | | `assets/icons/{folder,compass,message}.svg` | New assets |
| `crates/settings/src/lib.rs` | Optional step 8 only | | `crates/settings/src/lib.rs` | Step 8: `pinned_rooms`, `expanded_sections`, accessors, `entity()` |
## 14. Edge cases ## 14. Edge cases
@@ -394,8 +430,10 @@ unused until step 5 consumes them. Run the checks in §15 after each step.
## 15. Validation ## 15. Validation
- `cargo fmt --check` (workspace `rustfmt.toml`). - `rustfmt +nightly --check` on the changed files (not `cargo fmt --all`: the
- `cargo check -p workspace` and `cargo clippy -p workspace --all-targets`. repo's committed formatting does not match the installed nightly rustfmt, so a
workspace-wide check reports many pre-existing diffs).
- `cargo check --workspace` and `cargo clippy --workspace --all-targets`.
- Manual QA checklist: - Manual QA checklist:
- Inbox/Browse/Search each open their panel; clicking the same nav item again - Inbox/Browse/Search each open their panel; clicking the same nav item again
focuses the existing panel instead of duplicating it; focuses the existing panel instead of duplicating it;
@@ -409,6 +447,8 @@ unused until step 5 consumes them. Run the checks in §15 after each step.
room; clicking a pinned row opens it; room; clicking a pinned row opens it;
- Messages lists ongoing rooms and still opens the screening modal for - Messages lists ongoing rooms and still opens the screening modal for
non-ongoing rooms; non-ongoing rooms;
- pins and expanded/collapsed folders survive an app restart (collapsing every
folder also survives, rather than reverting to the default sections);
- empty states at 0 ongoing and 0 requests. - empty states at 0 ongoing and 0 requests.
- There is no GPUI test infrastructure in the repo (no `#[gpui::test]` - There is no GPUI test infrastructure in the repo (no `#[gpui::test]`
anywhere), so tests are limited to pure helpers (`TreeSection` defaults, pin anywhere), so tests are limited to pure helpers (`TreeSection` defaults, pin
@@ -417,7 +457,8 @@ unused until step 5 consumes them. Run the checks in §15 after each step.
## 16. Open questions ## 16. Open questions
1. **Persistence.** Persist pins and folder state, or keep them session-local? 1. **Persistence.** Resolved in step 8: pins and expanded sections persist in
`settings::Settings`.
2. **Row density.** `h_8` vs the current `h_9`; `SIDEBAR_WIDTH` stays 240px for 2. **Row density.** `h_8` vs the current `h_9`; `SIDEBAR_WIDTH` stays 240px for
now, one indent level fits. now, one indent level fits.
3. **Community entries.** Preferred dummy names/branding before the real 3. **Community entries.** Preferred dummy names/branding before the real