diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index 109f661..01d7fee 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Error; -use assets::CustomIconName; use dock::{BasePanel, DockArea, Panel, PanelEvent}; use gpui::prelude::*; use gpui::{ @@ -20,7 +19,7 @@ use signed_state::{ use signed_ui::{CountBadge, UserAvatar}; use utils::relative_time; -use super::{RepoItem, open_repo_item, open_repo_panel}; +use super::{RepoItem, open_repo_item}; /// Delay between a refresh request and the actual re-query. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); @@ -124,8 +123,16 @@ impl InboxView { let Some(me) = Backend::global(cx).read(cx).current_user() else { return; }; - let all = self.all_notification_events(); - let inbox = Backend::global(cx).read(cx).inbox(); + + let all: Vec = self + .threads + .iter() + .flat_map(|item| item.events.iter().cloned()) + .collect(); + + let backend = Backend::global(cx); + let inbox = backend.read(cx).inbox(); + inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx)); } @@ -274,7 +281,7 @@ impl InboxView { fn rebuild(&mut self, cx: &mut Context) { let backend = Backend::global(cx); let repo_list = RepoListStore::global(cx); - let mut sections = group_sections(&self.threads); + let mut sections = self.group_sections(); if let Some(me) = backend.read(cx).current_user() { for announcement in repo_list.read(cx).announcements_of(&me) { @@ -296,11 +303,72 @@ impl InboxView { sections.sort_by_key(|section| std::cmp::Reverse(section.latest)); - let rows = flatten_rows(§ions); + let rows = self.flatten_rows(§ions); self.sections = Arc::new(sections); self.rows = Arc::new(rows); } + /// Group the threads into one section per repository. + fn group_sections(&self) -> Vec { + let mut by_repo: HashMap, InboxSection> = HashMap::new(); + + for (ix, item) in self.threads.iter().enumerate() { + if item.archived { + continue; + } + + let address = item.address.clone(); + let section = by_repo + .entry(address.clone()) + .or_insert_with(move || InboxSection { + address, + unread: 0, + entries: Vec::new(), + latest: Timestamp::default(), + }); + + if item.is_unread() { + section.unread += 1; + } + + section.latest = section.latest.max(item.latest_activity()); + section.entries.push(ix); + } + + let mut sections: Vec = by_repo.into_values().collect(); + + for section in &mut sections { + section.entries.sort_by(|a, b| { + self.threads[*b] + .latest_activity() + .cmp(&self.threads[*a].latest_activity()) + }); + } + + sections.sort_by_key(|section| std::cmp::Reverse(section.latest)); + sections + } + + /// Flatten the sections into the list of repository headers and their rows. + fn flatten_rows(&self, sections: &[InboxSection]) -> Vec { + let mut rows = Vec::new(); + + for (section_ix, section) in sections.iter().enumerate() { + rows.push(InboxRow::Repo(section_ix)); + + if section.entries.is_empty() { + rows.push(InboxRow::Empty); + continue; + } + + rows.extend( + (0..section.entries.len()).map(|entry_ix| InboxRow::Entry(section_ix, entry_ix)), + ); + } + + rows + } + /// Forget everything derived for the current user. fn clear(&mut self) { self.threads = Arc::new(Vec::new()); @@ -313,15 +381,39 @@ impl InboxView { self.refresh = RefreshGate::default(); } - /// Every notification event in every thread, archived threads included. - fn all_notification_events(&self) -> Vec { - self.threads + fn open( + &self, + root: EventId, + kind: Option, + address: Option, + window: &mut Window, + cx: &mut Context, + ) { + let Some(address) = address else { + return; + }; + + let Some(announcement) = RepoListStore::global(cx) + .read(cx) + .announcements .iter() - .flat_map(|item| item.events.iter().cloned()) - .collect() + .find(|announcement| announcement.addr() == address) + .cloned() + 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(&self.dock_area, &announcement, item, window, cx); } - fn render_entry(&self, ix: usize, cx: &App) -> AnyElement { + fn render_entry(&self, ix: usize, cx: &Context) -> AnyElement { let Some(row) = self.rows.get(ix) else { return div().into_any_element(); }; @@ -350,81 +442,19 @@ impl InboxView { let root = item.root; let kind = item.root_kind; let address = section.address.clone(); - let dock_area = self.dock_area.clone(); let first = entry_ix == 0; let last = entry_ix + 1 == section.entries.len(); thread("inbox-row", ix, item, first, last, cx) - .on_click(move |_, window, cx| { - open_item(&dock_area, root, kind, address.clone(), window, cx); - }) + .on_click(cx.listener(move |this, _ev, window, cx| { + this.open(root, kind, address.clone(), window, cx) + })) .into_any_element() } } } } -/// Group the threads into one section per repository. -fn group_sections(threads: &[InboxItem]) -> Vec { - let mut by_repo: HashMap, InboxSection> = HashMap::new(); - - for (ix, item) in threads.iter().enumerate() { - if item.archived { - continue; - } - - let address = item.address.clone(); - let section = by_repo - .entry(address.clone()) - .or_insert_with(move || InboxSection { - address, - unread: 0, - entries: Vec::new(), - latest: Timestamp::default(), - }); - - if item.is_unread() { - section.unread += 1; - } - - section.latest = section.latest.max(item.latest_activity()); - section.entries.push(ix); - } - - let mut sections: Vec = by_repo.into_values().collect(); - - for section in &mut sections { - section.entries.sort_by(|a, b| { - threads[*b] - .latest_activity() - .cmp(&threads[*a].latest_activity()) - }); - } - - sections.sort_by_key(|section| std::cmp::Reverse(section.latest)); - sections -} - -/// Flatten the sections into the list of repository headers and their rows. -fn flatten_rows(sections: &[InboxSection]) -> Vec { - let mut rows = Vec::new(); - - for (section_ix, section) in sections.iter().enumerate() { - rows.push(InboxRow::Repo(section_ix)); - - if section.entries.is_empty() { - rows.push(InboxRow::Empty); - continue; - } - - rows.extend( - (0..section.entries.len()).map(|entry_ix| InboxRow::Entry(section_ix, entry_ix)), - ); - } - - rows -} - /// Display name of the repository at `addr`, from the announcement store. fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option { let repo_list = RepoListStore::global(cx); @@ -475,43 +505,6 @@ fn empty_section_row(cx: &App) -> AnyElement { .into_any_element() } -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); -} - fn thread( prefix: &'static str, ix: usize, @@ -521,7 +514,6 @@ fn thread( cx: &App, ) -> Stateful
{ let title = SharedString::from(item.title()); - let kind = item.kind().unwrap_or(Kind::Comment); let unread = item.is_unread(); let backend = Backend::global(cx); @@ -530,7 +522,7 @@ fn thread( let mut timeline = v_flex().gap_2().w_full(); for event in item.timeline(MAX_SUB_ACTIVITIES) { - timeline = timeline.child(sub_activity_line(&event, me, cx)); + timeline = timeline.child(sub_activity(&event, me, cx)); } v_flex() @@ -543,7 +535,7 @@ fn thread( .when(first, |this| this.rounded_t(cx.theme().radius)) .when(last, |this| this.rounded_b(cx.theme().radius)) .when(!last, |this| { - this.border_b_1().border_color(cx.theme().border) + this.border_b_1().border_color(cx.theme().background) }) .hover(|this| this.bg(cx.theme().secondary_hover.alpha(0.8))) .child( @@ -556,7 +548,7 @@ fn thread( .flex_shrink_0() .items_center() .justify_center() - .child(kind_icon(kind)), + .child(Icon::new(IconName::Bell)), ) .child( div() @@ -579,7 +571,7 @@ fn thread( .child(timeline) } -fn sub_activity_line(event: &Event, me: Option, cx: &App) -> AnyElement { +fn sub_activity(event: &Event, me: Option, cx: &App) -> AnyElement { let profile_store = ProfileStore::global(cx).read(cx); let profile = profile_store.get(&event.pubkey); @@ -618,28 +610,6 @@ fn sub_activity_line(event: &Event, me: Option, cx: &App) -> AnyEleme .into_any_element() } -/// Leading icon for a notification or activity kind. -fn kind_icon(kind: Kind) -> Icon { - if kind == COVER_NOTE_KIND { - return Icon::new(IconName::File).small(); - } - - match kind { - Kind::GitIssue => Icon::new(CustomIconName::GitIssueOngoing), - Kind::GitPullRequest | Kind::GitPullRequestUpdate => { - Icon::new(CustomIconName::GitPullRequest) - } - Kind::GitPatch => Icon::new(CustomIconName::GitCommit), - Kind::Comment => Icon::new(IconName::Star), - Kind::GitStatusOpen - | Kind::GitStatusApplied - | Kind::GitStatusClosed - | Kind::GitStatusDraft => Icon::new(CustomIconName::GitIssueOpen), - _ => Icon::new(IconName::Bell), - } - .small() -} - /// Phrase describing an activity event, read as `[name] [phrase]`. fn activity_phrase(kind: Kind) -> &'static str { if kind == COVER_NOTE_KIND { diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 575ef3c..1ff0b63 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -231,12 +231,6 @@ 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, @@ -2660,6 +2654,11 @@ pub(crate) fn open_repo_panel( detail } +/// The nostr store of `announcement`'s repository, without opening a repository panel. +fn repo_store(announcement: &Announcement, cx: &mut App) -> Entity { + cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)) +} + /// 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 { @@ -2668,26 +2667,34 @@ pub(crate) enum RepoItem { Patch, } -/// Open the detail panel of `item` in `store`'s repository, in the dock's center. +/// Open the detail panel of `item` in `announcement`'s repository, in the dock's center. +/// +/// The repository store is built here, not taken from a `RepoDetailView`, so the +/// item panel is the only panel docked. /// /// 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, + announcement: &Announcement, 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 panel: Arc = + match item { + RepoItem::Issue(issue_id) => { + let store = repo_store(announcement, cx); + panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx))) + } + RepoItem::PullRequest(pr_id) => { + let store = repo_store(announcement, cx); + 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; diff --git a/docs/inbox-plan.md b/docs/inbox-plan.md index 91d34ad..a0e51ed 100644 --- a/docs/inbox-plan.md +++ b/docs/inbox-plan.md @@ -59,7 +59,7 @@ Data hooks: | Priority | Section | Notes | |---|---|---| | **P0** | Inbox panel | Activity directed at you and your own activity, **grouped by repository**; unread badge; mark all read; all groups shown | -| **P1** | Click-through | Open the repo panel at the relevant PR/issue | +| **P1** | Click-through | Open the issue/PR detail panel at the relevant thread root | | **P2 (defer)** | Standalone notifications page, NIP-65 relay discovery, pagination | Web-app concerns | | **Out of scope** | Greeting header, my repositories, followed repositories, private repositories, pinned repositories, Unread/Archived sub-views | Not needed in Signed | @@ -592,7 +592,6 @@ In `views/sidebar/mod.rs`: ### 5.4 Click-through (P1) -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`: @@ -606,25 +605,28 @@ pub(crate) enum RepoItem { pub(crate) fn open_repo_item( dock_area: &WeakEntity, - store: Entity, + announcement: &Announcement, item: RepoItem, window: &mut Window, cx: &mut App, -) { /* new IssueDetailView / PullRequestDetailView, added to the center */ } +) { /* build the RepoStore here, then a new IssueDetailView / PullRequestDetailView, added to the center */ } ``` -- `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` builds its own `RepoStore` from `announcement` (a private `repo_store` helper calls + `RepoStore::new(addr, relays, cx)`), so the item panel is the **only** panel docked. An earlier + version opened `RepoDetailView` first and reused its store via `RepoDetailView::store()`; that + docked the repository panel too, which surfaced the repository load state (a `not found` error for + an announced repo with no local worktree) and left two center tabs. `RepoDetailView::store()` was + removed with it. +- `views/mod.rs` re-exports `RepoItem` and `open_repo_item`. +- `InboxView::open_item` resolves `item.address` to an `Announcement` from `RepoListStore`, 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. +- The item panel is added to the center group and activated. Patches have no detail view in Signed (they are only consumed inside `PullRequestDetailView`), so a -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. +patch-root click opens nothing. `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 @@ -643,7 +645,7 @@ whose root is not an issue/PR/patch, or whose repository is not in `RepoListStor | `crates/workspace/src/views/inbox.rs` | `InboxView` home panel owning the threads, the repository grouping, and the thread 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` field, `open_inbox`, nav wiring | -| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `open_repo_item`, `RepoDetailView::store()` | +| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `open_repo_item` (builds its own `RepoStore` via the private `repo_store` helper) | 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. @@ -822,23 +824,23 @@ store changes. 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`. + in `repo_detail/mod.rs`, next to `open_repo_panel`. `open_repo_item` builds its own `RepoStore` from + the announcement (private `repo_store` helper), so only the item panel is docked. - 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`. + `item.address` through `RepoListStore`, returns silently when the repository is unknown, maps the + root kind to a `RepoItem`, and calls `open_repo_item`. +- Fixed: the first version opened `RepoDetailView` to borrow its store (`RepoDetailView::store()`), + which docked the repository panel alongside the item panel and showed its `not found` load error. + `open_repo_item` now builds the `RepoStore` itself and `RepoDetailView::store()` is gone. - Only the notification rows are clickable. Activity rows are display-only. The Phase 3 mark-read / archive row behaviour is gone with the sub-views. - `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. + `open_repo_item` returns before doing anything and nothing is docked. - `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).