Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d68c098818 | ||
|
|
f9f0d33bb1 | ||
|
|
651ffec22d | ||
|
|
40deb9db66 | ||
|
|
38e8b2b933 |
Generated
+199
-203
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,6 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
# GPUI Kit
|
||||
gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828", features = ["tree-sitter-languages"], }
|
||||
gpui-base = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" }
|
||||
gpui-fps = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" }
|
||||
|
||||
dock = { path = "crates/dock" }
|
||||
settings = { path = "crates/settings" }
|
||||
|
||||
@@ -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.);
|
||||
|
||||
|
||||
@@ -6,3 +6,7 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nostr.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
use crate::{COVER_NOTE_KIND, RepoAddr};
|
||||
|
||||
/// Kinds that make up the activity of a repository.
|
||||
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
||||
@@ -17,6 +17,41 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [
|
||||
Kind::GitStatusDraft,
|
||||
];
|
||||
|
||||
/// Kinds that notify a user when they tag them via their `p` tag.
|
||||
pub const NOTIFICATION_KINDS: [Kind; 9] = [
|
||||
Kind::GitIssue,
|
||||
Kind::GitPullRequest,
|
||||
Kind::GitPatch,
|
||||
Kind::GitPullRequestUpdate,
|
||||
COVER_NOTE_KIND,
|
||||
Kind::GitStatusOpen,
|
||||
Kind::GitStatusApplied,
|
||||
Kind::GitStatusClosed,
|
||||
Kind::GitStatusDraft,
|
||||
];
|
||||
|
||||
/// Git root kinds that make a comment or cover note count as git activity.
|
||||
const GIT_ROOT_KINDS: [Kind; 4] = [
|
||||
Kind::GitIssue,
|
||||
Kind::GitPatch,
|
||||
Kind::GitPullRequest,
|
||||
Kind::GitRepoAnnouncement,
|
||||
];
|
||||
|
||||
/// Value of the first tag named `name` on `event`.
|
||||
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == name)
|
||||
.and_then(|tag| tag.content())
|
||||
}
|
||||
|
||||
/// Kind named by the first tag `name` on `event`.
|
||||
fn tag_kind(event: &Event, name: &str) -> Option<Kind> {
|
||||
tag_value(event, name)?.parse::<Kind>().ok()
|
||||
}
|
||||
|
||||
/// Latest announcement event for a repository.
|
||||
pub fn announcement(addr: &RepoAddr) -> Filter {
|
||||
Filter::new()
|
||||
@@ -94,6 +129,68 @@ pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
||||
]
|
||||
}
|
||||
|
||||
/// NIP-22 comments on our issues, patches and pull requests.
|
||||
/// They are matched via the uppercase `P` and `K` tags, not authorship.
|
||||
pub fn notification_comments(me: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::Comment)
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
||||
}
|
||||
|
||||
/// Activity directed at us: comments on our roots, and git events tagging us
|
||||
/// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag.
|
||||
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
||||
vec![
|
||||
notification_comments(me),
|
||||
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
|
||||
]
|
||||
}
|
||||
|
||||
/// Git activity authored by `me`, for "Continue where you left off".
|
||||
///
|
||||
/// A comment on an unrelated kind is matched too, so results must be filtered
|
||||
/// through [`is_git_activity`] before display.
|
||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kinds(
|
||||
ACTIVITY_KINDS
|
||||
.into_iter()
|
||||
.chain(std::iter::once(COVER_NOTE_KIND)),
|
||||
)
|
||||
.author(me)
|
||||
}
|
||||
|
||||
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
|
||||
fn is_git_comment(event: &Event) -> bool {
|
||||
event.kind == Kind::Comment
|
||||
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||
}
|
||||
|
||||
/// Whether a kind-1624 cover note targets a git root, checked via its `k` tag.
|
||||
fn is_git_cover_note(event: &Event) -> bool {
|
||||
event.kind == COVER_NOTE_KIND
|
||||
&& tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||
}
|
||||
|
||||
/// Whether a status event references a git root, checked via its `k` tag.
|
||||
fn is_git_status(event: &Event) -> bool {
|
||||
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||
}
|
||||
|
||||
/// Whether `event` is git activity worth showing in the activity list.
|
||||
pub fn is_git_activity(event: &Event) -> bool {
|
||||
match event.kind {
|
||||
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
|
||||
Kind::Comment => is_git_comment(event),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => is_git_status(event),
|
||||
kind => kind == COVER_NOTE_KIND && is_git_cover_note(event),
|
||||
}
|
||||
}
|
||||
|
||||
/// All repository announcements, for global discovery.
|
||||
pub fn all_announcements() -> Filter {
|
||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||
@@ -134,3 +231,94 @@ pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
|
||||
Filter::new().kind(Kind::EventDeletion).coordinate(addr),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn keys(seed: u8) -> Keys {
|
||||
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
||||
hex.push_str(&format!("{seed:02x}"));
|
||||
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
|
||||
}
|
||||
|
||||
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
.finalize(author)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn kind_tag(name: &str, kind: Kind) -> Tag {
|
||||
Tag::parse([name, &kind.as_u16().to_string()]).expect("valid kind tag")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_git_kinds_are_activity() {
|
||||
for kind in [Kind::GitIssue, Kind::GitPatch, Kind::GitPullRequest] {
|
||||
assert!(is_git_activity(&signed(&keys(1), kind, Vec::new())));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_activity_depends_on_the_uppercase_k_tag() {
|
||||
let on_git = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::GitIssue)]);
|
||||
let on_repo = signed(
|
||||
&keys(1),
|
||||
Kind::Comment,
|
||||
vec![kind_tag("K", Kind::GitRepoAnnouncement)],
|
||||
);
|
||||
let on_note = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::TextNote)]);
|
||||
|
||||
assert!(is_git_activity(&on_git));
|
||||
assert!(is_git_activity(&on_repo));
|
||||
assert!(!is_git_activity(&on_note));
|
||||
assert!(!is_git_activity(&signed(
|
||||
&keys(1),
|
||||
Kind::Comment,
|
||||
Vec::new()
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_and_cover_note_activity_depend_on_the_lowercase_k_tag() {
|
||||
let status = signed(
|
||||
&keys(1),
|
||||
Kind::GitStatusClosed,
|
||||
vec![kind_tag("k", Kind::GitPullRequest)],
|
||||
);
|
||||
let cover = signed(
|
||||
&keys(1),
|
||||
COVER_NOTE_KIND,
|
||||
vec![kind_tag("k", Kind::GitPatch)],
|
||||
);
|
||||
let unrelated = signed(
|
||||
&keys(1),
|
||||
Kind::GitStatusClosed,
|
||||
vec![kind_tag("k", Kind::Metadata)],
|
||||
);
|
||||
|
||||
assert!(is_git_activity(&status));
|
||||
assert!(is_git_activity(&cover));
|
||||
assert!(!is_git_activity(&unrelated));
|
||||
assert!(!is_git_activity(&signed(
|
||||
&keys(1),
|
||||
Kind::GitStatusClosed,
|
||||
Vec::new()
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_git_kinds_are_not_activity() {
|
||||
assert!(!is_git_activity(&signed(
|
||||
&keys(1),
|
||||
Kind::TextNote,
|
||||
Vec::new()
|
||||
)));
|
||||
assert!(!is_git_activity(&signed(
|
||||
&keys(1),
|
||||
Kind::GitPullRequestUpdate,
|
||||
Vec::new(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{COVER_NOTE_KIND, RepoAddr, activity_subject};
|
||||
|
||||
/// Window before `now` that an advanced cutoff retreats to.
|
||||
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
||||
|
||||
/// Window before `now` that a mark-all cutoff retreats to.
|
||||
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
|
||||
|
||||
/// A thread of notification and own-activity events sharing one root.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InboxItem {
|
||||
/// The root issue, patch or pull request the events belong to.
|
||||
pub root: EventId,
|
||||
/// The root event itself, when it is known locally.
|
||||
pub root_event: Option<Event>,
|
||||
/// Kind of the root event, when it is known locally.
|
||||
pub root_kind: Option<Kind>,
|
||||
/// Repository the root belongs to, from the root's `a` tag.
|
||||
pub address: Option<RepoAddr>,
|
||||
/// Notification events directed at the user, newest first.
|
||||
pub events: Vec<Event>,
|
||||
/// The user's own events in the thread, newest first.
|
||||
pub own_events: Vec<Event>,
|
||||
/// Unread event ids, oldest first.
|
||||
pub unread_ids: Vec<EventId>,
|
||||
/// Whether every notification event in the thread is archived.
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
impl InboxItem {
|
||||
/// Title of the thread, read from its root issue/patch/PR when known.
|
||||
pub fn title(&self) -> String {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(activity_subject)
|
||||
.unwrap_or_else(|| "Untitled".to_string())
|
||||
}
|
||||
|
||||
/// Kind shown for the thread.
|
||||
pub fn kind(&self) -> Option<Kind> {
|
||||
self.root_kind.or_else(|| {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(|event| event.kind)
|
||||
})
|
||||
}
|
||||
|
||||
/// Timestamp of the newest event in the thread.
|
||||
pub fn latest_activity(&self) -> Timestamp {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.chain(self.own_events.first())
|
||||
.chain(self.events.first())
|
||||
.map(|event| event.created_at)
|
||||
.max()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Up to `limit` events of the thread, oldest first.
|
||||
pub fn timeline(&self, limit: usize) -> Vec<Event> {
|
||||
let mut seen: HashSet<EventId> = HashSet::new();
|
||||
let mut events: Vec<Event> = Vec::new();
|
||||
|
||||
if let Some(root) = &self.root_event {
|
||||
seen.insert(root.id);
|
||||
events.push(root.clone());
|
||||
}
|
||||
|
||||
let mut rest: Vec<Event> = self
|
||||
.own_events
|
||||
.iter()
|
||||
.chain(self.events.iter())
|
||||
.filter(|event| seen.insert(event.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
rest.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||
});
|
||||
rest.truncate(limit.saturating_sub(events.len()));
|
||||
events.extend(rest);
|
||||
|
||||
events.sort_by_key(|event| event.created_at);
|
||||
events
|
||||
}
|
||||
|
||||
/// Whether the thread has an unread event still visible in the inbox.
|
||||
pub fn is_unread(&self) -> bool {
|
||||
!self.archived && !self.unread_ids.is_empty()
|
||||
}
|
||||
|
||||
/// Recompute the unread and archived flags from `state`.
|
||||
pub fn apply_state(&mut self, state: &InboxReadState) {
|
||||
self.unread_ids = self
|
||||
.events
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|event| !state.is_read(event))
|
||||
.map(|event| event.id)
|
||||
.collect();
|
||||
|
||||
// A thread without notification events is never archived.
|
||||
self.archived =
|
||||
!self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event));
|
||||
}
|
||||
}
|
||||
|
||||
/// Root issue, patch or pull request of a notification event.
|
||||
///
|
||||
/// Returns `None` when the event is not git-related, or when its root is a
|
||||
/// coordinate rather than an event.
|
||||
///
|
||||
/// - issue (1621) / PR (1618): itself
|
||||
/// - patch (1617): its `e` parent patch, else itself
|
||||
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
||||
/// - PR update (1619): uppercase `E`
|
||||
/// - statuses (1630-1633) / cover note (1624): NIP-10 root `e`
|
||||
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
||||
where
|
||||
L: Fn(EventId) -> Option<Event>,
|
||||
{
|
||||
if event.kind == COVER_NOTE_KIND {
|
||||
return nip10_root_id(event).map(|root| resolve_thread_root(root, lookup));
|
||||
}
|
||||
match event.kind {
|
||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||
Kind::GitPatch => Some(match first_e_id(event) {
|
||||
Some(parent) => resolve_thread_root(parent, lookup),
|
||||
None => event.id,
|
||||
}),
|
||||
Kind::Comment => match nip22::extract_root(event) {
|
||||
Some(CommentTarget::Event { id, .. }) => Some(resolve_thread_root(id, lookup)),
|
||||
_ => None,
|
||||
},
|
||||
Kind::GitPullRequestUpdate => {
|
||||
first_uppercase_e_id(event).map(|root| resolve_thread_root(root, lookup))
|
||||
}
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => {
|
||||
nip10_root_id(event).map(|root| resolve_thread_root(root, lookup))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Group notification events and the user's own events into one item per thread.
|
||||
pub fn group<E, O, L>(
|
||||
events: E,
|
||||
own: O,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
lookup: &L,
|
||||
) -> Vec<InboxItem>
|
||||
where
|
||||
E: IntoIterator<Item = Event>,
|
||||
O: IntoIterator<Item = Event>,
|
||||
L: Fn(EventId) -> Option<Event>,
|
||||
{
|
||||
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||
for event in events {
|
||||
if event.pubkey == me {
|
||||
continue;
|
||||
}
|
||||
let Some(root) = notification_root(&event, lookup) else {
|
||||
continue;
|
||||
};
|
||||
groups.entry(root).or_default().push(event);
|
||||
}
|
||||
|
||||
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||
for event in own {
|
||||
let root = notification_root(&event, lookup).unwrap_or(event.id);
|
||||
own_groups.entry(root).or_default().push(event);
|
||||
}
|
||||
|
||||
let mut roots: Vec<EventId> = groups.keys().chain(own_groups.keys()).copied().collect();
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
|
||||
let mut items: Vec<InboxItem> = roots
|
||||
.into_iter()
|
||||
.map(|root| {
|
||||
let mut events = groups.remove(&root).unwrap_or_default();
|
||||
let mut own_events = own_groups.remove(&root).unwrap_or_default();
|
||||
sort_newest_first(&mut events);
|
||||
sort_newest_first(&mut own_events);
|
||||
|
||||
let root_event = lookup(root);
|
||||
|
||||
let mut item = InboxItem {
|
||||
root,
|
||||
root_kind: root_event.as_ref().map(|event| event.kind),
|
||||
address: root_event
|
||||
.as_ref()
|
||||
.and_then(|event| event.tags.coordinates().next()),
|
||||
root_event,
|
||||
events,
|
||||
own_events,
|
||||
unread_ids: Vec::new(),
|
||||
archived: false,
|
||||
};
|
||||
item.apply_state(state);
|
||||
item
|
||||
})
|
||||
.collect();
|
||||
|
||||
items.sort_by(|a, b| {
|
||||
b.latest_activity()
|
||||
.cmp(&a.latest_activity())
|
||||
.then_with(|| b.root.to_hex().cmp(&a.root.to_hex()))
|
||||
});
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
/// Sort thread events newest first, ties broken by id.
|
||||
fn sort_newest_first(events: &mut [Event]) {
|
||||
events.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||
});
|
||||
}
|
||||
|
||||
/// Read and archive state of the inbox, a high-water-mark model.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InboxReadState {
|
||||
#[serde(default)]
|
||||
pub read_before: Timestamp,
|
||||
#[serde(default)]
|
||||
pub read_ids: HashSet<EventId>,
|
||||
#[serde(default)]
|
||||
pub archived_before: Timestamp,
|
||||
#[serde(default)]
|
||||
pub archived_ids: HashSet<EventId>,
|
||||
}
|
||||
|
||||
impl InboxReadState {
|
||||
/// Whether `event` is at or before the read cutoff, or marked read.
|
||||
pub fn is_read(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
/// Whether `event` is at or before the archived cutoff, or marked archived.
|
||||
pub fn is_archived(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
/// Mark one event read. Events at or before the cutoff are already read.
|
||||
pub fn mark_read(&mut self, event: &Event) {
|
||||
if event.created_at > self.read_before {
|
||||
self.read_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark one event archived. Events at or before the cutoff are already archived.
|
||||
pub fn mark_archived(&mut self, event: &Event) {
|
||||
if event.created_at > self.archived_before {
|
||||
self.archived_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark every non-self event read, anchoring the cutoff ten days back.
|
||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = now - MARK_ALL_WINDOW;
|
||||
self.read_before = cutoff;
|
||||
self.read_ids = all
|
||||
.iter()
|
||||
.filter(|event| event.pubkey != me && event.created_at > cutoff)
|
||||
.map(|event| event.id)
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Advance the read cutoff to the newest point that keeps unread events
|
||||
/// unread, then prune the id set.
|
||||
pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event));
|
||||
self.read_before = cutoff;
|
||||
prune_ids(&mut self.read_ids, all, cutoff);
|
||||
}
|
||||
|
||||
/// Advance the archived cutoff, mirroring [`Self::advance_read`].
|
||||
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| {
|
||||
self.is_archived(event)
|
||||
});
|
||||
self.archived_before = cutoff;
|
||||
prune_ids(&mut self.archived_ids, all, cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
/// Newest cutoff that keeps unread events unread, never earlier than `current`.
|
||||
fn advance_cutoff<M>(
|
||||
all: &[Event],
|
||||
me: PublicKey,
|
||||
now: Timestamp,
|
||||
current: Timestamp,
|
||||
is_marked: M,
|
||||
) -> Timestamp
|
||||
where
|
||||
M: Fn(&Event) -> bool,
|
||||
{
|
||||
let fallback = now - ADVANCE_WINDOW;
|
||||
|
||||
let oldest = all
|
||||
.iter()
|
||||
.filter(|event| event.pubkey != me && !is_marked(event))
|
||||
.map(|event| event.created_at)
|
||||
.min();
|
||||
|
||||
let candidate = match oldest {
|
||||
Some(at) if at < fallback => at - 1,
|
||||
_ => fallback,
|
||||
};
|
||||
|
||||
candidate.max(current)
|
||||
}
|
||||
|
||||
/// Drop ids whose event is unknown or now covered by the cutoff.
|
||||
fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
|
||||
let created_at: HashMap<EventId, Timestamp> = all
|
||||
.iter()
|
||||
.map(|event| (event.id, event.created_at))
|
||||
.collect();
|
||||
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
|
||||
}
|
||||
|
||||
/// Follow NIP-10/NIP-22 parent pointers until a root item is reached.
|
||||
fn resolve_thread_root(id: EventId, lookup: &impl Fn(EventId) -> Option<Event>) -> EventId {
|
||||
let mut seen = HashSet::new();
|
||||
let mut root = id;
|
||||
|
||||
loop {
|
||||
if !seen.insert(root) {
|
||||
return id;
|
||||
}
|
||||
|
||||
let Some(event) = lookup(root) else {
|
||||
return root;
|
||||
};
|
||||
|
||||
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
|
||||
return root;
|
||||
}
|
||||
|
||||
match parent_id(&event) {
|
||||
Some(parent) => root = parent,
|
||||
None => return root,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parent of a thread event, mirroring gitworkshop's `getParentId`.
|
||||
fn parent_id(event: &Event) -> Option<EventId> {
|
||||
for marker in ["reply", "root"] {
|
||||
if let Some(id) = event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| e_tag_with_marker(tag, marker))
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = event.tags.iter().find_map(|tag| {
|
||||
if tag.kind() != "e" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let slice = tag.as_slice();
|
||||
let is_mention = slice.len() == 4 && slice[3] == "mention";
|
||||
|
||||
if is_mention {
|
||||
return None;
|
||||
}
|
||||
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
}) {
|
||||
return Some(id);
|
||||
}
|
||||
|
||||
first_uppercase_e_id(event)
|
||||
}
|
||||
|
||||
/// NIP-10 root of an event: the `e` tag marked `root`, else the first `e` tag.
|
||||
fn nip10_root_id(event: &Event) -> Option<EventId> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| e_tag_with_marker(tag, "root"))
|
||||
.or_else(|| first_e_id(event))
|
||||
}
|
||||
|
||||
/// First `e` tag id, in document order.
|
||||
fn first_e_id(event: &Event) -> Option<EventId> {
|
||||
first_tag_id(event, "e")
|
||||
}
|
||||
|
||||
/// First uppercase `E` tag id, in document order.
|
||||
fn first_uppercase_e_id(event: &Event) -> Option<EventId> {
|
||||
first_tag_id(event, "E")
|
||||
}
|
||||
|
||||
fn first_tag_id(event: &Event, name: &str) -> Option<EventId> {
|
||||
event.tags.iter().find_map(|tag| {
|
||||
if tag.kind() != name {
|
||||
return None;
|
||||
}
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
})
|
||||
}
|
||||
|
||||
/// Event id from a four-element `e` tag carrying `marker`.
|
||||
fn e_tag_with_marker(tag: &Tag, marker: &str) -> Option<EventId> {
|
||||
let slice = tag.as_slice();
|
||||
if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker {
|
||||
return None;
|
||||
}
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn keys(seed: u8) -> Keys {
|
||||
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
||||
hex.push_str(&format!("{seed:02x}"));
|
||||
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
|
||||
}
|
||||
|
||||
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from_secs(created_at))
|
||||
.finalize(author)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
|
||||
}
|
||||
|
||||
fn marked_e_tag(event: &Event, marker: &str) -> Tag {
|
||||
Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker])
|
||||
.expect("valid e tag")
|
||||
}
|
||||
|
||||
fn uppercase_e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
|
||||
}
|
||||
|
||||
fn a_tag(owner: &PublicKey, id: &str) -> Tag {
|
||||
Tag::parse(["a", &format!("30617:{}:{id}", owner.to_hex())]).expect("valid a tag")
|
||||
}
|
||||
|
||||
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
|
||||
move |id| events.iter().find(|event| event.id == id).cloned()
|
||||
}
|
||||
|
||||
fn issue(author: &Keys, at: u64) -> Event {
|
||||
signed(author, Kind::GitIssue, Vec::new(), at)
|
||||
}
|
||||
|
||||
fn titled_issue(author: &Keys, title: &str, at: u64) -> Event {
|
||||
signed(
|
||||
author,
|
||||
Kind::GitIssue,
|
||||
vec![Tag::parse(["subject", title]).expect("valid subject tag")],
|
||||
at,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_and_pull_request_are_their_own_root() {
|
||||
let events = [
|
||||
issue(&keys(1), 100),
|
||||
signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100),
|
||||
];
|
||||
let lookup = lookup(&events);
|
||||
for event in &events {
|
||||
assert_eq!(notification_root(event, &lookup), Some(event.id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_resolves_to_its_uppercase_root() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let comment = signed(
|
||||
&keys(2),
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("valid K tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
let events = [issue.clone(), comment.clone()];
|
||||
assert_eq!(
|
||||
notification_root(&comment, &lookup(&events)),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_without_root_pointer_has_no_root() {
|
||||
let comment = signed(
|
||||
&keys(2),
|
||||
Kind::Comment,
|
||||
vec![e_tag(&issue(&keys(1), 100))],
|
||||
200,
|
||||
);
|
||||
assert_eq!(notification_root(&comment, &lookup(&[])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_patch_resolves_to_the_root_patch() {
|
||||
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
|
||||
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
|
||||
let events = [root_patch.clone(), child_patch.clone()];
|
||||
assert_eq!(
|
||||
notification_root(&child_patch, &lookup(&events)),
|
||||
Some(root_patch.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_resolves_via_the_root_marker() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let status = signed(
|
||||
&keys(2),
|
||||
Kind::GitStatusClosed,
|
||||
vec![marked_e_tag(&issue, "root")],
|
||||
200,
|
||||
);
|
||||
let events = [issue.clone(), status.clone()];
|
||||
assert_eq!(notification_root(&status, &lookup(&events)), Some(issue.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_update_resolves_via_uppercase_e() {
|
||||
let pr = signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100);
|
||||
let update = signed(
|
||||
&keys(2),
|
||||
Kind::GitPullRequestUpdate,
|
||||
vec![uppercase_e_tag(&pr)],
|
||||
200,
|
||||
);
|
||||
let events = [pr.clone(), update.clone()];
|
||||
assert_eq!(notification_root(&update, &lookup(&events)), Some(pr.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_comment_chain_follows_to_the_root() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||
let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300);
|
||||
let events = [issue.clone(), reply, nested.clone()];
|
||||
assert_eq!(notification_root(&nested, &lookup(&events)), Some(issue.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_excludes_self_and_sorts_groups_newest_first() {
|
||||
let me = keys(1);
|
||||
let issue = issue(&keys(2), 100);
|
||||
let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 300);
|
||||
let other_issue = signed(
|
||||
&keys(2),
|
||||
Kind::GitIssue,
|
||||
vec![Tag::parse(["p", &me.public_key().to_hex()]).expect("valid p tag")],
|
||||
200,
|
||||
);
|
||||
let mine = signed(&keys(1), Kind::Comment, vec![uppercase_e_tag(&issue)], 400);
|
||||
|
||||
let events = [issue.clone(), comment.clone(), other_issue.clone(), mine];
|
||||
let items = group(
|
||||
events,
|
||||
Vec::new(),
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&[]),
|
||||
);
|
||||
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].root, issue.id);
|
||||
// The issue itself plus the comment; the self-authored comment is out.
|
||||
assert_eq!(items[0].events.len(), 2);
|
||||
assert_eq!(items[1].root, other_issue.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_reports_unread_oldest_first_and_archived() {
|
||||
let me = keys(1);
|
||||
let issue = issue(&keys(2), 100);
|
||||
let older = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||
let newer = signed(&keys(4), Kind::Comment, vec![uppercase_e_tag(&issue)], 300);
|
||||
|
||||
let events = [issue.clone(), older.clone(), newer.clone()];
|
||||
let items = group(
|
||||
events,
|
||||
Vec::new(),
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&[]),
|
||||
);
|
||||
assert_eq!(items[0].unread_ids, vec![issue.id, older.id, newer.id]);
|
||||
assert!(!items[0].archived);
|
||||
assert!(items[0].is_unread());
|
||||
|
||||
let state = InboxReadState {
|
||||
archived_before: Timestamp::from_secs(1000),
|
||||
..Default::default()
|
||||
};
|
||||
let items = group(
|
||||
[issue.clone(), older, newer],
|
||||
Vec::new(),
|
||||
me.public_key(),
|
||||
&state,
|
||||
&lookup(&[]),
|
||||
);
|
||||
assert!(items[0].archived);
|
||||
assert!(!items[0].unread_ids.is_empty());
|
||||
assert!(!items[0].is_unread());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_reads_root_kind_and_address_from_the_root_event() {
|
||||
let me = keys(1);
|
||||
let owner_keys = keys(2);
|
||||
let owner = owner_keys.public_key();
|
||||
let issue = signed(
|
||||
&owner_keys,
|
||||
Kind::GitIssue,
|
||||
vec![a_tag(&owner, "my-repo")],
|
||||
100,
|
||||
);
|
||||
let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||
|
||||
let events = [issue.clone(), comment];
|
||||
let items = group(
|
||||
events.clone(),
|
||||
Vec::new(),
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&events),
|
||||
);
|
||||
|
||||
assert_eq!(items[0].root_kind, Some(Kind::GitIssue));
|
||||
assert_eq!(items[0].address, issue.tags.coordinates().next());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_merges_own_events_into_the_matching_thread() {
|
||||
let me = keys(1);
|
||||
let issue = titled_issue(&me, "Add retry logic", 100);
|
||||
let mine = signed(
|
||||
&me,
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("K tag"),
|
||||
],
|
||||
150,
|
||||
);
|
||||
let reply = signed(
|
||||
&keys(2),
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("K tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
|
||||
let context = [issue.clone(), mine.clone(), reply.clone()];
|
||||
let items = group(
|
||||
[reply.clone()],
|
||||
[issue.clone(), mine.clone()],
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&context),
|
||||
);
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].root, issue.id);
|
||||
assert_eq!(
|
||||
items[0].root_event.as_ref().map(|event| event.id),
|
||||
Some(issue.id)
|
||||
);
|
||||
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
|
||||
assert_eq!(items[0].title(), "Add retry logic");
|
||||
assert_eq!(items[0].events, vec![reply.clone()]);
|
||||
// The own events are kept apart from the notifications, newest first.
|
||||
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
|
||||
assert_eq!(
|
||||
items[0]
|
||||
.timeline(5)
|
||||
.iter()
|
||||
.map(|event| event.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![issue.id, mine.id, reply.id]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_all_read_marks_known_recent_events() {
|
||||
let me = keys(1);
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
let recent = issue(&keys(2), now.as_secs() - 1000);
|
||||
let old = issue(&keys(2), now.as_secs() - 5 * 24 * 60 * 60);
|
||||
let ancient = issue(&keys(2), now.as_secs() - 20 * 24 * 60 * 60);
|
||||
let mine = issue(&keys(1), now.as_secs() - 100);
|
||||
|
||||
let mut state = InboxReadState::default();
|
||||
state.mark_all_read(
|
||||
&[recent.clone(), old.clone(), ancient.clone(), mine.clone()],
|
||||
me.public_key(),
|
||||
now,
|
||||
);
|
||||
|
||||
assert_eq!(state.read_before, now - MARK_ALL_WINDOW);
|
||||
assert_eq!(state.read_ids, HashSet::from([recent.id, old.id]));
|
||||
assert!(state.is_read(&recent));
|
||||
assert!(state.is_read(&ancient));
|
||||
assert!(!state.is_read(&mine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_read_never_moves_the_cutoff_backwards() {
|
||||
let me = keys(1);
|
||||
let unread = issue(&keys(2), 1_000);
|
||||
let all = [unread];
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
|
||||
let mut state = InboxReadState {
|
||||
read_before: Timestamp::from_secs(999_999_999),
|
||||
..Default::default()
|
||||
};
|
||||
state.advance_read(&all, me.public_key(), now);
|
||||
|
||||
assert_eq!(state.read_before, Timestamp::from_secs(999_999_999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_read_moves_before_the_oldest_unread_and_prunes_ids() {
|
||||
let me = keys(1);
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
let five_days = 5 * 24 * 60 * 60;
|
||||
let old_unread = issue(&keys(2), now.as_secs() - five_days);
|
||||
// Read ids that fall before and after the new cutoff.
|
||||
let stale = signed(
|
||||
&keys(2),
|
||||
Kind::GitIssue,
|
||||
Vec::new(),
|
||||
now.as_secs() - five_days - 1000,
|
||||
);
|
||||
let fresh = signed(
|
||||
&keys(2),
|
||||
Kind::GitIssue,
|
||||
Vec::new(),
|
||||
now.as_secs() - 100_000,
|
||||
);
|
||||
|
||||
let mut state = InboxReadState {
|
||||
read_ids: HashSet::from([stale.id, fresh.id]),
|
||||
..Default::default()
|
||||
};
|
||||
state.advance_read(
|
||||
&[old_unread.clone(), stale.clone(), fresh.clone()],
|
||||
me.public_key(),
|
||||
now,
|
||||
);
|
||||
|
||||
assert_eq!(state.read_before, old_unread.created_at - 1);
|
||||
assert_eq!(state.read_ids, HashSet::from([fresh.id]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_archived_skips_events_at_or_before_the_cutoff() {
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
let event = issue(&keys(2), now.as_secs() - 1000);
|
||||
|
||||
let mut state = InboxReadState {
|
||||
archived_before: now,
|
||||
..Default::default()
|
||||
};
|
||||
state.mark_archived(&event);
|
||||
assert!(state.archived_ids.is_empty());
|
||||
|
||||
let mut state = InboxReadState::default();
|
||||
state.mark_archived(&event);
|
||||
assert_eq!(state.archived_ids, HashSet::from([event.id]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_state_recomputes_unread_and_archived() {
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
let first = issue(&keys(2), now.as_secs() - 2000);
|
||||
let second = issue(&keys(2), now.as_secs() - 1000);
|
||||
let mut item = InboxItem {
|
||||
root: first.id,
|
||||
root_event: None,
|
||||
root_kind: None,
|
||||
address: None,
|
||||
events: vec![second.clone(), first.clone()],
|
||||
own_events: Vec::new(),
|
||||
unread_ids: Vec::new(),
|
||||
archived: false,
|
||||
};
|
||||
|
||||
let state = InboxReadState {
|
||||
read_before: first.created_at,
|
||||
..Default::default()
|
||||
};
|
||||
item.apply_state(&state);
|
||||
|
||||
assert_eq!(item.unread_ids, vec![second.id]);
|
||||
assert!(!item.archived);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_preserves_state() {
|
||||
let first = issue(&keys(1), 100);
|
||||
let second = issue(&keys(2), 200);
|
||||
let state = InboxReadState {
|
||||
read_before: Timestamp::from_secs(150),
|
||||
read_ids: HashSet::from([second.id]),
|
||||
archived_before: Timestamp::from_secs(50),
|
||||
archived_ids: HashSet::from([first.id]),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&state).expect("serialized");
|
||||
let parsed: InboxReadState = serde_json::from_str(&json).expect("deserialized");
|
||||
|
||||
assert_eq!(parsed, state);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ pub mod annotations;
|
||||
pub mod clone_url;
|
||||
pub mod deletions;
|
||||
pub mod filters;
|
||||
pub mod inbox;
|
||||
pub mod model;
|
||||
pub mod state;
|
||||
pub mod status;
|
||||
@@ -11,6 +12,10 @@ pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
||||
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
||||
pub use clone_url::{CloneTarget, parse_clone_url};
|
||||
pub use deletions::Deletions;
|
||||
pub use filters::{
|
||||
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
|
||||
};
|
||||
pub use inbox::{InboxItem, InboxReadState, group, notification_root};
|
||||
pub use model::{
|
||||
Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of,
|
||||
fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches,
|
||||
|
||||
@@ -13,6 +13,7 @@ gix-worktree = "0.56"
|
||||
gix-worktree-state = "0.34"
|
||||
anyhow.workspace = true
|
||||
diffy = "0.5"
|
||||
ignore = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
|
||||
use crate::remote::{clone_repo, fetch_all};
|
||||
|
||||
/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitCache {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl GitCache {
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
Self { root }
|
||||
}
|
||||
|
||||
/// The root directory holding the mirror clones.
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// Local path of the clone for a repository.
|
||||
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
|
||||
self.root
|
||||
.join(addr.public_key.to_hex())
|
||||
.join(sanitize_path_component(&addr.identifier))
|
||||
}
|
||||
|
||||
/// Open an existing clone.
|
||||
pub fn open(&self, addr: &RepoAddr) -> Result<Option<gix::Repository>> {
|
||||
let path = self.repo_path(addr);
|
||||
match gix::open(&path) {
|
||||
Ok(repo) => Ok(Some(repo)),
|
||||
Err(gix::open::Error::NotARepository { .. }) => Ok(None),
|
||||
Err(gix::open::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the existing clone, fetching it first.
|
||||
pub fn ensure_clone<U: AsRef<str>>(
|
||||
&self,
|
||||
addr: &RepoAddr,
|
||||
clone_urls: &[U],
|
||||
) -> Result<gix::Repository> {
|
||||
let path = self.repo_path(addr);
|
||||
|
||||
if let Some(repo) = self.open(addr)? {
|
||||
fetch_all(&repo).ok();
|
||||
return Ok(repo);
|
||||
}
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
|
||||
clone_repo(clone_urls, &path)?;
|
||||
self.open(addr)?
|
||||
.ok_or_else(|| anyhow::anyhow!("clone finished but the repository cannot be opened"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an untrusted repository id or display name to a safe single path component.
|
||||
///
|
||||
/// Everything outside `[A-Za-z0-9._-]` becomes `_`.
|
||||
/// An id that maps to exactly `.` or `..` becomes `_`.
|
||||
pub fn sanitize_path_component(id: &str) -> String {
|
||||
let sanitized: String = id
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if sanitized == "." || sanitized == ".." {
|
||||
return "_".to_owned();
|
||||
}
|
||||
|
||||
sanitized
|
||||
}
|
||||
|
||||
/// The refs namespace of a fork's import in the target mirror.
|
||||
pub fn fork_namespace(announcement: &Announcement) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
announcement.owner.to_hex(),
|
||||
sanitize_path_component(&announcement.id)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
|
||||
|
||||
/// The kind of a [`DiffLine`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiffLineKind {
|
||||
/// An unchanged context line, present on both sides.
|
||||
Context,
|
||||
/// A line added by the commit.
|
||||
Addition,
|
||||
/// A line removed by the commit.
|
||||
Deletion,
|
||||
}
|
||||
|
||||
/// One line of a file diff.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffLine {
|
||||
pub kind: DiffLineKind,
|
||||
/// 1-based line number in the old version, if the line exists there.
|
||||
pub old: Option<u32>,
|
||||
/// 1-based line number in the new version, if the line exists there.
|
||||
pub new: Option<u32>,
|
||||
/// Line content without the trailing newline.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// A hunk of a file diff, like `@@ -a,b +c,d @@`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffHunk {
|
||||
/// 1-based start line in the old version.
|
||||
pub old_start: u32,
|
||||
/// Number of old lines covered by the hunk.
|
||||
pub old_lines: u32,
|
||||
/// 1-based start line in the new version.
|
||||
pub new_start: u32,
|
||||
/// Number of new lines covered by the hunk.
|
||||
pub new_lines: u32,
|
||||
pub lines: Vec<DiffLine>,
|
||||
}
|
||||
|
||||
/// How a file changed in a commit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiffStatus {
|
||||
Added,
|
||||
Modified,
|
||||
Deleted,
|
||||
Renamed,
|
||||
Copied,
|
||||
}
|
||||
|
||||
/// The diff of one file in a commit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileDiff {
|
||||
/// Path of the file relative to the repo root.
|
||||
///
|
||||
/// For renames and copies, this is the destination path.
|
||||
pub path: String,
|
||||
/// Previous path, for renames and copies.
|
||||
pub old_path: Option<String>,
|
||||
pub status: DiffStatus,
|
||||
/// Number of added lines, 0 for binary files.
|
||||
pub insertions: usize,
|
||||
/// Number of removed lines, 0 for binary files.
|
||||
pub deletions: usize,
|
||||
/// True if either version is binary, then `hunks` is empty.
|
||||
pub binary: bool,
|
||||
pub hunks: Vec<DiffHunk>,
|
||||
}
|
||||
|
||||
/// The changes of one commit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommitDiff {
|
||||
pub files: Vec<FileDiff>,
|
||||
}
|
||||
|
||||
/// The changes of the commit `id`, short or full, in the repository at `workdir`.
|
||||
///
|
||||
/// Compared against its first parent, the empty tree for the root commit.
|
||||
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
|
||||
commit_diff(&gix::open(workdir)?, id)
|
||||
}
|
||||
|
||||
fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
|
||||
let commit_id = repo.rev_parse_single(id.as_bytes())?;
|
||||
let commit = commit_id.object()?.into_commit();
|
||||
let new_tree = commit.tree()?;
|
||||
let old_tree = match commit.parent_ids().next() {
|
||||
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
||||
None => None,
|
||||
};
|
||||
tree_diff(repo, old_tree.as_ref(), &new_tree)
|
||||
}
|
||||
|
||||
/// The changes between two commits, `base`..`tip`, like `git diff base tip`.
|
||||
///
|
||||
/// Directories and submodules are skipped, files are sorted by path.
|
||||
pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result<CommitDiff> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let base_tree = repo
|
||||
.rev_parse_single(base.as_bytes())?
|
||||
.object()?
|
||||
.into_commit()
|
||||
.tree()?;
|
||||
let tip_tree = repo
|
||||
.rev_parse_single(tip.as_bytes())?
|
||||
.object()?
|
||||
.into_commit()
|
||||
.tree()?;
|
||||
tree_diff(&repo, Some(&base_tree), &tip_tree)
|
||||
}
|
||||
/// The changes between two trees. Used by both [`commit_diff`] and [`worktree_commit_range_diff`].
|
||||
fn tree_diff(
|
||||
repo: &gix::Repository,
|
||||
old_tree: Option<&gix::Tree<'_>>,
|
||||
new_tree: &gix::Tree<'_>,
|
||||
) -> Result<CommitDiff> {
|
||||
use gix::diff::blob::platform::prepare_diff::Operation;
|
||||
use gix::object::tree::diff::Change;
|
||||
use gix::objs::tree::EntryKind;
|
||||
|
||||
let changes = repo.diff_tree_to_tree(old_tree, Some(new_tree), None)?;
|
||||
|
||||
let mut cache = repo.diff_resource_cache_for_tree_diff()?;
|
||||
let mut files = Vec::new();
|
||||
|
||||
for change in changes {
|
||||
let attached = Change::from_change_ref(change.to_ref(), repo, repo);
|
||||
|
||||
// Skip directory trees and submodule gitlinks, only files are listed.
|
||||
let (path, old_path, status) = match attached {
|
||||
Change::Addition {
|
||||
location,
|
||||
entry_mode,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||
(location.to_owned(), None, DiffStatus::Added)
|
||||
}
|
||||
Change::Deletion {
|
||||
location,
|
||||
entry_mode,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||
(location.to_owned(), None, DiffStatus::Deleted)
|
||||
}
|
||||
Change::Modification {
|
||||
location,
|
||||
previous_entry_mode,
|
||||
entry_mode,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
||||
&& !matches!(
|
||||
previous_entry_mode.kind(),
|
||||
EntryKind::Tree | EntryKind::Commit
|
||||
) =>
|
||||
{
|
||||
(location.to_owned(), None, DiffStatus::Modified)
|
||||
}
|
||||
Change::Rewrite {
|
||||
location,
|
||||
source_location,
|
||||
source_entry_mode,
|
||||
entry_mode,
|
||||
copy,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
||||
&& !matches!(
|
||||
source_entry_mode.kind(),
|
||||
EntryKind::Tree | EntryKind::Commit
|
||||
) =>
|
||||
{
|
||||
let status = if copy {
|
||||
DiffStatus::Copied
|
||||
} else {
|
||||
DiffStatus::Renamed
|
||||
};
|
||||
(
|
||||
location.to_owned(),
|
||||
Some(source_location.to_owned()),
|
||||
status,
|
||||
)
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Always diff with the built-in algorithm.
|
||||
// External diff drivers would shell out, out of scope for a read-only viewer.
|
||||
let platform = attached.diff(&mut cache)?;
|
||||
platform
|
||||
.resource_cache
|
||||
.options
|
||||
.skip_internal_diff_if_external_is_configured = true;
|
||||
let outcome = platform.resource_cache.prepare_diff()?;
|
||||
|
||||
let (binary, hunks, insertions, deletions) = match outcome.operation {
|
||||
Operation::InternalDiff { algorithm } => {
|
||||
let input = outcome.interned_input();
|
||||
let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input);
|
||||
|
||||
let mut hunks = Vec::new();
|
||||
let mut insertions = 0usize;
|
||||
let mut deletions = 0usize;
|
||||
let collector = HunkCollector {
|
||||
hunks: &mut hunks,
|
||||
insertions: &mut insertions,
|
||||
deletions: &mut deletions,
|
||||
};
|
||||
gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default())
|
||||
.consume()?;
|
||||
(false, hunks, insertions, deletions)
|
||||
}
|
||||
Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0),
|
||||
Operation::ExternalCommand { .. } => unreachable!("external diff drivers are disabled"),
|
||||
};
|
||||
|
||||
files.push(FileDiff {
|
||||
path: String::from_utf8_lossy(&path).into_owned(),
|
||||
old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()),
|
||||
status,
|
||||
insertions,
|
||||
deletions,
|
||||
binary,
|
||||
hunks,
|
||||
});
|
||||
}
|
||||
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
|
||||
Ok(CommitDiff { files })
|
||||
}
|
||||
|
||||
/// Collects the hunks of one blob diff while tracking per-line numbers.
|
||||
struct HunkCollector<'a> {
|
||||
hunks: &'a mut Vec<DiffHunk>,
|
||||
insertions: &'a mut usize,
|
||||
deletions: &'a mut usize,
|
||||
}
|
||||
|
||||
impl ConsumeHunk for HunkCollector<'_> {
|
||||
type Out = ();
|
||||
|
||||
fn consume_hunk(
|
||||
&mut self,
|
||||
header: HunkHeader,
|
||||
lines: &[(GixLineKind, &[u8])],
|
||||
) -> std::io::Result<()> {
|
||||
let mut old_ln = header.before_hunk_start;
|
||||
let mut new_ln = header.after_hunk_start;
|
||||
let mut out = Vec::with_capacity(lines.len());
|
||||
|
||||
for (kind, content) in lines {
|
||||
let text = String::from_utf8_lossy(content).into_owned();
|
||||
let line = match kind {
|
||||
GixLineKind::Context => {
|
||||
let line = DiffLine {
|
||||
kind: DiffLineKind::Context,
|
||||
old: Some(old_ln),
|
||||
new: Some(new_ln),
|
||||
text,
|
||||
};
|
||||
old_ln += 1;
|
||||
new_ln += 1;
|
||||
line
|
||||
}
|
||||
GixLineKind::Remove => {
|
||||
*self.deletions += 1;
|
||||
let line = DiffLine {
|
||||
kind: DiffLineKind::Deletion,
|
||||
old: Some(old_ln),
|
||||
new: None,
|
||||
text,
|
||||
};
|
||||
old_ln += 1;
|
||||
line
|
||||
}
|
||||
GixLineKind::Add => {
|
||||
*self.insertions += 1;
|
||||
let line = DiffLine {
|
||||
kind: DiffLineKind::Addition,
|
||||
old: None,
|
||||
new: Some(new_ln),
|
||||
text,
|
||||
};
|
||||
new_ln += 1;
|
||||
line
|
||||
}
|
||||
};
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
self.hunks.push(DiffHunk {
|
||||
old_start: header.before_hunk_start,
|
||||
old_lines: header.before_hunk_len,
|
||||
new_start: header.after_hunk_start,
|
||||
new_lines: header.after_hunk_len,
|
||||
lines: out,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(self) {}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
/// In-memory object cache for history walks, see [`open_with_cache`].
|
||||
///
|
||||
/// Without one, a walk re-decodes the same commit objects from the object database.
|
||||
/// Sized generously: a walk can cover a large portion of the repository's history.
|
||||
const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// Metadata of a commit, as shown in the repository browser's file header.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileCommit {
|
||||
/// Shortened commit id, 7+ hex chars, disambiguated if needed.
|
||||
pub id: String,
|
||||
/// First line of the commit message.
|
||||
pub summary: String,
|
||||
/// Rest of the commit message after the title.
|
||||
///
|
||||
/// `None` for single-line commit messages.
|
||||
pub description: Option<String>,
|
||||
/// Author name.
|
||||
pub author: String,
|
||||
/// Author time, seconds since the Unix epoch.
|
||||
pub time: i64,
|
||||
}
|
||||
|
||||
/// Open the repository at `workdir` with an in-memory object cache.
|
||||
///
|
||||
/// Only history walks use it, they re-decode the same commit objects repeatedly.
|
||||
/// Single-object reads open the repository plain.
|
||||
pub(crate) fn open_with_cache(workdir: &Path) -> Result<gix::Repository> {
|
||||
let mut repo = gix::open(workdir)?;
|
||||
repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES);
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
/// A [`FileCommit`] from a commit, with author, message title, body and shortened id.
|
||||
///
|
||||
/// The diff panel fetches the full commit on demand.
|
||||
fn file_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
file_commit_with_description(commit, true)
|
||||
}
|
||||
|
||||
/// A [`FileCommit`] without the message body, for history lists that never display it.
|
||||
///
|
||||
/// Skipping the body saves an allocation per listed commit.
|
||||
fn file_commit_summary(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
file_commit_with_description(commit, false)
|
||||
}
|
||||
|
||||
/// [`file_commit`] and [`file_commit_summary`], `include_description` picks the body.
|
||||
fn file_commit_with_description(
|
||||
commit: &gix::Commit<'_>,
|
||||
include_description: bool,
|
||||
) -> Result<FileCommit> {
|
||||
let author = commit.author()?;
|
||||
let message = commit.message()?;
|
||||
|
||||
Ok(FileCommit {
|
||||
id: commit.id().shorten_or_id().to_string(),
|
||||
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
||||
description: if include_description {
|
||||
message
|
||||
.body
|
||||
.map(|body| String::from_utf8_lossy(body).trim().to_string())
|
||||
.filter(|body| !body.is_empty())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
||||
time: author.time()?.seconds,
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the most recent commit that changed `rel`, a path relative to the worktree.
|
||||
///
|
||||
/// `Ok(None)` when no commit touched the file, e.g. an untracked file.
|
||||
pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileCommit>> {
|
||||
let rel = rel.to_path_buf();
|
||||
Ok(last_commits(repo, std::slice::from_ref(&rel))?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|(_, commit)| commit))
|
||||
}
|
||||
|
||||
/// Newest commit touching each of `rels`, like `git log -1 -- <rel>` per path.
|
||||
/// `rels` are paths relative to the worktree.
|
||||
///
|
||||
/// Paths without any commit, like untracked files, are absent from the result.
|
||||
pub fn worktree_last_commits(
|
||||
workdir: &Path,
|
||||
rels: &[PathBuf],
|
||||
) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||
last_commits(&open_with_cache(workdir)?, rels)
|
||||
}
|
||||
|
||||
/// The walk behind [`last_commit`] and [`worktree_last_commits`].
|
||||
///
|
||||
/// Stops as soon as every pending path has its commit.
|
||||
fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let Some(head) = repo.head_id().ok() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
// De-duplicate while preserving order.
|
||||
let mut pending: Vec<PathBuf> = Vec::with_capacity(rels.len());
|
||||
let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len());
|
||||
|
||||
for rel in rels {
|
||||
if seen.insert(rel.as_path()) {
|
||||
pending.push(rel.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let walk = repo
|
||||
.rev_walk([head])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
CommitTimeOrder::NewestFirst,
|
||||
));
|
||||
|
||||
let mut found = Vec::new();
|
||||
for info in walk.all()? {
|
||||
if pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
let info = info?;
|
||||
let commit = info.object()?;
|
||||
let tree = commit.tree()?;
|
||||
let parent_tree = match info.parent_ids().next() {
|
||||
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Compare each unresolved path against this commit and its first parent.
|
||||
// Resolved paths leave the pending set.
|
||||
let mut ix = 0;
|
||||
while ix < pending.len() {
|
||||
let rel = &pending[ix];
|
||||
let blob = tree.lookup_entry_by_path(rel)?;
|
||||
let parent_blob = match &parent_tree {
|
||||
Some(tree) => tree.lookup_entry_by_path(rel)?,
|
||||
None => None,
|
||||
};
|
||||
|
||||
if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach())
|
||||
{
|
||||
found.push((rel.clone(), file_commit(&commit)?));
|
||||
pending.swap_remove(ix);
|
||||
} else {
|
||||
ix += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Cap on [`CommitList::commits`]. The virtual list renders a window at a time,
|
||||
/// the tab badge shows the real count.
|
||||
///
|
||||
/// A huge history is never fully materialized in memory.
|
||||
pub const MAX_LISTED_COMMITS: usize = 20_000;
|
||||
|
||||
/// Commits reachable from `HEAD`, newest first, possibly capped.
|
||||
pub struct CommitList {
|
||||
/// Number of commits reachable from HEAD.
|
||||
pub total: usize,
|
||||
/// Newest commits, capped at [`MAX_LISTED_COMMITS`].
|
||||
pub commits: Vec<FileCommit>,
|
||||
}
|
||||
|
||||
/// All commits reachable from `HEAD`, newest first, with author and summary.
|
||||
///
|
||||
/// Returns an empty list for a repository without any commits yet.
|
||||
pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let Some(head) = repo.head_id().ok() else {
|
||||
return Ok(CommitList {
|
||||
total: 0,
|
||||
commits: Vec::new(),
|
||||
});
|
||||
};
|
||||
|
||||
let walk = repo
|
||||
.rev_walk([head])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
CommitTimeOrder::NewestFirst,
|
||||
));
|
||||
|
||||
let mut commits = Vec::new();
|
||||
let mut total = 0;
|
||||
|
||||
for info in walk.all()? {
|
||||
let info = info?;
|
||||
total += 1;
|
||||
if commits.len() < MAX_LISTED_COMMITS {
|
||||
commits.push(file_commit_summary(&info.object()?)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CommitList { total, commits })
|
||||
}
|
||||
|
||||
/// Like [`all_commits`], but opens the repository at `workdir` first.
|
||||
///
|
||||
/// For non-bare clones the clone root is the worktree.
|
||||
pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> {
|
||||
all_commits(&open_with_cache(workdir)?)
|
||||
}
|
||||
|
||||
/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`.
|
||||
pub fn worktree_commit_range_commits(
|
||||
workdir: &Path,
|
||||
base: &str,
|
||||
tip: &str,
|
||||
) -> Result<Vec<FileCommit>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let repo = open_with_cache(workdir)?;
|
||||
let base_id = repo.rev_parse_single(base.as_bytes())?;
|
||||
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
|
||||
let walk = repo
|
||||
.rev_walk([tip_id])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
CommitTimeOrder::NewestFirst,
|
||||
))
|
||||
.with_hidden([base_id]);
|
||||
|
||||
let mut commits = Vec::new();
|
||||
|
||||
for info in walk.all()? {
|
||||
let info = info?;
|
||||
commits.push(file_commit_summary(&info.object()?)?);
|
||||
}
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
/// The commit HEAD points to, like `git log -1`.
|
||||
///
|
||||
/// `Ok(None)` for a repository without commits yet, an unborn HEAD.
|
||||
pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
|
||||
let Some(head) = repo.head_id().ok() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let commit = head.object()?.into_commit();
|
||||
Ok(Some(file_commit(&commit)?))
|
||||
}
|
||||
|
||||
/// Full metadata of the commit `id`, short or full, in the repository at `workdir`.
|
||||
/// Like [`head_commit`] for an arbitrary commit.
|
||||
///
|
||||
/// `Ok(None)` when the id cannot be resolved.
|
||||
pub fn worktree_commit(workdir: &Path, id: &str) -> Result<Option<FileCommit>> {
|
||||
let repo = gix::open(workdir)?;
|
||||
match repo.rev_parse_single(id.as_bytes()) {
|
||||
Ok(commit_id) => {
|
||||
let commit = commit_id.object()?.into_commit();
|
||||
Ok(Some(file_commit(&commit)?))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
+42
-3985
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,323 @@
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
|
||||
use diffy::{Hunk, Line};
|
||||
|
||||
use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
|
||||
use crate::history::FileCommit;
|
||||
|
||||
/// Apply a `git format-patch` patch or series with `git am`,
|
||||
/// uses the git CLI because it handles the mbox format natively.
|
||||
///
|
||||
/// TODO: Replaced with a pure-Rust implementation later without changing callers.
|
||||
pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
|
||||
let mut child = Command::new("git")
|
||||
.arg("am")
|
||||
.current_dir(repo_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("failed to spawn `git am`")?;
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.expect("stdin piped")
|
||||
.write_all(patch.as_bytes())?;
|
||||
|
||||
let output = child.wait_with_output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`.
|
||||
/// Fails when the range has no commits.
|
||||
///
|
||||
/// The mbox is returned untrimmed. Trailing newlines are part of the format.
|
||||
pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["format-patch", "--stdout", &format!("{base}..{tip}")])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git format-patch`")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git format-patch failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let patch = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
|
||||
if patch.trim().is_empty() {
|
||||
bail!("no commits between {base} and {tip}");
|
||||
}
|
||||
|
||||
Ok(patch)
|
||||
}
|
||||
|
||||
/// Split a `git format-patch` series into its individual patches, mbox messages.
|
||||
///
|
||||
/// A single patch yields one element.
|
||||
/// A malformed input yields one element covering it.
|
||||
pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
||||
let mut starts = vec![0usize];
|
||||
let mut search_from = 1;
|
||||
|
||||
while let Some(rel) = patch[search_from..].find("\nFrom ") {
|
||||
let ix = search_from + rel + 1;
|
||||
let hex = patch[ix + 5..]
|
||||
.split(|c: char| !c.is_ascii_hexdigit())
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
if hex.len() == 40 {
|
||||
starts.push(ix);
|
||||
}
|
||||
search_from = ix + 1;
|
||||
}
|
||||
|
||||
starts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &start)| {
|
||||
let end = starts.get(i + 1).copied().unwrap_or(patch.len());
|
||||
&patch[start..end]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse `git format-patch` output, a single patch or a series.
|
||||
///
|
||||
/// Backed by [`diffy::patch_set`], which implements git's extended diff format:
|
||||
/// `diff --git` headers, rename and copy detection, binary detection, and
|
||||
/// C-style quoted or octal-escaped paths.
|
||||
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
||||
return Ok(CommitDiff { files: Vec::new() });
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
|
||||
files.push(file_diff(file?)?);
|
||||
}
|
||||
|
||||
Ok(CommitDiff { files })
|
||||
}
|
||||
|
||||
/// The [`FileDiff`] of one parsed file patch.
|
||||
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
|
||||
// The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path
|
||||
// component is dropped, the same way `git apply -p1` does.
|
||||
// Rename and copy paths come from their own headers, unprefixed.
|
||||
let stripped;
|
||||
let operation = match file.operation() {
|
||||
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
|
||||
operation => {
|
||||
stripped = operation.strip_prefix(1);
|
||||
&stripped
|
||||
}
|
||||
};
|
||||
|
||||
let (path, old_path, status) = match operation {
|
||||
FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added),
|
||||
FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted),
|
||||
FileOperation::Modify { modified, .. } => (modified.as_ref(), None, DiffStatus::Modified),
|
||||
FileOperation::Rename { from, to } => {
|
||||
(to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed)
|
||||
}
|
||||
FileOperation::Copy { from, to } => (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied),
|
||||
};
|
||||
|
||||
let mut insertions = 0usize;
|
||||
let mut deletions = 0usize;
|
||||
let mut hunks = Vec::new();
|
||||
|
||||
let patch = file.patch();
|
||||
|
||||
if let Some(text) = patch.as_text() {
|
||||
for hunk in text.hunks() {
|
||||
let hunk = hunk_diff(hunk);
|
||||
insertions += hunk
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| line.kind == DiffLineKind::Addition)
|
||||
.count();
|
||||
deletions += hunk
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| line.kind == DiffLineKind::Deletion)
|
||||
.count();
|
||||
hunks.push(hunk);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(FileDiff {
|
||||
path: path.to_owned(),
|
||||
old_path: old_path.map(str::to_owned),
|
||||
status,
|
||||
insertions,
|
||||
deletions,
|
||||
binary: patch.is_binary(),
|
||||
hunks,
|
||||
})
|
||||
}
|
||||
|
||||
/// The [`DiffHunk`] of one parsed hunk, including the line number of every line.
|
||||
///
|
||||
/// `diffy` reports only the hunk header ranges. The per-line numbers are
|
||||
/// counted from them the way the header encodes them: context lines advance
|
||||
/// both sides, deletions only the old, insertions only the new.
|
||||
fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk {
|
||||
let old_range = hunk.old_range();
|
||||
let new_range = hunk.new_range();
|
||||
|
||||
let mut old = old_range.start() as u32;
|
||||
let mut new = new_range.start() as u32;
|
||||
let mut lines = Vec::with_capacity(hunk.lines().len());
|
||||
|
||||
for line in hunk.lines() {
|
||||
let (kind, text) = match line {
|
||||
Line::Context(text) => (DiffLineKind::Context, *text),
|
||||
Line::Delete(text) => (DiffLineKind::Deletion, *text),
|
||||
Line::Insert(text) => (DiffLineKind::Addition, *text),
|
||||
};
|
||||
|
||||
let (old_no, new_no) = match kind {
|
||||
DiffLineKind::Context => {
|
||||
let numbers = (Some(old), Some(new));
|
||||
old += 1;
|
||||
new += 1;
|
||||
numbers
|
||||
}
|
||||
DiffLineKind::Addition => {
|
||||
let number = Some(new);
|
||||
new += 1;
|
||||
(None, number)
|
||||
}
|
||||
DiffLineKind::Deletion => {
|
||||
let number = Some(old);
|
||||
old += 1;
|
||||
(number, None)
|
||||
}
|
||||
};
|
||||
|
||||
lines.push(DiffLine {
|
||||
kind,
|
||||
old: old_no,
|
||||
new: new_no,
|
||||
text: line_text(text),
|
||||
});
|
||||
}
|
||||
|
||||
DiffHunk {
|
||||
old_start: old_range.start() as u32,
|
||||
old_lines: old_range.len() as u32,
|
||||
new_start: new_range.start() as u32,
|
||||
new_lines: new_range.len() as u32,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
/// The content of a parsed line without its line ending.
|
||||
///
|
||||
/// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off.
|
||||
fn line_text(text: &str) -> String {
|
||||
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||
text.strip_suffix('\r').unwrap_or(text).to_owned()
|
||||
}
|
||||
|
||||
/// Commits of a `git format-patch` output, a single patch or a series.
|
||||
///
|
||||
/// Entries appear in patch order, oldest first as `git format-patch` produces them.
|
||||
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
||||
let lines: Vec<&str> = patch.lines().collect();
|
||||
|
||||
let mut commits = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < lines.len() {
|
||||
// A patch starts with its `From <id> <date>` envelope line.
|
||||
let Some(rest) = lines[i].strip_prefix("From ") else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(id) = rest.split_whitespace().next() else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
if id.len() != 40 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut author = String::new();
|
||||
let mut summary = String::new();
|
||||
let mut time = 0i64;
|
||||
|
||||
// Envelope headers run up to the blank line before the commit message.
|
||||
i += 1;
|
||||
while i < lines.len() && !lines[i].is_empty() {
|
||||
let header = lines[i];
|
||||
if let Some(value) = header.strip_prefix("From: ") {
|
||||
author = name_from_address(value);
|
||||
} else if let Some(value) = header.strip_prefix("Subject: ") {
|
||||
summary = strip_patch_prefix(value);
|
||||
} else if let Some(value) = header.strip_prefix("Date: ") {
|
||||
time = gix::date::parse(value.trim(), None)
|
||||
.map(|t| t.seconds)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
commits.push(FileCommit {
|
||||
id: id.to_string(),
|
||||
summary,
|
||||
description: None,
|
||||
author,
|
||||
time,
|
||||
});
|
||||
}
|
||||
|
||||
commits
|
||||
}
|
||||
|
||||
/// The name part of a `From: Name <email>` header value.
|
||||
fn name_from_address(from: &str) -> String {
|
||||
match from.trim().find('<') {
|
||||
Some(ix) => from[..ix].trim().to_string(),
|
||||
None => from.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the patch prefix from a `Subject:` header.
|
||||
///
|
||||
/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`.
|
||||
fn strip_patch_prefix(subject: &str) -> String {
|
||||
let trimmed = subject.trim();
|
||||
let Some(rest) = trimmed.strip_prefix('[') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
let Some(end) = rest.find(']') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
if rest[..end].to_ascii_lowercase().contains("patch") {
|
||||
rest[end + 1..].trim().to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use gix::interrupt::IS_INTERRUPTED;
|
||||
use gix::progress::Discard;
|
||||
|
||||
/// Clone into `path` from the first working URL in `clone_urls`.
|
||||
///
|
||||
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache.
|
||||
pub fn clone_repo<U: AsRef<str>>(clone_urls: &[U], path: &Path) -> Result<()> {
|
||||
if path.exists() {
|
||||
bail!("destination {} already exists", path.display());
|
||||
}
|
||||
|
||||
try_each_url(clone_urls, "clone", |url| {
|
||||
let repo = clone(url, path)?;
|
||||
// The initial clone uses the default refspecs. Also fetch the `refs/nostr/*` PR refs.
|
||||
fetch_all(&repo).ok();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace.
|
||||
pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
|
||||
let options = gix::remote::ref_map::Options {
|
||||
extra_refspecs: vec![
|
||||
gix::refspec::parse(
|
||||
gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"),
|
||||
gix::refspec::parse::Operation::Fetch,
|
||||
)?
|
||||
.to_owned(),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
repo.find_remote("origin")?
|
||||
.connect(gix::remote::Direction::Fetch)?
|
||||
.prepare_fetch(Discard, options)?
|
||||
.receive(Discard, &IS_INTERRUPTED)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Push `commit` to `reference` on the server at `url`, from `repo_path`.
|
||||
pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["push"])
|
||||
.arg(url)
|
||||
.arg(format!("{commit}:{reference}"))
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git push`")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git push failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rewrite a grasp server URL to the https URL the git transport actually uses.
|
||||
///
|
||||
/// GRASP servers announce `grasp://<host>/<owner>/<repo>` clone URLs.
|
||||
/// The transport is git smart HTTP, so the scheme is rewritten for gix.
|
||||
fn transport_url(url: &str) -> String {
|
||||
url.strip_prefix("grasp://")
|
||||
.map(|rest| format!("https://{rest}"))
|
||||
.unwrap_or_else(|| url.to_owned())
|
||||
}
|
||||
|
||||
/// Run `attempt` against each URL in `urls` until one succeeds.
|
||||
///
|
||||
/// Returns the last error wrapped in `failed to {verb} from any mirror`,
|
||||
/// or `no clone URLs provided` when the list is empty.
|
||||
fn try_each_url<U: AsRef<str>, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()>
|
||||
where
|
||||
F: FnMut(&str) -> Result<()>,
|
||||
{
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
|
||||
for url in urls {
|
||||
match attempt(url.as_ref()) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
match last_err {
|
||||
Some(e) => Err(e).context(format!("failed to {verb} from any mirror")),
|
||||
None => bail!("no clone URLs provided"),
|
||||
}
|
||||
}
|
||||
|
||||
fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
|
||||
let url = transport_url(url);
|
||||
let url = gix::url::parse(url).context("invalid clone URL")?;
|
||||
|
||||
let mut prepare = gix::prepare_clone(url, path)?;
|
||||
let (mut checkout, _fetch) = prepare.fetch_then_checkout(Discard, &IS_INTERRUPTED)?;
|
||||
let (repo, _checkout) = checkout.main_worktree(Discard, &IS_INTERRUPTED)?;
|
||||
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
/// Push the `main` branch of the repository at `repo_path` to a grasp server.
|
||||
pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
push_refspecs(
|
||||
repo_path,
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/main:refs/heads/main"],
|
||||
)
|
||||
}
|
||||
|
||||
/// Push every local branch and tag of the repository at `repo_path` to a grasp server.
|
||||
///
|
||||
/// This mirrors an initialized repository's whole history.
|
||||
pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
push_refspecs(
|
||||
repo_path,
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
|
||||
)
|
||||
}
|
||||
|
||||
/// Push `refspecs` to the grasp server URL derived from `base_url`, `owner` and `repo_id`.
|
||||
fn push_refspecs(
|
||||
repo_path: &Path,
|
||||
base_url: &str,
|
||||
owner: &str,
|
||||
repo_id: &str,
|
||||
refspecs: &[&str],
|
||||
) -> Result<()> {
|
||||
let url = format!("{base_url}/{owner}/{repo_id}.git");
|
||||
|
||||
let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2);
|
||||
args.push("push");
|
||||
args.push(&url);
|
||||
args.extend_from_slice(refspecs);
|
||||
|
||||
let output = git_output(repo_path, &args, "git push")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git push to {base_url} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `url` advertises every ref in `expected` at the given commit.
|
||||
///
|
||||
/// Extra advertised refs are ignored: the question is whether the data this
|
||||
/// push wanted to land is already there, not whether the remote is an exact mirror.
|
||||
/// This is the convergence probe for a push that lost the compare-and-swap race
|
||||
/// to the grasp server's own background ref alignment.
|
||||
pub fn remote_has_refs(repo_path: &Path, url: &str, expected: &[(String, String)]) -> Result<bool> {
|
||||
if expected.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let repo = gix::open(repo_path)?;
|
||||
let url = transport_url(url);
|
||||
|
||||
// A URL-created remote has no configured fetch refspecs, and `ref_map` only
|
||||
// keeps refs that match one. Match each expected ref by its exact name,
|
||||
// like `git ls-remote <url> <name>` would; ref maps never write to the repository.
|
||||
let refspecs = expected
|
||||
.iter()
|
||||
.map(|(name, _)| {
|
||||
gix::refspec::parse(
|
||||
gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()),
|
||||
gix::refspec::parse::Operation::Fetch,
|
||||
)
|
||||
.map(|spec| spec.to_owned())
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.context("invalid refspec")?;
|
||||
|
||||
let options = gix::remote::ref_map::Options {
|
||||
extra_refspecs: refspecs,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (refs, _) = repo
|
||||
.remote_at(url.as_str())
|
||||
.with_context(|| format!("cannot use remote {url}"))?
|
||||
.connect(gix::remote::Direction::Fetch)
|
||||
.with_context(|| format!("cannot connect to {url}"))?
|
||||
.ref_map(Discard, options)
|
||||
.with_context(|| format!("listing refs of {url} failed"))?;
|
||||
|
||||
// Peeled tag entries carry the tag object in their direct oid, so mapping
|
||||
// each advertised ref to its direct oid matches `git ls-remote` while
|
||||
// skipping the duplicated `^{}` lines.
|
||||
let advertised: HashMap<String, String> = refs
|
||||
.remote_refs
|
||||
.iter()
|
||||
.filter_map(|reference| {
|
||||
let (name, object, _peeled) = reference.unpack();
|
||||
object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(expected
|
||||
.iter()
|
||||
.all(|(name, oid)| advertised.get(name.as_str()) == Some(oid)))
|
||||
}
|
||||
|
||||
/// Add `origin` pointing at `url` when the repository has no remote yet.
|
||||
///
|
||||
/// No-op if `origin` already exists.
|
||||
pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
|
||||
let repo = gix::open(repo_path)?;
|
||||
if repo.find_remote("origin").is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// `git remote add` also configures the default fetch refspec.
|
||||
edit_local_config(&repo, |config| {
|
||||
config.set_raw_value("remote.origin.url", url)?;
|
||||
config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Point `origin` at `url`, replacing an existing remote,
|
||||
/// used after a clone whose `origin` points at the cloned-from path.
|
||||
///
|
||||
/// A working copy cloned from a local mirror is re-targeted at the grasp server.
|
||||
pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> {
|
||||
let repo = gix::open(repo_path)?;
|
||||
let had_origin = repo.find_remote("origin").is_ok();
|
||||
|
||||
edit_local_config(&repo, |config| {
|
||||
// Replaces the existing url, like `git remote set-url origin <url>`.
|
||||
// A pre-existing fetch refspec is left untouched.
|
||||
config.set_raw_value("remote.origin.url", url)?;
|
||||
|
||||
if !had_origin {
|
||||
config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply `edit` to the repository-local configuration and persist it.
|
||||
///
|
||||
/// The config file is locked while it is read, edited and written back,
|
||||
/// like git would when running `git config` or `git remote`.
|
||||
fn edit_local_config(
|
||||
repo: &gix::Repository,
|
||||
edit: impl FnOnce(&mut gix::config::File) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let config_path = repo.common_dir().join("config");
|
||||
|
||||
let mut lock = gix::lock::File::acquire_to_update_resource(
|
||||
&config_path,
|
||||
gix::lock::acquire::Fail::Immediately,
|
||||
None,
|
||||
)
|
||||
.context("failed to lock repository config")?;
|
||||
|
||||
let mut config =
|
||||
match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) {
|
||||
Ok(config) => config,
|
||||
// A repository without a config file yet starts from scratch.
|
||||
Err(gix::config::file::init::from_paths::Error::Io { source, .. })
|
||||
if source.kind() == std::io::ErrorKind::NotFound =>
|
||||
{
|
||||
gix::config::File::default()
|
||||
}
|
||||
Err(error) => return Err(error).context("failed to read repository config"),
|
||||
};
|
||||
|
||||
edit(&mut config)?;
|
||||
|
||||
config
|
||||
.write_to(&mut lock)
|
||||
.context("failed to write repository config")?;
|
||||
|
||||
lock.commit().context("failed to save repository config")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch `refspec` into `repo_path` from the first working URL in `urls`.
|
||||
/// When no URL works, the last error is returned.
|
||||
///
|
||||
/// Never touches the checked-out refs or the worktree.
|
||||
pub fn fetch_repo_refs<U: AsRef<str>>(repo_path: &Path, urls: &[U], refspec: &str) -> Result<()> {
|
||||
let repo = gix::open(repo_path)?;
|
||||
let refspec = gix::refspec::parse(
|
||||
gix::bstr::BStr::new(refspec),
|
||||
gix::refspec::parse::Operation::Fetch,
|
||||
)
|
||||
.context("invalid fetch refspec")?
|
||||
.to_owned();
|
||||
|
||||
try_each_url(urls, "fetch", |url| {
|
||||
let url = transport_url(url);
|
||||
let options = gix::remote::ref_map::Options {
|
||||
extra_refspecs: vec![refspec.clone()],
|
||||
..Default::default()
|
||||
};
|
||||
repo.remote_at(url.as_str())
|
||||
.with_context(|| format!("fetch from {url} failed"))?
|
||||
.connect(gix::remote::Direction::Fetch)
|
||||
.with_context(|| format!("fetch from {url} failed"))?
|
||||
.prepare_fetch(Discard, options)
|
||||
.with_context(|| format!("fetch from {url} failed"))?
|
||||
.receive(Discard, &IS_INTERRUPTED)
|
||||
.with_context(|| format!("fetch from {url} failed"))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// The URL of the `origin` remote of the repository at `workdir`.
|
||||
///
|
||||
/// `None` when it has no `origin` yet.
|
||||
pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
|
||||
let Ok(repo) = gix::open(workdir) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Ok(remote) = repo.find_remote("origin") else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(remote
|
||||
.url(gix::remote::Direction::Fetch)
|
||||
.map(|url| url.to_string()))
|
||||
}
|
||||
|
||||
/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr.
|
||||
///
|
||||
/// `what` names the command in the spawn error.
|
||||
pub(crate) fn git_output(dir: &Path, args: &[&str], what: &str) -> Result<std::process::Output> {
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.with_context(|| format!("failed to spawn `{what}`"))
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
use crate::history::open_with_cache;
|
||||
use crate::worktree::{force_checkout, worktree_dirty};
|
||||
|
||||
/// The merge base of two revisions in the repository at `repo_path`,
|
||||
/// revisions may be branch names, remote-tracking refs or commit ids.
|
||||
///
|
||||
/// `Ok(None)` when the revisions share no common ancestor.
|
||||
///
|
||||
/// Unresolvable revisions are errors.
|
||||
pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result<Option<String>> {
|
||||
let repo = open_with_cache(repo_path)?;
|
||||
let a = repo.rev_parse_single(a.as_bytes())?;
|
||||
let b = repo.rev_parse_single(b.as_bytes())?;
|
||||
match repo.merge_base(a, b) {
|
||||
Ok(id) => Ok(Some(id.to_string())),
|
||||
// No common ancestor, a valid outcome for a proposal.
|
||||
Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The commit HEAD points to in the repository at `repo_path`.
|
||||
///
|
||||
/// `None` when the repository has no commits yet, an unborn HEAD.
|
||||
pub fn head_commit_id(repo_path: &Path) -> Result<Option<String>> {
|
||||
let Ok(repo) = gix::open(repo_path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match repo.head_id() {
|
||||
Ok(id) => Ok(Some(id.to_string())),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The commits in `base..HEAD` of the repository at `repo_path`, oldest first.
|
||||
/// This is the order `git am` creates them.
|
||||
///
|
||||
/// `HEAD` alone when `base` is `None`.
|
||||
pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result<Vec<String>> {
|
||||
let repo = match gix::open(repo_path) {
|
||||
Ok(repo) => repo,
|
||||
Err(_) if base.is_none() => return Ok(Vec::new()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let head = match repo.head_id() {
|
||||
Ok(head) => head,
|
||||
Err(_) if base.is_none() => return Ok(Vec::new()),
|
||||
Err(e) => return Err(e).context("repository has no commits"),
|
||||
};
|
||||
|
||||
let Some(base) = base else {
|
||||
// `HEAD` alone when no base is given.
|
||||
return Ok(vec![head.to_string()]);
|
||||
};
|
||||
|
||||
let base = repo.rev_parse_single(base.as_bytes())?;
|
||||
let mut commits = Vec::new();
|
||||
|
||||
for info in repo
|
||||
.rev_walk([head])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
gix::traverse::commit::simple::CommitTimeOrder::NewestFirst,
|
||||
))
|
||||
.with_hidden([base])
|
||||
.all()?
|
||||
{
|
||||
commits.push(info?.id().to_string());
|
||||
}
|
||||
|
||||
// Oldest first, like `git rev-list --reverse`, the order `git am` creates them.
|
||||
commits.reverse();
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
/// The identity written to reflogs and commits created by this crate itself.
|
||||
///
|
||||
/// Like `git -c user.name=… -c user.email=…` per invocation: the repository works
|
||||
/// without a global git identity, and `gix` runs no hooks and never signs.
|
||||
pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) {
|
||||
let seconds = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
.unwrap_or_default();
|
||||
|
||||
let signature = gix::actor::Signature {
|
||||
name: gix::bstr::BString::from("Signed"),
|
||||
email: gix::bstr::BString::from("signed@localhost"),
|
||||
time: gix::date::Time { seconds, offset: 0 },
|
||||
};
|
||||
|
||||
(signature, gix::date::parse::TimeBuf::default())
|
||||
}
|
||||
|
||||
/// Create a repository at `path` with an initial `main` branch.
|
||||
/// Write a `README.md` from `name` and `description`, then create the initial commit.
|
||||
///
|
||||
/// Returns the initial commit id.
|
||||
pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<String> {
|
||||
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||
|
||||
std::fs::create_dir_all(path)
|
||||
.with_context(|| format!("failed to create {}", path.display()))?;
|
||||
|
||||
let repo = gix::init(path)?;
|
||||
|
||||
let (signature, mut time_buf) = repository_signature();
|
||||
let signature = signature.to_ref(&mut time_buf);
|
||||
|
||||
// The initial branch is `main`, regardless of `init.defaultBranch` in
|
||||
// the user's git configuration: point the unborn HEAD there.
|
||||
let head = gix::refs::FullName::try_from("HEAD")
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||
|
||||
repo.edit_references_as(
|
||||
[RefEdit {
|
||||
change: Change::Update {
|
||||
log: LogChange {
|
||||
mode: RefLog::AndReference,
|
||||
force_create_reflog: false,
|
||||
message: "checkout: moving to main".into(),
|
||||
},
|
||||
expected: PreviousValue::Any,
|
||||
new: gix::refs::Target::Symbolic(
|
||||
gix::refs::FullName::try_from("refs/heads/main")
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?,
|
||||
),
|
||||
},
|
||||
name: head,
|
||||
deref: false,
|
||||
}],
|
||||
Some(signature),
|
||||
)?;
|
||||
|
||||
let readme = if description.trim().is_empty() {
|
||||
format!("# {name}\n")
|
||||
} else {
|
||||
format!("# {name}\n\n{description}\n")
|
||||
};
|
||||
|
||||
std::fs::write(path.join("README.md"), &readme).context("failed to write README.md")?;
|
||||
|
||||
let blob = repo.write_object(gix::objs::Blob {
|
||||
data: readme.into_bytes(),
|
||||
})?;
|
||||
|
||||
let tree = repo.write_object(gix::objs::Tree {
|
||||
entries: vec![gix::objs::tree::Entry {
|
||||
mode: gix::objs::tree::EntryKind::Blob.into(),
|
||||
filename: gix::bstr::BString::from("README.md"),
|
||||
oid: blob.into(),
|
||||
}],
|
||||
})?;
|
||||
|
||||
let commit = repo.commit_as(
|
||||
signature,
|
||||
signature,
|
||||
"HEAD",
|
||||
"Initial commit",
|
||||
tree,
|
||||
Vec::<gix::ObjectId>::new(),
|
||||
)?;
|
||||
|
||||
// Populate the index so the fresh repository is clean,
|
||||
// as `git add` and`git commit` would leave it.
|
||||
let mut index = repo.index_from_tree(&tree)?;
|
||||
index.write(gix::index::write::Options::default())?;
|
||||
|
||||
let commit = commit.to_string();
|
||||
if commit.len() != 40 {
|
||||
bail!("unexpected initial commit id: {commit}");
|
||||
}
|
||||
|
||||
Ok(commit)
|
||||
}
|
||||
|
||||
/// The earliest unique commit of the repository at `repo_path`.
|
||||
/// Used as the NIP-34 announcement's `euc` marker.
|
||||
///
|
||||
/// `None` for a repository without commits.
|
||||
pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
|
||||
let Ok(repo) = gix::open(repo_path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Ok(head) = repo.head_id() else {
|
||||
// An unborn HEAD with no commits yet has no root commit.
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
for info in repo
|
||||
.rev_walk([head])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
gix::traverse::commit::simple::CommitTimeOrder::NewestFirst,
|
||||
))
|
||||
.all()?
|
||||
{
|
||||
let info = info?;
|
||||
if info.parent_ids().next().is_none() {
|
||||
let id = info.id().to_string();
|
||||
return Ok((id.len() == 40).then_some(id));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`.
|
||||
/// `prefix` is a ref namespace like `refs/fork/<owner>/<id>`.
|
||||
///
|
||||
/// Returns an empty list when nothing matches.
|
||||
pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
|
||||
let pattern = prefix.trim_end_matches('/');
|
||||
let repo = gix::open(repo_path)?;
|
||||
let mut names = Vec::new();
|
||||
|
||||
for reference in repo.references()?.all()? {
|
||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned();
|
||||
|
||||
// Match the pattern itself and everything beneath it, like `git for-each-ref`.
|
||||
let under_pattern = name
|
||||
.strip_prefix(pattern)
|
||||
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
|
||||
|
||||
if under_pattern {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort lexicographically, like `git for-each-ref`.
|
||||
names.sort();
|
||||
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Delete every ref under `prefix` of the repository at `repo_path`.
|
||||
/// `prefix` is a ref namespace like `refs/fork/<owner>/<id>`.
|
||||
pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> {
|
||||
use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog};
|
||||
|
||||
let refs = refs_with_prefix(repo_path, prefix)?;
|
||||
if refs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let repo = gix::open(repo_path)?;
|
||||
let edits: Vec<RefEdit> = refs
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let full = gix::refs::FullName::try_from(name.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name {name}: {e}"))?;
|
||||
Ok(RefEdit {
|
||||
change: Change::Delete {
|
||||
expected: PreviousValue::Any,
|
||||
log: RefLog::AndReference,
|
||||
},
|
||||
name: full,
|
||||
deref: false,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
// Delete all refs with the given prefix.
|
||||
repo.edit_references(edits)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Short name of the branch HEAD points to at `workdir`,
|
||||
/// `None` when detached or unreadable, like `git branch --show-current`.
|
||||
pub fn worktree_current_branch(workdir: &Path) -> Option<String> {
|
||||
let repo = gix::open(workdir).ok()?;
|
||||
let head = repo.head().ok()?;
|
||||
let name = head.referent_name()?;
|
||||
Some(String::from_utf8_lossy(name.shorten()).into_owned())
|
||||
}
|
||||
|
||||
/// Whether the reference `name` exists in the repository at `workdir`.
|
||||
pub fn worktree_ref_exists(workdir: &Path, name: &str) -> bool {
|
||||
let Ok(repo) = gix::open(workdir) else {
|
||||
return false;
|
||||
};
|
||||
repo.find_reference(name).is_ok()
|
||||
}
|
||||
|
||||
/// Fast-forward local branches that trail their remote-tracking counterpart.
|
||||
///
|
||||
/// Returns whether any branch moved.
|
||||
pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let current = worktree_current_branch(workdir);
|
||||
let heads = refs_with_prefix(workdir, "refs/heads")?;
|
||||
|
||||
let (signature, mut time_buf) = repository_signature();
|
||||
let signature = signature.to_ref(&mut time_buf);
|
||||
|
||||
let mut moved = false;
|
||||
|
||||
for head in heads {
|
||||
let Some(branch) = head.strip_prefix("refs/heads/") else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let remote = format!("refs/remotes/origin/{branch}");
|
||||
// No remote-tracking counterpart means the remote lacks this branch.
|
||||
let Ok(mut remote_reference) = repo.find_reference(&remote) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(mut local_reference) = repo.find_reference(&head) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(remote_oid) = remote_reference.peel_to_id() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(local_oid) = local_reference.peel_to_id() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let remote_oid = remote_oid.detach();
|
||||
let local_oid = local_oid.detach();
|
||||
|
||||
if local_oid == remote_oid {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only fast-forward.
|
||||
// Local-only commits or diverged history must never be rewritten by a refresh.
|
||||
let Ok(base) = repo.merge_base(local_oid, remote_oid) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if base != local_oid {
|
||||
continue;
|
||||
}
|
||||
|
||||
let full = gix::refs::FullName::try_from(head.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||
|
||||
let edit = |new: gix::refs::Target| {
|
||||
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||
RefEdit {
|
||||
change: Change::Update {
|
||||
log: LogChange {
|
||||
mode: RefLog::AndReference,
|
||||
force_create_reflog: false,
|
||||
message: format!("merge {remote}: Fast-forward").into(),
|
||||
},
|
||||
expected: PreviousValue::ExistingMustMatch(gix::refs::Target::Object(
|
||||
local_oid,
|
||||
)),
|
||||
new,
|
||||
},
|
||||
name: full.clone(),
|
||||
deref: false,
|
||||
}
|
||||
};
|
||||
|
||||
if current.as_deref() == Some(branch) {
|
||||
// Merge so the checked-out worktree follows the branch.
|
||||
// Only proceed on a clean worktree, like `git merge --ff-only`.
|
||||
if worktree_dirty(workdir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tree = repo.find_object(remote_oid)?.peel_to_tree()?.id;
|
||||
|
||||
// Check out the remote tree, discarding local changes.
|
||||
force_checkout(&repo, &tree)?;
|
||||
|
||||
// Update the branch reference to point to the remote tree.
|
||||
repo.edit_references_as(
|
||||
[edit(gix::refs::Target::Object(remote_oid))],
|
||||
Some(signature),
|
||||
)?;
|
||||
|
||||
moved = true;
|
||||
} else {
|
||||
// Update the branch reference to point to the remote tree.
|
||||
repo.edit_references_as(
|
||||
[edit(gix::refs::Target::Object(remote_oid))],
|
||||
Some(signature),
|
||||
)?;
|
||||
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Short names of local branches, `refs/heads/*`, of `repo`, sorted alphabetically.
|
||||
pub fn repo_branches(repo: &gix::Repository) -> Result<Vec<String>> {
|
||||
let mut names = Vec::new();
|
||||
for reference in repo.references()?.local_branches()? {
|
||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned());
|
||||
}
|
||||
names.sort();
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Short names of tags, `refs/tags/*`, of `repo`, sorted alphabetically.
|
||||
pub fn repo_tags(repo: &gix::Repository) -> Result<Vec<String>> {
|
||||
let mut names = Vec::new();
|
||||
for reference in repo.references()?.tags()? {
|
||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned());
|
||||
}
|
||||
names.sort();
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Short names of local branches, `refs/heads/*`, sorted alphabetically.
|
||||
pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> {
|
||||
repo_branches(&gix::open(workdir)?)
|
||||
}
|
||||
|
||||
/// Short name of the branch HEAD points to, or `None` when detached.
|
||||
///
|
||||
/// Detached after checking out a tag or a commit directly.
|
||||
pub fn current_branch(repo: &gix::Repository) -> Result<Option<String>> {
|
||||
let head = repo.head()?;
|
||||
let Some(name) = head.referent_name() else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned()))
|
||||
}
|
||||
|
||||
/// Branch, tag and HEAD refs of a repository.
|
||||
///
|
||||
/// Ready for a NIP-34 kind-30618 repository state announcement.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RepoRefState {
|
||||
/// `(full refname, commit id)` pairs for heads and tags, sorted.
|
||||
pub refs: Vec<(String, String)>,
|
||||
/// Short branch name HEAD points to, or `None` when detached.
|
||||
pub head: Option<String>,
|
||||
}
|
||||
|
||||
/// Collect the refs of `repo`.
|
||||
///
|
||||
/// Local branches and tags become `(refname, commit-id)` pairs.
|
||||
/// Also reports the branch HEAD points to.
|
||||
pub fn repo_ref_state(repo: &gix::Repository) -> Result<RepoRefState> {
|
||||
let mut refs = Vec::new();
|
||||
|
||||
for reference in repo.references()?.local_branches()? {
|
||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
refs.push((
|
||||
String::from_utf8_lossy(reference.name().as_bstr()).into_owned(),
|
||||
reference.id().to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
for reference in repo.references()?.tags()? {
|
||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
refs.push((
|
||||
String::from_utf8_lossy(reference.name().as_bstr()).into_owned(),
|
||||
reference.id().to_string(),
|
||||
));
|
||||
}
|
||||
refs.sort();
|
||||
|
||||
let head = match repo.head() {
|
||||
Ok(head) => head
|
||||
.referent_name()
|
||||
.filter(|name| name.as_bstr().starts_with(b"refs/heads/"))
|
||||
.map(|name| String::from_utf8_lossy(name.shorten()).into_owned()),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
Ok(RepoRefState { refs, head })
|
||||
}
|
||||
|
||||
/// [`repo_ref_state`] for the repository at `workdir`.
|
||||
pub fn worktree_ref_state(workdir: &Path) -> Result<RepoRefState> {
|
||||
repo_ref_state(&gix::open(workdir)?)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
/// Maximum directory nesting depth when scanning for local repositories.
|
||||
///
|
||||
/// Pathological trees can't stall the scan.
|
||||
const SCAN_MAX_DEPTH: usize = 12;
|
||||
|
||||
/// Walk `root` recursively and collect the paths of git repositories below it,
|
||||
/// honouring `.gitignore` (and `.ignore`) files.
|
||||
pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
|
||||
if !root.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let walker = WalkBuilder::new(root)
|
||||
.max_depth(Some(SCAN_MAX_DEPTH))
|
||||
// Honour `.gitignore` even when the scan root is not itself a repository.
|
||||
.require_git(false)
|
||||
.build();
|
||||
|
||||
let mut repos: Vec<PathBuf> = walker
|
||||
.flatten()
|
||||
.filter(|entry| entry.file_type().is_some_and(|kind| kind.is_dir()))
|
||||
.map(ignore::DirEntry::into_path)
|
||||
.filter(|dir| dir.join(".git").exists())
|
||||
.filter_map(|dir| dir.canonicalize().ok())
|
||||
.collect();
|
||||
|
||||
repos.sort();
|
||||
repos.dedup();
|
||||
|
||||
// A repository nested inside another, like a submodule worktree, is not reported.
|
||||
let mut roots: Vec<PathBuf> = Vec::with_capacity(repos.len());
|
||||
for repo in repos {
|
||||
if !roots.iter().any(|kept| repo.starts_with(kept)) {
|
||||
roots.push(repo);
|
||||
}
|
||||
}
|
||||
|
||||
roots
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,354 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use gix::progress::Discard;
|
||||
|
||||
use crate::history::{FileCommit, head_commit};
|
||||
use crate::repo::{current_branch, repository_signature};
|
||||
|
||||
/// Whether the worktree of `workdir` has uncommitted changes.
|
||||
///
|
||||
/// Best-effort: any read failure is reported as clean.
|
||||
pub fn worktree_dirty(workdir: &Path) -> bool {
|
||||
let Ok(repo) = gix::open(workdir) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Changes to tracked files, staged or not; untracked files are excluded.
|
||||
match repo.is_dirty() {
|
||||
Ok(true) => return true,
|
||||
Ok(false) => {}
|
||||
Err(_) => return false,
|
||||
}
|
||||
|
||||
// Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk,
|
||||
// tracked files only appear there when modified.
|
||||
let Ok(platform) = repo.status(Discard) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Ok(mut changes) = platform.into_index_worktree_iter(Vec::<gix::bstr::BString>::new())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
for change in changes.by_ref() {
|
||||
match change {
|
||||
Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true,
|
||||
Ok(_) => {}
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Commits in `base..branch` of the checkout at `workdir`.
|
||||
///
|
||||
/// Best-effort: 0 when the range cannot be computed.
|
||||
pub fn worktree_commits_ahead(workdir: &Path, base: &str, branch: &str) -> u32 {
|
||||
let Ok(repo) = gix::open(workdir) else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let (Some(base), Some(branch)) = (resolve_commit(&repo, base), resolve_commit(&repo, branch))
|
||||
else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let Ok(walk) = repo.rev_walk([branch]).with_hidden([base]).all() else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32
|
||||
}
|
||||
|
||||
/// Resolve `rev` to a commit id, accepting full refs,
|
||||
/// symbolic refs and the bare branch names callers pass, like git's DWIM.
|
||||
fn resolve_commit<'a>(repo: &'a gix::Repository, rev: &str) -> Option<gix::Id<'a>> {
|
||||
if let Ok(id) = repo.rev_parse_single(rev.as_bytes()) {
|
||||
return Some(id);
|
||||
}
|
||||
|
||||
// Branch names arrive bare, like git resolving `main`.
|
||||
if rev.contains('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
repo.rev_parse_single(format!("refs/heads/{rev}").as_bytes())
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Relative paths of all entries in the worktree, files and directories.
|
||||
///
|
||||
/// The `.git` directory is skipped.
|
||||
pub fn worktree_entries(repo: &gix::Repository) -> Result<Vec<PathBuf>> {
|
||||
let workdir = repo.workdir().context("repository has no worktree")?;
|
||||
|
||||
let mut entries: Vec<(PathBuf, bool)> = Vec::new();
|
||||
collect_entries(workdir, workdir, &mut entries)?;
|
||||
|
||||
entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| {
|
||||
b_is_dir
|
||||
.cmp(a_is_dir)
|
||||
.then_with(|| a.as_os_str().cmp(b.as_os_str()))
|
||||
});
|
||||
Ok(entries.into_iter().map(|(path, _)| path).collect())
|
||||
}
|
||||
|
||||
/// Read a file from the worktree.
|
||||
///
|
||||
/// Returns `Ok(None)` if the path is missing or not a regular file.
|
||||
pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result<Option<Vec<u8>>> {
|
||||
let workdir = repo.workdir().context("repository has no worktree")?;
|
||||
let path = workdir.join(rel);
|
||||
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => Ok(Some(bytes)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None),
|
||||
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the README file in the repository root.
|
||||
///
|
||||
/// Falls back to any other file whose name starts with `readme`.
|
||||
pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
|
||||
let Some(workdir) = repo.workdir() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
for entry in std::fs::read_dir(workdir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if name.to_ascii_lowercase().starts_with("readme") {
|
||||
candidates.push(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort_by_key(|path| {
|
||||
let ext = path
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_ascii_lowercase());
|
||||
match ext.as_deref() {
|
||||
Some("md") => 0,
|
||||
Some("markdown") => 1,
|
||||
Some("mdown") => 2,
|
||||
Some("mkdn") => 3,
|
||||
Some(_) => 5,
|
||||
None => 4,
|
||||
}
|
||||
});
|
||||
|
||||
Ok(candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
|
||||
}
|
||||
|
||||
/// Everything the browser needs to refresh after a branch or tag switch.
|
||||
pub struct WorktreeSnapshot {
|
||||
/// Relative paths of all worktree entries, directories first.
|
||||
pub entries: Vec<PathBuf>,
|
||||
/// README path relative to the worktree, if any.
|
||||
pub readme_path: Option<PathBuf>,
|
||||
/// Contents of the README, if any.
|
||||
pub readme: Option<Vec<u8>>,
|
||||
/// Branch HEAD points to, `None` when detached, for example on a tag.
|
||||
pub current_branch: Option<String>,
|
||||
/// Commit HEAD points to, if any, see [`head_commit`].
|
||||
pub head_commit: Option<FileCommit>,
|
||||
}
|
||||
|
||||
/// Snapshot the worktree after a branch or tag switch.
|
||||
///
|
||||
/// Collects entries, the README, the branch HEAD points to and its commit.
|
||||
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let readme_path = find_readme(&repo)?;
|
||||
let readme = match &readme_path {
|
||||
Some(path) => worktree_read(&repo, path)?,
|
||||
None => None,
|
||||
};
|
||||
Ok(WorktreeSnapshot {
|
||||
entries: worktree_entries(&repo)?,
|
||||
readme_path,
|
||||
readme,
|
||||
current_branch: current_branch(&repo)?,
|
||||
head_commit: head_commit(&repo)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check out `tree` into the worktree of `repo`
|
||||
pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> {
|
||||
let workdir = repo
|
||||
.workdir()
|
||||
.context("repository has no worktree")?
|
||||
.to_path_buf();
|
||||
|
||||
let mut index = repo.index_from_tree(tree)?;
|
||||
|
||||
// Files the previous index tracked but `tree` no longer contains are removed,
|
||||
// like git deleting files that vanish between branches.
|
||||
if let Ok(previous) = repo.index_or_empty() {
|
||||
let keep: HashSet<PathBuf> = index
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|entry| PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned()))
|
||||
.collect();
|
||||
for entry in previous.entries() {
|
||||
let rel = entry.path(&previous);
|
||||
let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned());
|
||||
|
||||
if keep.contains(&rel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = workdir.join(&rel);
|
||||
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(error)
|
||||
.with_context(|| format!("failed to remove {}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut options =
|
||||
repo.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?;
|
||||
options.overwrite_existing = true;
|
||||
|
||||
let objects = repo.objects.clone().into_arc()?;
|
||||
let files = gix::progress::Discard;
|
||||
let bytes = gix::progress::Discard;
|
||||
|
||||
// Check out the index into the worktree.
|
||||
gix_worktree_state::checkout(
|
||||
&mut index,
|
||||
workdir,
|
||||
objects,
|
||||
&files,
|
||||
&bytes,
|
||||
&gix::interrupt::IS_INTERRUPTED,
|
||||
options,
|
||||
)?;
|
||||
|
||||
// Write the index to disk.
|
||||
index.write(gix::index::write::Options::default())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Point `HEAD` at `target` and record the switch in the reflog.
|
||||
fn move_head(
|
||||
repo: &gix::Repository,
|
||||
signature: gix::actor::SignatureRef<'_>,
|
||||
target: gix::refs::Target,
|
||||
message: &str,
|
||||
) -> Result<()> {
|
||||
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||
|
||||
let head = gix::refs::FullName::try_from("HEAD")
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||
|
||||
// Update the reference, creating a reflog entry.
|
||||
repo.edit_references_as(
|
||||
[RefEdit {
|
||||
change: Change::Update {
|
||||
log: LogChange {
|
||||
mode: RefLog::AndReference,
|
||||
force_create_reflog: false,
|
||||
message: message.into(),
|
||||
},
|
||||
expected: PreviousValue::Any,
|
||||
new: target,
|
||||
},
|
||||
name: head,
|
||||
deref: false,
|
||||
}],
|
||||
Some(signature),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check out the local branch `name`, HEAD stays attached to it.
|
||||
pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let full = format!("refs/heads/{name}");
|
||||
|
||||
let branch = gix::refs::FullName::try_from(full.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||
|
||||
let mut reference = repo.find_reference(&full)?;
|
||||
let tree = reference.peel_to_tree()?.id;
|
||||
|
||||
let (signature, mut time_buf) = repository_signature();
|
||||
let signature = signature.to_ref(&mut time_buf);
|
||||
|
||||
// Move HEAD to the branch, creating a reflog entry.
|
||||
move_head(
|
||||
&repo,
|
||||
signature,
|
||||
gix::refs::Target::Symbolic(branch),
|
||||
&format!("checkout: moving to {name}"),
|
||||
)?;
|
||||
|
||||
// Check out the branch's tree, replacing index + worktree.
|
||||
force_checkout(&repo, &tree)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check out the tag `name`, HEAD becomes detached at the tagged commit.
|
||||
pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let full = format!("refs/tags/{name}");
|
||||
|
||||
let mut reference = repo.find_reference(&full)?;
|
||||
|
||||
let commit = reference.peel_to_id()?;
|
||||
let tree = reference.peel_to_tree()?.id;
|
||||
|
||||
let (signature, mut time_buf) = repository_signature();
|
||||
let signature = signature.to_ref(&mut time_buf);
|
||||
|
||||
// Move HEAD to the tag, creating a reflog entry.
|
||||
move_head(
|
||||
&repo,
|
||||
signature,
|
||||
gix::refs::Target::Object(commit.detach()),
|
||||
&format!("checkout: moving to {name}"),
|
||||
)?;
|
||||
|
||||
// Check out the tag's tree, replacing index + worktree.
|
||||
force_checkout(&repo, &tree)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
if entry.file_name() == ".git" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_dir = entry.file_type()?.is_dir();
|
||||
let path = entry.path();
|
||||
let rel = path.strip_prefix(root)?.to_path_buf();
|
||||
out.push((rel, is_dir));
|
||||
|
||||
if is_dir {
|
||||
collect_entries(root, &path, out)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -22,6 +22,7 @@ flume.workspace = true
|
||||
futures.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rustls = "0.23"
|
||||
|
||||
@@ -14,6 +14,8 @@ use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_
|
||||
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
||||
|
||||
use crate::git_store::GitStore;
|
||||
use crate::inbox::Inbox;
|
||||
use crate::repos::RepoListStore;
|
||||
|
||||
/// Keyring entry for the user credential.
|
||||
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
||||
@@ -32,10 +34,6 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||
|
||||
/// Delay the notification pump waits for more events before emitting a batch.
|
||||
///
|
||||
/// A negentropy sync can deliver hundreds of events in a burst; batching
|
||||
/// them here means every subscriber debounces the burst once, not once per
|
||||
/// subscriber.
|
||||
const PUMP_DEBOUNCE: Duration = Duration::from_millis(200);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -77,21 +75,17 @@ impl BackendEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// The global backend entity.
|
||||
///
|
||||
/// Owns the nostr client, the signer and the notification pump.
|
||||
pub struct Backend {
|
||||
client: Client,
|
||||
signer: UniversalSigner,
|
||||
current_user: Option<PublicKey>,
|
||||
/// User's inbox, including notifications and recent activity.
|
||||
inbox: Entity<Inbox>,
|
||||
/// The progress of the current sync operation, if any.
|
||||
sync_progress: Option<(u64, u64)>,
|
||||
/// True when the stored credential is NIP-49 encrypted.
|
||||
passphrase_required: bool,
|
||||
/// Repositories with a push in flight, mirror or checkout based.
|
||||
///
|
||||
/// A child entity: views that only care whether one repository is
|
||||
/// pushing can `cx.observe` it without being invoked on unrelated
|
||||
/// `Backend` changes (a `sync_progress` tick, a new relay connecting).
|
||||
pushing_repos: Entity<HashSet<RepoAddr>>,
|
||||
}
|
||||
|
||||
@@ -112,6 +106,7 @@ impl Backend {
|
||||
}
|
||||
|
||||
pub(crate) fn new(client: Client, signer: UniversalSigner, cx: &mut Context<Self>) -> Self {
|
||||
let weak = cx.entity().downgrade();
|
||||
let pump_client = client.clone();
|
||||
|
||||
let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
@@ -133,13 +128,17 @@ impl Backend {
|
||||
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
|
||||
if now >= deadline {
|
||||
break;
|
||||
}
|
||||
|
||||
let timer = cx.background_executor().timer(deadline - now);
|
||||
futures::pin_mut!(timer);
|
||||
|
||||
let next = notifications.next();
|
||||
futures::pin_mut!(next);
|
||||
|
||||
match futures::future::select(next, timer).await {
|
||||
futures::future::Either::Left((
|
||||
Some(ClientNotification::Event { event, .. }),
|
||||
@@ -156,7 +155,9 @@ impl Backend {
|
||||
// Collect and emit the collected events.
|
||||
let batch = std::mem::take(&mut pending);
|
||||
|
||||
if let Err(e) = this.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(batch))) {
|
||||
if let Err(e) =
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::NostrUpdate(batch)))
|
||||
{
|
||||
log::warn!("failed to emit nostr update: {e}");
|
||||
}
|
||||
}
|
||||
@@ -167,7 +168,6 @@ impl Backend {
|
||||
pump.detach();
|
||||
|
||||
// Bootstrap the client.
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) {
|
||||
log::warn!("backend dropped before bootstrap could run: {error}");
|
||||
@@ -178,53 +178,54 @@ impl Backend {
|
||||
client,
|
||||
signer,
|
||||
current_user: None,
|
||||
inbox: cx.new(|_| Inbox::default()),
|
||||
sync_progress: None,
|
||||
passphrase_required: false,
|
||||
pushing_repos: cx.new(|_| HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bootstrap the client.
|
||||
///
|
||||
/// Restore the saved session, if any.
|
||||
/// Bootstrap the client and restore the saved session, if any.
|
||||
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
let task = cx.background_spawn(async move {
|
||||
for url in BOOTSTRAP_RELAYS {
|
||||
client.add_relay(url).and_connect().await?;
|
||||
client.add_relay(url).await?;
|
||||
}
|
||||
|
||||
for url in INDEXER_RELAYS {
|
||||
client
|
||||
.add_relay(url)
|
||||
.capabilities(RelayCapabilities::DISCOVERY)
|
||||
.and_connect()
|
||||
.await?;
|
||||
}
|
||||
|
||||
client.connect().await;
|
||||
|
||||
Ok::<(), Error>(())
|
||||
});
|
||||
|
||||
let notify_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(()) => {
|
||||
this.update(cx, |_this, cx| cx.notify())?;
|
||||
this.update(cx, |this, cx| {
|
||||
this.restore_session(cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok::<(), Error>(())
|
||||
});
|
||||
notify_task.detach();
|
||||
|
||||
self.restore_session(cx);
|
||||
}
|
||||
|
||||
/// Restore the saved session from the keyring.
|
||||
/// Restore the saved session from the Keyring.
|
||||
///
|
||||
/// Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
||||
///
|
||||
/// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
|
||||
/// - Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
||||
/// - Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
|
||||
pub fn restore_session(&mut self, cx: &mut Context<Self>) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
cx.emit(BackendEvent::SignerRequired);
|
||||
@@ -237,7 +238,7 @@ impl Backend {
|
||||
let content = match user.await {
|
||||
Ok(Some((_username, secret))) => String::from_utf8(secret)?,
|
||||
_ => {
|
||||
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?;
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
@@ -258,15 +259,13 @@ impl Backend {
|
||||
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
||||
} else if content.starts_with("ncryptsec1") {
|
||||
// Encrypted identity.
|
||||
// A passphrase is required to decrypt it before the session can resume.
|
||||
log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase");
|
||||
this.update(cx, |this, cx| {
|
||||
this.passphrase_required = true;
|
||||
cx.emit(BackendEvent::PassphraseRequired);
|
||||
})?;
|
||||
} else {
|
||||
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?;
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?;
|
||||
}
|
||||
|
||||
Ok::<_, Error>(())
|
||||
@@ -274,7 +273,7 @@ impl Backend {
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
this.update(cx, |_, cx| {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(BackendEvent::error(e.to_string()));
|
||||
cx.emit(BackendEvent::SignerRequired);
|
||||
})?;
|
||||
@@ -360,7 +359,10 @@ impl Backend {
|
||||
this.signer.swap_inner(keys);
|
||||
this.current_user = Some(public_key);
|
||||
this.bootstrap_user(public_key, cx);
|
||||
|
||||
cx.emit(BackendEvent::SignerChanged);
|
||||
this.sync_inbox(cx);
|
||||
|
||||
cx.notify();
|
||||
|
||||
let relays: Vec<(RelayUrl, Option<RelayMetadata>)> = [
|
||||
@@ -968,9 +970,7 @@ impl Backend {
|
||||
} else if credential.starts_with("bunker://") {
|
||||
self.login_with_bunker(credential, cx);
|
||||
} else {
|
||||
cx.emit(BackendEvent::error(
|
||||
"Unsupported credential, expected nsec1... or bunker://...",
|
||||
));
|
||||
cx.emit(BackendEvent::error("Unsupported credential."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -999,7 +999,7 @@ impl Backend {
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = write.await {
|
||||
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||
return Ok(());
|
||||
}
|
||||
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
||||
@@ -1045,7 +1045,7 @@ impl Backend {
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1066,6 +1066,7 @@ impl Backend {
|
||||
this.passphrase_required = false;
|
||||
cx.emit(BackendEvent::SignerChanged);
|
||||
cx.emit(BackendEvent::SignerRequired);
|
||||
this.sync_inbox(cx);
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
@@ -1096,7 +1097,7 @@ impl Backend {
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1121,6 +1122,13 @@ impl Backend {
|
||||
self.pushing_repos.clone()
|
||||
}
|
||||
|
||||
/// The inbox child entity backing the home screen.
|
||||
///
|
||||
/// A child entity: `cx.observe` it to react only to inbox changes.
|
||||
pub fn inbox(&self) -> Entity<Inbox> {
|
||||
self.inbox.clone()
|
||||
}
|
||||
|
||||
/// Get the current user's public key.
|
||||
pub fn current_user(&self) -> Option<PublicKey> {
|
||||
self.current_user
|
||||
@@ -1136,6 +1144,35 @@ impl Backend {
|
||||
cx.emit(BackendEvent::error(message));
|
||||
}
|
||||
|
||||
/// Attach the inbox to the current signer and activate or clear it.
|
||||
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
let me = self.current_user;
|
||||
|
||||
if let Some(me) = me {
|
||||
self.subscribe_bootstrap(filters::notifications(me), cx);
|
||||
self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
|
||||
|
||||
let relays: HashSet<RelayUrl> = RepoListStore::global(cx)
|
||||
.read(cx)
|
||||
.announcements_of(&me)
|
||||
.into_iter()
|
||||
.flat_map(|announcement| announcement.relays)
|
||||
.collect();
|
||||
|
||||
if !relays.is_empty() {
|
||||
let relays: Vec<RelayUrl> = relays.into_iter().collect();
|
||||
self.connect_repo_relays(relays.clone(), filters::notifications(me), cx);
|
||||
self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx);
|
||||
}
|
||||
}
|
||||
|
||||
self.inbox.update(cx, |inbox, cx| match me {
|
||||
Some(me) => inbox.activate(me, client, cx),
|
||||
None => inbox.reset(cx),
|
||||
});
|
||||
}
|
||||
|
||||
/// Progress of the in-flight negentropy sync, if any.
|
||||
pub fn sync_progress(&self) -> Option<(u64, u64)> {
|
||||
self.sync_progress
|
||||
@@ -1149,7 +1186,7 @@ impl Backend {
|
||||
<T as AsyncSignEvent>::Error: std::error::Error + Send + Sync + 'static,
|
||||
<T as AsyncNip44>::Error: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
cx.spawn(async move |this, cx| {
|
||||
match new_signer.get_public_key_async().await {
|
||||
Ok(public_key) => {
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -1158,6 +1195,7 @@ impl Backend {
|
||||
this.passphrase_required = false;
|
||||
this.bootstrap_user(public_key, cx);
|
||||
cx.emit(BackendEvent::SignerChanged);
|
||||
this.sync_inbox(cx);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
@@ -1168,15 +1206,12 @@ impl Backend {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
task.detach();
|
||||
Ok::<(), Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Connect to a repository's announced relays, its NIP-34 `relays` tag.
|
||||
///
|
||||
/// Callers are responsible for not repeating this for relays they already
|
||||
/// connected, e.g. `RepoStore::repo_relays`.
|
||||
pub fn connect_repo_relays(
|
||||
&mut self,
|
||||
relays: Vec<RelayUrl>,
|
||||
@@ -1185,13 +1220,13 @@ impl Backend {
|
||||
) {
|
||||
let client = self.client.clone();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |_this, _cx| {
|
||||
cx.spawn(async move |_this, _cx| {
|
||||
if let Err(e) = connect_repo_relays(&client, relays, filters).await {
|
||||
log::warn!("repo relay fetch failed: {e}");
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
task.detach();
|
||||
Ok::<(), Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// One-shot subscription on the bootstrap relays only.
|
||||
@@ -1201,24 +1236,25 @@ impl Backend {
|
||||
let fetch =
|
||||
cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await });
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = fetch.await {
|
||||
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(BackendEvent::error(e.to_string()));
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
task.detach();
|
||||
Ok::<(), Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Negentropy-sync the given filter against the bootstrap relays.
|
||||
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
let (tx, mut rx) = SyncProgress::channel();
|
||||
|
||||
self.sync_progress = Some((0, 0));
|
||||
cx.notify();
|
||||
|
||||
let (tx, mut rx) = SyncProgress::channel();
|
||||
|
||||
let progress_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let mut last_percent: u64 = 0;
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::{AppContext, Context, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox};
|
||||
|
||||
use crate::backend::Backend;
|
||||
|
||||
/// The user's persisted inbox read state.
|
||||
#[derive(Default)]
|
||||
pub struct Inbox {
|
||||
state: InboxReadState,
|
||||
/// Set once the stored state has been read for the current user.
|
||||
loaded: bool,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
/// The current read/archive cutoffs.
|
||||
pub fn state(&self) -> &InboxReadState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Whether the stored state has been read for the current user.
|
||||
pub fn is_loaded(&self) -> bool {
|
||||
self.loaded
|
||||
}
|
||||
|
||||
/// Mark the events of one notification group read, then bound the id sets.
|
||||
pub fn mark_read(
|
||||
&mut self,
|
||||
group: &[Event],
|
||||
all: &[Event],
|
||||
me: PublicKey,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
for event in group {
|
||||
self.state.mark_read(event);
|
||||
}
|
||||
self.state.advance_read(all, me, Timestamp::now());
|
||||
self.persist(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Archive one notification group. Archived events are always read too.
|
||||
pub fn mark_archived(
|
||||
&mut self,
|
||||
group: &[Event],
|
||||
all: &[Event],
|
||||
me: PublicKey,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
for event in group {
|
||||
self.state.mark_archived(event);
|
||||
self.state.mark_read(event);
|
||||
}
|
||||
|
||||
let now = Timestamp::now();
|
||||
self.state.advance_archived(all, me, now);
|
||||
self.state.advance_read(all, me, now);
|
||||
self.persist(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Mark every known notification read.
|
||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context<Self>) {
|
||||
self.state.mark_all_read(all, me, Timestamp::now());
|
||||
self.persist(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Load the stored state for current user.
|
||||
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
|
||||
self.state = InboxReadState::default();
|
||||
self.loaded = false;
|
||||
cx.notify();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let work = cx.background_spawn(async move { load_state(&client, me).await });
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let loaded = work.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
if backend.read(cx).current_user() != Some(me) {
|
||||
return;
|
||||
}
|
||||
|
||||
match loaded {
|
||||
Ok(Some(state)) => this.state = state,
|
||||
Ok(None) => this.state = InboxReadState::default(),
|
||||
Err(error) => log::warn!("failed to load inbox state: {error}"),
|
||||
}
|
||||
|
||||
this.loaded = true;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok::<(), Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Clear the state of the signed-out user.
|
||||
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.state = InboxReadState::default();
|
||||
self.loaded = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Sign the state with a random key and store it locally.
|
||||
fn persist(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let client = Backend::global(cx).read(cx).client();
|
||||
let state = self.state.clone();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
if let Err(error) = save_state(&client, me, &state).await {
|
||||
log::warn!("failed to save inbox state: {error}");
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the inbox home screen's threads for `me` from the local database.
|
||||
pub async fn query_inbox(
|
||||
client: &Client,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
) -> Result<(Vec<InboxItem>, usize), Error> {
|
||||
let deletion_events = client.database().query(filters::deletions()).await?;
|
||||
let deletions = Deletions::from_events(deletion_events);
|
||||
|
||||
let (notification_events, mut by_id) = fetch_notifications(client, me, &deletions).await?;
|
||||
|
||||
let mut activity = Vec::new();
|
||||
for event in client
|
||||
.database()
|
||||
.query(filters::authored_activity(me))
|
||||
.await?
|
||||
{
|
||||
if deletions.is_deleted(&event) || !filters::is_git_activity(&event) {
|
||||
continue;
|
||||
}
|
||||
by_id.entry(event.id).or_insert_with(|| event.clone());
|
||||
activity.push(event);
|
||||
}
|
||||
|
||||
let items = inbox::group(notification_events, activity, me, state, &|id| {
|
||||
by_id.get(&id).cloned()
|
||||
});
|
||||
|
||||
let unread_count = items.iter().filter(|item| item.is_unread()).count();
|
||||
|
||||
Ok((items, unread_count))
|
||||
}
|
||||
|
||||
/// `d` tag identifying the inbox state event of `me`.
|
||||
fn inbox_state_d_tag(me: PublicKey) -> String {
|
||||
format!("signed-inbox-state:{}", me.to_hex())
|
||||
}
|
||||
|
||||
/// Newest stored state for `me`.
|
||||
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(inbox_state_d_tag(me));
|
||||
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match serde_json::from_str(&event.content) {
|
||||
Ok(state) => Ok(Some(state)),
|
||||
Err(error) => {
|
||||
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign with a random key and store locally.
|
||||
async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> {
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||
.tags([Tag::identifier(inbox_state_d_tag(me))])
|
||||
.finalize(&Keys::generate())?;
|
||||
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Notification events and a lookup of every ancestor they reference.
|
||||
async fn fetch_notifications(
|
||||
client: &Client,
|
||||
me: PublicKey,
|
||||
deletions: &Deletions,
|
||||
) -> Result<(Vec<Event>, HashMap<EventId, Event>), Error> {
|
||||
let mut notifications: Vec<Event> = Vec::new();
|
||||
let mut by_id: HashMap<EventId, Event> = HashMap::new();
|
||||
|
||||
for filter in filters::notifications(me) {
|
||||
for event in client.database().query(filter).await? {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if by_id.insert(event.id, event.clone()).is_none() {
|
||||
notifications.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut pending: Vec<EventId> = notifications.iter().flat_map(event_references).collect();
|
||||
let mut seen: HashSet<EventId> = by_id.keys().copied().collect();
|
||||
|
||||
loop {
|
||||
// Keep only ids not walked yet, and remember them.
|
||||
pending.retain(|id| seen.insert(*id));
|
||||
|
||||
if pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let ancestors = client
|
||||
.database()
|
||||
.query(Filter::new().ids(pending.iter().copied()))
|
||||
.await?;
|
||||
|
||||
let mut next = Vec::new();
|
||||
|
||||
for event in ancestors {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
next.extend(event_references(&event).filter(|id| !seen.contains(id)));
|
||||
by_id.entry(event.id).or_insert(event);
|
||||
}
|
||||
|
||||
pending = next;
|
||||
}
|
||||
|
||||
Ok((notifications, by_id))
|
||||
}
|
||||
|
||||
/// Event ids referenced by `event` through its `e` and `E` tags.
|
||||
fn event_references(event: &Event) -> impl Iterator<Item = EventId> + '_ {
|
||||
event.tags.iter().filter_map(|tag| {
|
||||
if tag.kind() != "e" && tag.kind() != "E" {
|
||||
return None;
|
||||
}
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod backend;
|
||||
mod checkouts;
|
||||
mod git_store;
|
||||
mod inbox;
|
||||
mod profile;
|
||||
mod refresh;
|
||||
mod repo;
|
||||
@@ -11,22 +12,23 @@ use std::path::{Path, PathBuf};
|
||||
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
|
||||
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
||||
pub use git_store::GitStore;
|
||||
use gpui::{App, AppContext, Entity};
|
||||
use gpui::{App, AppContext};
|
||||
pub use inbox::{Inbox, query_inbox};
|
||||
pub use nostr_sdk::prelude::Timestamp;
|
||||
pub use profile::{Profile, ProfileStore};
|
||||
pub use refresh::{RefreshGate, RefreshRequest};
|
||||
pub use repo::RepoStore;
|
||||
pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore};
|
||||
use signed_nostr::new_backend;
|
||||
|
||||
/// Initialize the backend and stores, and install them as globals.
|
||||
/// Call once at startup, before opening any window that uses the stores.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn init(
|
||||
db_path: impl AsRef<Path>,
|
||||
repos_root: impl Into<PathBuf>,
|
||||
scan_paths: Vec<PathBuf>,
|
||||
cx: &mut App,
|
||||
) -> Entity<Backend> {
|
||||
) {
|
||||
// rustls uses the `aws_lc_rs` provider by default.
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
@@ -37,27 +39,22 @@ pub fn init(
|
||||
.expect("failed to initialize nostr backend")
|
||||
});
|
||||
|
||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||
Backend::set_global(entity.clone(), cx);
|
||||
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||
GitStore::set_global(repos_root, cx);
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||
|
||||
entity
|
||||
}
|
||||
|
||||
/// Initialize the backend with an in-memory database on wasm.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn init(cx: &mut App) -> Entity<Backend> {
|
||||
pub fn init(cx: &mut App) {
|
||||
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||
Backend::set_global(entity.clone(), cx);
|
||||
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||
GitStore::set_global(PathBuf::new(), cx);
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
||||
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
|
||||
entity
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
/// Refresh coalescing shared by the event stores.
|
||||
///
|
||||
/// [`crate::RepoStore`], [`crate::RepoListStore`] and [`crate::CheckoutsStore`]
|
||||
/// re-query their inputs on a debounce timer with the same policy:
|
||||
/// a request arriving while a run is in flight is folded into a follow-up run,
|
||||
/// a request arriving while the debounce timer is pending is dropped by it.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RefreshGate {
|
||||
/// A run is in flight.
|
||||
|
||||
@@ -397,6 +397,22 @@ impl RepoStore {
|
||||
};
|
||||
|
||||
let again = this.update(cx, |this, cx| {
|
||||
// Compare before moving the freshly queried data in, so a pass
|
||||
// that found nothing new does not notify observers. The store
|
||||
// is polled in bursts while a sync is in flight; notifying on
|
||||
// every identical pass would re-render the repository panel
|
||||
// several times for no visible change.
|
||||
let head_changed = state
|
||||
.as_ref()
|
||||
.is_some_and(|(_, head)| this.head.as_deref() != head.as_deref());
|
||||
let changed = this.announcement != announcement
|
||||
|| head_changed
|
||||
|| this.issues != issues
|
||||
|| this.patches != patches
|
||||
|| this.pull_requests != pull_requests
|
||||
|| this.comments != comments
|
||||
|| this.status_by_root != status_by_root;
|
||||
|
||||
this.announcement = announcement;
|
||||
|
||||
// The announcement may list relays for this repository's activity.
|
||||
@@ -454,7 +470,9 @@ impl RepoStore {
|
||||
});
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
if changed {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
this.refresh.finish()
|
||||
})?;
|
||||
|
||||
@@ -154,7 +154,6 @@ pub struct RepoListStore {
|
||||
/// Shared so views can clone the list per frame without a deep copy.
|
||||
pub announcements: Arc<Vec<Announcement>>,
|
||||
/// Latest known activity timestamp per repository.
|
||||
/// Covers announcements, state updates, patches, PRs, issues and statuses.
|
||||
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
||||
/// Issues, pull requests and commits per repository.
|
||||
///
|
||||
@@ -178,6 +177,7 @@ impl RepoListStore {
|
||||
/// Create the store listing all announcements.
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let weak = cx.entity().downgrade();
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let relevant = match event {
|
||||
@@ -217,17 +217,12 @@ impl RepoListStore {
|
||||
}
|
||||
});
|
||||
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
let result = weak.update(cx, |this, cx| {
|
||||
weak.update(cx, |this, cx| {
|
||||
this.subscribe_remote(cx);
|
||||
// Query the local database right away.
|
||||
// The list never waits for the relay syncs started above to finish.
|
||||
this.refresh_initial(cx);
|
||||
});
|
||||
if let Err(error) = result {
|
||||
log::warn!("repo list store dropped before bootstrap could run: {error}");
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, SharedString, StyleRefinement, Window};
|
||||
use gpui_component::avatar::Avatar;
|
||||
use gpui_component::{ActiveTheme, Sizable, StyledExt};
|
||||
use gpui_component::{ActiveTheme, Sizable, Size, StyledExt};
|
||||
|
||||
/// A small user avatar from gpui-component [`Avatar`], rounded with the theme radius.
|
||||
/// It shows the user's picture or falls back to name initials.
|
||||
@@ -9,6 +9,7 @@ use gpui_component::{ActiveTheme, Sizable, StyledExt};
|
||||
pub struct UserAvatar {
|
||||
name: SharedString,
|
||||
picture: Option<SharedString>,
|
||||
size: Size,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
@@ -19,6 +20,7 @@ impl UserAvatar {
|
||||
Self {
|
||||
name: name.into(),
|
||||
picture: None,
|
||||
size: Size::Small,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
@@ -30,6 +32,13 @@ impl UserAvatar {
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for UserAvatar {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for UserAvatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
@@ -43,6 +52,6 @@ impl RenderOnce for UserAvatar {
|
||||
.when_some(self.picture, |this, url| this.src(url))
|
||||
.rounded(cx.theme().radius)
|
||||
.refine_style(&self.style)
|
||||
.small()
|
||||
.with_size(self.size)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ utils = { path = "../utils" }
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
gpui-base.workspace = true
|
||||
gpui-fps.workspace = true
|
||||
gix.workspace = true
|
||||
nostr.workspace = true
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ use signed_git::{CommitDiff, DiffStatus, FileCommit, FileDiff};
|
||||
use signed_ui::{placeholder, tree_row};
|
||||
use utils::relative_time_secs;
|
||||
|
||||
use super::helpers::{
|
||||
use crate::views::repo::helpers::{
|
||||
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,739 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Div, 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::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
||||
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
|
||||
use signed_core::{COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, filters};
|
||||
use signed_state::{
|
||||
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
|
||||
};
|
||||
use signed_ui::{CountBadge, UserAvatar};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::{RepoItem, open_repo_item};
|
||||
|
||||
/// Delay between a refresh request and the actual re-query.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// Extra list rows measured above and below the visible area.
|
||||
const LIST_OVERDRAW: Pixels = px(400.);
|
||||
|
||||
/// Maximum number of sub-activity lines shown under a thread row.
|
||||
const MAX_SUB_ACTIVITIES: usize = 5;
|
||||
|
||||
/// A repository's slice of the inbox: the threads that belong to it.
|
||||
struct InboxSection {
|
||||
/// Repository the section groups, `None` for items without one.
|
||||
address: Option<RepoAddr>,
|
||||
/// Number of threads with an unread event.
|
||||
unread: usize,
|
||||
/// Indices into the threads, newest activity first.
|
||||
entries: Vec<usize>,
|
||||
/// Timestamp of the newest entry, used to order the sections.
|
||||
latest: Timestamp,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum InboxRow {
|
||||
Repo(usize),
|
||||
Entry(usize, usize),
|
||||
Empty,
|
||||
}
|
||||
|
||||
pub struct InboxView {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// One row per thread, merging notifications and own activity, newest first.
|
||||
threads: Arc<Vec<InboxItem>>,
|
||||
/// The threads grouped by repository, newest first.
|
||||
sections: Arc<Vec<InboxSection>>,
|
||||
/// The flattened repository headers and rows of the list.
|
||||
rows: Arc<Vec<InboxRow>>,
|
||||
/// Number of non-archived threads with an unread event.
|
||||
unread_count: usize,
|
||||
/// Copy of the global read state the current lists were derived with.
|
||||
state: InboxReadState,
|
||||
/// Set once the global state has been read for the current user.
|
||||
state_loaded: bool,
|
||||
refresh: RefreshGate,
|
||||
list: ListState,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl InboxView {
|
||||
pub fn new(dock_area: WeakEntity<DockArea>, 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 list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW);
|
||||
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);
|
||||
}));
|
||||
|
||||
// Rebuild when the user's own repositories load or change,
|
||||
// so a repository without any activity still gets an empty section.
|
||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||
this.rebuild(cx);
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
// Derive the sections once the panel exists.
|
||||
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 {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
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(),
|
||||
list,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark every known notification read.
|
||||
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));
|
||||
}
|
||||
|
||||
/// Re-derive from the global state when it is loaded or changes.
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a backend event that can change the derived sections.
|
||||
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
BackendEvent::NostrUpdate(updates) => {
|
||||
let relevant = updates.iter().any(|update| {
|
||||
let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind);
|
||||
let is_comment = update.kind == Kind::Comment;
|
||||
let is_event_deletion = update.kind == Kind::EventDeletion;
|
||||
let is_request_to_vanish = update.kind == Kind::RequestToVanish;
|
||||
|
||||
is_notification || is_comment || is_event_deletion || is_request_to_vanish
|
||||
});
|
||||
|
||||
if relevant {
|
||||
self.refresh(cx);
|
||||
}
|
||||
}
|
||||
BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot initial load, no debounce.
|
||||
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||
debug_assert!(!self.refresh.debouncing());
|
||||
if self.refresh.running() {
|
||||
self.refresh.request();
|
||||
return;
|
||||
}
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
/// Re-query the local database.
|
||||
fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.state_loaded {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.refresh.request() != RefreshRequest::Schedule {
|
||||
return;
|
||||
}
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||
this.update(cx, |this, cx| this.run_refresh(cx))
|
||||
}));
|
||||
}
|
||||
|
||||
/// One query and apply cycle, the debounced entry point.
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refresh.begin();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let Some(me) = backend.read(cx).current_user() else {
|
||||
self.refresh.abort();
|
||||
return;
|
||||
};
|
||||
|
||||
let client = backend.read(cx).client();
|
||||
let state = self.state.clone();
|
||||
|
||||
let work = cx.background_spawn(async move { query_inbox(&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(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Recompute the unread and archived flags from the current state.
|
||||
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);
|
||||
}
|
||||
|
||||
/// Regroup the current threads by repository and flatten them into rows.
|
||||
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(§ions);
|
||||
self.sections = Arc::new(sections);
|
||||
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.
|
||||
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;
|
||||
// Drop any in-flight or pending run belonging to the previous user.
|
||||
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 Some(announcement) = RepoListStore::global(cx)
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|announcement| announcement.addr() == address)
|
||||
.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: &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();
|
||||
};
|
||||
repo_header(section, cx)
|
||||
}
|
||||
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_kind;
|
||||
let address = section.address.clone();
|
||||
let first = entry_ix == 0;
|
||||
let last = entry_ix + 1 == section.entries.len();
|
||||
|
||||
thread("inbox-row", ix, item, first, last, cx)
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.open(root, kind, address.clone(), window, cx)
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Display name of the repository at `addr`, from the announcement store.
|
||||
fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> {
|
||||
let repo_list = RepoListStore::global(cx);
|
||||
let addr = addr?;
|
||||
repo_list
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|announcement| announcement.addr() == *addr)
|
||||
.map(|announcement| announcement.name().map(SharedString::from))
|
||||
}
|
||||
|
||||
/// Header of a repository section.
|
||||
fn repo_header(section: &InboxSection, cx: &App) -> AnyElement {
|
||||
let name =
|
||||
repo_name(section.address.as_ref(), cx).unwrap_or_else(|| SharedString::from("Untitled"));
|
||||
|
||||
h_flex()
|
||||
.h_12()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(name),
|
||||
)
|
||||
.when(section.unread > 0, |this| {
|
||||
this.child(CountBadge::new(section.unread))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Placeholder under a repository header that has nothing to show.
|
||||
fn empty_section_row(cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.h_12()
|
||||
.w_full()
|
||||
.px_3()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
.bg(cx.theme().secondary.alpha(0.6))
|
||||
.rounded(cx.theme().radius)
|
||||
.child(SharedString::from("No activity yet."))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn thread(
|
||||
prefix: &'static str,
|
||||
ix: usize,
|
||||
item: &InboxItem,
|
||||
first: bool,
|
||||
last: bool,
|
||||
cx: &App,
|
||||
) -> Stateful<Div> {
|
||||
let title = SharedString::from(item.title());
|
||||
let unread = item.is_unread();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let me = backend.read(cx).current_user();
|
||||
|
||||
let mut timeline = v_flex().gap_2().w_full();
|
||||
|
||||
for event in item.timeline(MAX_SUB_ACTIVITIES) {
|
||||
timeline = timeline.child(sub_activity(&event, me, cx));
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.id((prefix, ix))
|
||||
.w_full()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.gap_2()
|
||||
.bg(cx.theme().secondary.alpha(0.6))
|
||||
.when(first, |this| this.rounded_t(cx.theme().radius))
|
||||
.when(last, |this| this.rounded_b(cx.theme().radius))
|
||||
.when(!last, |this| {
|
||||
this.border_b_1().border_color(cx.theme().background)
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().secondary_hover.alpha(0.8)))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.size_6()
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Icon::new(IconName::Bell)),
|
||||
)
|
||||
.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().primary),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(timeline)
|
||||
}
|
||||
|
||||
fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
|
||||
let profile_store = ProfileStore::global(cx).read(cx);
|
||||
let profile = profile_store.get(&event.pubkey);
|
||||
|
||||
let name = if Some(event.pubkey) == me {
|
||||
SharedString::from("You")
|
||||
} else {
|
||||
profile.name()
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(div().w_6().flex_shrink_0())
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.text_xs()
|
||||
.child(
|
||||
UserAvatar::new(name.clone())
|
||||
.picture(profile.picture())
|
||||
.xsmall(),
|
||||
)
|
||||
.child(name)
|
||||
.child(SharedString::from(activity_phrase(event.kind)))
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(relative_time(event.created_at))),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Phrase describing an activity event, read as `[name] [phrase]`.
|
||||
fn activity_phrase(kind: Kind) -> &'static str {
|
||||
if kind == COVER_NOTE_KIND {
|
||||
return "added a note";
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
/// Centered muted icon and message filling its container.
|
||||
fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.py_8()
|
||||
.child(
|
||||
Icon::new(icon)
|
||||
.large()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(message)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
impl BasePanel for InboxView {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"inbox"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for InboxView {
|
||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().text_sm().child(SharedString::from("Inbox"))
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for InboxView {}
|
||||
|
||||
impl Focusable for InboxView {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for InboxView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let rows = self.rows.clone();
|
||||
|
||||
if self.list.item_count() != rows.len() {
|
||||
self.list.reset(rows.len());
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.image_cache(gpui::retain_all("inbox"))
|
||||
.size_full()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_4()
|
||||
.h_12()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Inbox")),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
Button::new("mark-all")
|
||||
.icon(IconName::CircleCheck)
|
||||
.secondary()
|
||||
.tooltip("Mark all as read")
|
||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||
this.mark_all_read(cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.px_4()
|
||||
.when_else(
|
||||
rows.is_empty(),
|
||||
|this| {
|
||||
this.child(empty_state(IconName::Inbox, "You're all caught up.", cx))
|
||||
},
|
||||
|this| {
|
||||
this.child(
|
||||
list(
|
||||
self.list.clone(),
|
||||
cx.processor(|this, ix, _window, cx| this.render_entry(ix, cx)),
|
||||
)
|
||||
.size_full()
|
||||
.min_h_0()
|
||||
.into_any_element(),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(div().h_6().w_full().flex_shrink_0()),
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -13,7 +13,7 @@ use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{UserAvatar, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::helpers::{comment_form, comments_section, issue_roots, sidebar_section};
|
||||
use crate::views::repo::helpers::{comment_form, comments_section, issue_roots, sidebar_section};
|
||||
|
||||
/// Detail panel of a single issue.
|
||||
pub struct IssueDetailView {
|
||||
+3
-1
@@ -21,7 +21,9 @@ use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::issue_detail::IssueDetailView;
|
||||
pub(super) mod detail;
|
||||
|
||||
use self::detail::IssueDetailView;
|
||||
|
||||
/// Height of one issue row in the virtual list.
|
||||
const ISSUE_ROW_HEIGHT: f32 = 73.;
|
||||
@@ -1,9 +1,15 @@
|
||||
mod commit_diff;
|
||||
mod dialog_state;
|
||||
mod repo_detail;
|
||||
mod inbox;
|
||||
mod issues;
|
||||
mod pull_requests;
|
||||
mod repo;
|
||||
mod repo_list;
|
||||
mod send_patch;
|
||||
pub(crate) mod sidebar;
|
||||
|
||||
pub use repo_detail::RepoDetailView;
|
||||
pub(crate) use repo_detail::open_repo_panel;
|
||||
pub use inbox::InboxView;
|
||||
pub use repo::RepoDetailView;
|
||||
pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel};
|
||||
pub use repo_list::RepoListView;
|
||||
pub use sidebar::SidebarPanel;
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ use signed_state::{Backend, GitStore, ProfileStore, RepoStore};
|
||||
use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
|
||||
use utils::{relative_time, relative_time_secs};
|
||||
|
||||
use super::diff::{CommitDiffView, DiffPane};
|
||||
use super::helpers::{comment_form, comments_section, pr_roots, sidebar_section};
|
||||
use crate::views::commit_diff::{CommitDiffView, DiffPane};
|
||||
use crate::views::repo::helpers::{comment_form, comments_section, pr_roots, sidebar_section};
|
||||
|
||||
/// Height of one commit row in the commits tab's virtual list.
|
||||
const ROW_HEIGHT: f32 = 37.;
|
||||
+6
-3
@@ -19,10 +19,13 @@ use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::RepoAction;
|
||||
use super::new_pull_request::open_new_pull_panel;
|
||||
use super::pull_request_detail::PullRequestDetailView;
|
||||
pub(super) mod detail;
|
||||
pub(super) mod new;
|
||||
|
||||
use self::detail::PullRequestDetailView;
|
||||
use self::new::open_new_pull_panel;
|
||||
use super::send_patch::open_send_patch_panel;
|
||||
use crate::views::repo::RepoAction;
|
||||
|
||||
/// Height of one pull request row in the virtual list.
|
||||
const ROW_HEIGHT: f32 = 73.;
|
||||
+3
-4
@@ -29,9 +29,8 @@ use signed_git::{
|
||||
use signed_state::{Backend, CheckoutsStore, GitStore, RepoListStore, RepoStore};
|
||||
use signed_ui::{CountBadge, placeholder};
|
||||
|
||||
use super::commits::{COMMIT_ROW_HEIGHT, commit_row};
|
||||
use super::diff::{CommitDiffView, DiffPane};
|
||||
use super::helpers::ref_selector_trigger;
|
||||
use crate::views::commit_diff::{CommitDiffView, DiffPane};
|
||||
use crate::views::repo::helpers::{COMMIT_ROW_HEIGHT, commit_row, ref_selector_trigger};
|
||||
|
||||
/// The new pull request panel of a repository.
|
||||
pub struct NewPullRequestView {
|
||||
@@ -1342,7 +1341,7 @@ impl NewPullRequestView {
|
||||
}
|
||||
|
||||
/// Open the new pull request panel in the center dock.
|
||||
pub(super) fn open_new_pull_panel(
|
||||
pub(crate) fn open_new_pull_panel(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
window: &mut Window,
|
||||
@@ -0,0 +1,265 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use dock::{DockArea, add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Context, Entity, WeakEntity, Window};
|
||||
use gpui_base::dock::PanelView;
|
||||
use nostr::prelude::EventId;
|
||||
use signed_core::{Announcement, filters};
|
||||
use signed_state::{Backend, RepoListStore, RepoStore};
|
||||
|
||||
use super::RepoDetailView;
|
||||
use crate::views::issues::IssuesView;
|
||||
use crate::views::issues::detail::IssueDetailView;
|
||||
use crate::views::pull_requests::PullRequestsView;
|
||||
use crate::views::pull_requests::detail::PullRequestDetailView;
|
||||
use crate::views::repo::init_dialog;
|
||||
|
||||
impl RepoDetailView {
|
||||
/// Re-push the repository's refs to its announced grasp servers.
|
||||
pub(super) fn push_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(store) = self.store.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
store
|
||||
.update(cx, |store, cx| store.push_repository(cx))
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Push the unpushed commits of the local checkout at `path`.
|
||||
pub(super) fn push_unpushed_checkout(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(store) = self.store.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if store.read(cx).pushing {
|
||||
return;
|
||||
}
|
||||
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||
// The store owns the push, its busy flag and error reporting.
|
||||
let push = this.update_in(cx, |_this, _window, cx| {
|
||||
store.update(cx, |store, cx| store.push_checkout(path.clone(), cx))
|
||||
})?;
|
||||
|
||||
// The remote moved, refresh the mirror browsing.
|
||||
// Failures already surfaced in the store's error banner.
|
||||
if let Ok(()) = push.await {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.load_repo(window, cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Delete the repository from nostr, announcement, state and activity.
|
||||
pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(store) = self.store.clone() else {
|
||||
return;
|
||||
};
|
||||
store
|
||||
.update(cx, |store, cx| store.delete_repository(cx))
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Open the issues list panel in the dock area.
|
||||
pub(super) fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(store) = self.store.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx));
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the pull requests list panel in the dock area.
|
||||
pub(super) fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(store) = self.store.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx));
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the upstream repository, the `u` tag of this fork's announcement.
|
||||
/// The upstream announcement may not be in the local database yet.
|
||||
/// Subscribe for it and open the panel as soon as it lands.
|
||||
pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.pending_upstream.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(announcement) = self.announcement(cx).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(addr) = announcement.upstream.and_then(|upstream| upstream.addr) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(found) = RepoListStore::global(cx)
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|a| a.addr() == addr)
|
||||
.cloned()
|
||||
{
|
||||
open_repo_panel(&self.dock_area, &found, window, &mut *cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
backend.update(cx, |backend, cx| {
|
||||
backend.subscribe_bootstrap(vec![filters::announcement(&addr)], cx);
|
||||
});
|
||||
self.pending_upstream = Some(addr);
|
||||
|
||||
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||
for _ in 0..60 {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(250))
|
||||
.await;
|
||||
|
||||
let opened = this.update_in(cx, |this, window, cx| {
|
||||
let Some(addr) = this.pending_upstream.clone() else {
|
||||
return true;
|
||||
};
|
||||
let found = RepoListStore::global(cx)
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|a| a.addr() == addr)
|
||||
.cloned();
|
||||
match found {
|
||||
Some(found) => {
|
||||
this.pending_upstream = None;
|
||||
open_repo_panel(&this.dock_area, &found, window, &mut *cx);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
})?;
|
||||
|
||||
if opened {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
this.update(cx, |this, _cx| this.pending_upstream = None)?;
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Open the dialog guiding the user through publishing the local repository to NIP-34.
|
||||
pub(super) fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(local_path) = self.local_path.clone() else {
|
||||
return;
|
||||
};
|
||||
let view = cx.entity().downgrade();
|
||||
init_dialog::open(local_path, view, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `announcement` as a repository panel in the dock's center.
|
||||
pub(crate) fn open_repo_panel(
|
||||
dock_area: &WeakEntity<DockArea>,
|
||||
announcement: &Announcement,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<RepoDetailView> {
|
||||
let detail =
|
||||
cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx));
|
||||
|
||||
if let Some(dock_area) = dock_area.upgrade() {
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(detail.clone()), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
/// 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 `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
|
||||
/// detail panel, and have no panel of their own.
|
||||
pub(crate) fn open_repo_item(
|
||||
dock_area: &WeakEntity<DockArea>,
|
||||
announcement: &Announcement,
|
||||
item: RepoItem,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let panel: Arc<dyn PanelView> =
|
||||
match item {
|
||||
RepoItem::Issue(issue_id) => {
|
||||
let store = repo_store(announcement, cx);
|
||||
panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx)))
|
||||
}
|
||||
RepoItem::PullRequest(pr_id) => {
|
||||
let store = repo_store(announcement, cx);
|
||||
panel_handle(cx.new(|cx| {
|
||||
PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx)
|
||||
}))
|
||||
}
|
||||
RepoItem::Patch => return,
|
||||
};
|
||||
|
||||
let Some(dock_area) = dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel, window, cx);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Context, SharedString, div, transparent_white};
|
||||
use gpui_base::Disableable;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::{ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, h_flex};
|
||||
use signed_core::RepoStatus;
|
||||
use signed_state::{Backend, CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
||||
|
||||
use super::RepoDetailView;
|
||||
use crate::views::pull_requests::new::open_new_pull_panel;
|
||||
|
||||
impl RepoDetailView {
|
||||
/// The first checkout ready for a pull request on this repository.
|
||||
/// Not covered by an open PR of the signed-in user.
|
||||
/// Not dismissed in this panel.
|
||||
/// The repository's own checkouts are not suggested here.
|
||||
/// Their work is pushed, see [`Self::push_suggestion`].
|
||||
fn ready_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
|
||||
let store = self.store.as_ref()?;
|
||||
let addr = store.read(cx).addr().clone();
|
||||
let user = Backend::global(cx).read(cx).current_user()?;
|
||||
if store.read(cx).is_author(&user) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(&addr);
|
||||
|
||||
'status: for status in statuses {
|
||||
if self
|
||||
.banner_dismissed
|
||||
.contains(&(status.path.clone(), status.branch.clone()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let store = store.read(cx);
|
||||
for pr in &store.pull_requests {
|
||||
if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status)
|
||||
{
|
||||
continue 'status;
|
||||
}
|
||||
}
|
||||
return Some(status);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// The first checkout of this owned repository with unpushed commits.
|
||||
///
|
||||
/// Not dismissed in this panel.
|
||||
fn push_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
|
||||
let entity = self.store.as_ref()?;
|
||||
let user = Backend::global(cx).read(cx).current_user()?;
|
||||
|
||||
if !entity.read(cx).is_author(&user) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let addr = entity.read(cx).addr().clone();
|
||||
let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(&addr);
|
||||
|
||||
statuses.into_iter().find(|status| {
|
||||
!self
|
||||
.banner_dismissed
|
||||
.contains(&(status.path.clone(), status.branch.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
/// The ready-to-push banner of an owned repository.
|
||||
///
|
||||
/// A local checkout has unpushed commits, with a Push action and a dismiss control.
|
||||
pub(super) fn render_push_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
|
||||
let status = self.push_suggestion(cx)?;
|
||||
let key = (status.path.clone(), status.branch.clone());
|
||||
let path = status.path.clone();
|
||||
// The push busy flag lives on the store; it disables the banner's triggers.
|
||||
let pushing = self
|
||||
.store
|
||||
.as_ref()
|
||||
.is_some_and(|store| store.read(cx).pushing);
|
||||
|
||||
let commits = if status.ahead == 1 {
|
||||
SharedString::from("1 commit")
|
||||
} else {
|
||||
SharedString::from(format!("{} commits", status.ahead))
|
||||
};
|
||||
|
||||
Some(
|
||||
h_flex()
|
||||
.p_4()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.bg(cx.theme().muted)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().info)
|
||||
.child(
|
||||
h_flex()
|
||||
.px_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.border_1()
|
||||
.border_color(cx.theme().info)
|
||||
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.child(status.branch),
|
||||
)
|
||||
.child("has")
|
||||
.child(
|
||||
h_flex()
|
||||
.px_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.border_1()
|
||||
.border_color(cx.theme().info)
|
||||
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.child(commits),
|
||||
)
|
||||
.child("ready to push"),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("push-checkout-banner")
|
||||
.icon(IconName::ArrowUp)
|
||||
.label("Push")
|
||||
.small()
|
||||
.info()
|
||||
.loading(pushing)
|
||||
.disabled(pushing)
|
||||
.on_click(cx.listener(move |this, _event, window, cx| {
|
||||
this.push_unpushed_checkout(path.clone(), window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("close-repo")
|
||||
.icon(IconName::Close)
|
||||
.tooltip("Dismiss")
|
||||
.small()
|
||||
.ghost()
|
||||
.disabled(pushing)
|
||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||
this.banner_dismissed.insert(key.clone());
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Warning after a push that only some grasp servers accepted.
|
||||
pub(super) fn render_push_warning_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
|
||||
let store = self.store.as_ref()?;
|
||||
let store = store.read(cx);
|
||||
let warning = store.last_push_warning.clone()?;
|
||||
let pushing = store.pushing;
|
||||
|
||||
Some(
|
||||
h_flex()
|
||||
.p_4()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.items_start()
|
||||
.justify_between()
|
||||
.bg(cx.theme().warning.mix_oklab(transparent_white(), 0.08))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.min_w_0()
|
||||
.flex_1()
|
||||
.items_start()
|
||||
.child(Icon::new(IconName::TriangleAlert).small().flex_shrink_0())
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().warning)
|
||||
.child(SharedString::from(warning)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.flex_shrink_0()
|
||||
.child(
|
||||
Button::new("republish-after-partial-push")
|
||||
.icon(CustomIconName::Init)
|
||||
.label("Republish")
|
||||
.small()
|
||||
.info()
|
||||
.loading(pushing)
|
||||
.disabled(pushing)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.push_repository(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("dismiss-push-warning")
|
||||
.icon(IconName::Close)
|
||||
.tooltip("Dismiss")
|
||||
.small()
|
||||
.ghost()
|
||||
.disabled(pushing)
|
||||
.on_click(cx.listener(|this, _ev, _window, cx| {
|
||||
if let Some(store) = this.store.clone() {
|
||||
store.update(cx, |store, _| {
|
||||
store.last_push_warning = None;
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The ready-to-contribute banner of the repository panel.
|
||||
pub(super) fn render_ready_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
|
||||
let status = self.ready_suggestion(cx)?;
|
||||
let key = (status.path.clone(), status.branch.clone());
|
||||
|
||||
let commits = if status.ahead == 1 {
|
||||
SharedString::from("1 commit")
|
||||
} else {
|
||||
SharedString::from(format!("{} commits", status.ahead))
|
||||
};
|
||||
|
||||
Some(
|
||||
h_flex()
|
||||
.p_4()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.bg(cx.theme().muted)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().info)
|
||||
.child(
|
||||
h_flex()
|
||||
.px_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.border_1()
|
||||
.border_color(cx.theme().info)
|
||||
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.child(status.branch),
|
||||
)
|
||||
.child("is")
|
||||
.child(
|
||||
h_flex()
|
||||
.px_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.border_1()
|
||||
.border_color(cx.theme().info)
|
||||
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.child(commits),
|
||||
)
|
||||
.child("ahead of")
|
||||
.child(
|
||||
h_flex()
|
||||
.px_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.border_1()
|
||||
.border_color(cx.theme().info)
|
||||
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.child(status.base),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("create-pr-from-banner")
|
||||
.icon(IconName::Plus)
|
||||
.label("Create")
|
||||
.small()
|
||||
.info()
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
if let Some(store) = this.store.clone() {
|
||||
open_new_pull_panel(
|
||||
this.dock_area.clone(),
|
||||
store,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("dismiss-ready-banner")
|
||||
.icon(IconName::Close)
|
||||
.tooltip("Dismiss")
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||
this.banner_dismissed.insert(key.clone());
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+220
-3
@@ -1,3 +1,6 @@
|
||||
use std::path::{Component, Path};
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
@@ -10,7 +13,7 @@ use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
|
||||
use signed_ui::{placeholder, tree_row};
|
||||
|
||||
use super::RepoDetailView;
|
||||
use super::helpers::{code_language, is_markdown_path};
|
||||
use crate::views::repo::helpers::{code_language, is_markdown_path};
|
||||
|
||||
/// Width of the file explorer column.
|
||||
const TREE_WIDTH: f32 = 240.;
|
||||
@@ -39,6 +42,8 @@ pub(super) struct MarkdownView {
|
||||
/// Source path, `None` means the repository README.
|
||||
pub(super) path: Option<SharedString>,
|
||||
pub(super) state: Entity<TextViewState>,
|
||||
/// Hash of the source, so the same document is not re-parsed on a refresh.
|
||||
source_hash: u64,
|
||||
}
|
||||
|
||||
/// A code file loaded into a persistent [`InputState`].
|
||||
@@ -46,6 +51,21 @@ pub(super) struct CodeView {
|
||||
/// Source path, relative to the worktree root.
|
||||
pub(super) path: SharedString,
|
||||
pub(super) state: Entity<EditorState>,
|
||||
/// Hash of the source, so the same document is not re-parsed on a refresh.
|
||||
source_hash: u64,
|
||||
}
|
||||
|
||||
/// Hash of a preview's source text.
|
||||
///
|
||||
/// Two loads of the same document produce the same hash, so the persistent
|
||||
/// markdown/editor state can be kept instead of rebuilt, which would re-parse
|
||||
/// and flash the pane.
|
||||
fn source_hash(text: &str) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
text.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Spinner shown while a document is being loaded/parsed.
|
||||
@@ -217,9 +237,21 @@ impl RepoDetailView {
|
||||
text: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let hash = source_hash(text);
|
||||
if let Some(md) = &self.md
|
||||
&& md.path == path
|
||||
&& md.source_hash == hash
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let state = cx.new(|cx| TextViewState::markdown("", cx));
|
||||
state.update(cx, |state, cx| state.push_str(text, cx));
|
||||
self.md = Some(MarkdownView { path, state });
|
||||
self.md = Some(MarkdownView {
|
||||
path,
|
||||
state,
|
||||
source_hash: hash,
|
||||
});
|
||||
}
|
||||
|
||||
/// The persistent markdown TextView for `path`, where `None` is the README.
|
||||
@@ -257,6 +289,14 @@ impl RepoDetailView {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let hash = source_hash(text);
|
||||
if let Some(code) = &self.code
|
||||
&& code.path == path
|
||||
&& code.source_hash == hash
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let language = code_language(path.as_ref()).unwrap_or("text");
|
||||
let state = cx.new(|cx| {
|
||||
EditorState::new(window, cx)
|
||||
@@ -265,7 +305,11 @@ impl RepoDetailView {
|
||||
.line_number(true)
|
||||
.folding(true)
|
||||
});
|
||||
self.code = Some(CodeView { path, state });
|
||||
self.code = Some(CodeView {
|
||||
path,
|
||||
state,
|
||||
source_hash: hash,
|
||||
});
|
||||
}
|
||||
|
||||
/// The persistent code editor for `path`, or a spinner while the file loads or parses.
|
||||
@@ -286,3 +330,176 @@ impl RepoDetailView {
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
/// Preview the file at `path`, relative to the worktree root.
|
||||
fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.selected_file = Some(path.into());
|
||||
|
||||
if self.files.contains_key(path) {
|
||||
// The file is cached, but the markdown or code state may hold a different file.
|
||||
// Re-point it at this one, the parse runs on a background task either way.
|
||||
// Without this, the pane would show a spinner forever.
|
||||
if let Some(FileContent::Text(text)) = self.files.get(path) {
|
||||
let text = text.clone();
|
||||
if is_markdown_path(path) {
|
||||
if self.md.as_ref().map(|md| md.path.as_deref()) != Some(Some(path)) {
|
||||
self.set_markdown(Some(path.into()), &text, cx);
|
||||
}
|
||||
} else if self.code.as_ref().map(|code| code.path.as_str()) != Some(path) {
|
||||
self.set_code(path.into(), &text, window, cx);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
if self.loading_files.contains(path) {
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Paths come from our own tree walk, but never trust them.
|
||||
// Refuse anything that could escape the worktree.
|
||||
let rel = Path::new(path);
|
||||
let unsafe_path = rel.is_absolute()
|
||||
|| rel.components().any(|c| {
|
||||
matches!(
|
||||
c,
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||
)
|
||||
});
|
||||
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if unsafe_path {
|
||||
return;
|
||||
}
|
||||
|
||||
self.loading_files.insert(path.to_string());
|
||||
let path = path.to_string();
|
||||
|
||||
self.load_commit(&path, cx);
|
||||
let generation = self.ref_generation;
|
||||
|
||||
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||
let path_for_read = path.clone();
|
||||
let content = cx
|
||||
.background_spawn(async move {
|
||||
let full = worktree.join(&path_for_read);
|
||||
// Refuse oversized files before reading them.
|
||||
// Reading a multi-gigabyte file just to classify it is wasteful.
|
||||
// It would burn disk and memory bandwidth.
|
||||
let metadata = match std::fs::metadata(&full) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => return Err(anyhow::anyhow!("{}", error)),
|
||||
};
|
||||
if metadata.len() > MAX_PREVIEW_BYTES as u64 {
|
||||
return Ok(FileContent::TooLarge);
|
||||
}
|
||||
let bytes = match std::fs::read(&full) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => return Err(anyhow::anyhow!("{}", error)),
|
||||
};
|
||||
match String::from_utf8(bytes) {
|
||||
Ok(text) => Ok(FileContent::Text(text)),
|
||||
Err(_) => Ok(FileContent::Binary),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
// The worktree was switched while this file was reading.
|
||||
// The result belongs to the previous branch.
|
||||
// Clear the in-flight marker either way.
|
||||
// Otherwise the path could never be loaded again.
|
||||
if generation != this.ref_generation {
|
||||
this.loading_files.remove(&path);
|
||||
return;
|
||||
}
|
||||
this.loading_files.remove(&path);
|
||||
match content {
|
||||
Ok(kind) => {
|
||||
if let FileContent::Text(text) = &kind {
|
||||
if is_markdown_path(&path) {
|
||||
let same = this.md.as_ref().map(|md| md.path.as_deref())
|
||||
== Some(Some(path.as_str()));
|
||||
if !same {
|
||||
this.set_markdown(Some(path.clone().into()), text, cx);
|
||||
}
|
||||
} else {
|
||||
let same = this.code.as_ref().map(|code| code.path.as_str())
|
||||
== Some(path.as_str());
|
||||
if !same {
|
||||
this.set_code(path.clone().into(), text, window, cx);
|
||||
}
|
||||
}
|
||||
this.preview_bytes += text.len();
|
||||
}
|
||||
this.files.insert(path.clone(), kind);
|
||||
this.file_order.push_back(path);
|
||||
this.evict_previews();
|
||||
}
|
||||
Err(error) => {
|
||||
this.files
|
||||
.insert(path, FileContent::Failed(error.to_string()));
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Drop the cached preview, editor and commit state of `path`.
|
||||
pub(super) fn drop_preview_of(&mut self, path: &str) {
|
||||
if let Some(FileContent::Text(text)) = self.files.remove(path) {
|
||||
self.preview_bytes -= text.len();
|
||||
}
|
||||
self.commits.remove(path);
|
||||
if self.selected_file.as_deref() == Some(path) {
|
||||
self.selected_file = None;
|
||||
}
|
||||
if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) {
|
||||
self.md = None;
|
||||
}
|
||||
if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) {
|
||||
self.code = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the oldest previews beyond the cache caps.
|
||||
/// Keep the currently selected file.
|
||||
/// An evicted file's parsed editor state drops with its entry.
|
||||
/// Re-opening it re-parses on a background task.
|
||||
fn evict_previews(&mut self) {
|
||||
while (self.files.len() > MAX_PREVIEWED_FILES
|
||||
|| self.preview_bytes > MAX_PREVIEW_CACHE_BYTES)
|
||||
&& self.file_order.len() > 1
|
||||
{
|
||||
let path = self.file_order.pop_front().expect("non-empty");
|
||||
if Some(path.as_str()) == self.selected_file.as_deref() {
|
||||
self.file_order.push_back(path);
|
||||
continue;
|
||||
}
|
||||
if let Some(FileContent::Text(text)) = self.files.remove(&path) {
|
||||
self.preview_bytes -= text.len();
|
||||
}
|
||||
if self.md.as_ref().map(|md| md.path.as_deref()) == Some(Some(path.as_str())) {
|
||||
self.md = None;
|
||||
}
|
||||
if self
|
||||
.code
|
||||
.as_ref()
|
||||
.is_some_and(|code| code.path.as_ref() == path.as_str())
|
||||
{
|
||||
self.code = None;
|
||||
}
|
||||
self.commits.remove(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{Anchor, AnyElement, ClipboardItem, Context, SharedString, div, px, relative};
|
||||
use gpui_base::{Button as BaseButton, Disableable, Popover};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::combobox::Combobox;
|
||||
use gpui_component::menu::DropdownMenu;
|
||||
use gpui_component::{
|
||||
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex,
|
||||
};
|
||||
use nostr::prelude::{RelayUrl, ToBech32};
|
||||
use signed_core::Announcement;
|
||||
use signed_state::{Backend, ProfileStore, RepoListStore};
|
||||
use signed_ui::{CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row};
|
||||
|
||||
use super::{RepoAction, RepoDetailView};
|
||||
use crate::views::issues::open_new_issue_dialog;
|
||||
use crate::views::pull_requests::new::open_new_pull_panel;
|
||||
use crate::views::repo::about::open_about_dialog;
|
||||
use crate::views::repo::helpers::{ShareTargets, ref_selector_trigger};
|
||||
use crate::views::send_patch::open_send_patch_panel;
|
||||
|
||||
impl RepoDetailView {
|
||||
/// The NIP-34 header, actions and issues/PR counts.
|
||||
/// Or the local header with an Init button for an unpublished repository.
|
||||
pub(super) fn render_header(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.local_path.is_some() {
|
||||
return self.render_local_header(cx);
|
||||
}
|
||||
|
||||
let Some(store_entity) = self.store.as_ref() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let store = store_entity.read(cx);
|
||||
let issue_count = SharedString::from(store.issue_count().to_string());
|
||||
let pr_count = SharedString::from(store.pull_request_count().to_string());
|
||||
|
||||
// Busy flags are owned by the store; observers re-render on their changes.
|
||||
let pushing = store.pushing;
|
||||
let cloning = store.cloning;
|
||||
|
||||
let Some(source) = store.announcement.as_ref().or(self.initial.as_ref()) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
// Derived NIP-34 header data, share targets and clone commands.
|
||||
// Rebuilt per frame: two bech32 encodes and a couple of format strings.
|
||||
let nip05 = ProfileStore::global(cx)
|
||||
.read(cx)
|
||||
.get(&source.owner)
|
||||
.metadata()
|
||||
.nip05
|
||||
.clone()
|
||||
.filter(|nip05| !nip05.trim().is_empty());
|
||||
|
||||
let announcement = Rc::new(source.clone());
|
||||
let share = Rc::new(ShareTargets::from_announcement(&announcement));
|
||||
|
||||
let nostr_url = nostr_clone_url(&announcement, nip05.as_deref());
|
||||
let ngit_command = SharedString::from(format!("git clone {nostr_url}"));
|
||||
let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
|
||||
let git_commands = Rc::new(announcement.clone_urls());
|
||||
|
||||
let name = self.display_name(cx);
|
||||
let description = announcement.description();
|
||||
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
|
||||
|
||||
v_flex()
|
||||
.on_action(
|
||||
cx.listener(|this, action: &RepoAction, window, cx| match action {
|
||||
RepoAction::NewIssue => {
|
||||
if let Some(store) = this.store.clone() {
|
||||
open_new_issue_dialog(store, window, cx);
|
||||
}
|
||||
}
|
||||
RepoAction::NewPR => {
|
||||
if let Some(store) = this.store.clone() {
|
||||
open_new_pull_panel(this.dock_area.clone(), store, window, cx);
|
||||
}
|
||||
}
|
||||
RepoAction::SendPatch => {
|
||||
if let Some(store) = this.store.clone() {
|
||||
open_send_patch_panel(this.dock_area.clone(), store, window, cx);
|
||||
}
|
||||
}
|
||||
RepoAction::About => {
|
||||
if let Some(announcement) = this.announcement(cx) {
|
||||
open_about_dialog(announcement.clone(), window, cx);
|
||||
}
|
||||
}
|
||||
RepoAction::Push => this.push_repository(window, cx),
|
||||
RepoAction::Delete => this.delete_repository(window, cx),
|
||||
}),
|
||||
)
|
||||
.p_4()
|
||||
.w_full()
|
||||
.gap_8()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_4()
|
||||
.items_start()
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.min_h_8()
|
||||
.font_semibold()
|
||||
.child(avatar.size_6())
|
||||
.child(name),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.line_clamp(2)
|
||||
.line_height(relative(1.25))
|
||||
.text_ellipsis()
|
||||
.child(description),
|
||||
)
|
||||
.when_some(fork_row(&announcement, cx), |this, row| this.child(row))
|
||||
.child(
|
||||
h_flex()
|
||||
.mt_2()
|
||||
.w_full()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.font_semibold()
|
||||
.child("Maintainers:"),
|
||||
)
|
||||
.child(self.render_maintainers(cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.gap_2()
|
||||
.justify_end()
|
||||
.child(
|
||||
DropdownButton::new("issues")
|
||||
.action(
|
||||
BaseButton::new("issues-open")
|
||||
.child(
|
||||
h_flex()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().secondary)
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().secondary_hover)
|
||||
})
|
||||
.text_sm()
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
.child(Icon::new(CustomIconName::GitIssueDone))
|
||||
.child("Issues")
|
||||
.child(
|
||||
div()
|
||||
.mx_1()
|
||||
.h_5()
|
||||
.w_px()
|
||||
.bg(cx.theme().border.darken(0.1)),
|
||||
)
|
||||
.child(issue_count),
|
||||
)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.open_issue_detail(window, cx);
|
||||
})),
|
||||
)
|
||||
.dropdown_menu(|menu, _, _| {
|
||||
menu.menu_element(Box::new(RepoAction::NewIssue), |_, _| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(Icon::new(IconName::Plus))
|
||||
.child("New issue")
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
DropdownButton::new("prs")
|
||||
.action(
|
||||
BaseButton::new("prs-open")
|
||||
.child(
|
||||
h_flex()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().secondary)
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().secondary_hover)
|
||||
})
|
||||
.text_sm()
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
.child(Icon::new(
|
||||
CustomIconName::GitPullRequest,
|
||||
))
|
||||
.child("Pull Requests")
|
||||
.child(
|
||||
div()
|
||||
.mx_1()
|
||||
.h_5()
|
||||
.w_px()
|
||||
.bg(cx.theme().border.darken(0.1)),
|
||||
)
|
||||
.child(pr_count),
|
||||
)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.open_pull_request_detail(window, cx);
|
||||
})),
|
||||
)
|
||||
.dropdown_menu(|menu, _, _| {
|
||||
menu.menu_element(Box::new(RepoAction::NewPR), |_, _| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(Icon::new(IconName::Plus))
|
||||
.child("New Pull Request")
|
||||
})
|
||||
.menu_element(
|
||||
Box::new(RepoAction::SendPatch),
|
||||
|_, _| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(Icon::new(IconName::File))
|
||||
.child("Send Patch")
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
DropdownButton::new("share")
|
||||
.action(
|
||||
Button::new("link")
|
||||
.icon(IconName::Copy)
|
||||
.tooltip("Copy ID")
|
||||
.secondary()
|
||||
.on_click({
|
||||
let naddr = share.naddr.clone();
|
||||
move |_, _, cx| {
|
||||
cx.write_to_clipboard(
|
||||
ClipboardItem::new_string(naddr.clone()),
|
||||
);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.dropdown_menu(move |menu, _, _| share.menu(menu)),
|
||||
)
|
||||
.child(
|
||||
Button::new("repo-menu-open")
|
||||
.icon(IconName::EllipsisVertical)
|
||||
.tooltip("Repository management")
|
||||
.compact()
|
||||
.secondary()
|
||||
.loading(pushing)
|
||||
.disabled(pushing)
|
||||
.dropdown_menu(move |menu, _, cx| {
|
||||
let backend = Backend::global(cx);
|
||||
let current_user = backend.read(cx).current_user();
|
||||
let owner = current_user == Some(announcement.owner);
|
||||
|
||||
let menu = menu.menu_element(
|
||||
Box::new(RepoAction::About),
|
||||
|_, _| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(Icon::new(IconName::Info))
|
||||
.child("About")
|
||||
},
|
||||
);
|
||||
|
||||
if owner {
|
||||
menu.menu_element(Box::new(RepoAction::Push), |_, _| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(Icon::new(CustomIconName::Init))
|
||||
.child("Republish")
|
||||
})
|
||||
.separator()
|
||||
.menu_element(Box::new(RepoAction::Delete), |_, cx| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(Icon::new(IconName::Delete))
|
||||
.child("Delete")
|
||||
})
|
||||
} else {
|
||||
menu
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child({
|
||||
let view = cx.entity();
|
||||
let ngit_command = ngit_command.clone();
|
||||
let nak_command = nak_command.clone();
|
||||
let git_commands = git_commands.clone();
|
||||
|
||||
Popover::new("clone")
|
||||
.anchor(Anchor::TopRight)
|
||||
.trigger(
|
||||
Button::new("clone")
|
||||
.icon(CustomIconName::GitClone)
|
||||
.tooltip("Clone")
|
||||
.loading(cloning)
|
||||
.disabled(cloning)
|
||||
.primary(),
|
||||
)
|
||||
.content(move |_, _window, cx| {
|
||||
let state = cx.entity();
|
||||
let ngit_row = copy_row("copy-ngit", &ngit_command, cx);
|
||||
let nak_row = copy_row("copy-nak", &nak_command, cx);
|
||||
|
||||
v_flex()
|
||||
.w(px(440.))
|
||||
.mt_1()
|
||||
.p_3()
|
||||
.gap_4()
|
||||
.popover_style(cx)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Clone with ngit"),
|
||||
)
|
||||
.child(ngit_row),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Clone with nak"),
|
||||
)
|
||||
.child(nak_row),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Grasp Servers"),
|
||||
)
|
||||
.when(!git_commands.is_empty(), |this| {
|
||||
this.children(
|
||||
git_commands.iter().enumerate().map(
|
||||
|(ix, cmd)| {
|
||||
copy_row(
|
||||
format!("copy-git-{ix}"),
|
||||
cmd,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
.when(git_commands.is_empty(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.child("No git clone urls."),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(div().h_px().w_full().bg(cx.theme().border))
|
||||
.child(
|
||||
h_flex().gap_1().justify_end().child(
|
||||
Button::new("download")
|
||||
.icon(CustomIconName::GitClone)
|
||||
.label("Download")
|
||||
.primary()
|
||||
.on_click(move |_event, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.dismiss(window, cx);
|
||||
});
|
||||
view.update(cx, |this, cx| {
|
||||
this.clone_to_folder(window, cx);
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(self.render_header_tabs(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Header for a local, not yet published, repository.
|
||||
/// The directory name and path with an Init button instead of the NIP-34 actions.
|
||||
fn render_local_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let name = self.display_name(cx);
|
||||
let path = self
|
||||
.local_path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_default();
|
||||
let avatar = PixelAvatar::new(path.clone());
|
||||
|
||||
v_flex()
|
||||
.px_4()
|
||||
.pb_4()
|
||||
.w_full()
|
||||
.gap_8()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_4()
|
||||
.items_start()
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.min_h_8()
|
||||
.font_semibold()
|
||||
.child(avatar.size_6())
|
||||
.child(name),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.line_clamp(2)
|
||||
.line_height(relative(1.25))
|
||||
.text_ellipsis()
|
||||
.child(path),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("init")
|
||||
.icon(CustomIconName::Init)
|
||||
.label("Initialize on Nostr")
|
||||
.primary()
|
||||
.tooltip("Publish this repository to Nostr")
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.open_init_dialog(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(self.render_header_tabs(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The tab row shared by both header variants.
|
||||
/// Files and Commits tabs, the HEAD commit button and the branch/tag selectors.
|
||||
fn render_header_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let commits_count = self.all_commits.as_ref().map(|list| list.total);
|
||||
let worktree_empty = self.switching_ref || self.worktree.is_none();
|
||||
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
BaseButton::new("files-tab")
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(Icon::new(CustomIconName::GitFile).small())
|
||||
.child("Files"),
|
||||
)
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
.active(|this| this.bg(cx.theme().button_active))
|
||||
.selected(self.active_tab == 0)
|
||||
.when(self.active_tab == 0, |this| {
|
||||
this.bg(cx.theme().button_active)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.active_tab = 0;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
BaseButton::new("commits-tab")
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(Icon::new(CustomIconName::GitCommit).small())
|
||||
.child("Commits"),
|
||||
)
|
||||
.when_some(commits_count, |this, count| {
|
||||
this.child(CountBadge::new(count))
|
||||
})
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
.active(|this| this.bg(cx.theme().button_active))
|
||||
.selected(self.active_tab == 1)
|
||||
.when(self.active_tab == 1, |this| {
|
||||
this.bg(cx.theme().button_active)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.active_tab = 1;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.justify_end()
|
||||
.child(
|
||||
Button::new("enc")
|
||||
.ghost()
|
||||
.when_some(self.head_commit.as_ref(), |this, commit| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(&commit.id)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.max_w(px(200.))
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.text_xs()
|
||||
.child(SharedString::from(&commit.summary)),
|
||||
)
|
||||
})
|
||||
.tooltip(
|
||||
self.head_commit
|
||||
.as_ref()
|
||||
.map_or_else(SharedString::default, |commit| {
|
||||
commit.summary.clone().into()
|
||||
}),
|
||||
)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
if let Some(commit) = &this.head_commit {
|
||||
let id = commit.id.clone();
|
||||
this.open_commit_diff(&id, window, cx);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div().w(px(120.)).child(
|
||||
Combobox::new(&self.branch_select)
|
||||
.placeholder("Branch")
|
||||
.appearance(false)
|
||||
.menu_width(px(200.))
|
||||
.disabled(worktree_empty)
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.render_trigger(|ctx, _window, cx| {
|
||||
ref_selector_trigger(ctx, CustomIconName::GitBranch, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().w(px(120.)).child(
|
||||
Combobox::new(&self.tag_select)
|
||||
.placeholder("Tag")
|
||||
.appearance(false)
|
||||
.menu_width(px(200.))
|
||||
.disabled(worktree_empty)
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.render_trigger(|ctx, _window, cx| {
|
||||
ref_selector_trigger(ctx, CustomIconName::Tag, cx)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_maintainers(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(announcement) = self.announcement(cx) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let rest: Vec<_> = announcement
|
||||
.maintainers
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| key != &announcement.owner && seen.insert(*key))
|
||||
.collect();
|
||||
|
||||
let owner = profile_store.read(cx).get(&announcement.owner);
|
||||
let owner_name = owner.name();
|
||||
let owner_picture = owner.picture();
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.child(
|
||||
Button::new("maintainers").compact().ghost().child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(UserAvatar::new(owner_name.clone()).picture(owner_picture))
|
||||
.child(div().text_xs().whitespace_nowrap().child(owner_name)),
|
||||
)
|
||||
.when(!rest.is_empty(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(format!("+{}", rest.len()))),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `nostr://...` clone URL of an announcement, NIP-34.
|
||||
fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString {
|
||||
let owner = announcement.owner;
|
||||
let user = nip05
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| owner.to_bech32().unwrap());
|
||||
|
||||
let mut url = format!("nostr://{user}");
|
||||
if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) {
|
||||
url.push('/');
|
||||
url.push_str(hint);
|
||||
}
|
||||
url.push('/');
|
||||
url.push_str(&announcement.id);
|
||||
|
||||
SharedString::from(url)
|
||||
}
|
||||
|
||||
/// The forked-from row of the detail header.
|
||||
///
|
||||
/// Clickable link to the upstream repository when the `u` tag references a NIP-34 repo.
|
||||
/// Plain text when it only carries a git URL.
|
||||
fn fork_row(announcement: &Announcement, cx: &mut Context<RepoDetailView>) -> Option<AnyElement> {
|
||||
let upstream = announcement.upstream.as_ref()?;
|
||||
|
||||
let (label, clickable) = match &upstream.addr {
|
||||
Some(addr) => {
|
||||
// Prefer the upstream's display name when its announcement is known locally.
|
||||
// Fall back to its repository id otherwise.
|
||||
let name = RepoListStore::global(cx)
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|a| a.addr() == *addr)
|
||||
.map(|a| {
|
||||
a.name
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
||||
})
|
||||
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
|
||||
(SharedString::from(format!("Forked from {name}")), true)
|
||||
}
|
||||
None => (SharedString::from(upstream.display().as_str()), false),
|
||||
};
|
||||
|
||||
let row = h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Icon::new(CustomIconName::GitBranch).small())
|
||||
.child(div().whitespace_nowrap().text_ellipsis().child(label));
|
||||
|
||||
Some(if clickable {
|
||||
row.id("fork-upstream")
|
||||
.cursor_pointer()
|
||||
.hover(|this| this.text_color(cx.theme().foreground))
|
||||
.on_click(cx.listener(|this, _ev, window, cx| this.open_upstream(window, cx)))
|
||||
.into_any_element()
|
||||
} else {
|
||||
row.into_any_element()
|
||||
})
|
||||
}
|
||||
+105
-30
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Entity, SharedString, div, px};
|
||||
use gpui::{AnyElement, App, Entity, SharedString, Window, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::combobox::{Caret, ComboboxTriggerContext};
|
||||
use gpui_component::input::{Textarea, TextareaState};
|
||||
@@ -15,12 +15,12 @@ use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
||||
use nostr::nips::nip19::{Nip19Coordinate, ToBech32};
|
||||
use nostr::prelude::{Event, EventId, PublicKey};
|
||||
use signed_core::Announcement;
|
||||
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff};
|
||||
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileCommit, FileDiff};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{UserAvatar, menu_copy_row, middle_truncate};
|
||||
use utils::relative_time;
|
||||
use utils::{relative_time, relative_time_secs};
|
||||
|
||||
pub(super) struct TreeItemSeed {
|
||||
pub(crate) struct TreeItemSeed {
|
||||
/// Path of the node, relative to the worktree root.
|
||||
id: String,
|
||||
/// File or directory name.
|
||||
@@ -28,7 +28,7 @@ pub(super) struct TreeItemSeed {
|
||||
children: Vec<TreeItemSeed>,
|
||||
}
|
||||
|
||||
pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
|
||||
pub(crate) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
|
||||
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
|
||||
let mut item = TreeItem::new(seed.id, seed.label);
|
||||
if expand_folders && !seed.children.is_empty() {
|
||||
@@ -49,7 +49,7 @@ pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<
|
||||
}
|
||||
|
||||
/// Build nested tree items from a flat entry list sorted dirs-first.
|
||||
pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||
pub(crate) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||
// Node indices by full path, so parents resolve in constant time while inserting.
|
||||
let mut index: HashMap<String, usize> = HashMap::new();
|
||||
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
|
||||
@@ -93,8 +93,21 @@ pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||
roots.iter().map(|root| assemble(*root, &nodes)).collect()
|
||||
}
|
||||
|
||||
/// Sorted relative paths of a worktree snapshot.
|
||||
///
|
||||
/// Compared against the `worktree_paths` of a repository panel to skip
|
||||
/// rebuilding the explorer when a refresh left the worktree unchanged.
|
||||
pub(crate) fn sorted_worktree_paths(entries: &[PathBuf]) -> Vec<String> {
|
||||
let mut paths: Vec<String> = entries
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
|
||||
/// The markdown fence language for a file path, or `None` for plain text.
|
||||
pub(super) fn code_language(path: &str) -> Option<&'static str> {
|
||||
pub(crate) fn code_language(path: &str) -> Option<&'static str> {
|
||||
let name = Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
@@ -145,7 +158,7 @@ pub(super) fn code_language(path: &str) -> Option<&'static str> {
|
||||
}
|
||||
|
||||
/// Whether a file path has a markdown extension.
|
||||
pub(super) fn is_markdown_path(path: &str) -> bool {
|
||||
pub(crate) fn is_markdown_path(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
@@ -157,21 +170,21 @@ pub(super) fn is_markdown_path(path: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) struct ShareTargets {
|
||||
pub(crate) struct ShareTargets {
|
||||
/// NIP-19 `naddr1...` of the announcement, with its announced relays.
|
||||
pub(super) naddr: String,
|
||||
pub(crate) naddr: String,
|
||||
/// Hex ID of the announcement event itself.
|
||||
pub(super) event_id: String,
|
||||
pub(crate) event_id: String,
|
||||
/// NIP-34 coordinate `30617:<pubkey>:<repo-id>`.
|
||||
pub(super) coordinate: String,
|
||||
pub(crate) coordinate: String,
|
||||
/// `https://gitworkshop.dev/<naddr>`
|
||||
pub(super) gitworkshop: String,
|
||||
pub(crate) gitworkshop: String,
|
||||
/// `https://ditto.pub/<naddr>`
|
||||
pub(super) ditto: String,
|
||||
pub(crate) ditto: String,
|
||||
}
|
||||
|
||||
impl ShareTargets {
|
||||
pub(super) fn from_announcement(announcement: &Announcement) -> Self {
|
||||
pub(crate) fn from_announcement(announcement: &Announcement) -> Self {
|
||||
let addr = announcement.addr();
|
||||
let coordinate = addr.to_string();
|
||||
let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned())
|
||||
@@ -190,7 +203,7 @@ impl ShareTargets {
|
||||
/// The share dropdown menu, one row per target.
|
||||
///
|
||||
/// Each shows a compact label, the copy button and row click copy the full value.
|
||||
pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu {
|
||||
pub(crate) fn menu(&self, menu: PopupMenu) -> PopupMenu {
|
||||
menu.min_w(px(340.))
|
||||
.item(menu_copy_row(
|
||||
"copy-gitworkshop",
|
||||
@@ -231,15 +244,15 @@ fn truncate_naddr_link(url: &str, tail: usize) -> String {
|
||||
}
|
||||
|
||||
/// Width of one line-number gutter in a diff row.
|
||||
pub(super) const GUTTER_WIDTH: f32 = 44.;
|
||||
pub(crate) const GUTTER_WIDTH: f32 = 44.;
|
||||
/// Height of one row in a virtual diff list.
|
||||
pub(super) const DIFF_ROW_HEIGHT: f32 = 20.;
|
||||
pub(crate) const DIFF_ROW_HEIGHT: f32 = 20.;
|
||||
|
||||
/// One row of a virtual diff list, a hunk header or a line of a hunk.
|
||||
///
|
||||
/// Shared by the commit diff and pull request diff viewers.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum DiffRow {
|
||||
pub(crate) enum DiffRow {
|
||||
Hunk {
|
||||
old_start: u32,
|
||||
old_lines: u32,
|
||||
@@ -251,7 +264,7 @@ pub(super) enum DiffRow {
|
||||
}
|
||||
|
||||
/// The rows of `file`'s diff, one header row per hunk then its lines.
|
||||
pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||
pub(crate) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||
let mut rows = Vec::new();
|
||||
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
|
||||
rows.push(DiffRow::Hunk {
|
||||
@@ -269,7 +282,7 @@ pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||
}
|
||||
|
||||
/// One row of the virtual diff list, a hunk header or a single line.
|
||||
pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
|
||||
pub(crate) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
|
||||
match row {
|
||||
DiffRow::Hunk {
|
||||
old_start,
|
||||
@@ -298,7 +311,7 @@ pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> Any
|
||||
/// One diff line, old and new line numbers in the gutters.
|
||||
///
|
||||
/// The content is tinted by kind, addition, deletion or context.
|
||||
pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||
pub(crate) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||
let bg = match line.kind {
|
||||
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
|
||||
DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)),
|
||||
@@ -346,7 +359,7 @@ pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||
}
|
||||
|
||||
/// Find a tree item by id, searching into nested children.
|
||||
pub(super) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> {
|
||||
pub(crate) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> {
|
||||
let id = id?;
|
||||
items.iter().find_map(|item| {
|
||||
if item.id.as_ref() == id {
|
||||
@@ -358,12 +371,12 @@ pub(super) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&
|
||||
}
|
||||
|
||||
/// The root issue events of a repo store, for the shared detail sections.
|
||||
pub(super) fn issue_roots(store: &RepoStore) -> &[Event] {
|
||||
pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.issues
|
||||
}
|
||||
|
||||
/// The root pull request events of a repo store, for the shared detail sections.
|
||||
pub(super) fn pr_roots(store: &RepoStore) -> &[Event] {
|
||||
pub(crate) fn pr_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.pull_requests
|
||||
}
|
||||
|
||||
@@ -372,7 +385,7 @@ pub(super) fn pr_roots(store: &RepoStore) -> &[Event] {
|
||||
/// The kind icon, the selection or placeholder, and the caret.
|
||||
/// `Combobox` replaces its default trigger entirely,
|
||||
/// the only way to show an icon inside it.
|
||||
pub(super) fn ref_selector_trigger(
|
||||
pub(crate) fn ref_selector_trigger(
|
||||
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
|
||||
icon: CustomIconName,
|
||||
cx: &App,
|
||||
@@ -406,7 +419,7 @@ pub(super) fn ref_selector_trigger(
|
||||
}
|
||||
|
||||
/// Section heading of a detail sidebar, shared by the issue and PR panels.
|
||||
pub(super) fn sidebar_title(text: &str, cx: &App) -> AnyElement {
|
||||
pub(crate) fn sidebar_title(text: &str, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
@@ -416,7 +429,7 @@ pub(super) fn sidebar_title(text: &str, cx: &App) -> AnyElement {
|
||||
}
|
||||
|
||||
/// Right sidebar with participants and labels of a root event, issue or PR.
|
||||
pub(super) fn sidebar_section(
|
||||
pub(crate) fn sidebar_section(
|
||||
store: &Entity<RepoStore>,
|
||||
id: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
@@ -499,7 +512,7 @@ pub(super) fn sidebar_section(
|
||||
}
|
||||
|
||||
/// The comments on a root event, issue or PR, one card per comment.
|
||||
pub(super) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &App) -> AnyElement {
|
||||
pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &App) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let comments: Vec<&Event> = store.comments_of(&root).collect();
|
||||
let title = SharedString::from(format!("Discussions {}", comments.len()));
|
||||
@@ -549,7 +562,7 @@ pub(super) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &Ap
|
||||
/// The comment form posting to an issue or PR root event.
|
||||
///
|
||||
/// `roots` selects the root's list within the store, issues or pull requests.
|
||||
pub(super) fn comment_form(
|
||||
pub(crate) fn comment_form(
|
||||
store: &Entity<RepoStore>,
|
||||
root: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
@@ -608,6 +621,68 @@ pub(super) fn comment_form(
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Height of one commit row in a commit virtual list.
|
||||
pub(crate) const COMMIT_ROW_HEIGHT: f32 = 56.;
|
||||
|
||||
/// One commit row of a virtual list, shared by the commits tab and the
|
||||
/// new-pull-request commit picker.
|
||||
pub(crate) fn commit_row(
|
||||
ix: usize,
|
||||
commit: &FileCommit,
|
||||
on_click: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_4()
|
||||
.h(px(COMMIT_ROW_HEIGHT))
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.border_b(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.justify_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.author.clone())
|
||||
.child(relative_time_secs(commit.time)),
|
||||
),
|
||||
)
|
||||
.on_click(move |_event, window, cx| on_click(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -0,0 +1,237 @@
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::Error;
|
||||
use dock::{add_center_panel, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, Context, Window, div, px, size};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{ActiveTheme, Sizable, v_flex, v_virtual_list};
|
||||
use signed_ui::placeholder;
|
||||
|
||||
use super::RepoDetailView;
|
||||
use crate::views::commit_diff::CommitDiffView;
|
||||
use crate::views::repo::helpers::{COMMIT_ROW_HEIGHT, commit_row};
|
||||
|
||||
impl RepoDetailView {
|
||||
pub(super) fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(list) = self.all_commits.as_ref() else {
|
||||
return if self.loading_all_commits {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element()
|
||||
} else {
|
||||
placeholder("Failed to load commits", cx)
|
||||
};
|
||||
};
|
||||
|
||||
if list.commits.is_empty() {
|
||||
return placeholder("No commits found", cx);
|
||||
}
|
||||
|
||||
// Copy only the values the element tree needs.
|
||||
// The list is borrowed by the renderer below instead of cloned per frame.
|
||||
// A full history can be tens of thousands of commits.
|
||||
let view = cx.entity().clone();
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
let shown = list.commits.len();
|
||||
let total = list.total;
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.child(
|
||||
v_virtual_list(view, "commits", sizes, move |this, range, _window, cx| {
|
||||
let view = cx.entity().downgrade();
|
||||
let commits = this
|
||||
.all_commits
|
||||
.as_ref()
|
||||
.map(|list| list.commits.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
range
|
||||
.map(|ix| {
|
||||
let id = commits[ix].id.clone();
|
||||
let view = view.clone();
|
||||
|
||||
commit_row(
|
||||
ix,
|
||||
&commits[ix],
|
||||
move |window, cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| {
|
||||
this.open_commit_diff(&id, window, cx)
|
||||
});
|
||||
}
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.track_scroll(&scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.when(shown < total, |this| {
|
||||
// The history is capped.
|
||||
// Tell the user the list is truncated.
|
||||
this.child(
|
||||
div()
|
||||
.py_2()
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!("Showing {shown} of {total} commits")),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(&self.scroll_handle)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
/// Queue `path` for the per-file commit query.
|
||||
/// Requests are batched into one history walk, see [`Self::load_commits`].
|
||||
pub(super) fn load_commit(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||
if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) {
|
||||
return;
|
||||
}
|
||||
self.pending_commits.push(path.to_string());
|
||||
if !self.loading_commits {
|
||||
self.load_commits(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk history once for every queued path on a background task.
|
||||
/// Cache the latest commit touching each path in [`Self::commits`].
|
||||
/// That feeds the file header in the content column.
|
||||
/// Batching shares one walk across paths queued while the previous walk ran.
|
||||
fn load_commits(&mut self, cx: &mut Context<Self>) {
|
||||
if self.pending_commits.is_empty() || self.loading_commits {
|
||||
return;
|
||||
}
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
self.pending_commits.clear();
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading_commits = true;
|
||||
let paths = std::mem::take(&mut self.pending_commits);
|
||||
let generation = self.ref_generation;
|
||||
|
||||
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let rels: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
|
||||
let result = cx
|
||||
.background_spawn(
|
||||
async move { signed_git::worktree_last_commits(&worktree, &rels) },
|
||||
)
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.loading_commits = false;
|
||||
if generation == this.ref_generation
|
||||
&& let Ok(found) = result
|
||||
{
|
||||
for (path, commit) in found {
|
||||
this.commits
|
||||
.insert(path.to_string_lossy().into_owned(), commit);
|
||||
}
|
||||
}
|
||||
// Paths queued while the walk was in flight start the next batch.
|
||||
// A stale walk, branch switched mid-flight, must not strand them.
|
||||
// This runs under the current generation regardless of the result.
|
||||
if !this.pending_commits.is_empty() {
|
||||
this.load_commits(cx);
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Walk all commits reachable from HEAD on a background task.
|
||||
/// For the Commits tab and its total-count badge.
|
||||
/// [`CommitList`] caps the list, only the newest commits are materialized.
|
||||
pub(super) fn load_all_commits(&mut self, cx: &mut Context<Self>) {
|
||||
if self.loading_all_commits || self.all_commits.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading_all_commits = true;
|
||||
let generation = self.ref_generation;
|
||||
|
||||
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn(async move { signed_git::worktree_all_commits(&worktree) })
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
// A stale walk, branch switched mid-flight, must not leave the flag set.
|
||||
// Otherwise the Commits tab would spin forever.
|
||||
if generation != this.ref_generation {
|
||||
this.loading_all_commits = false;
|
||||
return;
|
||||
}
|
||||
if let Ok(list) = result {
|
||||
let count = list.commits.len();
|
||||
this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
|
||||
this.all_commits = Some(list);
|
||||
}
|
||||
this.loading_all_commits = false;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Open a new panel showing the diff of `commit_id`.
|
||||
pub(super) fn open_commit_diff(
|
||||
&mut self,
|
||||
commit_id: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Same display name as the repo detail panel's title.
|
||||
let repo_name = self.display_name(cx);
|
||||
|
||||
let panel =
|
||||
cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx));
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Error;
|
||||
use gix::Repository;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{Context, Entity, PathPromptOptions, SharedString, Window};
|
||||
use gpui_component::combobox::ComboboxState;
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
use nostr::prelude::Url;
|
||||
use signed_git::FileCommit;
|
||||
use signed_state::GitStore;
|
||||
|
||||
use super::RepoDetailView;
|
||||
use crate::views::repo::helpers::{
|
||||
TreeItemSeed, build_tree_items, sorted_worktree_paths, tree_items,
|
||||
};
|
||||
|
||||
/// Everything loaded from the local clone for the explorer.
|
||||
struct RepoData {
|
||||
tree: Vec<TreeItemSeed>,
|
||||
/// Relative paths of the worktree entries, for [`RepoDetailView::worktree_paths`].
|
||||
entries: Vec<PathBuf>,
|
||||
readme_path: Option<PathBuf>,
|
||||
readme: Option<Vec<u8>>,
|
||||
worktree: Option<PathBuf>,
|
||||
branches: Vec<String>,
|
||||
tags: Vec<String>,
|
||||
current_branch: Option<String>,
|
||||
head_commit: Option<FileCommit>,
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
/// Load the repository and populate the file explorer.
|
||||
///
|
||||
/// A local, not yet published, repository opens straight from disk.
|
||||
/// An announced repository's clone, if any, loads first without touching the network.
|
||||
pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
// Local repositories live on disk at their scan path.
|
||||
// No clone step or network refresh applies here.
|
||||
if let Some(local_path) = self.local_path.clone() {
|
||||
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||
let data = cx
|
||||
.background_spawn(async move {
|
||||
let repo = gix::open(&local_path)?;
|
||||
load_repo_data(&repo)
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match data {
|
||||
Ok(data) => this.apply_repo_data(data, window, cx),
|
||||
Err(error) => this.error = Some(error.to_string().into()),
|
||||
}
|
||||
this.loading = false;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(initial) = self.initial.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let cache = GitStore::global(cx).cache().clone();
|
||||
let addr = initial.addr();
|
||||
let clone_urls: Vec<Url> = initial.clone.clone();
|
||||
|
||||
// Captured before the loads start.
|
||||
// A branch/tag switch bumps the generation, discarding the refresh below.
|
||||
let refresh_generation = self.ref_generation;
|
||||
|
||||
let disk = {
|
||||
let cache = cache.clone();
|
||||
let addr = addr.clone();
|
||||
cx.background_spawn(async move {
|
||||
match cache.open(&addr)? {
|
||||
Some(repo) => Ok(Some(load_repo_data(&repo)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||
let disk = disk.await;
|
||||
let had_clone = matches!(&disk, Ok(Some(_)));
|
||||
|
||||
// No local clone yet, so clone from the network then load.
|
||||
let data = match disk {
|
||||
Ok(Some(data)) => Ok(data),
|
||||
Ok(None) => {
|
||||
let cache = cache.clone();
|
||||
let addr = addr.clone();
|
||||
let clone_urls = clone_urls.clone();
|
||||
cx.background_spawn(async move {
|
||||
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
||||
load_repo_data(&repo)
|
||||
})
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match data {
|
||||
Ok(data) => this.apply_repo_data(data, window, cx),
|
||||
Err(error) => this.error = Some(error.to_string().into()),
|
||||
}
|
||||
this.loading = false;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
// Refresh the clone from the network in the background.
|
||||
// When it completes, update the refs and commit list.
|
||||
// Loads started before a branch/tag switch are discarded via the generation.
|
||||
if !had_clone {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let refresh = {
|
||||
let cache = cache.clone();
|
||||
let addr = addr.clone();
|
||||
cx.background_spawn(async move {
|
||||
let Some(repo) = cache.open(&addr)? else {
|
||||
return Ok::<_, Error>(None);
|
||||
};
|
||||
|
||||
// Best-effort, a fetch failure, e.g. offline, keeps the cached state.
|
||||
// The state is already shown.
|
||||
signed_git::fetch_all(&repo).ok();
|
||||
|
||||
let worktree = repo.workdir().map(Path::to_path_buf);
|
||||
// A fetch never moves a mirror's local branches.
|
||||
// A push landing on the grasp servers would never show up.
|
||||
// That covers own repo pushes from a checkout and updates fetched here.
|
||||
// Fast-forward branches from the remote, like `git pull --ff-only`.
|
||||
// Only the checked-out branch's worktree can change on disk.
|
||||
let moved = match &worktree {
|
||||
Some(worktree) => {
|
||||
signed_git::fast_forward_branches(worktree).unwrap_or(false)
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
|
||||
let (branches, tags) = match &worktree {
|
||||
Some(_) => (
|
||||
signed_git::repo_branches(&repo).unwrap_or_default(),
|
||||
signed_git::repo_tags(&repo).unwrap_or_default(),
|
||||
),
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
|
||||
let current_branch = signed_git::current_branch(&repo).unwrap_or(None);
|
||||
let head_commit = signed_git::head_commit(&repo).unwrap_or(None);
|
||||
|
||||
Ok::<_, Error>(Some((moved, branches, tags, current_branch, head_commit)))
|
||||
})
|
||||
}
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
if refresh_generation != this.ref_generation {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh {
|
||||
let branches: Vec<SharedString> = branches.iter().map(Into::into).collect();
|
||||
let tags: Vec<SharedString> = tags.iter().map(Into::into).collect();
|
||||
|
||||
let branches_changed = Self::sync_ref_selector(
|
||||
&this.branch_select,
|
||||
&mut this.ref_branches,
|
||||
branches,
|
||||
current_branch.map(Into::into),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let tags_changed = Self::sync_ref_selector(
|
||||
&this.tag_select,
|
||||
&mut this.ref_tags,
|
||||
tags,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
let new_head_commit = head_commit.as_ref().map(|c| &c.id);
|
||||
let current_head_commit = this.head_commit.as_ref().map(|c| &c.id);
|
||||
let head_changed = new_head_commit != current_head_commit;
|
||||
this.head_commit = head_commit;
|
||||
|
||||
log::debug!(
|
||||
"repo detail refresh reconcile: branches_changed={branches_changed} tags_changed={tags_changed} head_changed={head_changed} moved={moved}"
|
||||
);
|
||||
|
||||
// Only a moved HEAD invalidates the commit list.
|
||||
// Leaving an in-flight walk alone when HEAD did not move
|
||||
// keeps a refresh that learned nothing new from flashing
|
||||
// the commits tab.
|
||||
if head_changed {
|
||||
this.all_commits = None;
|
||||
this.loading_all_commits = false;
|
||||
this.load_all_commits(cx);
|
||||
}
|
||||
|
||||
// A fast-forward may touch a branch that is not checked out.
|
||||
// `catch_up_worktree` no-ops when the tree is unchanged and
|
||||
// re-renders only when it actually rebuilt something.
|
||||
if moved {
|
||||
this.catch_up_worktree(cx);
|
||||
}
|
||||
|
||||
if branches_changed || tags_changed || head_changed {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Apply the loaded repository data.
|
||||
fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context<Self>) {
|
||||
log::debug!("repo detail: apply_repo_data");
|
||||
let RepoData {
|
||||
tree,
|
||||
entries,
|
||||
readme_path,
|
||||
readme,
|
||||
worktree,
|
||||
branches,
|
||||
tags,
|
||||
current_branch,
|
||||
head_commit,
|
||||
} = data;
|
||||
let Some(worktree) = worktree else {
|
||||
self.error = Some("Repository has no worktree".into());
|
||||
return;
|
||||
};
|
||||
|
||||
self.worktree = Some(worktree);
|
||||
self.head_commit = head_commit;
|
||||
self.worktree_paths = sorted_worktree_paths(&entries);
|
||||
self.tree_state.update(cx, |state, cx| {
|
||||
state.set_items(tree_items(tree, false), cx);
|
||||
});
|
||||
|
||||
// Populate the branch/tag selectors with the local refs.
|
||||
// Select the branch HEAD points to.
|
||||
let branches: Vec<SharedString> = branches.into_iter().map(Into::into).collect();
|
||||
let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect();
|
||||
|
||||
Self::sync_ref_selector(
|
||||
&self.branch_select,
|
||||
&mut self.ref_branches,
|
||||
branches,
|
||||
current_branch.map(Into::into),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::sync_ref_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx);
|
||||
|
||||
self.load_all_commits(cx);
|
||||
|
||||
if let Some((path, bytes)) = readme_path.zip(readme) {
|
||||
self.readme_name = Some(path.to_string_lossy().into());
|
||||
self.load_commit(&path.to_string_lossy(), cx);
|
||||
if let Ok(text) = String::from_utf8(bytes) {
|
||||
self.set_markdown(None, &text, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point a ref selector at `items`, selecting `selected` when given.
|
||||
///
|
||||
/// Updates the items and selection only when they differ from `cached` and
|
||||
/// the current selection. `set_items`/`set_selected_values` notify the
|
||||
/// combobox, which re-renders the header, so skipping the no-op keeps a
|
||||
/// background refresh that learned nothing new from flashing the selectors.
|
||||
/// Returns whether anything was set.
|
||||
fn sync_ref_selector(
|
||||
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
cached: &mut Vec<SharedString>,
|
||||
items: Vec<SharedString>,
|
||||
selected: Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let items_changed = *cached != items;
|
||||
let selection_changed = selected
|
||||
.as_ref()
|
||||
.is_some_and(|value| select.read(cx).selected_value().as_ref() != Some(value));
|
||||
|
||||
if !items_changed && !selection_changed {
|
||||
return false;
|
||||
}
|
||||
|
||||
select.update(cx, |state, cx| {
|
||||
if items_changed {
|
||||
state.set_items(SearchableVec::from(items.clone()), window, cx);
|
||||
}
|
||||
if let Some(value) = selected
|
||||
&& (items_changed || selection_changed)
|
||||
{
|
||||
state.set_selected_values(std::slice::from_ref(&value), window, cx);
|
||||
}
|
||||
});
|
||||
*cached = items;
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Clone the repository into a user-chosen folder outside the cache.
|
||||
pub(super) fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(store) = self.store.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let name = {
|
||||
let Some(announcement) = self.announcement(cx) else {
|
||||
return;
|
||||
};
|
||||
let addr = announcement.addr();
|
||||
// Directory name, the display name falling back to the repo id.
|
||||
// Both are sanitized to a safe single path component.
|
||||
let name = announcement
|
||||
.name
|
||||
.as_ref()
|
||||
.map(|name| name.to_string())
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| addr.identifier.clone());
|
||||
let name = signed_git::sanitize_path_component(&name);
|
||||
if name.is_empty() {
|
||||
"repository".to_owned()
|
||||
} else {
|
||||
name
|
||||
}
|
||||
};
|
||||
|
||||
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Clone".into()),
|
||||
});
|
||||
|
||||
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||
// `Ok(Ok(Some(paths)))` means the user picked a folder.
|
||||
// A cancel or picker failure resolves to anything else.
|
||||
let picked = match prompt.await {
|
||||
Ok(Ok(Some(mut paths))) => paths.pop(),
|
||||
_ => None,
|
||||
};
|
||||
let Some(folder) = picked else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let destination = folder.join(&name);
|
||||
let destination_for_open = destination.clone();
|
||||
|
||||
// The store owns the clone, its busy flag and error reporting.
|
||||
let clone = this.update_in(cx, |_this, _window, cx| {
|
||||
store.update(cx, |store, cx| store.clone_to_folder(destination, cx))
|
||||
})?;
|
||||
|
||||
// Reveal the new clone in the system file manager on success.
|
||||
// Failures already surfaced in the store's error banner.
|
||||
if let Ok(()) = clone.await {
|
||||
this.update_in(cx, |_this, _window, cx| {
|
||||
cx.open_with_system(&destination_for_open);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the worktree state of `repo`, no network.
|
||||
///
|
||||
/// Entries, README, refs and HEAD commit.
|
||||
fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
|
||||
let entries = signed_git::worktree_entries(repo)?;
|
||||
let tree = build_tree_items(&entries);
|
||||
let readme_path = signed_git::find_readme(repo)?;
|
||||
let readme = match &readme_path {
|
||||
Some(path) => signed_git::worktree_read(repo, path)?,
|
||||
None => None,
|
||||
};
|
||||
let worktree = repo.workdir().map(Path::to_path_buf);
|
||||
// Ref listing is auxiliary UI.
|
||||
// A broken ref must not prevent the explorer from loading.
|
||||
// Failures degrade to empty selectors.
|
||||
let (branches, tags, current_branch) = match &worktree {
|
||||
Some(_) => (
|
||||
signed_git::repo_branches(repo).unwrap_or_default(),
|
||||
signed_git::repo_tags(repo).unwrap_or_default(),
|
||||
signed_git::current_branch(repo).unwrap_or(None),
|
||||
),
|
||||
None => (Vec::new(), Vec::new(), None),
|
||||
};
|
||||
let head_commit = signed_git::head_commit(repo).unwrap_or(None);
|
||||
|
||||
Ok(RepoData {
|
||||
tree,
|
||||
entries,
|
||||
readme_path,
|
||||
readme,
|
||||
worktree,
|
||||
branches,
|
||||
tags,
|
||||
current_branch,
|
||||
head_commit,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, Subscription, WeakEntity, Window,
|
||||
};
|
||||
use gpui_component::alert::Alert;
|
||||
use gpui_component::combobox::{ComboboxEvent, ComboboxState};
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
use gpui_component::tree::TreeState;
|
||||
use gpui_component::{VirtualListScrollHandle, h_flex, v_flex};
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
use signed_git::{CommitList, FileCommit};
|
||||
use signed_state::{CheckoutStatus, CheckoutsStore, RepoStore};
|
||||
|
||||
mod about;
|
||||
mod actions;
|
||||
mod banners;
|
||||
mod files;
|
||||
mod header;
|
||||
pub(super) mod helpers;
|
||||
mod history;
|
||||
mod init_dialog;
|
||||
mod loading;
|
||||
mod refs;
|
||||
mod store;
|
||||
|
||||
pub(crate) use actions::{RepoItem, open_repo_item, open_repo_panel};
|
||||
|
||||
use self::files::{CodeView, FileContent, MarkdownView};
|
||||
|
||||
/// What kind of ref the header selectors switch to.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum RefKind {
|
||||
/// A local branch `refs/heads/*`, HEAD stays attached.
|
||||
Branch,
|
||||
/// A tag `refs/tags/*`, HEAD becomes detached.
|
||||
Tag,
|
||||
}
|
||||
|
||||
/// Header actions dispatched by the dropdown menus of the header buttons.
|
||||
/// `pub(crate)` because the pull-request list panel shares this action set.
|
||||
/// It offers the New-PR and Send-patch actions in its own dropdown.
|
||||
#[derive(Clone, Action, PartialEq, Eq)]
|
||||
#[action(namespace = repo, no_json)]
|
||||
pub(crate) enum RepoAction {
|
||||
/// Open the new issue dialog.
|
||||
NewIssue,
|
||||
/// Open the new pull request dialog.
|
||||
NewPR,
|
||||
/// Open the send patch panel.
|
||||
SendPatch,
|
||||
/// Open the about dialog.
|
||||
About,
|
||||
/// Re-push the repository to its grasp servers.
|
||||
Push,
|
||||
/// Delete the repository from nostr, owner only.
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// Detail view of a repository, header, stats and metadata.
|
||||
///
|
||||
/// A file explorer with README preview, cloned from the announcement's `clone` URLs.
|
||||
pub struct RepoDetailView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Dock area the detail view lives in.
|
||||
///
|
||||
/// New panels, commit diffs, are added there.
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// Snapshot taken at open time.
|
||||
///
|
||||
/// `None` for local repositories that haven't been published yet.
|
||||
initial: Option<Announcement>,
|
||||
/// Per-repository nostr store, holding announcement, issues, PRs and statuses.
|
||||
///
|
||||
/// `None` until a local repository is initialized to NIP-34.
|
||||
store: Option<Entity<RepoStore>>,
|
||||
/// Path of the local repository when opened from the scan.
|
||||
///
|
||||
/// `None` once it is initialized to NIP-34, or for announced repositories.
|
||||
local_path: Option<PathBuf>,
|
||||
/// File explorer state, the worktree of the local clone.
|
||||
tree_state: Entity<TreeState>,
|
||||
/// Root of the local clone, for reading files on demand.
|
||||
worktree: Option<PathBuf>,
|
||||
/// Sorted relative paths of the tree currently shown.
|
||||
///
|
||||
/// A background refresh that did not change the tree skips rebuilding it,
|
||||
/// see [`Self::catch_up_worktree`], so a fetch that learned nothing new
|
||||
/// does not flash the explorer.
|
||||
worktree_paths: Vec<String>,
|
||||
/// Markdown document currently in the preview pane, README or a file.
|
||||
md: Option<MarkdownView>,
|
||||
/// Code file currently in the preview pane.
|
||||
code: Option<CodeView>,
|
||||
readme_name: Option<SharedString>,
|
||||
/// Currently previewed file, a relative path, and its contents.
|
||||
selected_file: Option<SharedString>,
|
||||
files: HashMap<String, FileContent>,
|
||||
/// Paths of cached previews, oldest first.
|
||||
/// Feeds the eviction caps in [`Self::evict_previews`].
|
||||
file_order: VecDeque<String>,
|
||||
/// Total text bytes held by [`Self::files`].
|
||||
preview_bytes: usize,
|
||||
/// Reads in flight, to avoid duplicate loads.
|
||||
loading_files: HashSet<String>,
|
||||
/// Latest commit touching a previewed file or the README, keyed by path.
|
||||
commits: HashMap<String, FileCommit>,
|
||||
/// Paths queued for the next batched commit query, see [`Self::load_commits`].
|
||||
pending_commits: Vec<String>,
|
||||
/// A batched commit query is in flight.
|
||||
loading_commits: bool,
|
||||
/// Active header tab, 0 = Files tree, 1 = Commits.
|
||||
active_tab: usize,
|
||||
/// Commits reachable from HEAD, newest first.
|
||||
/// `None` until the walk finishes or fails.
|
||||
/// [`CommitList`] caps the list, `total` feeds the tab badge.
|
||||
all_commits: Option<CommitList>,
|
||||
/// Commit walk in flight.
|
||||
loading_all_commits: bool,
|
||||
/// Virtual list state of the Commits tab.
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// A clone/fetch is in flight.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
/// Commit HEAD currently points to, shown in the header button.
|
||||
head_commit: Option<FileCommit>,
|
||||
/// Branch selector in the header, local branches, searchable.
|
||||
branch_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
/// Tag selector in the header, tags, searchable.
|
||||
tag_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
/// Branch names currently in `branch_select`, for cheap no-op detection.
|
||||
ref_branches: Vec<SharedString>,
|
||||
/// Tag names currently in `tag_select`, for cheap no-op detection.
|
||||
ref_tags: Vec<SharedString>,
|
||||
/// A branch/tag switch is in flight, checkout plus explorer reload.
|
||||
switching_ref: bool,
|
||||
/// Bumped on every branch/tag switch.
|
||||
/// In-flight loads with an older generation are discarded when they complete.
|
||||
ref_generation: u64,
|
||||
/// Subscriptions keeping the selectors' confirm events alive.
|
||||
_subscriptions: Vec<Subscription>,
|
||||
/// `(path, branch)` ready-suggestions dismissed by the user, per panel.
|
||||
banner_dismissed: HashSet<(PathBuf, String)>,
|
||||
/// The announced HEAD the ready statuses were last requested with.
|
||||
/// Whether they were requested at all.
|
||||
/// Re-requested only when the HEAD, the base default, changes.
|
||||
/// e.g. when the store's first refresh lands.
|
||||
ready_requested: bool,
|
||||
ready_head: Option<String>,
|
||||
/// The global checkouts store's ready-to-contribute statuses of this
|
||||
/// repository, last seen when they drove a render.
|
||||
///
|
||||
/// The store notifies on any recompute pass; the observer re-renders this
|
||||
/// panel only when these slices changed.
|
||||
ready_statuses: Vec<CheckoutStatus>,
|
||||
/// The global checkouts store's ready-to-push statuses of this repository,
|
||||
/// last seen when they drove a render.
|
||||
push_statuses: Vec<CheckoutStatus>,
|
||||
/// Upstream repository, from this fork's `u` tag, the user asked to open.
|
||||
/// Its announcement is still being fetched.
|
||||
pending_upstream: Option<RepoAddr>,
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
/// Open a repository announced.
|
||||
///
|
||||
/// The store connects to the announcement's relays and loads issues, PRs and statuses.
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
initial: Announcement,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
// The announcement we opened from already carries the NIP-34 `relays` tag.
|
||||
//
|
||||
// The store connects to those relays immediately, no bootstrap fetch wait.
|
||||
let addr = initial.addr();
|
||||
let relays = initial.relays.clone();
|
||||
let store = cx.new(|cx| RepoStore::new(addr, relays, cx));
|
||||
|
||||
let mut view = Self::new_common(
|
||||
dock_area,
|
||||
Some(initial),
|
||||
Some(store.clone()),
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
view.attach_store(&store, cx);
|
||||
view
|
||||
}
|
||||
|
||||
/// Open a local repository discovered by the scan.
|
||||
pub fn new_local(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
local_path: PathBuf,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self::new_common(dock_area, None, None, Some(local_path), window, cx)
|
||||
}
|
||||
|
||||
/// Shared construction.
|
||||
///
|
||||
/// File explorer state, ref selectors and the deferred repository load.
|
||||
fn new_common(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
initial: Option<Announcement>,
|
||||
store: Option<Entity<RepoStore>>,
|
||||
local_path: Option<PathBuf>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let tree_state = cx.new(|cx| TreeState::new(cx));
|
||||
|
||||
// Empty until the clone completes, then filled with the local refs.
|
||||
let branch_select: Entity<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
SearchableVec::new(Vec::<SharedString>::new()),
|
||||
Vec::new(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.searchable(true)
|
||||
});
|
||||
let tag_select: Entity<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
SearchableVec::new(Vec::<SharedString>::new()),
|
||||
Vec::new(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.searchable(true)
|
||||
});
|
||||
|
||||
let mut subscriptions = vec![
|
||||
cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| {
|
||||
// `Change` fires only when the selection actually changed.
|
||||
// Picking the already-selected branch emits nothing.
|
||||
// A confirmed value always means a switch.
|
||||
if let ComboboxEvent::Change(values) = event
|
||||
&& let Some(name) = values.first()
|
||||
{
|
||||
this.switch_ref(RefKind::Branch, name.clone(), window, cx);
|
||||
}
|
||||
}),
|
||||
cx.subscribe_in(&tag_select, window, |this, _state, event, window, cx| {
|
||||
if let ComboboxEvent::Change(values) = event
|
||||
&& let Some(name) = values.first()
|
||||
{
|
||||
this.switch_ref(RefKind::Tag, name.clone(), window, cx);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
// The ready-to-contribute and ready-to-push banners are driven by the
|
||||
// global checkouts store. It notifies on every recompute; compare the
|
||||
// statuses of this repository so unrelated updates (the sidebar badges,
|
||||
// other open panels) do not re-render this panel.
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
|
||||
if this.refresh_statuses(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
// Defer loading the repository until the window is ready.
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load_repo(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
initial,
|
||||
dock_area,
|
||||
store,
|
||||
local_path,
|
||||
tree_state,
|
||||
worktree: None,
|
||||
worktree_paths: Vec::new(),
|
||||
md: None,
|
||||
code: None,
|
||||
readme_name: None,
|
||||
selected_file: None,
|
||||
files: HashMap::new(),
|
||||
file_order: VecDeque::new(),
|
||||
preview_bytes: 0,
|
||||
loading_files: HashSet::new(),
|
||||
commits: HashMap::new(),
|
||||
pending_commits: Vec::new(),
|
||||
loading_commits: false,
|
||||
active_tab: 0,
|
||||
all_commits: None,
|
||||
loading_all_commits: false,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
loading: true,
|
||||
error: None,
|
||||
head_commit: None,
|
||||
branch_select,
|
||||
tag_select,
|
||||
ref_branches: Vec::new(),
|
||||
ref_tags: Vec::new(),
|
||||
switching_ref: false,
|
||||
ref_generation: 0,
|
||||
banner_dismissed: HashSet::new(),
|
||||
ready_requested: false,
|
||||
ready_head: None,
|
||||
ready_statuses: Vec::new(),
|
||||
push_statuses: Vec::new(),
|
||||
pending_upstream: None,
|
||||
focus_handle: cx.focus_handle(),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// The latest announcement from the store or the open-time snapshot.
|
||||
/// `None` for local repositories that haven't been published yet.
|
||||
fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> {
|
||||
let store = self.store.as_ref()?;
|
||||
store
|
||||
.read(cx)
|
||||
.announcement
|
||||
.as_ref()
|
||||
.or(self.initial.as_ref())
|
||||
}
|
||||
|
||||
/// Display name, the announcement's name or ID for announced repositories.
|
||||
/// The directory name for local ones.
|
||||
fn display_name(&self, cx: &App) -> SharedString {
|
||||
if let Some(path) = &self.local_path {
|
||||
return SharedString::from(
|
||||
path.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.display().to_string()),
|
||||
);
|
||||
}
|
||||
self.announcement(cx)
|
||||
.map(|announcement| {
|
||||
announcement
|
||||
.name
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for RepoDetailView {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"repo"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for RepoDetailView {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
self.display_name(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for RepoDetailView {}
|
||||
|
||||
impl Focusable for RepoDetailView {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RepoDetailView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let tree_state = self.tree_state.clone();
|
||||
let view = cx.entity().downgrade();
|
||||
|
||||
let pane_title = self
|
||||
.selected_file
|
||||
.clone()
|
||||
.or_else(|| self.readme_name.clone())
|
||||
.unwrap_or_else(|| "Overview".into());
|
||||
|
||||
let banner = self
|
||||
.render_ready_banner(cx)
|
||||
.or_else(|| self.render_push_banner(cx));
|
||||
|
||||
// View-level load/switch errors, plus the errors of the store-owned
|
||||
// operations, republish, checkout push, delete and clone-to-folder.
|
||||
let error = self.error.clone().or_else(|| {
|
||||
self.store
|
||||
.as_ref()
|
||||
.and_then(|store| store.read(cx).last_error.clone().map(SharedString::from))
|
||||
});
|
||||
|
||||
v_flex()
|
||||
.image_cache(gpui::retain_all("repo"))
|
||||
.id("repo")
|
||||
.size_full()
|
||||
.when_some(banner, |this, banner| this.child(banner))
|
||||
.when_some(self.render_push_warning_banner(cx), |this, banner| {
|
||||
this.child(banner)
|
||||
})
|
||||
.child(self.render_header(cx))
|
||||
.when_some(error, |this, error| {
|
||||
this.child(
|
||||
Alert::error("repo-error", error)
|
||||
.banner()
|
||||
.on_close(cx.listener(|this, _event, _window, cx| {
|
||||
this.error = None;
|
||||
if let Some(store) = this.store.clone() {
|
||||
store.update(cx, |store, _| store.last_error = None);
|
||||
}
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
})
|
||||
.map(|this| match self.active_tab {
|
||||
0 => this.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.overflow_hidden()
|
||||
.child(Self::render_tree_column(tree_state, view, cx))
|
||||
.child(self.render_content_column(pane_title, cx))
|
||||
.into_any_element(),
|
||||
),
|
||||
_ => this.child(self.render_commits_tab(cx)),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{Context, Entity, SharedString, Window};
|
||||
use gpui_component::combobox::ComboboxState;
|
||||
use gpui_component::searchable_list::SearchableVec;
|
||||
|
||||
use super::{RefKind, RepoDetailView};
|
||||
use crate::views::repo::helpers::{build_tree_items, sorted_worktree_paths, tree_items};
|
||||
|
||||
impl RepoDetailView {
|
||||
/// Check out `name`, a branch or tag picked in the header.
|
||||
/// Refresh the explorer once the switch completes.
|
||||
pub(super) fn switch_ref(
|
||||
&mut self,
|
||||
kind: RefKind,
|
||||
name: SharedString,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.switching_ref {
|
||||
return;
|
||||
}
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Branches and tags are mutually exclusive states of HEAD.
|
||||
// Selecting one clears the other selector.
|
||||
// Remember the previous selections to restore them if the checkout fails.
|
||||
let previous_branch = self.branch_select.read(cx).selected_value();
|
||||
let previous_tag = self.tag_select.read(cx).selected_value();
|
||||
|
||||
match kind {
|
||||
RefKind::Branch => {
|
||||
self.tag_select
|
||||
.update(cx, |state, cx| state.clear_selection(cx));
|
||||
}
|
||||
RefKind::Tag => {
|
||||
self.branch_select
|
||||
.update(cx, |state, cx| state.clear_selection(cx));
|
||||
}
|
||||
}
|
||||
self.switching_ref = true;
|
||||
// In-flight loads of the previous branch are discarded when they complete.
|
||||
self.ref_generation += 1;
|
||||
cx.notify();
|
||||
|
||||
let checkout_name = name.clone();
|
||||
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn(async move {
|
||||
match kind {
|
||||
RefKind::Branch => {
|
||||
signed_git::worktree_checkout_branch(&worktree, &checkout_name)
|
||||
}
|
||||
RefKind::Tag => {
|
||||
signed_git::worktree_checkout_tag(&worktree, &checkout_name)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(()) => this.reload_worktree(cx),
|
||||
Err(error) => {
|
||||
this.error = Some(format!("Failed to check out {name}: {error}").into());
|
||||
this.switching_ref = false;
|
||||
this.restore_selection(&this.branch_select, &previous_branch, window, cx);
|
||||
this.restore_selection(&this.tag_select, &previous_tag, window, cx);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Restore a selector to `previous`, or clear it after a failed switch.
|
||||
fn restore_selection(
|
||||
&self,
|
||||
select: &Entity<ComboboxState<SearchableVec<SharedString>>>,
|
||||
previous: &Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
select.update(cx, |state, cx| match previous {
|
||||
Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx),
|
||||
None => state.clear_selection(cx),
|
||||
});
|
||||
}
|
||||
|
||||
/// Refresh the file explorer, preview pane and commit list after a successful switch.
|
||||
/// The selectors were already updated by [`Self::switch_ref`].
|
||||
/// [`Self::switching_ref`] stays set until this reload finishes.
|
||||
/// A second switch cannot interleave.
|
||||
fn reload_worktree(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn(async move {
|
||||
let snapshot = signed_git::worktree_snapshot(&worktree)?;
|
||||
// Build the tree off the main thread, like [`Self::load_repo`].
|
||||
let tree = build_tree_items(&snapshot.entries);
|
||||
let paths = sorted_worktree_paths(&snapshot.entries);
|
||||
Ok::<_, Error>((snapshot, tree, paths))
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.switching_ref = false;
|
||||
match result {
|
||||
Ok((snapshot, tree, paths)) => {
|
||||
this.head_commit = snapshot.head_commit;
|
||||
this.worktree_paths = paths;
|
||||
// Rebuild the tree from scratch.
|
||||
// Entries of the previous branch are gone.
|
||||
// The expansion state goes with them.
|
||||
this.tree_state.update(cx, |state, cx| {
|
||||
state.set_items(tree_items(tree, false), cx);
|
||||
});
|
||||
|
||||
// Drop cached previews and commits of the old branch.
|
||||
this.selected_file = None;
|
||||
this.files.clear();
|
||||
this.file_order.clear();
|
||||
this.preview_bytes = 0;
|
||||
this.loading_files.clear();
|
||||
this.commits.clear();
|
||||
this.pending_commits.clear();
|
||||
this.loading_commits = false;
|
||||
this.md = None;
|
||||
this.code = None;
|
||||
this.readme_name = None;
|
||||
this.all_commits = None;
|
||||
this.loading_all_commits = false;
|
||||
|
||||
if let Some((path, bytes)) = snapshot.readme_path.zip(snapshot.readme) {
|
||||
this.readme_name = Some(path.to_string_lossy().into());
|
||||
this.load_commit(&path.to_string_lossy(), cx);
|
||||
if let Ok(text) = String::from_utf8(bytes) {
|
||||
this.set_markdown(None, &text, cx);
|
||||
}
|
||||
}
|
||||
this.load_all_commits(cx);
|
||||
}
|
||||
Err(error) => {
|
||||
this.error = Some(error.to_string().into());
|
||||
this.head_commit = None;
|
||||
this.worktree_paths.clear();
|
||||
// The tree may show files that no longer exist.
|
||||
this.tree_state.update(cx, |state, cx| {
|
||||
state.set_items(Vec::new(), cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Refresh the file explorer, previews and commit list after the mirror
|
||||
/// caught up with the remote.
|
||||
///
|
||||
/// The checked-out branch fast-forwarded in place, so unlike
|
||||
/// [`Self::reload_worktree`] this keeps the panel's selection and previews:
|
||||
/// it rebuilds the tree, drops previews of files the refresh removed and
|
||||
/// re-renders the README when it is on screen.
|
||||
pub(super) fn catch_up_worktree(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn(async move {
|
||||
let snapshot = signed_git::worktree_snapshot(&worktree)?;
|
||||
let tree = build_tree_items(&snapshot.entries);
|
||||
let paths = sorted_worktree_paths(&snapshot.entries);
|
||||
Ok::<_, Error>((snapshot, tree, paths))
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
match result {
|
||||
Ok((snapshot, tree, paths)) => {
|
||||
let head_changed = snapshot.head_commit.as_ref().map(|c| &c.id)
|
||||
!= this.head_commit.as_ref().map(|c| &c.id);
|
||||
|
||||
// A fast-forward of a branch other than the checked-out
|
||||
// one leaves the worktree untouched. Rebuilding the tree
|
||||
// and re-parsing the README would flash the panel for
|
||||
// nothing, so it is a no-op.
|
||||
if !head_changed && paths == this.worktree_paths {
|
||||
log::debug!("repo detail catch_up_worktree: no-op");
|
||||
return;
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"repo detail catch_up_worktree: head_changed={head_changed} entries={}",
|
||||
paths.len()
|
||||
);
|
||||
|
||||
this.head_commit = snapshot.head_commit;
|
||||
this.worktree_paths = paths;
|
||||
this.tree_state.update(cx, |state, cx| {
|
||||
state.set_items(tree_items(tree, false), cx);
|
||||
});
|
||||
|
||||
// Drop previews of files the refresh removed from the worktree,
|
||||
// everything else stays put.
|
||||
let present: HashSet<String> = snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
let mut previewed: Vec<String> = Vec::new();
|
||||
previewed.extend(this.files.keys().cloned());
|
||||
previewed.extend(this.selected_file.clone().map(|p| p.to_string()));
|
||||
|
||||
if let Some(path) = this.md.as_ref().and_then(|md| md.path.clone()) {
|
||||
previewed.push(path.to_string());
|
||||
}
|
||||
|
||||
if let Some(path) = this.code.as_ref().map(|code| code.path.clone()) {
|
||||
previewed.push(path.to_string());
|
||||
}
|
||||
|
||||
previewed.sort();
|
||||
previewed.dedup();
|
||||
|
||||
for path in previewed {
|
||||
if !present.contains(&path) {
|
||||
this.drop_preview_of(&path);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render the README when it is on screen, i.e. when no file preview is open.
|
||||
if this.selected_file.is_none() {
|
||||
match snapshot.readme_path.zip(snapshot.readme) {
|
||||
Some((path, bytes)) => {
|
||||
this.readme_name = Some(path.to_string_lossy().into());
|
||||
if let Ok(text) = String::from_utf8(bytes) {
|
||||
this.set_markdown(None, &text, cx);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
this.md = None;
|
||||
this.readme_name = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if head_changed {
|
||||
this.all_commits = None;
|
||||
this.loading_all_commits = false;
|
||||
this.load_all_commits(cx);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
this.error = Some(error.to_string().into());
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{Context, Entity};
|
||||
use signed_core::Announcement;
|
||||
use signed_state::{Backend, CheckoutsStore, LocalReposStore, RepoStore};
|
||||
|
||||
use super::RepoDetailView;
|
||||
|
||||
impl RepoDetailView {
|
||||
/// Switch the repository into its NIP-34 mode after a successful init.
|
||||
/// Creates the nostr store for the announced repository.
|
||||
/// Drops the local scan identity.
|
||||
/// The worktree is unchanged, so the explorer keeps its loaded content.
|
||||
pub(crate) fn apply_announcement(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// The repository is no longer a bare local repo.
|
||||
// Drop it from the scan results so it leaves the sidebar's local section.
|
||||
if let Some(path) = self.local_path.take() {
|
||||
LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx));
|
||||
}
|
||||
let store =
|
||||
cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx));
|
||||
// Re-render on store refreshes, issues, PRs and statuses.
|
||||
// Keep the ready-to-contribute statuses of this repository requested.
|
||||
self.attach_store(&store, cx);
|
||||
self.store = Some(store);
|
||||
self.initial = Some(announcement);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Observe the repository's store, re-render on refreshes.
|
||||
/// Request the ready-to-contribute statuses for it.
|
||||
pub(super) fn attach_store(&mut self, store: &Entity<RepoStore>, cx: &mut Context<Self>) {
|
||||
self._subscriptions
|
||||
.push(cx.observe(store, |this, _store, cx| {
|
||||
log::debug!("repo detail: store notify");
|
||||
this.refresh_ready_statuses(cx);
|
||||
cx.notify();
|
||||
}));
|
||||
self.refresh_ready_statuses(cx);
|
||||
}
|
||||
|
||||
/// Request the statuses of this repository again when the announced HEAD changes.
|
||||
/// The HEAD is the base the checkouts are compared against.
|
||||
/// Owned repositories are watched for unpushed commits.
|
||||
/// Other repositories for ready-to-contribute checkouts.
|
||||
fn refresh_ready_statuses(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(entity) = self.store.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let head = entity.read(cx).head.clone();
|
||||
|
||||
if self.ready_requested && self.ready_head == head {
|
||||
return;
|
||||
}
|
||||
|
||||
self.ready_requested = true;
|
||||
self.ready_head = head.clone();
|
||||
|
||||
let addr = entity.read(cx).addr().clone();
|
||||
let backend = Backend::global(cx);
|
||||
let checkout = CheckoutsStore::global(cx);
|
||||
|
||||
let owned = backend
|
||||
.read(cx)
|
||||
.current_user()
|
||||
.is_some_and(|user| entity.read(cx).is_author(&user));
|
||||
|
||||
checkout.update(cx, |store, cx| {
|
||||
// The ready statuses keep the fast poll running while the panel is open.
|
||||
// The sidebar's push watch alone polls slower.
|
||||
store.request_statuses(&addr, head, cx);
|
||||
|
||||
if owned {
|
||||
store.request_push_statuses(&addr, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The ready-to-push statuses of this repository in the global checkouts
|
||||
/// store changed since they last drove a render.
|
||||
///
|
||||
/// Updates the cached slices. `None` store (a local, not yet published,
|
||||
/// repository) has no statuses.
|
||||
pub(super) fn refresh_statuses(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let Some(entity) = self.store.clone() else {
|
||||
return false;
|
||||
};
|
||||
let addr = entity.read(cx).addr().clone();
|
||||
let checkouts = CheckoutsStore::global(cx).read(cx);
|
||||
let ready_statuses = checkouts.ready_statuses_of(&addr);
|
||||
let push_statuses = checkouts.push_statuses_of(&addr);
|
||||
|
||||
let changed = ready_statuses != self.ready_statuses || push_statuses != self.push_statuses;
|
||||
self.ready_statuses = ready_statuses;
|
||||
self.push_statuses = push_statuses;
|
||||
changed
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Context, Window, div, px};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list};
|
||||
use signed_git::FileCommit;
|
||||
use signed_ui::placeholder;
|
||||
use utils::relative_time_secs;
|
||||
|
||||
use super::RepoDetailView;
|
||||
|
||||
/// Height of one commit row in the virtual list.
|
||||
pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.;
|
||||
|
||||
pub(super) fn commit_row(
|
||||
ix: usize,
|
||||
commit: &FileCommit,
|
||||
on_click: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_4()
|
||||
.h(px(COMMIT_ROW_HEIGHT))
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.border_b(px(1.))
|
||||
.border_color(cx.theme().border)
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.justify_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(commit.author.clone())
|
||||
.child(relative_time_secs(commit.time)),
|
||||
),
|
||||
)
|
||||
.on_click(move |_event, window, cx| on_click(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
pub(super) fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(list) = self.all_commits.as_ref() else {
|
||||
return if self.loading_all_commits {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new().small())
|
||||
.into_any_element()
|
||||
} else {
|
||||
placeholder("Failed to load commits", cx)
|
||||
};
|
||||
};
|
||||
|
||||
if list.commits.is_empty() {
|
||||
return placeholder("No commits found", cx);
|
||||
}
|
||||
|
||||
// Copy only the values the element tree needs.
|
||||
// The list is borrowed by the renderer below instead of cloned per frame.
|
||||
// A full history can be tens of thousands of commits.
|
||||
let view = cx.entity().clone();
|
||||
let sizes = self.item_sizes.clone();
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
let shown = list.commits.len();
|
||||
let total = list.total;
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.child(
|
||||
v_virtual_list(view, "commits", sizes, move |this, range, _window, cx| {
|
||||
let view = cx.entity().downgrade();
|
||||
let commits = this
|
||||
.all_commits
|
||||
.as_ref()
|
||||
.map(|list| list.commits.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
range
|
||||
.map(|ix| {
|
||||
let id = commits[ix].id.clone();
|
||||
let view = view.clone();
|
||||
|
||||
commit_row(
|
||||
ix,
|
||||
&commits[ix],
|
||||
move |window, cx| {
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| {
|
||||
this.open_commit_diff(&id, window, cx)
|
||||
});
|
||||
}
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.track_scroll(&scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.when(shown < total, |this| {
|
||||
// The history is capped.
|
||||
// Tell the user the list is truncated.
|
||||
this.child(
|
||||
div()
|
||||
.py_2()
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!("Showing {shown} of {total} commits")),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(&self.scroll_handle)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
use std::fmt::Display;
|
||||
use std::rc::Rc;
|
||||
|
||||
use assets::CustomIconName;
|
||||
@@ -5,7 +6,7 @@ use dock::{BasePanel, DockArea, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, Subscription, WeakEntity, Window, div, px, size,
|
||||
SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size,
|
||||
};
|
||||
use gpui_component::input::{Input, InputEvent, InputState};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
@@ -38,6 +39,22 @@ enum RepoFilter {
|
||||
Recent,
|
||||
}
|
||||
|
||||
impl AsRef<str> for RepoFilter {
|
||||
fn as_ref(&self) -> &str {
|
||||
match self {
|
||||
RepoFilter::All => "all",
|
||||
RepoFilter::Popular => "popular",
|
||||
RepoFilter::Recent => "recent",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for RepoFilter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl RepoFilter {
|
||||
/// Indices into the store's `announcements` this filter includes, in display order.
|
||||
///
|
||||
@@ -49,6 +66,7 @@ impl RepoFilter {
|
||||
// Narrow by the search query first.
|
||||
// Recent then limits the matches and Popular ranks them.
|
||||
let query = query.trim().to_lowercase();
|
||||
|
||||
if !query.is_empty() {
|
||||
indices.retain(|&ix| {
|
||||
let announcement = &announcements[ix];
|
||||
@@ -125,7 +143,11 @@ impl RepoListView {
|
||||
this.rebuild_rows(cx);
|
||||
});
|
||||
|
||||
let mut this = Self {
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.rebuild_rows(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
store,
|
||||
dock_area,
|
||||
focus_handle: cx.focus_handle(),
|
||||
@@ -137,14 +159,7 @@ impl RepoListView {
|
||||
search,
|
||||
_search_subscription: search_subscription,
|
||||
_subscription: subscription,
|
||||
};
|
||||
|
||||
// Seed the rows right away.
|
||||
// The store may already hold announcements from before the panel opened.
|
||||
// The first render must not depend on a later store update.
|
||||
this.rebuild_rows(cx);
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the store.
|
||||
@@ -154,10 +169,12 @@ impl RepoListView {
|
||||
let filter = self.filter;
|
||||
let query = self.search.read(cx).value();
|
||||
let store = self.store.read(cx);
|
||||
|
||||
self.visible = filter.visible(store, &query);
|
||||
|
||||
// Each virtual list row holds `COLUMNS` repo cards.
|
||||
let rows = self.visible.len().div_ceil(COLUMNS);
|
||||
|
||||
if self.repo_len != rows {
|
||||
self.repo_len = rows;
|
||||
self.item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]);
|
||||
@@ -172,7 +189,7 @@ impl RepoListView {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
|
||||
open_repo_panel(&self.dock_area, announcement, window, cx);
|
||||
}
|
||||
|
||||
fn render_card(
|
||||
@@ -308,6 +325,22 @@ impl RepoListView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_filter<T>(&self, filter: RepoFilter, label: T, cx: &mut Context<Self>) -> AnyElement
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
let active = self.filter == filter;
|
||||
|
||||
SegmentButton::new(filter.to_string(), label)
|
||||
.icon(Icon::new(filter.icon_name()))
|
||||
.selected(active)
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.filter = filter;
|
||||
this.rebuild_rows(cx);
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_header(&self, count: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||
h_flex()
|
||||
.px_4()
|
||||
@@ -315,18 +348,24 @@ impl RepoListView {
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child(div().font_semibold().child("Repositories"))
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.w_10()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.font_semibold()
|
||||
.text_xs()
|
||||
.line_height(relative(1.2))
|
||||
.child("Repositories"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(10.))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(format!("({count})"))),
|
||||
.line_height(relative(1.2))
|
||||
.child(SharedString::from(format!("Total: {count}"))),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -342,31 +381,12 @@ impl RepoListView {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(self.filter_button(RepoFilter::All, "All", cx))
|
||||
.child(self.filter_button(RepoFilter::Popular, "Popular", cx))
|
||||
.child(self.filter_button(RepoFilter::Recent, "Recent", cx)),
|
||||
.child(self.render_filter(RepoFilter::All, "All", cx))
|
||||
.child(self.render_filter(RepoFilter::Popular, "Popular", cx))
|
||||
.child(self.render_filter(RepoFilter::Recent, "Recent", cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One segmented header filter button, like the issues list's status filter buttons.
|
||||
fn filter_button(
|
||||
&self,
|
||||
filter: RepoFilter,
|
||||
label: &'static str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let active = self.filter == filter;
|
||||
|
||||
SegmentButton::new(label, label)
|
||||
.icon(Icon::new(filter.icon_name()))
|
||||
.selected(active)
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.filter = filter;
|
||||
this.rebuild_rows(cx);
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for RepoListView {
|
||||
|
||||
@@ -23,7 +23,7 @@ use signed_state::{
|
||||
};
|
||||
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
|
||||
|
||||
use super::{RepoDetailView, RepoListView, open_repo_panel};
|
||||
use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel};
|
||||
|
||||
mod create_repo_dialog;
|
||||
pub(crate) mod grasp_servers;
|
||||
@@ -37,6 +37,7 @@ use self::onboarding_dialog::OnboardingState;
|
||||
pub struct SidebarPanel {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
inbox: Option<WeakEntity<InboxView>>,
|
||||
explore: Option<WeakEntity<RepoListView>>,
|
||||
/// Artwork for the sign-in screen.
|
||||
banner: SharedString,
|
||||
@@ -71,6 +72,7 @@ impl SidebarPanel {
|
||||
|
||||
if signer_required {
|
||||
this.banner = pick_banner();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
if this.refresh(cx) || signer_required {
|
||||
@@ -99,9 +101,10 @@ impl SidebarPanel {
|
||||
}
|
||||
}));
|
||||
|
||||
let mut this = Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
inbox: None,
|
||||
explore: None,
|
||||
banner: pick_banner(),
|
||||
announcements: Arc::new(Vec::new()),
|
||||
@@ -109,22 +112,9 @@ impl SidebarPanel {
|
||||
scanning: false,
|
||||
unpushed: HashMap::new(),
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
|
||||
// Seed the snapshot right away.
|
||||
// The stores may already hold data from before the panel opened.
|
||||
// The first render must not depend on a later store update.
|
||||
this.refresh(cx);
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/// The sidebar renders only its own derived fields, never the stores
|
||||
/// directly. Because the panel is a cached view, a store update alone does
|
||||
/// not re-render it: the observers notify this panel, which re-runs
|
||||
/// `render` over the fresh snapshot.
|
||||
///
|
||||
/// Returns `true` when a rendered field changed.
|
||||
fn refresh(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let backend = Backend::global(cx);
|
||||
let user = backend.read(cx).current_user();
|
||||
@@ -174,12 +164,12 @@ impl SidebarPanel {
|
||||
|
||||
/// Recompute the badge counts from the global checkouts store's ready-to-push statuses
|
||||
fn refresh_unpushed(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let checkouts = CheckoutsStore::global(cx).read(cx);
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
let mut unpushed = HashMap::with_capacity(self.announcements.len());
|
||||
|
||||
for announcement in self.announcements.iter() {
|
||||
let addr = announcement.addr();
|
||||
let count = checkouts.unpushed(&addr);
|
||||
let count = checkouts.read(cx).unpushed(&addr);
|
||||
if count > 0 {
|
||||
unpushed.insert(addr, count);
|
||||
}
|
||||
@@ -203,6 +193,22 @@ impl SidebarPanel {
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the inbox home panel in the dock area's center.
|
||||
pub fn open_inbox(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.inbox.as_ref().and_then(WeakEntity::upgrade).is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let panel = cx.new(|cx| InboxView::new(self.dock_area.clone(), cx));
|
||||
self.inbox = Some(panel.downgrade());
|
||||
|
||||
self.dock_area
|
||||
.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Open the Explore repository list panel in the dock area's center.
|
||||
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self
|
||||
@@ -217,9 +223,11 @@ impl SidebarPanel {
|
||||
let panel = cx.new(|cx| RepoListView::new(self.dock_area.clone(), window, cx));
|
||||
self.explore = Some(panel.downgrade());
|
||||
|
||||
let _ = self.dock_area.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
});
|
||||
self.dock_area
|
||||
.update(cx, |dock_area, cx| {
|
||||
add_center_panel(dock_area, panel_handle(panel), window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Show the Onboarding dialog.
|
||||
@@ -595,9 +603,9 @@ impl Render for SidebarPanel {
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.image_cache(gpui::retain_all("sidebar"))
|
||||
.size_full()
|
||||
.justify_between()
|
||||
.image_cache(gpui::retain_all("sidebar"))
|
||||
.bg(cx.theme().sidebar)
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
.child(
|
||||
@@ -616,7 +624,7 @@ impl Render for SidebarPanel {
|
||||
.child(
|
||||
NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small())
|
||||
.on_click(cx.listener(|this, _ev, window, cx| {
|
||||
this.open_explore(window, cx)
|
||||
this.open_inbox(window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
use dock::{DockArea, DockEvent, DockLayout, DockPlacement, SignedDockSkin, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{Context, Entity, KeyBinding, Render, Subscription, Window, actions, div, px};
|
||||
use gpui::{Context, Entity, Render, Subscription, Window, div, px};
|
||||
use gpui_component::{Root, StyledExt, Theme};
|
||||
use gpui_fps::{FpsMonitor, FpsOverlay};
|
||||
use settings::{AppearanceMode, SettingsStore};
|
||||
use signed_state::{Backend, BackendEvent};
|
||||
|
||||
use crate::views::SidebarPanel;
|
||||
use crate::views::sidebar::passphrase_dialog;
|
||||
|
||||
actions!(workspace, [ToggleMonitor]);
|
||||
|
||||
pub struct Workspace {
|
||||
dock: Entity<DockArea>,
|
||||
fps: Entity<FpsMonitor>,
|
||||
/// Debug HUD, toggled with `cmd-shift-f`.
|
||||
show_fps: bool,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
_passphrase_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let fps = cx.new(|cx| FpsMonitor::new(window, cx).continuous(false));
|
||||
cx.bind_keys([KeyBinding::new("cmd-shift-f", ToggleMonitor, None)]);
|
||||
let backend = Backend::global(cx);
|
||||
let settings = SettingsStore::global(cx);
|
||||
|
||||
let dock = cx.new(|cx| {
|
||||
let skin = SignedDockSkin::new(cx);
|
||||
@@ -33,20 +27,16 @@ impl Workspace {
|
||||
let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx));
|
||||
let weak_sidebar = sidebar.downgrade();
|
||||
|
||||
dock.update(cx, |dock_area, cx| {
|
||||
dock_area.set_dock(
|
||||
DockPlacement::Left,
|
||||
DockLayout::tabs().panel_view(panel_handle(sidebar), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
dock_area.set_dock_size(DockPlacement::Left, px(240.), window, cx);
|
||||
});
|
||||
|
||||
let mut subscriptions = vec![];
|
||||
|
||||
// Sync the system appearance if the appearance mode is set to system.
|
||||
if settings.read(cx).settings().appearance == AppearanceMode::System {
|
||||
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
}));
|
||||
}
|
||||
|
||||
// A bottom or right dock whose last panel was dragged away is removed entirely.
|
||||
let dock_for_pruning = dock.clone();
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&dock,
|
||||
window,
|
||||
@@ -54,9 +44,9 @@ impl Workspace {
|
||||
if !matches!(event, DockEvent::LayoutChanged) {
|
||||
return;
|
||||
}
|
||||
let dock = dock_for_pruning.clone();
|
||||
let weak = weak_dock.clone();
|
||||
cx.spawn_in(window, async move |_, window| {
|
||||
dock.update_in(window, |area, window, cx| {
|
||||
weak.update_in(window, |area, window, cx| {
|
||||
for placement in [DockPlacement::Bottom, DockPlacement::Right] {
|
||||
if area.is_empty(placement, cx) {
|
||||
area.remove_dock(placement, window, cx);
|
||||
@@ -69,28 +59,36 @@ impl Workspace {
|
||||
},
|
||||
));
|
||||
|
||||
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
}));
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
// Ask for the passphrase when the stored identity is NIP-49 encrypted.
|
||||
let passphrase_subscription =
|
||||
window.subscribe(&backend, cx, |_backend, event, window, cx| {
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&backend,
|
||||
window,
|
||||
|_this, _state, event, window, cx| {
|
||||
if matches!(event, BackendEvent::PassphraseRequired) {
|
||||
passphrase_dialog::open(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
cx.defer_in(window, move |this, window, cx| {
|
||||
// The event may have fired before this window existed.
|
||||
// Fall back to the backend state in that case.
|
||||
if backend.read(cx).passphrase_required() {
|
||||
passphrase_dialog::open(window, cx);
|
||||
}
|
||||
|
||||
// Open the sidebar and explore panel.
|
||||
this.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.set_dock(
|
||||
DockPlacement::Left,
|
||||
DockLayout::tabs().panel_view(panel_handle(sidebar), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
dock_area.set_dock_size(DockPlacement::Left, px(240.), window, cx);
|
||||
});
|
||||
|
||||
// The event may have fired before this window existed.
|
||||
// Fall back to the backend state in that case.
|
||||
if backend.read(cx).passphrase_required() {
|
||||
passphrase_dialog::open(window, cx);
|
||||
}
|
||||
|
||||
// Open the explore panel after the sidebar has been initialized.
|
||||
cx.defer_in(window, move |_, window, cx| {
|
||||
// Open the explore panel.
|
||||
weak_sidebar
|
||||
.update(cx, |this, cx| {
|
||||
this.open_explore(window, cx);
|
||||
@@ -100,10 +98,7 @@ impl Workspace {
|
||||
|
||||
Self {
|
||||
dock,
|
||||
show_fps: cfg!(debug_assertions),
|
||||
fps,
|
||||
_subscriptions: subscriptions,
|
||||
_passphrase_subscription: passphrase_subscription,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,12 +110,6 @@ impl Render for Workspace {
|
||||
|
||||
div()
|
||||
.id("workspace")
|
||||
.on_action(
|
||||
cx.listener(|this: &mut Self, _ev: &ToggleMonitor, _window, cx| {
|
||||
this.show_fps = !this.show_fps;
|
||||
cx.notify();
|
||||
}),
|
||||
)
|
||||
.v_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
@@ -129,7 +118,5 @@ impl Render for Workspace {
|
||||
.children(notification_layer)
|
||||
// Modals
|
||||
.children(dialog_layer)
|
||||
// On top of everything, so it stays readable while debugging.
|
||||
.when(self.show_fps, |this| this.child(FpsOverlay::new(&self.fps)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,973 @@
|
||||
# Inbox (home screen) implementation plan
|
||||
|
||||
Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for a logged-in user.
|
||||
|
||||
> **Correction to the first draft.** The first draft assumed the inbox was the `/notifications`
|
||||
> 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-4 are implemented and green on `feat/inbox`, then the screen was redesigned to
|
||||
> group **threads by repository** and to merge notifications with own activity into one row per thread
|
||||
> (see the repository-grouping and thread-merge notes in §7).
|
||||
> `cargo test -p signed_core` (69), `cargo test -p signed_state` (24),
|
||||
> `cargo test -p workspace` (7), `cargo test -p dock` (1), `cargo clippy -p workspace --all-targets`
|
||||
> clean, `cargo check --workspace --all-targets` succeeds.
|
||||
> Phase 5 is not started. This document reflects the implementation as it stands: the Phase 1
|
||||
> refactors, the §4.3 split of the inbox into a thin global `Inbox` and a panel-owned derivation, the
|
||||
> Phase 4 click-through, and the repository-grouped thread list. The Phase 3 bottom-dock sub-views were
|
||||
> removed before the redesign; their implementation notes in §7 are historical.
|
||||
|
||||
## 1. What the GitWorkshop home screen is
|
||||
|
||||
`Index.tsx`:
|
||||
|
||||
```tsx
|
||||
if (account) return <Dashboard />;
|
||||
return <LandingPage />;
|
||||
```
|
||||
|
||||
`Dashboard.tsx` layout:
|
||||
|
||||
- Desktop: two columns.
|
||||
- **Left column**: `GreetingHeader`, `NotificationsPanel`, `RecentActivitySection`.
|
||||
- **Right column**: `MyRepositoriesPanel`, `AccessiblePrivateRepositoriesPanel`,
|
||||
`FollowedReposPanel`.
|
||||
- Mobile: a single column in a different order.
|
||||
|
||||
The panel that gives the screen its inbox identity is `NotificationsPanel`:
|
||||
|
||||
- heading **Notifications** with a bell icon and an unread count badge,
|
||||
- a **Mark all read** action and a **View all** link to `/notifications`,
|
||||
- a compact list of the first 5 **non-archived** notification items,
|
||||
- the empty state reads **"Your inbox is empty"** (with an `Inbox` icon).
|
||||
|
||||
So in GitWorkshop's vocabulary, "inbox" is the non-archived activity directed at you, surfaced
|
||||
inline on the home screen. The home screen also shows your own recent activity and your repositories.
|
||||
|
||||
Data hooks:
|
||||
|
||||
| Section | Hook | What it loads |
|
||||
|---|---|---|
|
||||
| Notifications (inbox) | `useNotifications()` | Notification model: grouped thread activity directed at you, read/archived state |
|
||||
| Continue where you left off | `useUserActivity(pubkey)` | Git activity authored by you: kinds 1621/1617/1618/1111 (git `K`)/1624/1630-1633, newest first, limit 50 |
|
||||
| My repositories | `useUserRepositories(pubkey)` | Kind 30617 announcements authored by you |
|
||||
| Followed repositories | `useUserFollowedRepos(pubkey)` | Repos you follow |
|
||||
| Accessible private repositories | `useAccessiblePrivateRepositories()` | Private repos from CI/services |
|
||||
|
||||
## 2. Scope for Signed
|
||||
|
||||
| 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 |
|
||||
| **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 |
|
||||
| **Out of scope** | Greeting header, my repositories, followed repositories, private repositories, pinned repositories, Unread/Archived sub-views | Not needed in Signed |
|
||||
|
||||
Notes:
|
||||
|
||||
- There is **no greeting header**. The screen starts with the inbox panel.
|
||||
- There is **no My repositories column**. The sidebar already lists the signed-in user's repositories, so the inbox is a single column.
|
||||
- The Unread/Archived sub-view panels were removed: the panel is a single repository-grouped list instead.
|
||||
|
||||
## 3. The Signed screen
|
||||
|
||||
`InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item. It is one bordered
|
||||
card holding a single virtual list. Every row is either a **repository header** or one of that
|
||||
repository's **threads**, newest first. A thread merges the notifications directed at the user with
|
||||
the user's own events in the same root, and shows the root's title plus up to five of its most recent
|
||||
events:
|
||||
The sections are **all of the user's own repositories**, seeded from `RepoListStore`, plus any other
|
||||
repository that has threads. Owned repositories with nothing to show render an
|
||||
empty state ("No activity yet.") under their header, and sort after the ones with activity (newest
|
||||
announcement first). Threads with no repository address fall into a single "Other repository" section.
|
||||
|
||||
```
|
||||
+-------------------------------------------------------------------------+
|
||||
| Inbox (3 unread) [Mark all read] |
|
||||
|-------------------------------------------------------------------------|
|
||||
| [repo] you/repo-a (2) |
|
||||
| [icon] Add retry logic (unread dot) |
|
||||
| [avatar] You opened an issue 3d |
|
||||
| [avatar] alice commented 2d |
|
||||
| [icon] Fix flaky test |
|
||||
| [avatar] You opened a PR 1h |
|
||||
|-------------------------------------------------------------------------|
|
||||
| [repo] you/repo-b |
|
||||
| No activity yet. |
|
||||
|-------------------------------------------------------------------------|
|
||||
| [repo] you/repo-c |
|
||||
| No activity yet. |
|
||||
+-------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
The sections are the repositories that actually have threads, ordered by their
|
||||
newest row. A repository the user owns but that has no items is not shown. Threads with no repository
|
||||
address fall into a single "Other repository" section.
|
||||
|
||||
## 4. Data layer
|
||||
|
||||
### 4.1 `signed_core`: pure logic
|
||||
|
||||
**`filters.rs`** (extend, next to `activity`/`comments_for`):
|
||||
|
||||
```rust
|
||||
/// Kinds that notify a user when they tag them directly.
|
||||
pub const NOTIFICATION_KINDS: [Kind; 9] = [
|
||||
Kind::GitIssue,
|
||||
Kind::GitPullRequest,
|
||||
Kind::GitPatch,
|
||||
Kind::GitPullRequestUpdate,
|
||||
COVER_NOTE_KIND,
|
||||
Kind::GitStatusOpen,
|
||||
Kind::GitStatusApplied,
|
||||
Kind::GitStatusClosed,
|
||||
Kind::GitStatusDraft,
|
||||
];
|
||||
|
||||
/// Comments on our issues/PRs/patches.
|
||||
pub fn notification_comments(me: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::Comment)
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
||||
}
|
||||
|
||||
/// Activity directed at us: comments on our roots, and git events tagging us.
|
||||
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
||||
vec![
|
||||
notification_comments(me),
|
||||
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
|
||||
]
|
||||
}
|
||||
|
||||
/// Git activity authored by `me`, for "Continue where you left off".
|
||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([ACTIVITY_KINDS.as_slice(), &[COVER_NOTE_KIND]].concat())
|
||||
.author(me)
|
||||
}
|
||||
```
|
||||
|
||||
`ACTIVITY_KINDS` already exists in this file. All builders use existing SDK APIs
|
||||
(`Filter::kind/kinds/pubkey/custom_tags`, `SingleLetterTag::{UPPERCASE_P, UPPERCASE_K}`).
|
||||
|
||||
Comments authored by `me` are not all git comments, so the activity query needs a post-filter:
|
||||
keep kind 1111 only when its uppercase `K` tag is a git root kind (1621/1617/1618/30617), matching
|
||||
gitworkshop's `isGitComment`.
|
||||
|
||||
**`inbox.rs`** (new file):
|
||||
|
||||
```rust
|
||||
pub struct InboxItem {
|
||||
pub root: EventId,
|
||||
/// The root event itself, when known locally; drives the row title.
|
||||
pub root_event: Option<Event>,
|
||||
pub root_kind: Option<Kind>,
|
||||
pub address: Option<RepoAddr>,
|
||||
/// Notification events directed at the user, newest first.
|
||||
pub events: Vec<Event>,
|
||||
/// The user's own events in the same thread, newest first.
|
||||
pub own_events: Vec<Event>,
|
||||
/// Unread event ids, oldest first.
|
||||
pub unread_ids: Vec<EventId>,
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
impl InboxItem {
|
||||
/// Title of the thread root; falls back to the newest event it has.
|
||||
pub fn title(&self) -> String;
|
||||
/// Kind of the thread root; falls back to the newest event it has.
|
||||
pub fn kind(&self) -> Option<Kind>;
|
||||
pub fn latest_activity(&self) -> Timestamp;
|
||||
/// Up to `limit` most recent events of the thread, oldest first.
|
||||
pub fn timeline(&self, limit: usize) -> Vec<Event>;
|
||||
pub fn is_unread(&self) -> bool;
|
||||
pub fn apply_state(&mut self, state: &InboxReadState);
|
||||
}
|
||||
|
||||
/// The thread root of a notification event, or `None` if it isn't git-related.
|
||||
pub fn notification_root(
|
||||
event: &Event,
|
||||
lookup: &impl Fn(EventId) -> Option<Event>,
|
||||
) -> Option<EventId>;
|
||||
|
||||
/// Group the notifications directed at the user together with the user's own
|
||||
/// events into one item per thread, newest activity first.
|
||||
pub fn group(
|
||||
events: impl IntoIterator<Item = Event>,
|
||||
own: impl IntoIterator<Item = Event>,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
lookup: &impl Fn(EventId) -> Option<Event>,
|
||||
) -> Vec<InboxItem>;
|
||||
```
|
||||
|
||||
Root resolution, ported from `getNotificationRootId`:
|
||||
|
||||
- issue (1621) / PR (1618): itself
|
||||
- patch (1617): its `e` parent patch, else itself
|
||||
- NIP-22 comment (1111): uppercase `E` root pointer (SDK `nip22::extract_root`)
|
||||
- PR update (1619): uppercase `E`
|
||||
- statuses (1630-1633) / cover note (1624): NIP-10 root `e`
|
||||
- notification events authored by `me` are dropped; the user's own events are kept in
|
||||
`own_events` instead, never in `events`
|
||||
- `unread_ids` and `archived` are derived from `events` only, so the user's own activity is never
|
||||
unread and a thread with only own events is never archived
|
||||
|
||||
Read/archive state, the compact high-water-mark model:
|
||||
|
||||
```rust
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct InboxReadState {
|
||||
#[serde(default)] pub read_before: Timestamp,
|
||||
#[serde(default)] pub read_ids: HashSet<EventId>,
|
||||
#[serde(default)] pub archived_before: Timestamp,
|
||||
#[serde(default)] pub archived_ids: HashSet<EventId>,
|
||||
}
|
||||
|
||||
impl InboxReadState {
|
||||
pub fn is_read(&self, event: &Event) -> bool;
|
||||
pub fn is_archived(&self, event: &Event) -> bool;
|
||||
pub fn mark_read(&mut self, event: &Event);
|
||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey);
|
||||
/// Move the cutoff to `min(oldest unread - 1, now - 3 days)` and prune ids.
|
||||
pub fn advance_read(&mut self, all: &[Event], me: PublicKey);
|
||||
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey);
|
||||
}
|
||||
```
|
||||
|
||||
`activity_subject` in `model.rs` already gives an issue/PR title from the `subject` tag or first
|
||||
line; reuse it for the home screen rows.
|
||||
|
||||
### 4.2 Persistence: NIP-78 in the local database, never published
|
||||
|
||||
Read state is a normal NIP-78 (kind `30078`, `Kind::ApplicationSpecificData`) addressable event
|
||||
**written to LMDB only**. It is never broadcast to a relay, so the read state stays on this device.
|
||||
|
||||
It is signed with a **random keypair**, never the user's signer. The event is local application
|
||||
storage, so its author carries no identity; this avoids a signing round-trip and does not depend on
|
||||
the signer type. The `d` tag identifies the owning user, so state does not leak across identities
|
||||
when the signed-in key changes.
|
||||
|
||||
```rust
|
||||
/// d tag identifying the inbox read/archive state event of `me`.
|
||||
fn inbox_state_d_tag(me: PublicKey) -> String {
|
||||
format!("signed-inbox-state:{}", me.to_hex())
|
||||
}
|
||||
|
||||
/// Newest stored read state for `me`.
|
||||
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
|
||||
// No author filter: the signing key is random per save.
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(inbox_state_d_tag(me));
|
||||
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match serde_json::from_str(&event.content) {
|
||||
Ok(state) => Ok(Some(state)),
|
||||
Err(error) => {
|
||||
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign with a fresh random key and store locally.
|
||||
async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> {
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||
.tags([Tag::identifier(inbox_state_d_tag(me))])
|
||||
.finalize(&Keys::generate())?; // synchronous: random key, no user signer
|
||||
// Local-only: no `send_event`, no broadcast. The event lives in LMDB.
|
||||
client.database().save_event(&event).await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
A fresh random key is generated on every save, so each save writes a new event rather than
|
||||
replacing the previous one. LMDB only auto-replaces an addressable event when the incoming event
|
||||
has the **same pubkey**, so old copies accumulate. Nothing prunes them; `load_state` reads the
|
||||
newest by `created_at`, so the behavior is correct. This is a deliberate trade for not caching a
|
||||
key in the store (see §4.3). An earlier implementation deleted the previous event by tracking its
|
||||
id across saves; that was removed as more derived state than it was worth.
|
||||
`NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs.
|
||||
|
||||
### 4.3 Data layer: a thin global `Inbox`, a panel-owned derivation
|
||||
|
||||
The inbox is split in two, because the expensive derivation is only needed while the home screen is
|
||||
open.
|
||||
|
||||
**`Inbox`** is a child `Entity<Inbox>` owned by `Backend` (`inbox: Entity<Inbox>`) and is
|
||||
deliberately thin: it owns only the read/archive state that must outlive the panel and the NIP-78
|
||||
load/save.
|
||||
|
||||
```rust
|
||||
// backend.rs
|
||||
pub struct Backend {
|
||||
...
|
||||
inbox: Entity<Inbox>,
|
||||
}
|
||||
|
||||
// inbox.rs
|
||||
#[derive(Default)]
|
||||
pub struct Inbox {
|
||||
state: InboxReadState,
|
||||
loaded: bool,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
pub fn state(&self) -> &InboxReadState;
|
||||
pub fn is_loaded(&self) -> bool;
|
||||
pub fn mark_read(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx);
|
||||
pub fn mark_archived(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx);
|
||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx);
|
||||
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx);
|
||||
pub(crate) fn reset(&mut self, cx);
|
||||
}
|
||||
|
||||
// inbox.rs (signed_state)
|
||||
/// One item per thread, notifications and own activity merged.
|
||||
pub async fn query_inbox(
|
||||
client: &Client,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
) -> Result<(Vec<InboxItem>, usize), Error>;
|
||||
```
|
||||
|
||||
**The panel owns the derivation.** `InboxView` itself holds the derived lists, the copy of the read
|
||||
state they were computed with, and the refresh coalescing. There is no separate store entity: the
|
||||
panel is the only consumer, so an `Entity<InboxStore>` would add an `update` indirection and a
|
||||
forwarding subscription without buying any sharing. The panel's own `unread_count` feeds its header
|
||||
badge only; there is no global count and no sidebar badge.
|
||||
|
||||
```rust
|
||||
pub struct InboxView {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
threads: Arc<Vec<InboxItem>>, // one row per thread, merged
|
||||
sections: Arc<Vec<InboxSection>>, // grouped by repository
|
||||
rows: Arc<Vec<InboxRow>>, // flattened list
|
||||
unread_count: usize,
|
||||
state: InboxReadState,
|
||||
state_loaded: bool,
|
||||
refresh: RefreshGate,
|
||||
list: ListState,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl InboxView {
|
||||
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>); // cx.defer(… sync_state)
|
||||
pub fn sync_state(&mut self, cx); // observes the global Inbox
|
||||
pub fn mark_all_read(&mut self, cx);
|
||||
fn handle_backend_event(&mut self, event: &BackendEvent, cx);
|
||||
fn refresh(&mut self, cx);
|
||||
fn run_refresh(&mut self, cx);
|
||||
fn regroup(&mut self, cx); // re-apply read state
|
||||
fn rebuild(&mut self, cx); // group by repository, seed owned, flatten
|
||||
fn clear(&mut self);
|
||||
}
|
||||
```
|
||||
|
||||
The panel owns three subscriptions that carry logic: it observes the global `Inbox`
|
||||
(`InboxView::sync_state`), subscribes to `Backend` (`InboxView::handle_backend_event`), and observes
|
||||
`RepoListStore` to rebuild when the user's own repositories load. Re-rendering itself needs no
|
||||
subscription: GPUI invalidates a window for every entity it read during render, so the panel tracks
|
||||
`RepoListStore` and `ProfileStore` just by reading them in `render`. The panel does not write back
|
||||
to the global.
|
||||
|
||||
**`signed_state::query_inbox`.** The database work stays in `signed_state`, so the UI crate never
|
||||
queries LMDB directly. `query_inbox` returns the grouped notifications, the user's own git
|
||||
activity and the unread count; the panel applies the results on the main thread. `RefreshGate` is
|
||||
re-exported for the panel's debounce.
|
||||
|
||||
```rust
|
||||
pub async fn query_inbox(
|
||||
client: &Client,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
) -> Result<(Vec<InboxItem>, Vec<Event>, usize), Error>;
|
||||
```
|
||||
|
||||
**Lifespan.** `Inbox` is created with the backend but idles until the user has a signer. The
|
||||
derived lists live only as long as the panel. Nothing is wired from the `desktop` crate and
|
||||
`signed_state::init` gains no parameters.
|
||||
|
||||
**No sidebar badge.** The sidebar's inbox nav item has no unread suffix (an earlier global count
|
||||
derivation was removed with it). The unread count lives entirely in the panel, which shows it in
|
||||
its header and per repository section. The trade-off is that the count is only current while the
|
||||
panel is open, which is acceptable now that nothing outside it displays one.
|
||||
|
||||
The dependency chain is `Backend` → `Inbox` and `InboxView` → `query_inbox`.
|
||||
|
||||
`Backend` owns the inbox lifecycle (`sync_inbox`); the panel subscribes to `Backend` directly for
|
||||
its lists. `BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay:
|
||||
`CheckoutsStore` and `SidebarPanel` consume them. They no longer drive the inbox's activation
|
||||
directly.
|
||||
|
||||
`InboxView::handle_backend_event` refreshes on:
|
||||
|
||||
- `NostrUpdate(updates)`: when any update kind is in `NOTIFICATION_KINDS`, is `Kind::Comment`, or is
|
||||
a deletion (`EventDeletion` / `RequestToVanish`).
|
||||
- `Synced` / `Published`.
|
||||
- everything else: ignored.
|
||||
|
||||
**Signer lifecycle: `Backend::sync_inbox`.** `Backend` owns the wiring and calls `sync_inbox` from the
|
||||
three real signer transitions: `create_identity`, `set_signer` (nsec, bunker and passphrase restore)
|
||||
and `logout`. It starts the subscriptions and repo-relay connects, then calls `Inbox::activate` or
|
||||
`Inbox::reset`. The client is passed into `activate`, so the global inbox never reads `Backend`
|
||||
while `sync_inbox` is mid-update:
|
||||
|
||||
```rust
|
||||
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
|
||||
let me = self.current_user;
|
||||
|
||||
if let Some(me) = me {
|
||||
self.subscribe_bootstrap(filters::notifications(me), cx);
|
||||
self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
|
||||
|
||||
let relays: HashSet<RelayUrl> = RepoListStore::global(cx)
|
||||
.read(cx)
|
||||
.announcements_of(&me)
|
||||
.into_iter()
|
||||
.flat_map(|announcement| announcement.relays)
|
||||
.collect();
|
||||
|
||||
if !relays.is_empty() {
|
||||
let relays: Vec<RelayUrl> = relays.into_iter().collect();
|
||||
self.connect_repo_relays(relays.clone(), filters::notifications(me), cx);
|
||||
self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx);
|
||||
}
|
||||
}
|
||||
|
||||
let client = self.client.clone();
|
||||
self.inbox.update(cx, |inbox, cx| match me {
|
||||
Some(me) => inbox.activate(me, client, cx),
|
||||
None => inbox.reset(cx),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The repo relays are read from `RepoListStore::global(cx).read(cx).announcements_of(&me)` at call
|
||||
time and never cached. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind
|
||||
10002 yet.) `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` has no `subscribe_remote`
|
||||
/ `connect_own_repo_relays`.
|
||||
|
||||
**Activation** clears the state and loads the NIP-78 state from LMDB. The panel clears its own
|
||||
lists and in-flight refresh when it sees the unloaded state, then refreshes once it is loaded:
|
||||
|
||||
```rust
|
||||
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
|
||||
// state = default; state_loaded = false; cx.notify();
|
||||
// spawn load_state(client, me), then set state and state_loaded = true
|
||||
}
|
||||
```
|
||||
|
||||
`reset` performs the same clearing without a state load, and is used on logout.
|
||||
|
||||
Reading the state event needs no signer at all (the `d` tag carries the identity); activation is
|
||||
still gated on the signer because the fetch filters need the user's pubkey.
|
||||
|
||||
**Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays` through
|
||||
`Backend::sync_inbox` (see above). The query the panel runs is intentionally the offline-first cache
|
||||
read, not a wait on the network; see the note below.
|
||||
|
||||
**Refresh** (`InboxView::run_refresh`, mirrors `RepoListStore::run_refresh`):
|
||||
|
||||
- `cx.background_spawn`: query the notification filters and the activity filter from
|
||||
`client.database()`.
|
||||
- Query `filters::deletions()`, build `Deletions`, skip deleted events.
|
||||
- Build `HashMap<EventId, Event>` for root walking; group the notification events with
|
||||
`inbox::group`.
|
||||
- Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when
|
||||
their `K` tag is a git kind; sort newest first.
|
||||
- Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() ==
|
||||
Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a
|
||||
previous user's results never land. Then set `notifications`, `activity`, `unread_count`, rebuild the
|
||||
repository sections (`rebuild`), `cx.notify()`, `refresh.finish()`.
|
||||
|
||||
`InboxView::sync_state` reacts to the global `Inbox`: while the state is not loaded it clears the
|
||||
lists, on the first load it runs the initial refresh, and on a state change (a mark action) it
|
||||
re-derives the flags (`InboxItem::apply_state`).
|
||||
|
||||
**Fetch vs. the immediate query.** `subscribe_bootstrap` / `connect_repo_relays` return immediately,
|
||||
so the query that follows them reads the local cache rather than waiting for the relays. That is
|
||||
deliberate offline-first behavior: cached content appears at once on a warm start and with no
|
||||
network, instead of blocking the home screen on the network. The gap is closed by the SDK, not by
|
||||
timing: received events are written to LMDB and surfaced as `ClientNotification::Event`, so
|
||||
`Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the store refreshes. This was
|
||||
reviewed and left as-is.
|
||||
|
||||
**Actions**: `mark_all_read()` lives on the panel, which passes every known notification event to the
|
||||
global `Inbox`. The global marks them, advances the cutoffs against *all* notification events to bound
|
||||
the id sets, saves the state to LMDB (signed with a fresh random key, see 4.2), and notifies. The
|
||||
panel then re-derives and publishes the unread count.
|
||||
|
||||
**Repository names need no new store**: `RepoListStore` already holds every announcement and
|
||||
`repo_name` resolves an address to a display name.
|
||||
|
||||
### 4.4 `Cargo.toml`
|
||||
|
||||
- `signed_core`: add `serde.workspace` for the `InboxReadState` derives.
|
||||
- `signed_state`: add `serde_json.workspace` for the NIP-78 content.
|
||||
|
||||
## 5. UI
|
||||
|
||||
### 5.1 `InboxView` center panel
|
||||
|
||||
`crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`.
|
||||
It owns the derived lists directly, so `cx.notify()` from an update re-renders it. The panel is one
|
||||
bordered card (`flex_1`, `min_h_0`) with a header bar and a scrolling body. The body is a single
|
||||
`gpui::list` virtual list (`ListState` + `ListAlignment::Top`, 400px overdraw) with a
|
||||
`vertical_scrollbar`; the panel itself does not scroll, so the list gets a definite viewport height.
|
||||
The list count is reset from `render` whenever the rendered row count changes.
|
||||
|
||||
- **Header**: the unread count badge and **Mark all read**.
|
||||
- **Body**: the flattened repository-grouped rows. A repository header is a muted bar with a git icon,
|
||||
the repository name (or "Other repository" when the address is unknown) and its unread badge. Rows
|
||||
under it show the actor avatar, a kind icon, the subject, the kind label, a relative time, and an
|
||||
unread dot (the subject is semibold while unread). A repository with nothing to show renders
|
||||
"No activity yet."; the panel-level "You're all caught up." empty state appears only when there are
|
||||
no sections at all (no owned repositories and no items).
|
||||
|
||||
No greeting header, and no **My repositories** column - the sidebar already lists the user's
|
||||
repositories.
|
||||
|
||||
### 5.2 Grouping by repository
|
||||
|
||||
The grouping is panel-owned derivation, done once per data change in `InboxView::rebuild` (called
|
||||
from `run_refresh` and `regroup`), never per frame:
|
||||
|
||||
```rust
|
||||
struct InboxSection {
|
||||
address: Option<RepoAddr>, // repository, None for items without one
|
||||
unread: usize, // unread notification groups
|
||||
entries: Vec<InboxEntry>, // newest first
|
||||
latest: Timestamp, // orders the sections
|
||||
}
|
||||
|
||||
enum InboxEntry { // indices into the panel's own lists
|
||||
Notification(usize),
|
||||
Activity(usize),
|
||||
}
|
||||
|
||||
enum InboxRow { // the flattened list
|
||||
Repo(usize),
|
||||
Entry(usize, usize),
|
||||
Empty, // "No activity yet." under an empty section
|
||||
}
|
||||
```
|
||||
|
||||
The section list and the flattened rows are stored as `Arc`s and cloned into the `gpui::list`
|
||||
closure, which indexes the panel's `notifications` / `activity` lists - no per-frame deep copies.
|
||||
Notification groups carry their repository in `InboxItem::address`; activity events carry it in a
|
||||
`GitRepoAnnouncement` `a` tag (`repo_address`). Archived notification groups are left out.
|
||||
|
||||
`rebuild` also seeds a section for every repository in `announcements_of(me)`. The panel observes
|
||||
`RepoListStore` so a repository that loads after the last refresh still appears (its own empty
|
||||
section, or with items if any arrived); this is the one logic subscription beyond the `Inbox` and
|
||||
`Backend` ones. Repository names are resolved per render through `repo_name` -> `RepoListStore`, so a
|
||||
late announcement still labels its section without re-deriving the grouping.
|
||||
|
||||
### 5.3 Sidebar
|
||||
|
||||
In `views/sidebar/mod.rs`:
|
||||
|
||||
- Add `inbox: Option<WeakEntity<InboxView>>` (mirrors `explore`).
|
||||
- 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 a repo for a row.
|
||||
- Point the existing nav item at it:
|
||||
|
||||
```rust
|
||||
NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small())
|
||||
.on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))),
|
||||
```
|
||||
|
||||
- No unread badge. The nav item carries no suffix, and the sidebar does not observe the global
|
||||
`Inbox`. The unread count lives in the panel only.
|
||||
|
||||
### 5.4 Click-through (P1)
|
||||
|
||||
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`:
|
||||
|
||||
```rust
|
||||
pub(crate) enum RepoItem {
|
||||
Issue(EventId),
|
||||
PullRequest(EventId),
|
||||
Patch,
|
||||
}
|
||||
|
||||
pub(crate) fn open_repo_item(
|
||||
dock_area: &WeakEntity<DockArea>,
|
||||
announcement: &Announcement,
|
||||
item: RepoItem,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) { /* build the RepoStore here, then a new IssueDetailView / PullRequestDetailView, added to the center */ }
|
||||
```
|
||||
|
||||
- `open_repo_item` builds its own `RepoStore` from `announcement` (a private `repo_store` helper calls
|
||||
`RepoStore::new(addr, relays, cx)`), so the item panel is the **only** panel docked. An earlier
|
||||
version opened `RepoDetailView` first and reused its store via `RepoDetailView::store()`; that
|
||||
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
|
||||
until the store's fetch lands, then re-renders.
|
||||
- The item panel is added to the center group and activated.
|
||||
|
||||
Patches have no detail view in Signed (they are only consumed inside `PullRequestDetailView`), so a
|
||||
patch-root click opens nothing. `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
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `crates/signed_core/Cargo.toml` | add `serde` |
|
||||
| `crates/signed_core/src/filters.rs` | `NOTIFICATION_KINDS`, `notification_comments`, `notifications`, `authored_activity`, `is_git_activity`, `deletions` |
|
||||
| `crates/signed_core/src/inbox.rs` | **new**: `InboxItem` (root event, notifications, own events), `notification_root`, `group`, `InboxReadState`, tests |
|
||||
| `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports |
|
||||
| `crates/signed_state/Cargo.toml` | add `serde_json` |
|
||||
| `crates/signed_state/src/inbox.rs` | thin global `Inbox` (NIP-78 read state, mark actions) and `query_inbox` (query, merge notifications + activity into threads) |
|
||||
| `crates/signed_state/src/backend.rs` | `inbox: Entity<Inbox>` field, construction, `inbox()` accessor, `sync_inbox`, `RepoListStore` import |
|
||||
| `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 (currently unused; left over from the removed sub-views) |
|
||||
| `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/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring |
|
||||
| `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`
|
||||
activates the `Inbox` child entity at each signer transition.
|
||||
|
||||
## 7. Phasing
|
||||
|
||||
1. **Phase 0 - pure logic**: `signed_core` filters and `inbox.rs` plus tests. **DONE.**
|
||||
Implemented as `filters::{NOTIFICATION_KINDS, notification_comments, notifications, authored_activity, is_git_activity}`
|
||||
and `inbox::{InboxItem, notification_root, group, InboxReadState}`. Two deviations from the sketch:
|
||||
the cutoff methods take an explicit `now: Timestamp` so the pure logic stays deterministic and testable,
|
||||
and `authored_activity` results must pass through `is_git_activity` before display (comments on
|
||||
non-git roots are matched by the filter). `cargo test -p signed_core` passes (66 tests at the
|
||||
end of Phase 0; 68 after the two Phase 1 additions).
|
||||
2. **Phase 1 - store**: `Inbox` child entity, activated by `Backend::sync_inbox` once a signer
|
||||
exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the
|
||||
implementation notes below.
|
||||
3. **Phase 2 - screen**: `InboxView` (inbox + activity) and the sidebar nav item.
|
||||
**DONE.** See the implementation notes below.
|
||||
4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived.
|
||||
**Done, then reverted.** The sub-views were removed before the repository-grouping redesign; the
|
||||
notes below are historical.
|
||||
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.
|
||||
|
||||
Each phase compiles and is usable on its own.
|
||||
|
||||
### Phase 1 implementation notes
|
||||
|
||||
Files: `crates/signed_state/{Cargo.toml, src/inbox.rs, src/lib.rs, src/backend.rs, src/refresh.rs}`
|
||||
and two additions to `crates/signed_core/src/inbox.rs`.
|
||||
|
||||
- `Inbox` is a child entity of `Backend` (`inbox: Entity<Inbox>`), created in `Backend::new` and
|
||||
reached via `Backend::inbox()`. Nothing in `desktop` is wired and `signed_state::init` gains no
|
||||
parameters. The dependency is strictly one-way: `Inbox` holds no `Backend` handle.
|
||||
- `Backend::emit` is the single funnel for every `BackendEvent`. It updates the inbox through
|
||||
`cx.defer` and then emits to the other subscribers. The defer is required: every emit site runs
|
||||
inside `Backend::update`, and the inbox handlers read `Backend`, so a synchronous call panics on
|
||||
a re-entrant entity access.
|
||||
- The signer lifecycle lives in `Backend::sync_inbox`, called from `create_identity`, `set_signer`
|
||||
and `logout`. It starts the subscriptions and repo-relay connects, then defers `inbox.activate`
|
||||
/ `inbox.reset`. `SignerChanged` / `SignerRequired` are still emitted for `CheckoutsStore` and
|
||||
`SidebarPanel`, but no longer drive the inbox.
|
||||
- `Inbox` mirrors `RepoListStore`: `RefreshGate` coalescing, `cx.background_spawn` for the
|
||||
database work, plain data applied on the main thread, refresh-on-`NostrUpdate`/`Synced`/`Published`.
|
||||
- Added `state_loaded: bool`, not in the sketch. Groups are derived from the read state, so a refresh
|
||||
before the stored state is read would briefly mark everything unread. The first refresh is chained
|
||||
after `load_state`, and later `refresh` calls are ignored until `state_loaded` is set.
|
||||
- Account switches are guarded. `activate` and `reset` both replace `self.refresh` with a fresh
|
||||
`RefreshGate`, dropping any in-flight or pending run of the previous user, and the apply step of
|
||||
`run_refresh` aborts instead of applying when `Backend::current_user()` no longer matches the
|
||||
user the query was started for.
|
||||
- Two additions to `signed_core::inbox` that Phase 1 needs: `InboxReadState::mark_archived` (mirrors
|
||||
`mark_read`) and `InboxItem::apply_state` (recomputes `unread_ids`/`archived`; `group` now uses it).
|
||||
Both are covered by tests.
|
||||
- The thread-root lookup is built by walking every `e`/`E` ancestor transitively (`fetch_notifications`)
|
||||
rather than a single hop, because a patch series chains through parent patches. Only the notification
|
||||
events are grouped; ancestors are used solely as the lookup, so a root authored by someone else is
|
||||
not mistaken for a notification.
|
||||
- The read/archive state event is written to LMDB only (`database().save_event`), signed with a fresh
|
||||
`Keys::generate()` on each save and never published. Filtering is by `d` tag only, no author, so the
|
||||
random key is irrelevant across sessions. `d` tag uses `me.to_hex()` rather than `Display`.
|
||||
- Actions: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each marks the group, advances
|
||||
the relevant cutoffs against **all** notification events (matching GitWorkshop's use of `allEvents`),
|
||||
re-derives the groups locally so the UI updates immediately, then persists in the background.
|
||||
- The global `Inbox` keeps no derived state. The signing key is generated per save, the current user is read
|
||||
from `Backend::current_user()` where needed, and the relays of the user's own repositories are
|
||||
queried from `RepoListStore` in `Backend::sync_inbox` rather than cached. There is no prune logic
|
||||
either: the newest state event is selected by `created_at`.
|
||||
- `Inbox::activate` / `Inbox::reset` are `pub(crate)`; the former `subscribe_remote` and
|
||||
`connect_own_repo_relays` methods were deleted once their work moved into `Backend::sync_inbox`.
|
||||
- `cargo test -p signed_core` passes (68 tests), `cargo test -p signed_state` passes (24 tests);
|
||||
`cargo clippy -p signed_state --all-targets` is clean; `cargo check --workspace` succeeds.
|
||||
|
||||
### Phase 2 implementation notes
|
||||
|
||||
Files: `crates/workspace/src/views/{inbox.rs, mod.rs, sidebar/mod.rs}`. No store changes.
|
||||
|
||||
- `InboxView` is a plain center panel like `RepoListView`; the sidebar holds a
|
||||
`WeakEntity<InboxView>` so there is no cycle. Re-rendering relies on GPUI's render-time entity
|
||||
tracking rather than explicit observations. (Phase 2 introduced an `Entity<InboxStore>` here; it
|
||||
was later folded into the panel - see the store-merge note below.)
|
||||
- The layout is a column of two flexible bordered cards (`gap_4`, `p_4`, each `flex_1`/`min_h_0`),
|
||||
inbox over activity. Each card is a rounded `v_flex` with a header bar (`section`) and a
|
||||
`gpui::list` body. There is no **My repositories** column: the sidebar already lists the user's
|
||||
repositories, so the panel is a single column.
|
||||
- Notification rows read the newest event of each group for the actor, subject and time, and the
|
||||
root's kind for the icon. The repo name is resolved from `item.address` through a linear scan of
|
||||
`RepoListStore::announcements` (`repo_name`); the list is small and this keeps the store unchanged.
|
||||
- The **Unread** / **Archived** header buttons are intentionally absent: they need
|
||||
`add_bottom_panel` / `InboxFilterView`, which are Phase 3. The header is only **Mark all read**,
|
||||
so the panel is fully usable on its own.
|
||||
- `kind_icon` / `kind_label` map a `Kind` to a `CustomIconName`/`IconName` and a short noun. The
|
||||
cover note is compared with `==` rather than matched, since `Kind` cannot appear in a pattern arm.
|
||||
- Sidebar: `open_inbox` mirrors `open_explore` (return if open, else add a center panel); the inbox
|
||||
nav item is repointed. The screen is still opened by the nav item, not on app startup, matching the
|
||||
"idle until signer" rule; auto-opening it as the post-login home is a possible follow-up.
|
||||
- The **My repositories** column (search `InputState`, **New** button, `open_repo_panel` rows) was
|
||||
removed after Phase 2 as redundant with the sidebar, along with the panel's `dock_area`,
|
||||
`open_repo` / `open_create_repo` helpers and the `create_repo_dialog` / `open_repo_panel` imports.
|
||||
`InboxView::new` now takes only `cx`. `create_repo_dialog` is private again.
|
||||
- `cargo clippy -p workspace --all-targets` is clean and `cargo check --workspace --all-targets`
|
||||
succeeds. `cargo test -p signed_core` (68) and `cargo test -p signed_state` (24) still pass.
|
||||
|
||||
### Architecture refactor (after Phase 2)
|
||||
|
||||
Phases 0-2 kept all derivation in the global `Inbox`, so every notification and activity query ran
|
||||
whether or not the home screen was open, and `Backend::emit` carried a deferred side effect just to
|
||||
feed it.
|
||||
|
||||
- The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, plus the NIP-78 load/save
|
||||
and the mark actions.
|
||||
- `Backend::emit` is gone. All `BackendEvent`s are emitted with `cx.emit` again, and `sync_inbox`
|
||||
updates the inbox synchronously, passing the client in so nothing reads `Backend` mid-update.
|
||||
- The panel became the client-side owner of the derivation, initially through a panel-scoped
|
||||
`Entity<InboxStore>`.
|
||||
- `signed_core` is unchanged.
|
||||
|
||||
### Store merged into the panel (after Phase 2)
|
||||
|
||||
The `InboxStore` entity was then folded into `InboxView`, since the panel was its only consumer.
|
||||
|
||||
- `InboxView` holds `notifications`, `activity`, `unread_count`, `state`, `state_loaded` and
|
||||
`RefreshGate` as fields, and the store's methods (`sync_state`, `handle_backend_event`,
|
||||
`refresh`/`run_refresh`, `regroup`, `publish_unread_count`, `clear`, the mark actions) became panel
|
||||
methods. The two subscriptions call them directly, with no `update` indirection.
|
||||
- The database work stayed in `signed_state` as `pub async fn query_inbox(...)`; `RefreshGate` and
|
||||
`RefreshRequest` are re-exported. The UI crate never queries LMDB directly.
|
||||
- `mark_read`, `mark_archived` and their `group_events` helper carry a scoped `#[allow(dead_code)]`
|
||||
until the Phase 3 sub-views wire them up.
|
||||
- `cargo test -p signed_core` (68), `cargo test -p signed_state` (24) and `cargo test -p workspace`
|
||||
(7) pass; clippy and `cargo check --workspace --all-targets` are clean.
|
||||
|
||||
Trade-off: the unread count is derived by the panel, so it is only current while the panel is open.
|
||||
(`publish_unread_count` fed a sidebar badge at the time; both were removed later - see "Sidebar
|
||||
badge removed" below.)
|
||||
|
||||
### Phase 3 implementation notes
|
||||
|
||||
> Historical: the Unread/Archived sub-views below were later removed; the panel is now a single
|
||||
> repository-grouped list. Kept for the `add_bottom_panel` / sub-view rationale.
|
||||
|
||||
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` builds its own `RepoStore` from
|
||||
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
|
||||
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.
|
||||
- `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, maps the
|
||||
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 /
|
||||
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
|
||||
`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,
|
||||
and `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1).
|
||||
|
||||
### Repository grouping redesign (after Phase 4)
|
||||
|
||||
Files: `crates/workspace/src/views/inbox.rs`. No store, no `signed_core` changes.
|
||||
|
||||
The two-card layout (notifications over activity) was replaced by a single repository-grouped list.
|
||||
|
||||
- The panel now derives `sections: Vec<InboxSection>` and a flattened `rows: Vec<InboxRow>` in
|
||||
`rebuild`, called from `run_refresh` and `regroup`. Both are stored as `Arc`s and cloned into the
|
||||
`gpui::list` closure, which indexes `notifications` / `activity` - no deep copies per frame and no
|
||||
data duplicated between the section list and the source lists.
|
||||
- `InboxSection` groups a repository's non-archived notification groups and the user's own activity,
|
||||
newest first; sections are ordered by their newest entry. `InboxEntry` holds indices into the
|
||||
panel's lists; `InboxRow::Repo` / `InboxRow::Entry` / `InboxRow::Empty` is the flattened shape the
|
||||
list renders.
|
||||
- All of the user's own repositories are seeded as sections from `RepoListStore::announcements_of`,
|
||||
so an owned repository with nothing to show gets an empty section ("No activity yet.") and sorts
|
||||
after the sections with activity. The panel observes `RepoListStore` to rebuild when the user's
|
||||
repositories load or change.
|
||||
- Activity is matched to a repository through a `GitRepoAnnouncement` `a` tag (`repo_address`).
|
||||
Items without an address share the "Other repository" section.
|
||||
- `notification_row` / `activity_row` no longer render the repository name - the section header does.
|
||||
That also drops one `RepoListStore` scan per row.
|
||||
- The single card has one `ListState`; the old `notifications_list` / `activity_list` and the
|
||||
`render_inbox_panel` / `render_activity_panel` / `section` helpers are gone. `notification_row` still
|
||||
takes an id prefix so rows stay unique within the list.
|
||||
- `cargo clippy -p workspace --all-targets` is clean and `cargo test -p signed_core -p signed_state
|
||||
-p workspace -p dock` passes (68 / 24 / 7 / 1).
|
||||
|
||||
### Sidebar badge removed (after the repository grouping redesign)
|
||||
|
||||
Files: `crates/signed_core/src/filters.rs`, `crates/signed_state/src/{inbox.rs,backend.rs}`,
|
||||
`crates/workspace/src/views/{inbox.rs,sidebar/mod.rs}`.
|
||||
|
||||
An intermediate change made the sidebar badge live by moving the unread count into the global
|
||||
`Inbox` (a `refresh_unread_count` driven by `Backend`). That was then reverted along with the badge
|
||||
itself, so the global is thin again.
|
||||
|
||||
- The sidebar nav item no longer renders a `CountBadge`; `SidebarPanel` lost its `unread` field and
|
||||
its observe of the global `Inbox`.
|
||||
- The global `Inbox` no longer stores an `unread_count` and has no `set_unread_count` /
|
||||
`refresh_unread_count`. `Backend` has no `refresh_inbox_unread` and no per-batch or per-sync count
|
||||
refresh. `filters::affects_inbox` and the `query_inbox` helper split were reverted with it.
|
||||
- `InboxView` keeps its local `unread_count` for its header badge and the per-section `unread` for
|
||||
the repository headers; `publish_unread_count` stays deleted.
|
||||
- Consequence: the unread count is only current while the panel is open, and there is no unread
|
||||
indication anywhere else in the app.
|
||||
- `cargo clippy -p signed_core -p signed_state -p workspace --all-targets` is clean,
|
||||
`cargo check -p signed_core -p signed_state -p workspace --all-targets` succeeds, and
|
||||
`cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1).
|
||||
|
||||
### Threads merged: notifications + activity (after the sidebar badge removal)
|
||||
|
||||
Files: `crates/signed_core/src/inbox.rs`, `crates/signed_state/src/inbox.rs`,
|
||||
`crates/workspace/src/views/inbox.rs`.
|
||||
|
||||
Notifications and own activity were two separate row kinds that could describe the same thread. They
|
||||
are now one item per thread: the notifications directed at the user and the user's own events in that
|
||||
thread live in the same `InboxItem`. A row shows the thread root's title and up to five of the
|
||||
thread's most recent events:
|
||||
|
||||
```
|
||||
[icon] Add retry logic (unread dot)
|
||||
[avatar] You opened an issue · 3d
|
||||
[avatar] alice commented · 2d
|
||||
```
|
||||
|
||||
- `InboxItem` gained `root_event: Option<Event>` and `own_events: Vec<Event>`. `events` keeps only the
|
||||
notifications (others' events); `own_events` holds the user's own. `unread_ids`/`archived` are
|
||||
derived from `events` alone, so own activity is never unread and a thread with only own events is
|
||||
never archived (`apply_state` guards the empty case).
|
||||
- New methods on `InboxItem`: `title()` (root event's subject, falling back to the newest event),
|
||||
`kind()` (root kind, same fallback), and `timeline(limit)` (thread events deduplicated by id,
|
||||
oldest first, always keeping the root event and filling the remaining slots with the most recent
|
||||
others).
|
||||
- `group` now takes both `events` (notifications) and `own` (the user's activity) and merges them on
|
||||
the resolved root. Own events resolve through the same `notification_root`; an unresolved own event
|
||||
becomes its own root. `query_inbox` returns `(Vec<InboxItem>, usize)` - the separate activity list
|
||||
is gone, and `by_id` is extended with the own events so a comment of ours resolves to its thread.
|
||||
- The panel holds `threads: Arc<Vec<InboxItem>>` instead of `notifications` + `activity`. The
|
||||
`InboxEntry` enum, `entry_time`, `repo_address`, `related_activity`, `notification_row`,
|
||||
`activity_row` and `kind_label` are gone. `thread_row` replaces both row kinds and is clickable like
|
||||
the old notification row; `group_sections` now just buckets threads by `item.address`.
|
||||
- `sub_activity_line` is unchanged and still renders `[avatar] [name] [phrase] · [ago]`, with `You`
|
||||
for the signed-in user and `activity_phrase(kind)` for the verb. Rows are variable height
|
||||
(`py_2`), which `gpui::list` auto-measures.
|
||||
- Thread rows in a section are drawn as one stack: `render_entry` passes `first`/`last` within the
|
||||
section (`entry_ix == 0` / `entry_ix + 1 == section.entries.len()`), and `thread_row` rounds the
|
||||
outer edges (`rounded_t` on the first, `rounded_b` on the last, theme radius) and draws a
|
||||
`border_b_1` divider on every row but the last.
|
||||
- Trade-off: the row title is the thread root's, not the newest event's, so a comment thread no longer
|
||||
previews the comment text. That is the point of the merge - the row identifies the thread.
|
||||
- `cargo clippy -p signed_core -p signed_state -p workspace --all-targets` is clean,
|
||||
`cargo check -p signed_core -p signed_state -p workspace --all-targets` succeeds, and
|
||||
`cargo test -p signed_core -p signed_state -p workspace` passes (69 / 24 / 7).
|
||||
|
||||
## 8. Validation
|
||||
|
||||
- `cargo test -p signed_core` (69 tests): root resolution, grouping, merging, read-state cutoff, serde
|
||||
round-trip.
|
||||
- `cargo test -p signed_state` (24 tests): the `Inbox` / `query_inbox` paths that do not need GPUI
|
||||
(state round-trip, grouping helpers).
|
||||
- `cargo test -p workspace` (7 tests): repository-detail helpers.
|
||||
- `cargo clippy -p signed_state --all-targets`, `cargo clippy -p workspace --all-targets` and
|
||||
`cargo check --workspace --all-targets` after each phase.
|
||||
- Manual: log in with a repo-owning identity; open the inbox from the sidebar and confirm the panel
|
||||
populates from another identity's issue/comment, the activity list shows your own items, and that no
|
||||
kind-30078 event is broadcast (watch the relays / `Published` events). Restart to confirm the read
|
||||
state is read back from LMDB. Confirm the sidebar has no unread badge.
|
||||
|
||||
## 9. SDK APIs used (verified in the pinned `5c669a4` checkout)
|
||||
|
||||
- `Kind::{Comment, GitIssue, GitPullRequest, GitPatch, GitPullRequestUpdate,`
|
||||
`GitStatusOpen/Applied/Closed/Draft, ApplicationSpecificData, EventDeletion, RequestToVanish}`
|
||||
- `Filter::{kind, kinds, pubkey, pubkeys, custom_tags, limit, since, events, coordinate, identifier}`
|
||||
- Non-obvious: `Filter::pubkey`/`pubkeys` set the lowercase **`p` tag**, not `authors`. Use
|
||||
`Filter::author`/`authors` for authorship. The `notifications` filter relies on this.
|
||||
- `SingleLetterTag::{LOWERCASE_P, LOWERCASE_E, UPPERCASE_P, UPPERCASE_K, UPPERCASE_E}`
|
||||
- `nostr::nips::nip22::{extract_root, extract_parent, CommentTarget}`: NIP-22 root/parent pointers
|
||||
- `Tags::{event_ids, public_keys, coordinates, identifier, hashtags}` iterators
|
||||
- `Client::{database, subscribe, sync, notifications, send_event, add_relay}`;
|
||||
`NostrDatabase::{save_event, query}`; `NostrLmdb`, `NostrGossipMemory`
|
||||
- `EventBuilder::{new, tags, finalize}`, `Tag::identifier`, `Keys::generate`
|
||||
- `Timestamp`, `EventId` (hex serde), `PublicKey`, `Coordinate`
|
||||
- Fetch paths converge on the same notification: `client.subscribe(...)` and negentropy
|
||||
`client.sync(...)` both persist received events to LMDB and surface them as
|
||||
`ClientNotification::Event`, which `Backend`'s pump batches into `BackendEvent::NostrUpdate`.
|
||||
This is why the query right after a fetch is a cache read, not a race.
|
||||
Reference in New Issue
Block a user