update
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run

This commit is contained in:
2026-09-12 10:27:36 +07:00
parent 4684e01e89
commit 8a229cb212
3 changed files with 158 additions and 179 deletions
+111 -141
View File
@@ -3,7 +3,6 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use anyhow::Error; use anyhow::Error;
use assets::CustomIconName;
use dock::{BasePanel, DockArea, Panel, PanelEvent}; use dock::{BasePanel, DockArea, Panel, PanelEvent};
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{ use gpui::{
@@ -20,7 +19,7 @@ use signed_state::{
use signed_ui::{CountBadge, UserAvatar}; use signed_ui::{CountBadge, UserAvatar};
use utils::relative_time; 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. /// Delay between a refresh request and the actual re-query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); 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 { let Some(me) = Backend::global(cx).read(cx).current_user() else {
return; return;
}; };
let all = self.all_notification_events();
let inbox = Backend::global(cx).read(cx).inbox(); let all: Vec<Event> = 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)); 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<Self>) { fn rebuild(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx); let backend = Backend::global(cx);
let repo_list = RepoListStore::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() { if let Some(me) = backend.read(cx).current_user() {
for announcement in repo_list.read(cx).announcements_of(&me) { 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)); sections.sort_by_key(|section| std::cmp::Reverse(section.latest));
let rows = flatten_rows(&sections); let rows = self.flatten_rows(&sections);
self.sections = Arc::new(sections); self.sections = Arc::new(sections);
self.rows = Arc::new(rows); self.rows = Arc::new(rows);
} }
/// Group the threads into one section per repository.
fn group_sections(&self) -> Vec<InboxSection> {
let mut by_repo: HashMap<Option<RepoAddr>, 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<InboxSection> = 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<InboxRow> {
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. /// Forget everything derived for the current user.
fn clear(&mut self) { fn clear(&mut self) {
self.threads = Arc::new(Vec::new()); self.threads = Arc::new(Vec::new());
@@ -313,15 +381,39 @@ impl InboxView {
self.refresh = RefreshGate::default(); self.refresh = RefreshGate::default();
} }
/// Every notification event in every thread, archived threads included. fn open(
fn all_notification_events(&self) -> Vec<Event> { &self,
self.threads root: EventId,
kind: Option<Kind>,
address: Option<RepoAddr>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(address) = address else {
return;
};
let Some(announcement) = RepoListStore::global(cx)
.read(cx)
.announcements
.iter() .iter()
.flat_map(|item| item.events.iter().cloned()) .find(|announcement| announcement.addr() == address)
.collect() .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<Self>) -> AnyElement {
let Some(row) = self.rows.get(ix) else { let Some(row) = self.rows.get(ix) else {
return div().into_any_element(); return div().into_any_element();
}; };
@@ -350,81 +442,19 @@ impl InboxView {
let root = item.root; let root = item.root;
let kind = item.root_kind; let kind = item.root_kind;
let address = section.address.clone(); let address = section.address.clone();
let dock_area = self.dock_area.clone();
let first = entry_ix == 0; let first = entry_ix == 0;
let last = entry_ix + 1 == section.entries.len(); let last = entry_ix + 1 == section.entries.len();
thread("inbox-row", ix, item, first, last, cx) thread("inbox-row", ix, item, first, last, cx)
.on_click(move |_, window, cx| { .on_click(cx.listener(move |this, _ev, window, cx| {
open_item(&dock_area, root, kind, address.clone(), window, cx); this.open(root, kind, address.clone(), window, cx)
}) }))
.into_any_element() .into_any_element()
} }
} }
} }
} }
/// Group the threads into one section per repository.
fn group_sections(threads: &[InboxItem]) -> Vec<InboxSection> {
let mut by_repo: HashMap<Option<RepoAddr>, 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<InboxSection> = 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<InboxRow> {
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. /// Display name of the repository at `addr`, from the announcement store.
fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> { fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> {
let repo_list = RepoListStore::global(cx); let repo_list = RepoListStore::global(cx);
@@ -475,43 +505,6 @@ fn empty_section_row(cx: &App) -> AnyElement {
.into_any_element() .into_any_element()
} }
fn open_item(
dock_area: &WeakEntity<DockArea>,
root: EventId,
kind: Option<Kind>,
address: Option<RepoAddr>,
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( fn thread(
prefix: &'static str, prefix: &'static str,
ix: usize, ix: usize,
@@ -521,7 +514,6 @@ fn thread(
cx: &App, cx: &App,
) -> Stateful<Div> { ) -> Stateful<Div> {
let title = SharedString::from(item.title()); let title = SharedString::from(item.title());
let kind = item.kind().unwrap_or(Kind::Comment);
let unread = item.is_unread(); let unread = item.is_unread();
let backend = Backend::global(cx); let backend = Backend::global(cx);
@@ -530,7 +522,7 @@ fn thread(
let mut timeline = v_flex().gap_2().w_full(); let mut timeline = v_flex().gap_2().w_full();
for event in item.timeline(MAX_SUB_ACTIVITIES) { 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() v_flex()
@@ -543,7 +535,7 @@ fn thread(
.when(first, |this| this.rounded_t(cx.theme().radius)) .when(first, |this| this.rounded_t(cx.theme().radius))
.when(last, |this| this.rounded_b(cx.theme().radius)) .when(last, |this| this.rounded_b(cx.theme().radius))
.when(!last, |this| { .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))) .hover(|this| this.bg(cx.theme().secondary_hover.alpha(0.8)))
.child( .child(
@@ -556,7 +548,7 @@ fn thread(
.flex_shrink_0() .flex_shrink_0()
.items_center() .items_center()
.justify_center() .justify_center()
.child(kind_icon(kind)), .child(Icon::new(IconName::Bell)),
) )
.child( .child(
div() div()
@@ -579,7 +571,7 @@ fn thread(
.child(timeline) .child(timeline)
} }
fn sub_activity_line(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement { fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
let profile_store = ProfileStore::global(cx).read(cx); let profile_store = ProfileStore::global(cx).read(cx);
let profile = profile_store.get(&event.pubkey); let profile = profile_store.get(&event.pubkey);
@@ -618,28 +610,6 @@ fn sub_activity_line(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyEleme
.into_any_element() .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]`. /// Phrase describing an activity event, read as `[name] [phrase]`.
fn activity_phrase(kind: Kind) -> &'static str { fn activity_phrase(kind: Kind) -> &'static str {
if kind == COVER_NOTE_KIND { if kind == COVER_NOTE_KIND {
+24 -17
View File
@@ -231,12 +231,6 @@ impl RepoDetailView {
view 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<Entity<RepoStore>> {
self.store.clone()
}
/// Open a local repository discovered by the scan. /// Open a local repository discovered by the scan.
pub fn new_local( pub fn new_local(
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
@@ -2660,6 +2654,11 @@ pub(crate) fn open_repo_panel(
detail detail
} }
/// The nostr store of `announcement`'s repository, without opening a repository panel.
fn repo_store(announcement: &Announcement, cx: &mut App) -> Entity<RepoStore> {
cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx))
}
/// An item of a repository to open from outside its detail panel. /// An item of a repository to open from outside its detail panel.
/// A patch has no detail view in Signed, so it opens nothing. /// A patch has no detail view in Signed, so it opens nothing.
pub(crate) enum RepoItem { pub(crate) enum RepoItem {
@@ -2668,26 +2667,34 @@ pub(crate) enum RepoItem {
Patch, 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 /// A patch opens nothing: patches are only consumed inside a pull request's
/// detail panel, and have no panel of their own. /// detail panel, and have no panel of their own.
pub(crate) fn open_repo_item( pub(crate) fn open_repo_item(
dock_area: &WeakEntity<DockArea>, dock_area: &WeakEntity<DockArea>,
store: Entity<RepoStore>, announcement: &Announcement,
item: RepoItem, item: RepoItem,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) { ) {
let panel: Arc<dyn PanelView> = match item { let panel: Arc<dyn PanelView> =
RepoItem::Issue(issue_id) => { match item {
panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx))) RepoItem::Issue(issue_id) => {
} let store = repo_store(announcement, cx);
RepoItem::PullRequest(pr_id) => panel_handle( panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx)))
cx.new(|cx| PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx)), }
), RepoItem::PullRequest(pr_id) => {
RepoItem::Patch => return, 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 { let Some(dock_area) = dock_area.upgrade() else {
return; return;
+23 -21
View File
@@ -59,7 +59,7 @@ Data hooks:
| Priority | Section | Notes | | 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 | | **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 | | **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 | | **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) ### 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`, 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 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`: a `RepoDetailView::open_item` method. In `repo_detail/mod.rs`:
@@ -606,25 +605,28 @@ pub(crate) enum RepoItem {
pub(crate) fn open_repo_item( pub(crate) fn open_repo_item(
dock_area: &WeakEntity<DockArea>, dock_area: &WeakEntity<DockArea>,
store: Entity<RepoStore>, announcement: &Announcement,
item: RepoItem, item: RepoItem,
window: &mut Window, window: &mut Window,
cx: &mut App, 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<Entity<RepoStore>>`, so the caller reuses the repo - `open_repo_item` builds its own `RepoStore` from `announcement` (a private `repo_store` helper calls
panel's store rather than building one. `views/mod.rs` re-exports `RepoItem` and `open_repo_item`. `RepoStore::new(addr, relays, cx)`), so the item panel is the **only** panel docked. An earlier
- `InboxView` resolves `item.address` to an `Announcement` from `RepoListStore`, calls version opened `RepoDetailView` first and reused its store via `RepoDetailView::store()`; that
`open_repo_panel` (which returns `Entity<RepoDetailView>`), takes its store, and calls 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 `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. 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 - The item panel is added to the center group and activated.
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 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 patch-root click opens nothing. `RepoItem::Patch` carries no id for that reason. A group whose root is
whose root is not an issue/PR/patch, or whose repository is not in `RepoListStore`, opens nothing. not an issue/PR/patch, or whose repository is not in `RepoListStore`, opens nothing.
## 6. File-by-file change list ## 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/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/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/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` 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. 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. 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 - `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 in `repo_detail/mod.rs`, next to `open_repo_panel`. `open_repo_item` builds its own `RepoStore` from
parameter, avoiding a second `RepoStore`. 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 - 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 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 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. function directly. The plan's original `detail.update_in(window, cx, ...)` sketch could not compile.
- `RepoDetailView::store()` (`pub(crate)`) exposes the panel's `Option<Entity<RepoStore>>`. 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 - `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 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 `item.address` through `RepoListStore`, returns silently when the repository is unknown, maps the
repo panel, then maps the root kind to a `RepoItem` and calls `open_repo_item`. 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 / - 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. 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 - `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, - `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). and `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1).