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
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:
@@ -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<dyn PanelView>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<DockArea>,
|
||||
) {
|
||||
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.);
|
||||
|
||||
|
||||
@@ -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<DockArea>,
|
||||
/// The open Unread / Archived sub-view, if any. Reused instead of adding a
|
||||
/// duplicate panel on every header click.
|
||||
filter_view: Option<WeakEntity<InboxFilterView>>,
|
||||
/// Notifications grouped by thread root, newest activity first.
|
||||
notifications: Arc<Vec<InboxItem>>,
|
||||
/// 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>) -> Self {
|
||||
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> 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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Vec<Event>> {
|
||||
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<SharedString> {
|
||||
.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<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);
|
||||
}
|
||||
|
||||
/// 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<Div> {
|
||||
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<InboxView>,
|
||||
mode: InboxFilter,
|
||||
list: ListState,
|
||||
}
|
||||
|
||||
impl InboxFilterView {
|
||||
fn new(mode: InboxFilter, inbox: Entity<InboxView>, cx: &mut Context<Self>) -> 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<Self>) {
|
||||
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<Self>) -> impl IntoElement {
|
||||
div().text_sm().child(SharedString::from(self.mode.label()))
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> 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<Self>) -> impl IntoElement {
|
||||
let mode = self.mode;
|
||||
let notifications = self.inbox.read(cx).notifications.clone();
|
||||
|
||||
let visible: Vec<usize> = 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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Entity<RepoStore>> {
|
||||
self.store.clone()
|
||||
}
|
||||
|
||||
/// Open a local repository discovered by the scan.
|
||||
pub fn new_local(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
@@ -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<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
item: RepoItem,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let panel: Arc<dyn PanelView> = 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
+114
-43
@@ -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 `<Dashboard />` 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<InboxView>`. 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<WeakEntity<InboxFilterView>>`; 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<InboxView>` 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<WeakEntity<InboxFilterView>>`. 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<WeakEntity<InboxView>>` (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<DockArea>` 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 {
|
||||
```rust
|
||||
pub(crate) enum RepoItem {
|
||||
Issue(EventId),
|
||||
PullRequest(EventId),
|
||||
Patch(EventId),
|
||||
}
|
||||
Patch,
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
pub(crate) fn open_item(
|
||||
&mut self,
|
||||
pub(crate) fn open_repo_item(
|
||||
dock_area: &WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
item: RepoItem,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) { /* open IssueDetailView / PullRequestDetailView in the dock */ }
|
||||
}
|
||||
```
|
||||
cx: &mut App,
|
||||
) { /* new IssueDetailView / PullRequestDetailView, added to the center */ }
|
||||
```
|
||||
|
||||
2. `open_repo_panel` already returns `Entity<RepoDetailView>`; 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<Entity<RepoStore>>`, 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<RepoDetailView>`), 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<InboxView>` (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<DockArea>` (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<Div>` 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<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
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user