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-11 10:46:31 +07:00
parent 1e7cf0020c
commit 568b6c0e41
6 changed files with 461 additions and 62 deletions
+13
View File
@@ -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.);
+279 -12
View File
@@ -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"
+1 -1
View File
@@ -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;
+49 -1
View File
@@ -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);
});
}
+1 -1
View File
@@ -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| {