From 88005fbc41d20d00de8cb2e64220637672ad38ec Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 15:53:40 +0700 Subject: [PATCH] update sidebar --- crates/settings/src/lib.rs | 19 ++++++++ crates/workspace/src/sidebar/mod.rs | 51 +++++++++++++++++-- crates/workspace/src/sidebar/tree.rs | 35 +++++++------ docs/sidebar-tree-redesign.md | 73 ++++++++++++++++++++++------ 4 files changed, 142 insertions(+), 36 deletions(-) diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index df86e28a..dac56ba0 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -46,6 +46,8 @@ setting_accessors! { pub nip4e: bool, pub trusted_relays: Vec, pub file_server: Url, + pub pinned_rooms: Vec, + pub expanded_sections: Option>, } /// Signer kind @@ -130,6 +132,14 @@ pub struct Settings { /// Server for blossom media attachments pub file_server: Url, + + /// Pinned sidebar room ids, in pin order + #[serde(default)] + pub pinned_rooms: Vec, + + /// Expanded sidebar tree sections; `None` means the default sections + #[serde(default)] + pub expanded_sections: Option>, } impl Default for Settings { @@ -142,6 +152,8 @@ impl Default for Settings { nip4e: false, trusted_relays: vec![], file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(), + pinned_rooms: vec![], + expanded_sections: None, } } } @@ -171,6 +183,13 @@ impl AppSettings { cx.global::().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 { + &self.inner + } + /// Set the global settings instance fn set_global(state: Entity, cx: &mut App) { cx.set_global(GlobalAppSettings(state)); diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 9498a9dc..a3e12e2a 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -12,6 +12,7 @@ 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}; @@ -49,17 +50,17 @@ pub struct Sidebar { pinned_rooms: Vec, /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, + _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 mut subscriptions = smallvec![]; subscriptions.push( - // Subscribe for registry new events cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| { if event == &ChatEvent::Ping { this.new_requests = true; @@ -68,12 +69,16 @@ impl Sidebar { }), ); + subscriptions.push(cx.observe(&settings, move |this, _settings, cx| { + this.restore_state(cx); + })); + Self { focus_handle: cx.focus_handle(), scroll_handle: UniformListScrollHandle::new(), new_requests: false, - expanded: BTreeSet::from([TreeSection::Community, TreeSection::Messages]), - pinned_rooms: Vec::new(), + expanded: load_expanded(cx), + pinned_rooms: AppSettings::get_pinned_rooms(cx), _subscriptions: subscriptions, } } @@ -87,6 +92,7 @@ impl Sidebar { self.new_requests = false; } + self.save_expanded(cx); cx.notify(); } @@ -94,16 +100,43 @@ impl Sidebar { self.expanded.contains(§ion) } + fn restore_state(&mut self, cx: &mut Context) { + 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) { if !self.pinned_rooms.contains(&room_id) { self.pinned_rooms.push(room_id); } self.expanded.insert(TreeSection::Pins); + + AppSettings::update_pinned_rooms(self.pinned_rooms.clone(), cx); + self.save_expanded(cx); cx.notify(); } fn unpin_room(&mut self, room_id: u64, cx: &mut Context) { self.pinned_rooms.retain(|id| *id != room_id); + + AppSettings::update_pinned_rooms(self.pinned_rooms.clone(), cx); 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 { + 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 { fn panel_id(&self) -> SharedString { "Sidebar".into() diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index f7c15a77..36a9db9e 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -9,7 +9,6 @@ use gpui::{ 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, @@ -34,9 +33,27 @@ impl TreeSection { 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 { + 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 { Section { section: TreeSection, @@ -57,12 +74,10 @@ pub enum SidebarRow { }, } -/// 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. &[ @@ -75,7 +90,6 @@ pub fn dummy_communities() -> &'static [CommunityEntry] { ] } -/// Presentation differences between the rows [`TreeRow`] draws. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TreeRowKind { Section, @@ -83,7 +97,6 @@ pub enum TreeRowKind { Hint, } -/// Folder/file row. One element for section headers, community rows and hints. #[derive(IntoElement)] pub struct TreeRow { id: ElementId, @@ -95,7 +108,6 @@ pub struct TreeRow { label: SharedString, count: Option, dot: bool, - selected: bool, #[allow(clippy::type_complexity)] on_click: Option>, } @@ -116,7 +128,6 @@ impl TreeRow { label: label.into(), count: None, dot: false, - selected: false, on_click: None, } } @@ -151,11 +162,6 @@ impl TreeRow { 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, @@ -196,9 +202,6 @@ impl RenderOnce for TreeRow { .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)) }) diff --git a/docs/sidebar-tree-redesign.md b/docs/sidebar-tree-redesign.md index a59ab35e..002995d5 100644 --- a/docs/sidebar-tree-redesign.md +++ b/docs/sidebar-tree-redesign.md @@ -1,10 +1,11 @@ # 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 -Community section from placeholder data (`TODO(concord)`). Remaining: optional -step 8 (persistence), step 9 (remove the unused `TreeRow::selected` and run the -final cleanup). +Community section from placeholder data (`TODO(concord)`). Pins and expanded +sections persist through `settings::Settings`. `cargo check`, `cargo clippy +--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`), new panel shells in `crates/workspace/src/panels/`, and the `Command` wiring in @@ -108,6 +109,20 @@ pinned_rooms: Vec, // room ids in pin order Defaults: `expanded = {Community, Messages}` (Requests intentionally absent; Pins only matters when non-empty and starts expanded). +Both fields persist through `settings::Settings`: + +```rust +#[serde(default)] pinned_rooms: Vec, +#[serde(default)] expanded_sections: Option>, +``` + +`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: ```rust @@ -116,6 +131,7 @@ fn is_expanded(&self, section: TreeSection) -> bool; fn pin_room(&mut self, room_id: u64, cx: &mut Context); fn unpin_room(&mut self, room_id: u64, cx: &mut Context); fn is_pinned(&self, room_id: u64) -> bool; +fn restore_state(&mut self, cx: &mut Context); // step 8 fn tree_rows(&self, cx: &App) -> Vec; // 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` -> `TreeRow`), so this step added the missing hint branch and confirmed the §10 placeholder names. -- [ ] **Step 8 (optional) — persistence.** Add - `#[serde(default)] pinned_rooms: Vec` (and optionally - `expanded_sections: Vec`) 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. +- [x] **Step 8 — persistence.** `settings::Settings` gained + `#[serde(default)] pinned_rooms: Vec` and + `#[serde(default)] expanded_sections: Option>`, both registered in + `setting_accessors!` (so `AppSettings::get_*`/`update_*` exist). The + `#[serde(default)]` attribute is required: `Settings` has no serde defaults, so + a new field without it breaks parsing of existing `.settings` files. `Sidebar::new` + loads both (falling back to the default sections when the setting is `None`), + 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` (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 @@ -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/ui/src/icon.rs` | New icon variants | | `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 @@ -394,8 +430,10 @@ unused until step 5 consumes them. Run the checks in §15 after each step. ## 15. Validation -- `cargo fmt --check` (workspace `rustfmt.toml`). -- `cargo check -p workspace` and `cargo clippy -p workspace --all-targets`. +- `rustfmt +nightly --check` on the changed files (not `cargo fmt --all`: the + 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: - Inbox/Browse/Search each open their panel; clicking the same nav item again 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; - Messages lists ongoing rooms and still opens the screening modal for 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. - There is no GPUI test infrastructure in the repo (no `#[gpui::test]` 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 -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 now, one indent level fits. 3. **Community entries.** Preferred dummy names/branding before the real