refactor 3

This commit is contained in:
2026-09-13 13:00:20 +07:00
parent 025fd15837
commit f8277e4c2e
11 changed files with 451 additions and 303 deletions
+208 -73
View File
@@ -31,8 +31,14 @@ const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
/// Always derived from the local database.
pub struct RepoStore {
addr: RepoAddr,
/// NIP-34 address. `None` while the repository is local-only.
addr: Option<RepoAddr>,
/// Latest announcement. Seeded from the open-time hint, replaced by the
/// database's latest on the first pass. `None` while local-only.
pub announcement: Option<Announcement>,
/// Local working copy. The scan path for a local repository, kept when it is
/// later announced so the panel keeps its worktree.
pub path: Option<PathBuf>,
/// The first local pass has been applied.
///
/// Views distinguish "no data yet" from a genuinely empty repository with it.
@@ -82,52 +88,21 @@ pub struct RepoStore {
root_fetches: HashSet<EventId>,
/// Refresh coalescing, see [`RefreshGate`].
refresh: RefreshGate,
_subscription: Subscription,
/// Backend subscription of an announced repository. `None` while local-only.
_subscription: Option<Subscription>,
}
impl RepoStore {
pub fn new(addr: RepoAddr, announced_relays: Vec<RelayUrl>, cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
let relevant = match event {
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
// Deletions may target any event of this repository.
let deletion =
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
let coordinate = update.coordinate.as_ref() == Some(&this.addr);
let author = update.author == this.addr.public_key;
let kind = update.kind == Kind::GitRepoAnnouncement;
// NIP-22 comments carry no `a` tag.
// Coordinate matching fails for them.
// Any comment may reference this repository's roots.
let comment = update.kind == Kind::Comment;
// Status events may omit their `a` tag, NIP-34.
// Any status event may reference a root of this repository.
let status = RepoStatus::from_kind(update.kind).is_some();
deletion || coordinate || (author && kind) || comment || status
}),
BackendEvent::Published(event) => {
let kind = event.kind == Kind::GitRepoAnnouncement;
let author = event.pubkey == this.addr.public_key;
let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr);
// Locally published deletions may target any event of this repository.
// Refresh so they take effect immediately, like relay deletions.
let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
coordinate || (kind && author) || deletion
}
_ => false,
};
if relevant {
this.refresh(cx);
}
});
/// Announced repository.
pub fn new(addr: RepoAddr, hint: Option<Announcement>, cx: &mut Context<Self>) -> Self {
let weak = cx.entity().downgrade();
let subscription = Self::subscribe_backend(cx);
let announced_relays = hint
.as_ref()
.map(|announcement| announcement.relays.clone())
.unwrap_or_default();
cx.defer(move |cx| {
let result = weak.update(cx, |this, cx| {
this.subscribe_remote(cx);
@@ -141,8 +116,9 @@ impl RepoStore {
});
Self {
addr,
announcement: None,
addr: Some(addr),
announcement: hint,
path: None,
loaded: false,
head: None,
issues: Vec::new(),
@@ -161,13 +137,103 @@ impl RepoStore {
repo_relays: HashSet::new(),
root_fetches: HashSet::new(),
refresh: RefreshGate::default(),
_subscription: subscription,
_subscription: Some(subscription),
}
}
/// Returns the repository's address.
pub fn addr(&self) -> &RepoAddr {
&self.addr
/// Local repository discovered by the scan, not announced to NIP-34 yet.
pub fn new_local(path: PathBuf) -> Self {
Self {
addr: None,
announcement: None,
path: Some(path),
loaded: true,
head: None,
issues: Vec::new(),
patches: Vec::new(),
pull_requests: Vec::new(),
comments: Vec::new(),
status_by_root: HashMap::new(),
open_issue_count: 0,
open_pr_count: 0,
version: 0,
last_error: None,
last_warning: None,
last_push_warning: None,
pushing: false,
cloning: false,
repo_relays: HashSet::new(),
root_fetches: HashSet::new(),
refresh: RefreshGate::default(),
_subscription: None,
}
}
/// Switch a local repository to its NIP-34 mode, keeping its path.
pub fn announce(&mut self, announcement: Announcement, cx: &mut Context<Self>) {
self.addr = Some(announcement.addr());
self.announcement = Some(announcement.clone());
self.loaded = false;
if self._subscription.is_none() {
self._subscription = Some(Self::subscribe_backend(cx));
}
self.subscribe_remote(cx);
self.connect_announced_relays(&announcement.relays, cx);
self.refresh(cx);
}
/// Subscriptions to the backend events concerning this repository.
fn subscribe_backend(cx: &mut Context<Self>) -> Subscription {
let backend = Backend::global(cx);
cx.subscribe(&backend, |this, _backend, event, cx| {
let Some(addr) = this.addr.as_ref() else {
return;
};
let relevant = match event {
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
// Deletions may target any event of this repository.
let deletion =
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
let coordinate = update.coordinate.as_ref() == Some(addr);
let author = update.author == addr.public_key;
let kind = update.kind == Kind::GitRepoAnnouncement;
// NIP-22 comments carry no `a` tag.
// Coordinate matching fails for them.
// Any comment may reference this repository's roots.
let comment = update.kind == Kind::Comment;
// Status events may omit their `a` tag, NIP-34.
// Any status event may reference a root of this repository.
let status = RepoStatus::from_kind(update.kind).is_some();
deletion || coordinate || (author && kind) || comment || status
}),
BackendEvent::Published(event) => {
let kind = event.kind == Kind::GitRepoAnnouncement;
let author = event.pubkey == addr.public_key;
let coordinate = event.tags.coordinates().into_iter().any(|c| c == *addr);
// Locally published deletions may target any event of this repository.
// Refresh so they take effect immediately, like relay deletions.
let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
coordinate || (kind && author) || deletion
}
_ => false,
};
if relevant {
this.refresh(cx);
}
})
}
/// Returns the repository's NIP-34 address. `None` while it is local-only.
pub fn addr(&self) -> Option<&RepoAddr> {
self.addr.as_ref()
}
/// Returns the repository's name, or `Unknown` when not known.
@@ -199,6 +265,10 @@ impl RepoStore {
/// Fetch this repository's events from the relays in its NIP-34 `relays` tag.
fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context<Self>) {
let Some(addr) = self.addr.clone() else {
return;
};
let new: Vec<RelayUrl> = relays
.iter()
.filter(|url| !self.repo_relays.contains(*url))
@@ -211,7 +281,6 @@ impl RepoStore {
self.repo_relays.extend(new.iter().cloned());
let backend = Backend::global(cx);
let addr = self.addr.clone();
backend.update(cx, |backend, cx| {
backend.connect_repo_relays(new, Self::repo_filters(&addr), cx);
@@ -220,8 +289,11 @@ impl RepoStore {
/// Fetch this repository's events from the bootstrap relays.
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
let Some(addr) = self.addr.clone() else {
return;
};
let backend = Backend::global(cx);
let addr = self.addr.clone();
backend.update(cx, |backend, cx| {
backend.subscribe_bootstrap(Self::repo_filters(&addr), cx);
@@ -233,6 +305,10 @@ impl RepoStore {
/// Runs immediately. The backend pump already batches the relay events that
/// trigger a refresh, so no per-store debounce is needed.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.addr.is_none() {
return;
}
if self.refresh.request() != RefreshRequest::Schedule {
return;
}
@@ -241,11 +317,14 @@ impl RepoStore {
}
fn run_refresh(&mut self, cx: &mut Context<Self>) {
let Some(addr) = self.addr.clone() else {
return;
};
self.refresh.begin();
let backend = Backend::global(cx);
let client = backend.read(cx).client();
let addr = self.addr.clone();
let work = cx.background_spawn(async move {
let (announcements, states, activity, deletion_events) = async {
@@ -405,12 +484,18 @@ impl RepoStore {
// The first pass is the exception: it must notify even when it
// found nothing, so views can leave their loading state and show
// the empty result.
//
// Keep the open-time hint until that first pass has confirmed what
// the database holds; afterwards the database is the truth,
// including a deletion.
let keep_hint = announcement.is_none() && !this.loaded;
let first_pass = !this.loaded;
let head_changed = state
.as_ref()
.is_some_and(|(_, head)| this.head.as_deref() != head.as_deref());
let changed = first_pass
|| this.announcement != announcement
|| (!keep_hint && this.announcement != announcement)
|| head_changed
|| this.issues != issues
|| this.patches != patches
@@ -418,7 +503,9 @@ impl RepoStore {
|| this.comments != comments
|| this.status_by_root != status_by_root;
this.announcement = announcement;
if !keep_hint {
this.announcement = announcement;
}
// The announcement may list relays for this repository's activity.
// Connect to any we have not fetched from yet.
@@ -526,13 +613,20 @@ impl RepoStore {
/// The author is the public key of the repository address.
/// Only the author may manage pull requests, close, reopen or merge.
pub fn is_author(&self, user: &PublicKey) -> bool {
&self.addr.public_key == user
self.addr
.as_ref()
.is_some_and(|addr| &addr.public_key == user)
}
/// Open an issue on this repository.
pub fn open_issue(&mut self, subject: Option<String>, content: String, cx: &mut Context<Self>) {
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let builder = GitIssue {
repository: self.addr.clone(),
repository: addr,
content,
subject,
labels: Vec::new(),
@@ -564,6 +658,11 @@ impl RepoStore {
content: String,
cx: &mut Context<Self>,
) {
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let relay_hint = self
.announcement
.as_ref()
@@ -571,7 +670,7 @@ impl RepoStore {
.cloned();
self.publish(
comment_builder(root, parent, relay_hint.as_ref(), &self.addr, content),
comment_builder(root, parent, relay_hint.as_ref(), &addr, content),
cx,
);
}
@@ -592,6 +691,11 @@ impl RepoStore {
self.last_error = None;
self.last_warning = None;
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let series: Vec<String> = signed_git::split_patch_series(&patch)
.into_iter()
.map(str::to_owned)
@@ -635,8 +739,7 @@ impl RepoStore {
// The author's npub names their GRASP-06 namespace, `/prs/...`.
let author_npub = user.to_bech32().unwrap();
let addr = self.addr.clone();
let owner = self.addr.public_key;
let owner = addr.public_key;
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
let repo_id = addr.identifier.clone();
let base_npub = owner.to_bech32().unwrap();
@@ -752,7 +855,7 @@ impl RepoStore {
let clone = pr_clone_urls(prs_urls, base_clone);
let builder = GitPullRequest {
repository: this.addr.clone(),
repository: addr.clone(),
content: description,
subject,
labels: Vec::new(),
@@ -971,8 +1074,11 @@ impl RepoStore {
.map(|p| p.id)
});
let addr = self.addr.clone();
let owner = self.addr.public_key;
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let owner = addr.public_key;
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
let root = root.clone();
let clone: Vec<Url> = self
@@ -1000,9 +1106,9 @@ impl RepoStore {
});
}
let builder = this.update(cx, |this, _cx| {
let builder = this.update(cx, |_this, _cx| {
let builder = GitPullRequestUpdate {
repository: this.addr.clone(),
repository: addr.clone(),
pull_request_event: root.id,
pull_request_author: root.pubkey,
current_commit,
@@ -1059,6 +1165,11 @@ impl RepoStore {
pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context<Self>) {
self.last_error = None;
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let maintainers = self
.announcement
.as_ref()
@@ -1084,9 +1195,9 @@ impl RepoStore {
let builder = EventBuilder::new(status.kind(), "").tags([
root_ref,
Tag::public_key(self.addr.public_key),
Tag::public_key(addr.public_key),
Tag::public_key(root.pubkey),
Tag::coordinate(self.addr.clone(), None),
Tag::coordinate(addr, None),
]);
self.publish(builder, cx);
@@ -1097,6 +1208,11 @@ impl RepoStore {
self.last_error = None;
self.last_warning = None;
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let is_author = Backend::global(cx)
.read(cx)
.current_user()
@@ -1107,7 +1223,6 @@ impl RepoStore {
}
let cache = GitStore::global(cx).cache().clone();
let addr = self.addr.clone();
let clone_urls: Vec<Url> = self
.announcement
@@ -1176,12 +1291,13 @@ impl RepoStore {
/// The latest announcement of this repository,
/// for operations that need its clone URLs and relays.
fn action_announcement(&self, cx: &App) -> Option<Announcement> {
let addr = self.addr.as_ref()?;
self.announcement.clone().or_else(|| {
RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|announcement| announcement.addr() == self.addr)
.find(|announcement| announcement.addr() == *addr)
.cloned()
})
}
@@ -1243,6 +1359,10 @@ impl RepoStore {
)));
}
let Some(addr) = self.addr.clone() else {
return self.action_error("This repository is not published to Nostr yet", cx);
};
let Some(announcement) = self.action_announcement(cx) else {
return self.action_error("Repository announcement is not loaded yet", cx);
};
@@ -1250,7 +1370,6 @@ impl RepoStore {
// The state event's `HEAD` stays the announced default branch.
// The checkout may be on a side branch.
let head = self.head.clone();
let addr = self.addr.clone();
self.pushing = true;
self.last_error = None;
@@ -1298,7 +1417,9 @@ impl RepoStore {
/// Only the repository owner may delete it. The lists update when the
/// deletion events arrive.
pub fn delete_repository(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
let addr = self.addr.clone();
let Some(addr) = self.addr.clone() else {
return self.action_error("This repository is not published to Nostr yet", cx);
};
self.last_error = None;
let backend = Backend::global(cx);
@@ -1328,12 +1449,16 @@ impl RepoStore {
"A clone of this repository is already in progress"
)));
}
let Some(addr) = self.addr.clone() else {
return self.action_error("This repository is not published to Nostr yet", cx);
};
let Some(announcement) = self.action_announcement(cx) else {
return self.action_error("Repository announcement is not loaded yet", cx);
};
let clone_urls = announcement.clone.clone();
let addr = self.addr.clone();
self.cloning = true;
self.last_error = None;
@@ -1370,6 +1495,12 @@ impl RepoStore {
})
}
/// Record that an action needs a NIP-34 address this repository does not have.
fn not_announced(&mut self, cx: &mut Context<Self>) {
self.last_error = Some("This repository is not published to Nostr yet".into());
cx.notify();
}
/// Fail an operation whose announcement is not loaded yet.
fn action_error(
&mut self,
@@ -1393,11 +1524,15 @@ impl RepoStore {
euc: Option<&str>,
cx: &mut Context<Self>,
) {
let Some(addr) = self.addr.clone() else {
return;
};
let mut tags = vec![
Tag::parse(["e", &root.id.to_hex(), "", "root"]).expect("valid root tag"),
Tag::public_key(self.addr.public_key),
Tag::public_key(addr.public_key),
Tag::public_key(root.pubkey),
Tag::coordinate(self.addr.clone(), None),
Tag::coordinate(addr, None),
];
if let Some(euc) = euc
@@ -151,35 +151,37 @@ impl PullRequestDetailView {
let binding = {
let store = self.store.read(cx);
store
.pull_requests
.iter()
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
.map(|root| {
let update = latest_update(store.pull_requests.iter(), root);
store.addr().and_then(|addr| {
store
.pull_requests
.iter()
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
.map(|root| {
let update = latest_update(store.pull_requests.iter(), root);
let tip = update
.and_then(current_commit_of)
.or_else(|| current_commit_of(root));
let tip = update
.and_then(current_commit_of)
.or_else(|| current_commit_of(root));
let base = update
.and_then(merge_base_of)
.or_else(|| merge_base_of(root));
let base = update
.and_then(merge_base_of)
.or_else(|| merge_base_of(root));
let clone_urls = clone_urls_of(root)
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()))
.unwrap_or_default();
let clone_urls = clone_urls_of(root)
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()))
.unwrap_or_default();
PrBinding {
description: root.content.clone(),
patch: pull_request_patch(root, store.patches.iter()),
tip,
base,
clone_urls,
addr: store.addr().clone(),
has_patch_link: root.tags.event_ids().next().is_some(),
}
})
PrBinding {
description: root.content.clone(),
patch: pull_request_patch(root, store.patches.iter()),
tip,
base,
clone_urls,
addr: addr.clone(),
has_patch_link: root.tags.event_ids().next().is_some(),
}
})
})
};
let Some(binding) = binding else {
+40 -26
View File
@@ -305,7 +305,24 @@ impl NewPullRequestView {
),
];
let mut view = Self {
cx.defer_in(window, |this, window, cx| {
let Some(addr) = this.store.read(cx).addr().cloned() else {
return;
};
let Some(path) = CheckoutsStore::global(cx)
.read(cx)
.associations_of(&addr)
.into_iter()
.next()
else {
return;
};
this.apply_folder_path(path, window, cx);
});
Self {
focus_handle: cx.focus_handle(),
dock_area,
store,
@@ -330,20 +347,7 @@ impl NewPullRequestView {
scroll_handle: VirtualListScrollHandle::new(),
item_sizes: Rc::new(Vec::new()),
_subscriptions: subscriptions,
};
// Prefill with the store's freshest associated checkout, no folder dialog.
let addr = view.store.read(cx).addr().clone();
if let Some(path) = CheckoutsStore::global(cx)
.read(cx)
.associations_of(&addr)
.into_iter()
.next()
{
view.apply_folder_path(path, window, cx);
}
view
}
/// Whether a compare source, a checkout or a fork, is applied.
@@ -496,11 +500,12 @@ impl NewPullRequestView {
// Remember this folder as a checkout of the target repository.
// The next panel pre-fills it.
let addr = self.store.read(cx).addr().clone();
let checkout_store = CheckoutsStore::global(cx);
checkout_store.update(cx, |store, cx| {
store.record(PathBuf::from(&path), addr, cx);
});
if let Some(addr) = self.store.read(cx).addr().cloned() {
let checkout_store = CheckoutsStore::global(cx);
checkout_store.update(cx, |store, cx| {
store.record(PathBuf::from(&path), addr, cx);
});
}
let branches = self.branches.clone();
let base = SharedString::from(base.clone());
@@ -524,18 +529,21 @@ impl NewPullRequestView {
/// The base repository of the panel, its address and announced EUC.
///
/// Used to find fork candidates.
fn base_repo(&self, cx: &App) -> (RepoAddr, Option<String>) {
/// Used to find fork candidates. `None` while the repository is not announced.
fn base_repo(&self, cx: &App) -> Option<(RepoAddr, Option<String>)> {
let store = self.store.read(cx);
let addr = store.addr()?.clone();
let euc = store.announcement.as_ref().and_then(|a| a.euc.clone());
(store.addr().clone(), euc)
Some((addr, euc))
}
/// Announced forks of the target repository a compare can use, own first.
///
/// Re-read whenever the picker opens.
fn fork_candidates(&self, cx: &App) -> Vec<Announcement> {
let (base, euc) = self.base_repo(cx);
let Some((base, euc)) = self.base_repo(cx) else {
return Vec::new();
};
let user = Backend::global(cx).read(cx).current_user();
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
fork_candidates(&announcements, &base, euc.as_deref(), user)
@@ -556,7 +564,9 @@ impl NewPullRequestView {
.as_ref()
.is_some_and(|fork| fork.announcement.addr() == announcement.addr());
let (base, _euc) = self.base_repo(cx);
let Some((base, _euc)) = self.base_repo(cx) else {
return;
};
let cache = GitStore::global(cx).cache().clone();
let mirror_path = cache.repo_path(&base);
let namespace = fork_namespace(&announcement);
@@ -1124,8 +1134,12 @@ impl NewPullRequestView {
cx: &Context<Self>,
) -> impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static {
let view = cx.entity().downgrade();
let addr = self.store.read(cx).addr().clone();
let associated = CheckoutsStore::global(cx).read(cx).associations_of(&addr);
let associated = self
.store
.read(cx)
.addr()
.map(|addr| CheckoutsStore::global(cx).read(cx).associations_of(addr))
.unwrap_or_default();
let active_path = (self.fork.is_none())
.then(|| self.repo_path.clone())
+13 -21
View File
@@ -20,14 +20,10 @@ 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
self.store
.update(cx, |store, cx| store.push_repository(cx))
.detach();
}
@@ -39,9 +35,7 @@ impl RepoDetailView {
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(store) = self.store.clone() else {
return;
};
let store = self.store.clone();
if store.read(cx).pushing {
return;
@@ -72,23 +66,22 @@ impl RepoDetailView {
/// 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
self.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 {
if self.store.read(cx).addr().is_none() {
return;
};
}
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
let store = self.store.clone();
let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx));
dock_area.update(cx, |dock_area, cx| {
@@ -98,13 +91,15 @@ impl RepoDetailView {
/// 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 {
if self.store.read(cx).addr().is_none() {
return;
};
}
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
let store = self.store.clone();
let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx));
dock_area.update(cx, |dock_area, cx| {
@@ -131,7 +126,7 @@ impl RepoDetailView {
/// 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 {
let Some(local_path) = self.store.read(cx).path.clone() else {
return;
};
let view = cx.entity().downgrade();
@@ -170,10 +165,7 @@ pub(crate) fn open_repo_panel(
/// relays to connect to right away; the store loads the announcement from the
/// local database on its first pass, so the hint is optional.
fn repo_store(addr: &RepoAddr, hint: Option<&Announcement>, cx: &mut App) -> Entity<RepoStore> {
let relays = hint
.map(|announcement| announcement.relays.clone())
.unwrap_or_default();
cx.new(|cx| RepoStore::new(addr.clone(), relays, cx))
cx.new(|cx| RepoStore::new(addr.clone(), hint.cloned(), cx))
}
/// An item of a repository to open from outside its detail panel.
+20 -28
View File
@@ -17,14 +17,15 @@ impl RepoDetailView {
/// 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 store = self.store.read(cx);
let addr = store.addr()?;
let user = Backend::global(cx).read(cx).current_user()?;
if store.read(cx).is_author(&user) {
if store.is_author(&user) {
return None;
}
let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(&addr);
let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(addr);
'status: for status in statuses {
if self
@@ -33,7 +34,6 @@ impl RepoDetailView {
{
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)
{
@@ -50,15 +50,15 @@ impl RepoDetailView {
///
/// Not dismissed in this panel.
fn push_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
let entity = self.store.as_ref()?;
let store = self.store.read(cx);
let addr = store.addr()?;
let user = Backend::global(cx).read(cx).current_user()?;
if !entity.read(cx).is_author(&user) {
if !store.is_author(&user) {
return None;
}
let addr = entity.read(cx).addr().clone();
let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(&addr);
let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(addr);
statuses.into_iter().find(|status| {
!self
@@ -75,10 +75,7 @@ impl RepoDetailView {
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 pushing = self.store.read(cx).pushing;
let commits = if status.ahead == 1 {
SharedString::from("1 commit")
@@ -160,8 +157,7 @@ impl RepoDetailView {
/// 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 store = self.store.read(cx);
let warning = store.last_push_warning.clone()?;
let pushing = store.pushing;
@@ -213,11 +209,9 @@ impl RepoDetailView {
.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;
});
}
this.store.update(cx, |store, _| {
store.last_push_warning = None;
});
cx.notify();
})),
),
@@ -299,14 +293,12 @@ impl RepoDetailView {
.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,
);
}
open_new_pull_panel(
this.dock_area.clone(),
this.store.clone(),
window,
cx,
);
})),
)
.child(
+14 -17
View File
@@ -27,15 +27,11 @@ 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() {
if self.store.read(cx).addr().is_none() {
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 store = self.store.read(cx);
let issue_count = SharedString::from(store.issue_count().to_string());
let pr_count = SharedString::from(store.pull_request_count().to_string());
@@ -43,7 +39,7 @@ impl RepoDetailView {
let pushing = store.pushing;
let cloning = store.cloning;
let Some(source) = store.announcement.as_ref().or(self.initial.as_ref()) else {
let Some(source) = store.announcement.as_ref() else {
return div().into_any_element();
};
@@ -73,19 +69,18 @@ impl RepoDetailView {
.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);
}
open_new_issue_dialog(this.store.clone(), window, cx);
}
RepoAction::NewPR => {
if let Some(store) = this.store.clone() {
open_new_pull_panel(this.dock_area.clone(), store, window, cx);
}
open_new_pull_panel(this.dock_area.clone(), this.store.clone(), window, cx);
}
RepoAction::SendPatch => {
if let Some(store) = this.store.clone() {
open_send_patch_panel(this.dock_area.clone(), store, window, cx);
}
open_send_patch_panel(
this.dock_area.clone(),
this.store.clone(),
window,
cx,
);
}
RepoAction::About => {
if let Some(announcement) = this.announcement(cx) {
@@ -421,7 +416,9 @@ impl RepoDetailView {
fn render_local_header(&self, cx: &mut Context<Self>) -> AnyElement {
let name = self.display_name(cx);
let path = self
.local_path
.store
.read(cx)
.path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_default();
@@ -190,7 +190,7 @@ fn init_repository(
window.close_dialog(cx);
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| {
this.apply_announcement(announcement, window, cx);
this.apply_announcement(announcement, cx);
});
}
})
+21 -7
View File
@@ -39,9 +39,24 @@ impl RepoDetailView {
self.error = None;
cx.notify();
let (addr, announcement, local_path) = {
let store = self.store.read(cx);
(
store.addr().cloned(),
store.announcement.clone(),
store.path.clone(),
)
};
// 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() {
if addr.is_none() {
self.repo_started = true;
let Some(local_path) = local_path else {
return;
};
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
let data = cx
.background_spawn(async move {
@@ -67,13 +82,14 @@ impl RepoDetailView {
return;
}
let Some(initial) = self.initial.as_ref() else {
let Some(announcement) = announcement else {
return;
};
self.repo_started = true;
let cache = GitStore::global(cx).cache().clone();
let addr = initial.addr();
let clone_urls: Vec<Url> = initial.clone.clone();
let addr = announcement.addr();
let clone_urls: Vec<Url> = announcement.clone.clone();
// Captured before the loads start.
// A branch/tag switch bumps the generation, discarding the refresh below.
@@ -324,9 +340,7 @@ impl RepoDetailView {
/// 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 store = self.store.clone();
let name = {
let Some(announcement) = self.announcement(cx) else {
+45 -53
View File
@@ -71,19 +71,14 @@ pub struct RepoDetailView {
///
/// New panels, commit diffs, are added there.
dock_area: WeakEntity<DockArea>,
/// Snapshot taken at open time.
/// Per-repository store, holding the local path and the announcement,
/// issues, PRs and statuses. The single identity of both modes.
store: Entity<RepoStore>,
/// The initial explorer load has been started.
///
/// `None` for local repositories that haven't been published yet, and for a
/// repository opened by address until its store loads the announcement.
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>,
/// A repository opened by address alone starts without an announcement; the
/// store observer starts the load once the first one lands.
repo_started: bool,
/// File explorer state, the worktree of the local clone.
tree_state: Entity<TreeState>,
/// Root of the local clone, for reading files on demand.
@@ -178,18 +173,8 @@ impl RepoDetailView {
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 relays = hint
.as_ref()
.map(|announcement| announcement.relays.clone())
.unwrap_or_default();
let store = cx.new(|cx| RepoStore::new(addr, relays, cx));
let mut view = Self::new_common(dock_area, hint, Some(store.clone()), None, window, cx);
view.attach_store(&store, window, cx);
view
let store = cx.new(|cx| RepoStore::new(addr, hint, cx));
Self::new_common(dock_area, store, window, cx)
}
/// Open a local repository discovered by the scan.
@@ -199,7 +184,8 @@ impl RepoDetailView {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::new_common(dock_area, None, None, Some(local_path), window, cx)
let store = cx.new(move |_cx| RepoStore::new_local(local_path));
Self::new_common(dock_area, store, window, cx)
}
/// Shared construction.
@@ -207,9 +193,7 @@ impl RepoDetailView {
/// 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>,
store: Entity<RepoStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -271,11 +255,10 @@ impl RepoDetailView {
this.load_repo(window, cx);
});
Self {
initial,
let mut view = Self {
dock_area,
store,
local_path,
store: store.clone(),
repo_started: false,
tree_state,
worktree: None,
worktree_paths: Vec::new(),
@@ -311,31 +294,40 @@ impl RepoDetailView {
push_statuses: Vec::new(),
focus_handle: cx.focus_handle(),
_subscriptions: subscriptions,
}
};
view.attach_store(&store, window, cx);
view
}
/// The latest announcement from the store or the open-time snapshot.
/// `None` for local repositories that haven't been published yet.
/// The latest announcement of the repository, `None` while local-only or
/// until the store's first pass loads it.
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())
self.store.read(cx).announcement.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()),
);
let store = self.store.read(cx);
if store.addr().is_none() {
return store
.path
.as_ref()
.map(|path| {
SharedString::from(
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string()),
)
})
.unwrap_or_default();
}
self.announcement(cx)
store
.announcement
.as_ref()
.map(|announcement| {
announcement
.name
@@ -386,8 +378,10 @@ impl Render for RepoDetailView {
// 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))
.read(cx)
.last_error
.clone()
.map(SharedString::from)
});
v_flex()
@@ -405,9 +399,7 @@ impl Render for RepoDetailView {
.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);
}
this.store.update(cx, |store, _| store.last_error = None);
cx.notify();
})),
)
+20 -39
View File
@@ -1,4 +1,3 @@
use gpui::prelude::*;
use gpui::{Context, Entity, Window};
use signed_core::Announcement;
use signed_state::{Backend, CheckoutsStore, LocalReposStore, RepoStore};
@@ -6,33 +5,29 @@ 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.
/// Switch a local repository into its NIP-34 mode after a successful init.
/// The store is kept, so the panel keeps its path and loaded worktree.
/// Drops the local scan identity so it leaves the sidebar's local section.
pub(crate) fn apply_announcement(
&mut self,
announcement: Announcement,
window: &mut Window,
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() {
let path = self.store.read(cx).path.clone();
if let Some(path) = path {
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, window, cx);
self.store = Some(store);
self.initial = Some(announcement);
self.store
.update(cx, |store, cx| store.announce(announcement, cx));
// The new address needs its ready-to-contribute statuses requested.
self.refresh_ready_statuses(cx);
cx.notify();
}
/// Observe the repository's store, re-render on refreshes.
/// Request the ready-to-contribute statuses for it.
/// Observe the repository's store and re-render on its refreshes.
/// Start the explorer once the store has an announcement.
pub(super) fn attach_store(
&mut self,
store: &Entity<RepoStore>,
@@ -42,18 +37,9 @@ impl RepoDetailView {
self._subscriptions
.push(cx.observe_in(store, window, |this, store, window, cx| {
this.refresh_ready_statuses(cx);
// A repository opened from its address alone starts without an
// announcement. Adopt the store's first one so the explorer can
// load; later passes leave the snapshot and the selection alone.
let announcement = store.read(cx).announcement.clone();
if this.initial.is_none()
&& let Some(announcement) = announcement
{
this.initial = Some(announcement);
if !this.repo_started && store.read(cx).announcement.is_some() {
this.load_repo(window, cx);
}
cx.notify();
}));
self.refresh_ready_statuses(cx);
@@ -64,11 +50,11 @@ impl RepoDetailView {
/// 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 {
let Some(addr) = self.store.read(cx).addr().cloned() else {
return;
};
let head = entity.read(cx).head.clone();
let head = self.store.read(cx).head.clone();
if self.ready_requested && self.ready_head == head {
return;
@@ -77,14 +63,13 @@ impl RepoDetailView {
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));
.is_some_and(|user| self.store.read(cx).is_author(&user));
checkout.update(cx, |store, cx| {
// The ready statuses keep the fast poll running while the panel is open.
@@ -97,16 +82,12 @@ impl RepoDetailView {
});
}
/// 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.
/// The ready-to-contribute and ready-to-push statuses of this repository.
pub(super) fn refresh_statuses(&mut self, cx: &mut Context<Self>) -> bool {
let Some(entity) = self.store.clone() else {
let Some(addr) = self.store.read(cx).addr().cloned() 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);