feat: add inbox panel #18
+111
-141
@@ -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(§ions);
|
let rows = self.flatten_rows(§ions);
|
||||||
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 {
|
||||||
|
|||||||
@@ -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,24 +2667,32 @@ 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> =
|
||||||
|
match item {
|
||||||
RepoItem::Issue(issue_id) => {
|
RepoItem::Issue(issue_id) => {
|
||||||
|
let store = repo_store(announcement, cx);
|
||||||
panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx)))
|
panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx)))
|
||||||
}
|
}
|
||||||
RepoItem::PullRequest(pr_id) => panel_handle(
|
RepoItem::PullRequest(pr_id) => {
|
||||||
cx.new(|cx| PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx)),
|
let store = repo_store(announcement, cx);
|
||||||
),
|
panel_handle(cx.new(|cx| {
|
||||||
|
PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx)
|
||||||
|
}))
|
||||||
|
}
|
||||||
RepoItem::Patch => return,
|
RepoItem::Patch => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+23
-21
@@ -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).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user