add inbox view with notifications panel
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-11-12 08:55:31 +07:00
parent d6e948474b
commit fd382405fd
19 changed files with 790 additions and 59 deletions
Generated
+8 -8
View File
@@ -3059,7 +3059,7 @@ dependencies = [
[[package]]
name = "gpui-native-core"
version = "0.1.0"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781"
dependencies = [
"cpal",
"flume 0.12.0",
@@ -3074,7 +3074,7 @@ dependencies = [
[[package]]
name = "gpui-native-data"
version = "0.1.0"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781"
dependencies = [
"gpui",
"gpui-native-core",
@@ -3086,7 +3086,7 @@ dependencies = [
[[package]]
name = "gpui-native-editor"
version = "0.1.0"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781"
dependencies = [
"gpui",
"gpui-native-core",
@@ -3105,7 +3105,7 @@ dependencies = [
[[package]]
name = "gpui-native-icons"
version = "0.1.0"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781"
dependencies = [
"anyhow",
"gpui",
@@ -3114,7 +3114,7 @@ dependencies = [
[[package]]
name = "gpui-native-kit"
version = "0.1.0"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781"
dependencies = [
"gpui",
"gpui-native-core",
@@ -3128,7 +3128,7 @@ dependencies = [
[[package]]
name = "gpui-native-layout"
version = "0.1.0"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781"
dependencies = [
"gpui",
"gpui-native-core",
@@ -3141,7 +3141,7 @@ dependencies = [
[[package]]
name = "gpui-native-theme"
version = "0.1.0"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781"
dependencies = [
"anyhow",
"async-channel",
@@ -3162,7 +3162,7 @@ dependencies = [
[[package]]
name = "gpui-native-utils"
version = "0.1.0"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8"
source = "git+https://git.reya.info/reya/gpui-native-kit?rev=ccd8997801186a33d4ed299d5bc9446c0f015781#ccd8997801186a33d4ed299d5bc9446c0f015781"
dependencies = [
"gpui",
"gpui-native-theme",
+1 -1
View File
@@ -14,7 +14,7 @@ gpui = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503de
gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc", features = ["font-kit"] }
reqwest_client = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc" }
gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "72118bdd6c6c6a90f17f92f767c6ad342c1516b8", features = ["layout", "editor"] }
gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "ccd8997801186a33d4ed299d5bc9446c0f015781", features = ["layout", "editor"] }
settings = { path = "crates/settings" }
signed_app = { path = "crates/signed_app" }
+2
View File
@@ -58,6 +58,7 @@ pub enum CustomIconName {
ArrowUp,
Trash,
Globe,
Inbox,
}
impl IconNamed for CustomIconName {
@@ -92,6 +93,7 @@ impl IconNamed for CustomIconName {
CustomIconName::ArrowUp => "icons/arrow-up.svg",
CustomIconName::Trash => "icons/trash.svg",
CustomIconName::Globe => "icons/globe.svg",
CustomIconName::Inbox => "icons/inbox.svg",
}
.into()
}
+2 -6
View File
@@ -67,12 +67,8 @@ impl ImportDialog {
pub fn new(cx: &mut App) -> Entity<Self> {
let state = cx.new(|_| ImportState::default());
let secret_input = InputState::new(cx);
let pass_input = InputState::new(cx);
pass_input.update(cx, |state, cx| state.set_password(true, cx));
let repass_input = InputState::new(cx);
repass_input.update(cx, |state, cx| state.set_password(true, cx));
let pass_input = InputState::builder().password().build(cx);
let repass_input = InputState::builder().password().build(cx);
cx.new(|cx| {
let mut subscriptions = Vec::new();
+2 -6
View File
@@ -25,12 +25,8 @@ impl OnboardingDialog {
pub fn new(cx: &mut App) -> Entity<Self> {
let state = cx.new(|_| DialogProgress::default());
let name_input = InputState::new(cx);
let pass_input = InputState::new(cx);
pass_input.update(cx, |state, cx| state.set_password(true, cx));
let repass_input = InputState::new(cx);
repass_input.update(cx, |state, cx| state.set_password(true, cx));
let pass_input = InputState::builder().password().build(cx);
let repass_input = InputState::builder().password().build(cx);
cx.new(|_| Self {
name_input,
+1 -3
View File
@@ -25,9 +25,7 @@ pub struct PassphraseDialog {
impl PassphraseDialog {
pub fn new(cx: &mut App) -> Entity<Self> {
let state = cx.new(|_| DialogProgress::default());
let pass_input = InputState::new(cx);
pass_input.update(cx, |state, cx| state.set_password(true, cx));
let pass_input = InputState::builder().password().build(cx);
cx.new(|cx| {
let enter_subscription =
+193
View File
@@ -0,0 +1,193 @@
use assets::CustomIconName;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, Div, Entity, InteractiveElement as _, IntoElement, ParentElement as _,
SharedString, Stateful, Styled as _, div, px,
};
use gpui_native_kit::avatar::Avatar;
use gpui_native_kit::badge::Badge;
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
use gpui_native_kit::icons::Icon;
use gpui_native_kit::theme::ActiveTheme as _;
use metadata::ProfileDirectory;
use nostr::prelude::{Event, Kind, PublicKey};
use utils::relative_time;
pub fn repo_header(name: SharedString, unread: usize) -> AnyElement {
h_flex()
.h_12()
.w_full()
.gap_2()
.child(
div()
.min_w_0()
.text_sm()
.whitespace_nowrap()
.text_ellipsis()
.child(name),
)
.when(unread > 0, |this| {
this.child(Badge::new("inbox-unread", unread.to_string()))
})
.into_any_element()
}
pub fn empty_section_row(cx: &App) -> AnyElement {
h_flex()
.h_12()
.w_full()
.px_3()
.text_xs()
.text_color(cx.theme().colors.muted_foreground)
.bg(cx.theme().colors.secondary)
.rounded(cx.theme().radius.control)
.child(SharedString::from("No activity yet."))
.into_any_element()
}
#[allow(clippy::too_many_arguments)]
pub fn thread(
prefix: &'static str,
ix: usize,
title: SharedString,
unread: bool,
first: bool,
last: bool,
events: Vec<Event>,
me: Option<PublicKey>,
directory: &Entity<ProfileDirectory>,
cx: &App,
) -> Stateful<Div> {
let mut timeline = v_flex().gap_2().w_full();
for event in events {
timeline = timeline.child(sub_activity(&event, me, directory, cx));
}
v_flex()
.id((prefix, ix))
.w_full()
.px_3()
.py_2()
.gap_2()
.bg(cx.theme().colors.secondary)
.when(first, |this| this.rounded_t(cx.theme().radius.control))
.when(last, |this| this.rounded_b(cx.theme().radius.control))
.when(!last, |this| {
this.border_b_1().border_color(cx.theme().colors.background)
})
.hover(|this| this.bg(cx.theme().colors.muted_hover))
.child(
h_flex()
.gap_2()
.text_sm()
.child(
h_flex()
.size_6()
.flex_shrink_0()
.items_center()
.justify_center()
.child(
Icon::named(CustomIconName::Inbox)
.size(px(14.))
.color(cx.theme().colors.muted_foreground),
),
)
.child(
div()
.min_w_0()
.whitespace_nowrap()
.text_ellipsis()
.child(title),
)
.child(div().flex_1())
.when(unread, |this| {
this.child(
div()
.flex_shrink_0()
.size_2()
.rounded_full()
.bg(cx.theme().colors.primary),
)
}),
)
.child(timeline)
}
fn sub_activity(
event: &Event,
me: Option<PublicKey>,
directory: &Entity<ProfileDirectory>,
cx: &App,
) -> AnyElement {
let profile = directory.read(cx).profile(&event.pubkey, cx);
let name = if Some(event.pubkey) == me {
SharedString::from("You")
} else {
profile.name()
};
h_flex()
.w_full()
.gap_2()
.child(div().w_6().flex_shrink_0())
.child(
h_flex()
.flex_1()
.min_w_0()
.gap_1()
.text_xs()
.child(
Avatar::new(event.pubkey)
.name(name.clone())
.picture(profile.picture())
.small(),
)
.child(name)
.child(SharedString::from(activity_phrase(event.kind)))
.child(div().flex_1())
.child(
div()
.text_color(cx.theme().colors.muted_foreground)
.child(SharedString::from(relative_time(event.created_at))),
),
)
.into_any_element()
}
fn activity_phrase(kind: Kind) -> &'static str {
match kind {
Kind::GitIssue => "opened an issue",
Kind::GitPullRequest => "opened a PR",
Kind::GitPullRequestUpdate => "updated a PR",
Kind::GitPatch => "created a patch",
Kind::Comment => "commented",
Kind::GitStatusOpen => "opened a status",
Kind::GitStatusApplied => "applied a status",
Kind::GitStatusClosed => "closed a status",
Kind::GitStatusDraft => "drafted a status",
_ => "did something",
}
}
pub fn empty_state(message: &'static str, cx: &App) -> AnyElement {
v_flex()
.w_full()
.flex_1()
.min_h_0()
.items_center()
.justify_center()
.gap_2()
.py_8()
.child(
Icon::named(CustomIconName::Inbox)
.size(px(24.))
.color(cx.theme().colors.muted_foreground),
)
.child(
div()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child(SharedString::from(message)),
)
.into_any_element()
}
+467
View File
@@ -0,0 +1,467 @@
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::Error;
use assets::CustomIconName;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task, WeakEntity,
Window, div,
};
use gpui_native_kit::button::Button;
use gpui_native_kit::common::{h_flex, v_flex};
use gpui_native_kit::icons::{Icon, IconRole};
use gpui_native_kit::layout::dock::{DockState, Panel};
use gpui_native_kit::scroll_area::ScrollArea;
use gpui_native_kit::theme::ActiveTheme as _;
use metadata::ProfileDirectory;
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
use signed_core::{InboxItem, InboxReadState, RepoAddr};
use signed_state::{Backend, BackendEvent, Inbox, RefreshGate, RefreshRequest, RepoListStore};
mod helpers;
use helpers::{empty_section_row, empty_state, repo_header, thread};
use crate::repo::actions::{RepoItem, open_repo_item};
const MAX_SUB_ACTIVITIES: usize = 5;
struct InboxSection {
address: Option<RepoAddr>,
unread: usize,
entries: Vec<usize>,
latest: Timestamp,
}
#[derive(Clone, Copy)]
enum InboxRow {
Repo(usize),
Entry(usize, usize),
Empty,
}
pub struct InboxView {
dock: WeakEntity<DockState>,
threads: Arc<Vec<InboxItem>>,
sections: Arc<Vec<InboxSection>>,
rows: Arc<Vec<InboxRow>>,
unread_count: usize,
state: InboxReadState,
state_loaded: bool,
refresh: RefreshGate,
tasks: Vec<Task<Result<(), Error>>>,
directory: Entity<ProfileDirectory>,
_subscriptions: Vec<Subscription>,
}
impl InboxView {
pub fn new(dock: WeakEntity<DockState>, cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let inbox = backend.read(cx).inbox();
let repos = RepoListStore::global(cx);
let weak = cx.entity().downgrade();
let mut subscriptions = vec![];
subscriptions.push(cx.observe(&inbox, |this, _inbox, cx| {
this.sync_state(cx);
}));
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
this.handle_backend_event(event, cx);
}));
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
this.rebuild(cx);
cx.notify();
}));
cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) {
log::warn!("inbox dropped before bootstrap could run: {error}");
}
});
Self {
dock,
threads: Arc::new(Vec::new()),
sections: Arc::new(Vec::new()),
rows: Arc::new(Vec::new()),
unread_count: 0,
state: InboxReadState::default(),
state_loaded: false,
refresh: RefreshGate::default(),
tasks: vec![],
directory: ProfileDirectory::new(cx),
_subscriptions: subscriptions,
}
}
pub fn mark_all_read(&mut self, cx: &mut Context<Self>) {
let Some(me) = Backend::global(cx).read(cx).current_user() else {
return;
};
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));
}
pub fn sync_state(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let inbox = backend.read(cx).inbox();
let (loaded, state) = {
let inbox = inbox.read(cx);
(inbox.is_loaded(), inbox.state().clone())
};
if !loaded {
let was_present =
self.state_loaded || !self.threads.is_empty() || !self.sections.is_empty();
self.clear();
if was_present {
cx.notify();
}
return;
}
if !self.state_loaded {
self.state_loaded = true;
self.state = state;
self.refresh_initial(cx);
return;
}
if self.state != state {
self.state = state;
self.regroup(cx);
cx.notify();
}
}
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
match event {
BackendEvent::RepoUpdates(_) | BackendEvent::Synced => self.refresh(cx),
_ => {}
}
}
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
if self.refresh.running() {
self.refresh.request();
return;
}
self.run_refresh(cx);
}
fn refresh(&mut self, cx: &mut Context<Self>) {
if !self.state_loaded || self.refresh.request() != RefreshRequest::Schedule {
return;
}
self.run_refresh(cx);
}
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refresh.begin();
let backend = Backend::global(cx);
let (me, client) = {
let backend = backend.read(cx);
(backend.current_user(), backend.client())
};
let Some(me) = me else {
self.refresh.abort();
return;
};
let state = self.state.clone();
let work = cx.background_spawn(async move { Inbox::query(&client, me, &state).await });
self.tasks.push(cx.spawn(async move |this, cx| {
let (threads, unread_count) = match work.await {
Ok(results) => results,
Err(error) => {
log::warn!("inbox refresh failed: {error}");
return this.update(cx, |this, _cx| this.refresh.abort());
}
};
let again = this.update(cx, |this, cx| {
if backend.read(cx).current_user() != Some(me) {
this.refresh.abort();
return false;
}
this.threads = Arc::new(threads);
this.unread_count = unread_count;
this.rebuild(cx);
cx.notify();
this.refresh.finish()
})?;
if again {
this.update(cx, |this, cx| this.refresh(cx))?;
}
Ok(())
}));
}
fn regroup(&mut self, cx: &mut Context<Self>) {
let mut items = (*self.threads).clone();
for item in items.iter_mut() {
item.apply_state(&self.state);
}
self.unread_count = items.iter().filter(|item| item.is_unread()).count();
self.threads = Arc::new(items);
self.rebuild(cx);
}
fn rebuild(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let repo_list = RepoListStore::global(cx);
let mut sections = self.group_sections();
if let Some(me) = backend.read(cx).current_user() {
for announcement in repo_list.read(cx).announcements_of(&me) {
let address = announcement.addr();
let known = sections
.iter()
.any(|section| section.address.as_ref() == Some(&address));
if !known {
sections.push(InboxSection {
address: Some(address),
unread: 0,
entries: Vec::new(),
latest: Timestamp::default(),
});
}
}
}
sections.sort_by_key(|section| std::cmp::Reverse(section.latest));
let rows = self.flatten_rows(&sections);
self.sections = Arc::new(sections);
self.rows = Arc::new(rows);
let mut keys: Vec<PublicKey> = self
.threads
.iter()
.flat_map(|item| item.events.iter().map(|event| event.pubkey))
.collect();
keys.sort_by_key(PublicKey::to_hex);
keys.dedup();
self.directory
.update(cx, |directory, cx| directory.track(keys, cx));
}
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
}
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
}
fn clear(&mut self) {
self.threads = Arc::new(Vec::new());
self.sections = Arc::new(Vec::new());
self.rows = Arc::new(Vec::new());
self.unread_count = 0;
self.state = InboxReadState::default();
self.state_loaded = false;
self.refresh = RefreshGate::default();
}
fn open(
&self,
root: EventId,
kind: Option<Kind>,
address: Option<RepoAddr>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(address) = address 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, &address, None, item, window, cx);
}
fn render_entry(&self, ix: usize, cx: &mut Context<Self>) -> AnyElement {
let Some(row) = self.rows.get(ix) else {
return div().into_any_element();
};
match *row {
InboxRow::Empty => empty_section_row(cx),
InboxRow::Repo(section_ix) => {
let Some(section) = self.sections.get(section_ix) else {
return div().into_any_element();
};
let name = repo_name(section.address.as_ref(), cx);
repo_header(name, section.unread)
}
InboxRow::Entry(section_ix, entry_ix) => {
let Some(section) = self.sections.get(section_ix) else {
return div().into_any_element();
};
let Some(&thread_ix) = section.entries.get(entry_ix) else {
return div().into_any_element();
};
let Some(item) = self.threads.get(thread_ix) else {
return div().into_any_element();
};
let root = item.root;
let kind = item.root_event.as_ref().map(|event| event.kind);
let address = section.address.clone();
let first = entry_ix == 0;
let last = entry_ix + 1 == section.entries.len();
let me = Backend::global(cx).read(cx).current_user();
thread(
"inbox-row",
ix,
SharedString::from(item.title()),
item.is_unread(),
first,
last,
item.timeline(MAX_SUB_ACTIVITIES),
me,
&self.directory,
cx,
)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open(root, kind, address.clone(), window, cx)
}))
.into_any_element()
}
}
}
}
fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> SharedString {
let Some(addr) = addr else {
return SharedString::from("Untitled");
};
RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|announcement| announcement.addr() == *addr)
.and_then(|announcement| announcement.name.as_deref().map(SharedString::from))
.unwrap_or_else(|| SharedString::from("Untitled"))
}
impl Panel for InboxView {
fn title(&self, _cx: &App) -> Option<SharedString> {
Some(SharedString::from("Inbox"))
}
fn lead(&self, cx: &App) -> Option<AnyElement> {
Some(
Icon::named(CustomIconName::Inbox)
.size(gpui::px(14.))
.color(cx.theme().colors.muted_foreground)
.into_any_element(),
)
}
}
impl Render for InboxView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let rows = self.rows.clone();
let mut children = Vec::with_capacity(rows.len());
for ix in 0..rows.len() {
children.push(self.render_entry(ix, cx));
}
v_flex()
.size_full()
.child(
h_flex()
.px_4()
.h_12()
.w_full()
.gap_2()
.child(
div()
.text_sm()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(SharedString::from("Inbox")),
)
.child(div().flex_1())
.child(
Button::new("mark-all")
.icon(Icon::named(IconRole::Check))
.label("Mark all read")
.on_click(cx.listener(|this, _, _, cx| this.mark_all_read(cx))),
),
)
.child(
div()
.relative()
.flex_1()
.min_h_0()
.px_4()
.child(if rows.is_empty() {
empty_state("You're all caught up.", cx)
} else {
ScrollArea::new("inbox-scroll")
.flex_1()
.child(v_flex().gap_2().py_2().children(children))
.into_any_element()
}),
)
.into_any_element()
}
}
+1 -2
View File
@@ -30,8 +30,7 @@ pub struct IssueDetailView {
impl IssueDetailView {
pub fn new(store: Entity<RepoStore>, issue_id: EventId, cx: &mut Context<Self>) -> Self {
let comment_input = InputState::new(cx);
comment_input.update(cx, |state, cx| state.set_multiline(true, cx));
let comment_input = InputState::builder().multiline().build(cx);
let subscription = cx.observe(&store, |this, _store, cx| {
this.track_participants(cx);
+1 -2
View File
@@ -21,8 +21,7 @@ pub struct NewIssueDialog {
impl NewIssueDialog {
pub fn new(store: Entity<RepoStore>, cx: &mut App) -> Entity<Self> {
let subject = InputState::new(cx);
let content = InputState::new(cx);
content.update(cx, |state, cx| state.set_multiline(true, cx));
let content = InputState::builder().multiline().build(cx);
cx.new(|_| Self {
store,
+1
View File
@@ -1,6 +1,7 @@
mod app;
mod explore;
mod identity;
mod inbox;
mod issues;
mod pull_requests;
mod repo;
@@ -76,17 +76,14 @@ impl PullRequestDetailView {
cx: &mut Context<Self>,
) -> Self {
let pane = cx.new(DiffPane::new);
let comment_input = InputState::new(cx);
comment_input.update(cx, |state, cx| state.set_multiline(true, cx));
let comment_input = InputState::builder().multiline().build(cx);
let weak = cx.entity().downgrade();
let repo_name = store.read(cx).name();
let subscription = cx.observe(&store, |this, _store, cx| {
this.sync(cx);
});
let weak = cx.entity().downgrade();
let repo_name = store.read(cx).name();
cx.defer(move |cx| {
let _ = weak.update(cx, |this, cx| this.sync(cx));
});
@@ -25,8 +25,7 @@ pub struct UpdatePrDialog {
impl UpdatePrDialog {
pub fn new(store: Entity<RepoStore>, root: Event, cx: &mut App) -> Entity<Self> {
let patch = InputState::new(cx);
patch.update(cx, |state, cx| state.set_multiline(true, cx));
let patch = InputState::builder().multiline().build(cx);
cx.new(|_| Self { store, root, patch })
}
+1 -3
View File
@@ -72,9 +72,7 @@ impl NewPullRequestView {
let weak = cx.entity().downgrade();
let subject = InputState::new(cx);
let description = InputState::new(cx);
description.update(cx, |state, cx| state.set_multiline(true, cx));
let description = InputState::builder().multiline().build(cx);
let base_select = cx.new(|cx| {
ComboboxState::new(
@@ -36,21 +36,19 @@ impl SendPatchView {
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let subject = InputState::new(cx);
let description = InputState::builder().multiline().build(cx);
let patch = InputState::builder().multiline().build(cx);
let repo_name = store.read(cx).name();
let directory = ProfileDirectory::new(cx);
if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) {
directory.update(cx, |directory, cx| directory.track([owner], cx));
directory.update(cx, |directory, cx| {
directory.track([owner], cx);
});
}
let subject = InputState::new(cx);
let description = InputState::new(cx);
description.update(cx, |state, cx| state.set_multiline(true, cx));
let patch = InputState::new(cx);
patch.update(cx, |state, cx| state.set_multiline(true, cx));
Self {
dock,
panel_id,
+61
View File
@@ -1,6 +1,8 @@
use gpui::{App, AppContext as _, SharedString, WeakEntity, Window};
use gpui_native_kit::layout::dock::DockState;
use nostr::prelude::EventId;
use signed_core::{Announcement, RepoAddr};
use signed_state::RepoStore;
use crate::repo::RepoDetailView;
use crate::shared::panels;
@@ -30,3 +32,62 @@ pub fn open_repo_panel(
cx,
);
}
/// An item of a repository to open from outside its detail panel.
pub enum RepoItem {
Issue(EventId),
PullRequest(EventId),
Patch,
}
/// Opens an issue or pull request using a store built here.
pub fn open_repo_item(
dock: &WeakEntity<DockState>,
addr: &RepoAddr,
hint: Option<&Announcement>,
item: RepoItem,
window: &mut Window,
cx: &mut App,
) {
let store = cx.new(|cx| RepoStore::new(addr.clone(), hint.cloned(), cx));
match item {
RepoItem::Issue(issue_id) => {
let id = SharedString::from(format!("issue:{issue_id}"));
let store = store.clone();
panels::open(
dock,
id,
move |_, cx| {
cx.new(|cx| {
crate::issues::detail::IssueDetailView::new(store.clone(), issue_id, cx)
})
},
window,
cx,
);
}
RepoItem::PullRequest(pr_id) => {
let id = SharedString::from(format!("pr:{pr_id}"));
let dock_for_build = dock.clone();
let store = store.clone();
panels::open(
dock,
id,
move |window, cx| {
cx.new(|cx| {
crate::pull_requests::PullRequestDetailView::new(
dock_for_build.clone(),
store.clone(),
pr_id,
window,
cx,
)
})
},
window,
cx,
);
}
RepoItem::Patch => {}
}
}
+5 -3
View File
@@ -46,10 +46,12 @@ impl CreateRepoDialog {
let grasp_settings = settings.read(cx).settings().grasp_servers.clone();
let name_input = InputState::new(cx);
let desc_input = InputState::new(cx);
desc_input.update(cx, |state, cx| state.set_multiline(true, cx));
let folder_input = InputState::new_with_text(default_folder.to_string_lossy(), cx);
let desc_input = InputState::builder().multiline().build(cx);
let folder_input = InputState::builder()
.text(default_folder.to_string_lossy())
.build(cx);
folder_input.update(cx, |state, cx| state.set_read_only(true, cx));
let relay_input = InputState::new(cx);
let state = cx.new(|_| DialogProgress::default());
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
+4 -3
View File
@@ -54,10 +54,11 @@ impl InitRepoDialog {
let state = cx.new(|_| DialogProgress::default());
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
let name_input = InputState::new_with_text(default_name, cx);
let name_input = InputState::builder().text(default_name).build(cx);
name_input.update(cx, |state, cx| state.set_read_only(true, cx));
let desc_input = InputState::new(cx);
desc_input.update(cx, |state, cx| state.set_multiline(true, cx));
let desc_input = InputState::builder().multiline().build(cx);
let relay_input = InputState::new(cx);
load_user_grasp_servers(grasp_state.clone(), window, cx);
+29 -5
View File
@@ -17,6 +17,7 @@ use gpui_native_kit::layout::dock::DockState;
use gpui_native_kit::menu::{Menu, MenuItem};
use gpui_native_kit::theme::ActiveTheme as _;
use gpui_native_kit::tooltip::Tooltip;
use helpers::{local_avatar, local_platform, pick_banner};
use metadata::{Profile, ProfileDirectory};
use nostr::prelude::PublicKey;
use signed_core::{Announcement, RepoAddr};
@@ -25,12 +26,12 @@ use signed_state::{
ResolvedLocalRepo, SyncStatusDirectory,
};
mod helpers;
use crate::explore::ExploreView;
use crate::identity;
use crate::inbox::InboxView;
use crate::repo::dialogs;
use helpers::{local_avatar, local_platform, pick_banner};
mod helpers;
pub struct SidebarView {
weak: WeakEntity<Self>,
@@ -228,6 +229,11 @@ impl SidebarView {
.entries(move |_, cx| {
let muted = cx.theme().colors.muted_foreground;
let mut entries = vec![
SidebarEntry::row("inbox", "Inbox").prefix(
Icon::named(CustomIconName::Inbox)
.size(px(14.))
.color(muted),
),
SidebarEntry::row("browse", "Browse").prefix(
Icon::named(CustomIconName::Globe)
.size(px(14.))
@@ -340,14 +346,18 @@ impl SidebarView {
};
match index {
0 | 1 => {
0 => {
self.open_inbox(window, cx);
return;
}
1 | 2 => {
self.open_explore(window, cx);
return;
}
_ => {}
}
let Some(row) = index.checked_sub(3) else {
let Some(row) = index.checked_sub(4) else {
return;
};
@@ -429,9 +439,23 @@ impl SidebarView {
);
}
fn open_inbox(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let dock = self.dock.clone();
let dock_for_build = dock.clone();
crate::shared::panels::open(
&dock,
"inbox",
move |_, cx| cx.new(|cx| InboxView::new(dock_for_build.clone(), cx)),
window,
cx,
);
}
fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let dock = self.dock.clone();
let dock_for_build = dock.clone();
crate::shared::panels::open(
&dock,
"explore",