From 568b6c0e415438cefa9c1c7e185c6d09ca19e96a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 11 Sep 2026 10:46:31 +0700 Subject: [PATCH] update --- crates/dock/src/lib.rs | 13 + crates/workspace/src/views/inbox.rs | 291 +++++++++++++++++- crates/workspace/src/views/mod.rs | 2 +- crates/workspace/src/views/repo_detail/mod.rs | 50 ++- crates/workspace/src/views/sidebar/mod.rs | 2 +- docs/inbox-plan.md | 165 +++++++--- 6 files changed, 461 insertions(+), 62 deletions(-) diff --git a/crates/dock/src/lib.rs b/crates/dock/src/lib.rs index 6c262af..005b376 100644 --- a/crates/dock/src/lib.rs +++ b/crates/dock/src/lib.rs @@ -26,6 +26,19 @@ pub fn add_center_panel( area.add_panel_view(panel, DockPlacement::Center, None, window, cx); } +/// Add an already-wrapped panel handle to the bottom dock of `area`. +/// +/// Used for sub-views that hang under the center, such as the inbox's Unread +/// and Archived lists. +pub fn add_bottom_panel( + area: &mut DockArea, + panel: Arc, + window: &mut Window, + cx: &mut Context, +) { + area.add_panel_view(panel, DockPlacement::Bottom, None, window, cx); +} + /// The fixed height of the tab bar, which doubles as the window title bar. pub const TAB_BAR_HEIGHT: Pixels = px(44.); diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index acd6826..7a39724 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -3,12 +3,14 @@ use std::time::Duration; use anyhow::Error; use assets::CustomIconName; -use dock::{BasePanel, Panel, PanelEvent}; +use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, add_bottom_panel, panel_handle}; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState, - Pixels, Render, SharedString, Subscription, Task, Window, div, list, px, + AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment, + ListState, Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div, + list, px, }; +use gpui_component::button::{Button, ButtonVariants}; use gpui_component::scroll::ScrollableElement; use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex}; use nostr::prelude::{Event, EventId, Kind}; @@ -21,6 +23,8 @@ use signed_state::{ use signed_ui::{CountBadge, SegmentButton, UserAvatar}; use utils::relative_time; +use super::{RepoItem, open_repo_item, open_repo_panel}; + /// Delay between a refresh request and the actual re-query. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); @@ -29,6 +33,11 @@ const LIST_OVERDRAW: Pixels = px(400.); pub struct InboxView { focus_handle: FocusHandle, + /// The dock area the Unread / Archived sub-view is added to. + dock_area: WeakEntity, + /// The open Unread / Archived sub-view, if any. Reused instead of adding a + /// duplicate panel on every header click. + filter_view: Option>, /// Notifications grouped by thread root, newest activity first. notifications: Arc>, /// The user's own recent git activity, newest first. @@ -49,7 +58,7 @@ pub struct InboxView { } impl InboxView { - pub fn new(cx: &mut Context) -> Self { + pub fn new(dock_area: WeakEntity, cx: &mut Context) -> Self { let backend = Backend::global(cx); let inbox = backend.read(cx).inbox(); let weak = cx.entity().downgrade(); @@ -87,6 +96,8 @@ impl InboxView { Self { focus_handle: cx.focus_handle(), + dock_area, + filter_view: None, notifications: Arc::new(Vec::new()), activity: Arc::new(Vec::new()), unread_count: 0, @@ -100,7 +111,6 @@ impl InboxView { } /// Mark every event in the group rooted at `root` read. - #[allow(dead_code)] // Wired up by the Phase 3 Unread/Archived panels. pub fn mark_read(&mut self, root: EventId, cx: &mut Context) { let Some(me) = Backend::global(cx).read(cx).current_user() else { return; @@ -116,7 +126,6 @@ impl InboxView { } /// Archive the group rooted at `root`. - #[allow(dead_code)] // Wired up by the Phase 3 Unread/Archived panels. pub fn mark_archived(&mut self, root: EventId, cx: &mut Context) { let Some(me) = Backend::global(cx).read(cx).current_user() else { return; @@ -142,6 +151,35 @@ impl InboxView { inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx)); } + /// Show `mode` in the bottom dock, reusing the panel when it is already open. + fn open_filter(&mut self, mode: InboxFilter, window: &mut Window, cx: &mut Context) { + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + if let Some(filter) = self.filter_view.as_ref().and_then(WeakEntity::upgrade) { + filter.update(cx, |filter, cx| filter.set_mode(mode, cx)); + + let handle = filter.read(cx).focus_handle.clone(); + window.focus(&handle, cx); + + dock_area.update(cx, |dock_area, cx| { + if !dock_area.is_dock_open(DockPlacement::Bottom) { + dock_area.toggle_dock(DockPlacement::Bottom, window, cx); + } + }); + return; + } + + let inbox = cx.entity(); + let panel = cx.new(|cx| InboxFilterView::new(mode, inbox, cx)); + self.filter_view = Some(panel.downgrade()); + + dock_area.update(cx, |dock_area, cx| { + add_bottom_panel(dock_area, panel_handle(panel), window, cx); + }); + } + /// Re-derive from the global state when it is loaded or changes. pub fn sync_state(&mut self, cx: &mut Context) { let inbox = Backend::global(cx).read(cx).inbox(); @@ -307,7 +345,6 @@ impl InboxView { } /// Events of the group rooted at `root`. - #[allow(dead_code)] // Only used by the Phase 3 mark actions. fn group_events(&self, root: EventId) -> Option> { self.notifications .iter() @@ -368,6 +405,20 @@ impl InboxView { ) .when(unread > 0, |this| this.child(CountBadge::new(unread))) .child(div().flex_1()) + .child( + SegmentButton::new("inbox-unread", "Unread") + .icon(Icon::new(IconName::Inbox).small()) + .on_click(cx.listener(|this, _event, window, cx| { + this.open_filter(InboxFilter::Unread, window, cx); + })), + ) + .child( + SegmentButton::new("inbox-archived", "Archived") + .icon(Icon::new(IconName::FolderClosed).small()) + .on_click(cx.listener(|this, _event, window, cx| { + this.open_filter(InboxFilter::Archived, window, cx); + })), + ) .child( SegmentButton::new("mark-all-read", "Mark all read") .on_click(cx.listener(|this, _event, _window, cx| this.mark_all_read(cx))), @@ -377,11 +428,22 @@ impl InboxView { empty_state(IconName::Inbox, "You're all caught up.", cx) } else { let list_state = self.notifications_list.clone(); + let dock_area = self.dock_area.clone(); let rows = list(list_state.clone(), move |ix, _window, cx| { let Some(item) = visible.get(ix).and_then(|&ix| notifications.get(ix)) else { return div().into_any_element(); }; - notification_row(ix, item, cx) + + let root = item.root; + let kind = item.root_kind; + let address = item.address.clone(); + let dock_area = dock_area.clone(); + + notification_row("inbox-row", ix, item, cx) + .on_click(move |_, window, cx| { + open_item(&dock_area, root, kind, address.clone(), window, cx); + }) + .into_any_element() }) .size_full() .min_h_0(); @@ -451,10 +513,56 @@ fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option { .map(display_name) } +/// Open the repository of a notification group, and the issue or pull request +/// detail when the group's root is one. +/// +/// The group carries only the repository coordinate, so the announcement is +/// looked up in the local list. A group whose repository is not known locally +/// opens nothing. +fn open_item( + dock_area: &WeakEntity, + root: EventId, + kind: Option, + address: Option, + window: &mut Window, + cx: &mut App, +) { + let Some(address) = address else { + return; + }; + + let Some(announcement) = RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|announcement| announcement.addr() == address) + .cloned() + else { + return; + }; + + let detail = open_repo_panel(dock_area, &announcement, window, cx); + let Some(store) = detail.read(cx).store() else { + return; + }; + + let item = match kind { + Some(Kind::GitIssue) => RepoItem::Issue(root), + Some(Kind::GitPullRequest) => RepoItem::PullRequest(root), + Some(Kind::GitPatch) => RepoItem::Patch, + _ => return, + }; + + open_repo_item(dock_area, store, item, window, cx); +} + /// Leading row of a notification group, newest event first. -fn notification_row(ix: usize, item: &InboxItem, cx: &App) -> AnyElement { +/// +/// `prefix` scopes the row's element id, so the inbox list and the Unread / +/// Archived list do not collide when both are on screen. +fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App) -> Stateful
{ let Some(newest) = item.events.first() else { - return div().into_any_element(); + return div().id((prefix, ix)); }; let profiles = ProfileStore::global(cx); @@ -466,7 +574,7 @@ fn notification_row(ix: usize, item: &InboxItem, cx: &App) -> AnyElement { let unread = item.is_unread(); h_flex() - .id(("inbox-row", ix)) + .id((prefix, ix)) .w_full() .gap_3() .px_3() @@ -511,7 +619,6 @@ fn notification_row(ix: usize, item: &InboxItem, cx: &App) -> AnyElement { .bg(cx.theme().primary), ) }) - .into_any_element() } /// One row of the user's own recent git activity. @@ -634,6 +741,166 @@ fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement { .into_any_element() } +/// Which subset of the inbox a bottom-dock sub-view shows. +#[derive(Clone, Copy, PartialEq, Eq)] +enum InboxFilter { + Unread, + Archived, +} + +impl InboxFilter { + /// Tab and empty-state label of the sub-view. + fn label(self) -> &'static str { + match self { + Self::Unread => "Unread", + Self::Archived => "Archived", + } + } + + /// Whether `item` belongs in this sub-view. + fn matches(self, item: &InboxItem) -> bool { + match self { + Self::Unread => item.is_unread(), + Self::Archived => item.archived, + } + } +} + +/// The Unread / Archived sub-view of the inbox, opened in the bottom dock. +struct InboxFilterView { + focus_handle: FocusHandle, + /// The inbox panel whose groups are filtered. A strong handle: the panel + /// keeps only a weak one back, so the two entities do not form a cycle. + inbox: Entity, + mode: InboxFilter, + list: ListState, +} + +impl InboxFilterView { + fn new(mode: InboxFilter, inbox: Entity, cx: &mut Context) -> Self { + let list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW); + let weak = cx.entity().downgrade(); + list.set_scroll_handler(move |_, _, cx| { + let weak = weak.clone(); + cx.defer(move |cx| { + let _ = weak.update(cx, |_, cx| cx.notify()); + }); + }); + + Self { + focus_handle: cx.focus_handle(), + inbox, + mode, + list, + } + } + + /// Switch which subset is shown, when the header asks for another mode. + fn set_mode(&mut self, mode: InboxFilter, cx: &mut Context) { + if self.mode == mode { + return; + } + self.mode = mode; + cx.notify(); + } +} + +impl BasePanel for InboxFilterView { + fn panel_name(&self) -> &'static str { + "inbox_filter" + } +} + +impl Panel for InboxFilterView { + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().text_sm().child(SharedString::from(self.mode.label())) + } +} + +impl EventEmitter for InboxFilterView {} + +impl Focusable for InboxFilterView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for InboxFilterView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let mode = self.mode; + let notifications = self.inbox.read(cx).notifications.clone(); + + let visible: Vec = notifications + .iter() + .enumerate() + .filter(|(_, item)| mode.matches(item)) + .map(|(ix, _)| ix) + .collect(); + + if self.list.item_count() != visible.len() { + self.list.reset(visible.len()); + } + + let list_state = self.list.clone(); + let inbox = self.inbox.clone(); + + let body: AnyElement = if visible.is_empty() { + let (icon, message) = match mode { + InboxFilter::Unread => (IconName::Inbox, "Nothing unread."), + InboxFilter::Archived => (IconName::FolderClosed, "Nothing archived."), + }; + empty_state(icon, message, cx) + } else { + let rows = list(list_state.clone(), move |ix, _window, cx| { + let Some(item) = visible.get(ix).and_then(|&ix| notifications.get(ix)) else { + return div().into_any_element(); + }; + + match mode { + InboxFilter::Unread => { + let root = item.root; + let open = inbox.clone(); + let archive = inbox.clone(); + + notification_row("inbox-filter-row", ix, item, cx) + .on_click(move |_, _, cx| { + open.update(cx, |view, cx| view.mark_read(root, cx)); + }) + .child( + Button::new(("inbox-filter-archive", ix)) + .icon(IconName::FolderClosed) + .small() + .ghost() + .tab_stop(false) + .tooltip("Archive") + .on_click(move |_, _, cx| { + cx.stop_propagation(); + archive.update(cx, |view, cx| view.mark_archived(root, cx)); + }), + ) + .into_any_element() + } + InboxFilter::Archived => { + notification_row("inbox-filter-row", ix, item, cx).into_any_element() + } + } + }) + .size_full() + .min_h_0(); + + div() + .relative() + .flex_1() + .min_h_0() + .child(rows) + .vertical_scrollbar(&list_state) + .into_any_element() + }; + + v_flex().size_full().min_h_0().p_2().child(body) + } +} + impl BasePanel for InboxView { fn panel_name(&self) -> &'static str { "inbox" diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index 6659761..eb79c7e 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -6,6 +6,6 @@ pub(crate) mod sidebar; pub use inbox::InboxView; pub use repo_detail::RepoDetailView; -pub(crate) use repo_detail::open_repo_panel; +pub(crate) use repo_detail::{RepoItem, open_repo_item, open_repo_panel}; pub use repo_list::RepoListView; pub use sidebar::SidebarPanel; diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 8f4f4dc..575ef3c 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Component, Path, PathBuf}; use std::rc::Rc; +use std::sync::Arc; use std::time::Duration; use anyhow::Error; @@ -13,6 +14,7 @@ use gpui::{ Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size, transparent_white, }; +use gpui_base::dock::PanelView; use gpui_base::{Button as BaseButton, Disableable, Popover}; use gpui_component::alert::Alert; use gpui_component::button::{Button, ButtonVariants}; @@ -24,7 +26,7 @@ use gpui_component::{ ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, VirtualListScrollHandle, h_flex, v_flex, }; -use nostr::prelude::{RelayUrl, ToBech32, Url}; +use nostr::prelude::{EventId, RelayUrl, ToBech32, Url}; use signed_core::{Announcement, RepoAddr, RepoStatus, filters}; use signed_git::{CommitList, FileCommit}; use signed_state::{ @@ -57,7 +59,9 @@ use helpers::{ ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, ref_selector_trigger, tree_items, }; +use issue_detail::IssueDetailView; use issues::{IssuesView, open_new_issue_dialog}; +use pull_request_detail::PullRequestDetailView; use pull_requests::PullRequestsView; use send_patch::open_send_patch_panel; @@ -227,6 +231,12 @@ impl RepoDetailView { view } + /// The per-repository nostr store, so another panel can open one of its + /// items. `None` until a local repository is initialized to NIP-34. + pub(crate) fn store(&self) -> Option> { + self.store.clone() + } + /// Open a local repository discovered by the scan. pub fn new_local( dock_area: WeakEntity, @@ -2649,3 +2659,41 @@ pub(crate) fn open_repo_panel( detail } + +/// An item of a repository to open from outside its detail panel. +/// A patch has no detail view in Signed, so it opens nothing. +pub(crate) enum RepoItem { + Issue(EventId), + PullRequest(EventId), + Patch, +} + +/// Open the detail panel of `item` in `store`'s repository, in the dock's center. +/// +/// A patch opens nothing: patches are only consumed inside a pull request's +/// detail panel, and have no panel of their own. +pub(crate) fn open_repo_item( + dock_area: &WeakEntity, + store: Entity, + item: RepoItem, + window: &mut Window, + cx: &mut App, +) { + let panel: Arc = match item { + RepoItem::Issue(issue_id) => { + panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx))) + } + RepoItem::PullRequest(pr_id) => panel_handle( + cx.new(|cx| PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx)), + ), + RepoItem::Patch => return, + }; + + let Some(dock_area) = dock_area.upgrade() else { + return; + }; + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel, window, cx); + }); +} diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 766fc76..502bf10 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -224,7 +224,7 @@ impl SidebarPanel { return; } - let panel = cx.new(InboxView::new); + let panel = cx.new(|cx| InboxView::new(self.dock_area.clone(), cx)); self.inbox = Some(panel.downgrade()); let _ = self.dock_area.update(cx, |dock_area, cx| { diff --git a/docs/inbox-plan.md b/docs/inbox-plan.md index 81ffb3a..4e32f7d 100644 --- a/docs/inbox-plan.md +++ b/docs/inbox-plan.md @@ -6,13 +6,13 @@ Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for > page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `` when > an account is active, and that home screen is the inbox. -> **Status.** Phases 0, 1 and 2 are implemented and green on `feat/inbox`: +> **Status.** Phases 0-4 are implemented and green on `feat/inbox`: > `cargo test -p signed_core` (68), `cargo test -p signed_state` (24), > `cargo test -p workspace` (7), `cargo clippy -p workspace --all-targets` clean, > `cargo check --workspace --all-targets` succeeds. -> Phases 3-5 are not started. This document reflects the implementation as it stands, including the -> Phase 1 refactors and the §4.3 split of the inbox into a thin global `Inbox` and a -> panel-owned derivation. +> Phase 5 is not started. This document reflects the implementation as it stands, including the +> Phase 1 refactors, the §4.3 split of the inbox into a thin global `Inbox` and a panel-owned +> derivation, the Phase 3 bottom-dock sub-views, and the Phase 4 click-through. ## 1. What the GitWorkshop home screen is @@ -479,16 +479,17 @@ definite viewport height. The list counts are reset from `render` whenever the r changes. Row ids are prefixed (`("inbox-row", ix)` / `("activity-row", ix)`) so the two lists do not collide. -- **Inbox card**: header with the unread count badge and **Mark all read**; then every non-archived - notification item. Rows show the actor avatar, a kind icon, the subject, the kind label, the repo - name, a relative time, and an unread dot (the subject is semibold while unread). Empty state: - "You're all caught up." with `IconName::Inbox`. +- **Inbox card**: header with the unread count badge, the **Unread** and **Archived** sub-view buttons + (Phase 3) and **Mark all read**; then every non-archived notification item. Rows show the actor + avatar, a kind icon, the subject, the kind label, the repo name, a relative time, and an unread dot + (the subject is semibold while unread). Empty state: "You're all caught up." with + `IconName::Inbox`. - **Continue where you left off**: every event in the activity list, each row a kind icon, subject, kind label, repo name, and relative time. No greeting header, and no **My repositories** column - the sidebar already lists the user's -repositories. The **Unread** and **Archived** header buttons belong to Phase 3 and are not -rendered until `add_bottom_panel` / `InboxFilterView` exist (see 5.2). +repositories. The **Unread** and **Archived** header buttons open the sub-views of 5.2 in the bottom +dock. ### 5.2 Unread / Archived as bottom-dock panels @@ -508,12 +509,20 @@ pub fn add_bottom_panel( The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then: -- New `InboxFilterView` panel taking a mode `InboxFilter::Unread | InboxFilter::Archived` and the - `Entity`. It renders the matching subset of the panel's notifications as a list. -- The **Unread** and **Archived** header buttons in `InboxView` (added in Phase 3) call - `add_bottom_panel` with the requested mode. `InboxView` keeps - `filter_view: Option>`; when it already exists, update its mode and - focus instead of adding a duplicate. Until then the inbox header has only **Mark all read**. +- `InboxFilterView` is a bottom-dock panel holding an `Entity` and a mode + `InboxFilter::Unread | InboxFilter::Archived`. It renders the matching subset of the panel's + notifications as a `gpui::list`, with the same rows as the inbox card. The mode filters on + `InboxItem::is_unread()` / `InboxItem::archived` and picks the tab title and empty state. It reads + the inbox entity during render, so GPUI's render-time tracking re-renders it whenever the inbox + re-derives; it needs no subscription of its own. +- The **Unread** and **Archived** header buttons in `InboxView` call `InboxView::open_filter`, which + keeps `filter_view: Option>`. When the panel already exists it updates + its mode, focuses it, and reopens the bottom dock if the user collapsed it, instead of adding a + duplicate. Otherwise it creates the panel and adds it to the bottom dock. +- **Unread rows**: clicking a row marks that group read (`InboxView::mark_read`); a trailing ghost + icon button archives it (`InboxView::mark_archived`). Both call back into the `InboxView` entity, + which updates the global `Inbox`. **Archived rows** are display-only, since the read state has no + un-archive operation. ### 5.3 Sidebar @@ -522,6 +531,7 @@ In `views/sidebar/mod.rs`: - Add `inbox: Option>` (mirrors `explore`) and `unread: usize`. - Add `fn open_inbox(&mut self, window, cx)` that returns when the panel is already open, else adds a center panel (same shape as `open_explore`; there is no dock API to focus an existing tab). + `InboxView::new` takes the sidebar's `WeakEntity` so the panel can open its sub-view. - Point the existing nav item at it and add an unread suffix: ```rust @@ -535,34 +545,39 @@ In `views/sidebar/mod.rs`: ### 5.4 Click-through (P1) -Reuse the single `RepoStore` that `RepoDetailView` already creates instead of making a second one: +Reuse the single `RepoStore` that `RepoDetailView` already creates instead of making a second one. +The detail panels need a `Window`, and GPUI's `Entity::update_in` only exists on a `VisualContext`, +which a synchronous `App` + `Window` pair is not - so the entry point is a free function rather than +a `RepoDetailView::open_item` method. In `repo_detail/mod.rs`: -1. In `repo_detail/mod.rs`, add: +```rust +pub(crate) enum RepoItem { + Issue(EventId), + PullRequest(EventId), + Patch, +} - ```rust - pub(crate) enum RepoItem { - Issue(EventId), - PullRequest(EventId), - Patch(EventId), - } +pub(crate) fn open_repo_item( + dock_area: &WeakEntity, + store: Entity, + item: RepoItem, + window: &mut Window, + cx: &mut App, +) { /* new IssueDetailView / PullRequestDetailView, added to the center */ } +``` - impl RepoDetailView { - pub(crate) fn open_item( - &mut self, - item: RepoItem, - window: &mut Window, - cx: &mut Context, - ) { /* open IssueDetailView / PullRequestDetailView in the dock */ } - } - ``` - -2. `open_repo_panel` already returns `Entity`; the caller invokes - `detail.update_in(window, cx, |detail, window, cx| detail.open_item(...))`. -3. `InboxView` resolves `item.address` to an `Announcement` from `RepoListStore`, opens the repo - panel, then calls `open_item` with the root id and kind. +- `RepoDetailView::store()` exposes its `Option>`, so the caller reuses the repo + panel's store rather than building one. `views/mod.rs` re-exports `RepoItem` and `open_repo_item`. +- `InboxView` resolves `item.address` to an `Announcement` from `RepoListStore`, calls + `open_repo_panel` (which returns `Entity`), takes its store, and calls + `open_repo_item` with the root id and kind. The detail panel renders a "not found" placeholder + until the store's fetch lands, then re-renders. +- The repository panel and the detail panel are two tabs of the center group; the detail is + activated. This matches the sidebar, which also opens a fresh repo panel per click. Patches have no detail view in Signed (they are only consumed inside `PullRequestDetailView`), so a -patch-root click opens the repo panel. Note as a known limitation. +patch-root click opens the repo panel only. `RepoItem::Patch` carries no id for that reason. A group +whose root is not an issue/PR/patch, or whose repository is not in `RepoListStore`, opens nothing. ## 6. File-by-file change list @@ -578,13 +593,10 @@ patch-root click opens the repo panel. Note as a known limitation. | `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users | | `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` and `query_inbox`; re-export `RefreshGate` (no global install) | | `crates/dock/src/lib.rs` | `add_bottom_panel` helper | -| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel owning the derived lists directly (`InboxFilterView` is Phase 3) | -| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;` | -| `crates/workspace/src/views/sidebar/mod.rs` | `inbox`/`unread` fields, `open_inbox`, nav wiring and badge, `create_repo_dialog` visibility | -| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `RepoDetailView::open_item` (P1) | - -`create_repo_dialog` changes from private (`mod`) to `pub(crate) mod` inside `sidebar`, so the inbox's -New button can open it. +| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel owning the derived lists directly, the `InboxFilterView` bottom-dock sub-view for Unread / Archived, and the notification click-through | +| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;`; re-export `RepoItem`, `open_repo_item`, `open_repo_panel` | +| `crates/workspace/src/views/sidebar/mod.rs` | `inbox`/`unread` fields, `open_inbox`, nav wiring and badge | +| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `open_repo_item`, `RepoDetailView::store()` | No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox` activates the `Inbox` child entity at each signer transition. @@ -604,7 +616,9 @@ activates the `Inbox` child entity at each signer transition. 3. **Phase 2 - screen**: `InboxView` (inbox + activity), sidebar nav and badge. **DONE.** See the implementation notes below. 4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived. -5. **Phase 4 - click-through**: `open_item` and announcement lookup. + **DONE.** See the implementation notes below. +5. **Phase 4 - click-through**: `open_item` and announcement lookup. **DONE.** See the implementation + notes below. 6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail view. @@ -720,6 +734,63 @@ The `InboxStore` entity was then folded into `InboxView`, since the panel was it Trade-off: the sidebar badge is only current after the inbox is opened once, because the unread count is derived by the panel. +### Phase 3 implementation notes + +Files: `crates/dock/src/lib.rs` and `crates/workspace/src/views/{inbox.rs, sidebar/mod.rs}`. No +store changes. + +- `add_bottom_panel` sits next to `add_center_panel` and wraps + `DockArea::add_panel_view(panel, DockPlacement::Bottom, None, ...)`. A new bottom dock starts open, + and the workspace's existing `DockEvent::LayoutChanged` subscription removes an emptied bottom dock, + so a closed sub-view leaves no strip behind. +- `InboxFilterView` is private to `views/inbox.rs`. It holds an `Entity` (strong; the + panel keeps only the weak `filter_view` back, so there is no cycle), the mode, and its own + `ListState`. There is no subscription: it reads the inbox entity during render, which is enough for + GPUI to invalidate the window when the inbox notifies. +- `InboxFilter` is a private two-variant enum with `label()` and `matches(&InboxItem)`. The tab title + comes from `Panel::title`, so switching modes through `set_mode` retitles the same tab instead of + opening a second one. +- `InboxView` regained a `dock_area: WeakEntity` (removed with the My-repositories column) + and takes it in `new`. `open_filter` reuses the existing panel, focuses it, and reopens the bottom + dock when it is collapsed; otherwise it creates and adds the panel. `InboxView::new` is now called + as `InboxView::new(self.dock_area.clone(), cx)` from `SidebarPanel::open_inbox`. +- The three `#[allow(dead_code)]` markers on `mark_read`, `mark_archived` and `group_events` are gone: + Unread rows call `mark_read` on click and `mark_archived` from a trailing ghost icon button + (`Button` + `IconName::FolderClosed`, tooltip "Archive"). The button calls `cx.stop_propagation()` + so it does not also trigger the row's mark-read click. Archived rows are display-only; the read + state has no un-archive operation. +- `notification_row` takes an id `prefix` and returns `Stateful
` rather than `AnyElement`, so + callers can attach a click handler and a trailing action. The inbox list passes `"inbox-row"` and + the sub-view `"inbox-filter-row"`, because the two lists render in the same window and would + otherwise collide on `(str, ix)` ids. +- `cargo clippy -p workspace -p dock --all-targets` is clean, `cargo check --workspace --all-targets` + succeeds, and `cargo test -p signed_core -p signed_state -p workspace` passes (68 / 24 / 7). + +### Phase 4 implementation notes + +Files: `crates/workspace/src/views/{inbox.rs, mod.rs, repo_detail/mod.rs}`. No store changes. + +- `RepoItem { Issue(EventId), PullRequest(EventId), Patch }` and `pub(crate) fn open_repo_item` live + in `repo_detail/mod.rs`, next to `open_repo_panel`. `open_repo_item` takes the store as a + parameter, avoiding a second `RepoStore`. +- It is a free function, not `RepoDetailView::open_item`: the detail constructors take a `Window`, and + a synchronous `&mut App` + `&mut Window` pair is not a `VisualContext`, so `Entity::update_in` is + not available. `InboxView` already has the window in the list's `on_click`, so it drives the free + function directly. The plan's original `detail.update_in(window, cx, ...)` sketch could not compile. +- `RepoDetailView::store()` (`pub(crate)`) exposes the panel's `Option>`. The repo + panel is opened first and its store reused, so the detail panel shares one store with the repo it + came from. +- `InboxView::open_item` is also a free function (it needs nothing but `dock_area`, which it captures + from the panel) because the `gpui::list` item closure only receives `&mut App`. It resolves + `item.address` through `RepoListStore`, returns silently when the repository is unknown, opens the + repo panel, then maps the root kind to a `RepoItem` and calls `open_repo_item`. +- Only the main notification list is clickable. Unread rows keep their Phase 3 behaviour (click marks + read, trailing button archives). Activity rows are unchanged. +- `RepoItem::Patch` is a unit variant because the id would be unused: patches have no detail panel, so + `open_repo_item` returns before doing anything and only the repository panel opens. +- `cargo clippy -p workspace --all-targets` is clean, `cargo check --workspace --all-targets` succeeds, + and `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1). + ## 8. Validation - `cargo test -p signed_core` (68 tests): root resolution, grouping, read-state cutoff, serde round-trip.