418 lines
19 KiB
Markdown
418 lines
19 KiB
Markdown
# 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.
|