Compare commits
18
Commits
a706fadafa
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a249174026 | ||
|
|
4f81501668 | ||
|
|
d6850d60ea | ||
|
|
4b4b8b5cf2 | ||
|
|
a2e7907193 | ||
|
|
946128433b | ||
|
|
30ff255372 | ||
|
|
cd61a09320 | ||
|
|
e2edb9dd12 | ||
|
|
5e061cf860 | ||
|
|
4c252dc589 | ||
|
|
e72e04c471 | ||
|
|
b78418d815 | ||
|
|
7878feb236 | ||
|
|
4d8aa97e24 | ||
|
|
31256a4870 | ||
|
|
5586f67f0e | ||
|
|
5887a14c50 |
@@ -2,7 +2,11 @@
|
||||
|
||||
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
|
||||
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
|
||||
* Keep doc comments and comments simple, one line, straightforward, and unbloated.
|
||||
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
||||
* Prefer `impl` blocks over standalone functions: place functionality as methods or associated functions on the relevant type, extending the type's existing `impl` block instead of creating a duplicate one in another file.
|
||||
* Do not wrap clients or SDK dependencies in project structs or pass-through helper functions, call their methods directly where needed. Extract a helper only when it holds real logic that is used in more than one place.
|
||||
* Bind a global store handle to a local before using it (`let state = GlobalState::global(cx);` then `state.update(...)`) instead of chaining on `CheckoutsStore::global(cx)` inline.
|
||||
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
|
||||
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
|
||||
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
|
||||
@@ -125,6 +129,12 @@ Action handlers can be registered on an element via the event handler `.on_actio
|
||||
|
||||
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
|
||||
|
||||
GPUI also tracks the entities a window reads while rendering. When an entity notifies, every view that read that entity during the previous frame is rerendered automatically.
|
||||
|
||||
* A view that reads an entity in render (e.g. `state.read(cx).profile(...)`) does not need `cx.observe(&entity, ...)` to pick up its changes - the read itself is the dependency. Reserve `cx.observe` and `cx.subscribe` for when the view must run logic in response to a change (rebuild derived state, re-query, dedupe), not to trigger a re-render.
|
||||
* Notify the narrowest entity holding the change. Notifying a global store rerenders every view that ever read it; notifying a per-panel entity rerenders only the panels reading it.
|
||||
* This works with cached panels (`panel.cached(...)`): a cached subtree repaints when the view it belongs to is invalidated.
|
||||
|
||||
## Entity events
|
||||
|
||||
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
|
||||
|
||||
+20
-5
@@ -1,18 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Breaking changes
|
||||
## v0.2.0 - 2026/10/21
|
||||
|
||||
### Added
|
||||
|
||||
- Follow the operating system's native theme on macOS, Windows and Linux (KDE, GNOME), applying the OS colors, fonts, radii and accessibility preferences.
|
||||
- Add an identity import dialog, importing an existing identity by pasting its `nsec` secret key (encrypted with a new passphrase), NIP-49 `ncryptsec` (unlocked with its passphrase), or `bunker://` NIP-46 URI
|
||||
- Add a dropdown menu on the signed-in user button in the sidebar
|
||||
- Add sign-out feature, confirmed by an alert dialog before deleting the stored session credential
|
||||
- Add a quit action, bound to `secondary-q` (cmd on macOS, ctrl on others)
|
||||
- Allow choosing the default branch when publishing a repository
|
||||
- Detect when local commits are not on Nostr yet, showing an unsynced count badge on sidebar repository rows and a Sync now banner on the repository view
|
||||
- Add a step-by-step push checklist while publishing and syncing a repository
|
||||
- Add a `Reset remote to local` action that republishes the mirror and deletes grasp refs the mirror does not have
|
||||
|
||||
### Changed
|
||||
|
||||
- Use the built-in light/dark syntax highlighting palette instead of the Signed palette bundled in `signed.json` when the native theme is active
|
||||
- Increase the default theme corner radii from 2 to 4 and large radii from 6 to 8
|
||||
- Surface backend errors as error notifications in the workspace instead of silently dropping them
|
||||
- Break ties in repository activity lists by event id, so same-second events order deterministically
|
||||
@@ -20,15 +24,26 @@
|
||||
- Fetch the logged-in user's grasp list, code follows, followed repositories, contacts, profile metadata, mute list, and blossom servers via gossip at login instead of the bootstrap relays
|
||||
- Connect to grasp relays and load the inbox only after the user's grasp list event arrives
|
||||
- Refresh the checkouts store only when the checkouts settings change
|
||||
- Restructure the core crate into focused modules for announcements, pull requests, git events, inbox, and upstream state
|
||||
- Publish nostr events directly through the client with relay acceptance required, removing the grasp push helper and its per-server retry loop so failures surface immediately
|
||||
- Poll the repository sync drift check every 180 seconds instead of 60
|
||||
- Track sync status per view instead of globally
|
||||
- Merge the `PixelAvatar` into the `Avatar` component. Seed user pixel avatars from the public key instead of the display name, so avatars stay stable when a user renames
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix the repository view refreshing its mirror from the network while a push is in progress, so the refresh no longer races the push
|
||||
- Fix opening a repository failing with a `destination already exists` error when two loads raced to clone its mirror, clones are now serialized and later loads reuse the finished clone
|
||||
- Fix a repository never opening again after an interrupted clone left an unusable mirror directory behind, which is now removed and re-cloned
|
||||
- Fix signing in with a `bunker://` URI silently generating a new identity instead of erroring when its master key cannot be parsed
|
||||
- Fix repository sync failing with `state event in purgatory ... doesn't match push` when grasp servers hold branches or tags the local repository lacks
|
||||
- Fix sync failing with `could not fetch refs/tags/...^{}` on repositories with annotated tags, where the peel entries `git ls-remote` reports for annotated tags were mistaken for missing refs
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove the light/dark theme pickers and the font size, corner radius, focus ring and shadow settings from the settings dialog; colors and geometry now come from the native theme
|
||||
- Remove dead code: the unused `login`/`logout` family, the unwired `SyncProgress` pipeline, `merge_pull_request`, inbox mark-read/archive APIs and the wasm32-only code paths
|
||||
|
||||
### Deprecated
|
||||
|
||||
## v0.2.0-alpha - 2026/09/27
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+450
-201
File diff suppressed because it is too large
Load Diff
+8
-4
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0-alpha"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
@@ -13,9 +13,13 @@ gpui = { package = "gpui-pre", version = "0.3.8" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
|
||||
|
||||
# GPUI Kit
|
||||
gpui-component = { version = "0.7.1", features = ["tree-sitter-languages"] }
|
||||
gpui-base = "0.7.1"
|
||||
# GPUI Kit; exact pins keep the graph on the gpui-pre 0.3.8 closure that
|
||||
# native-theme-gpui 0.6.1 is verified against (0.7.3 and 0.8 pin 0.3.9).
|
||||
gpui-component = { version = "=0.7.1", features = ["tree-sitter-languages"] }
|
||||
gpui-base = "=0.7.1"
|
||||
|
||||
native-theme = { version = "0.6.1", features = ["watch"] }
|
||||
native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] }
|
||||
|
||||
dock = { path = "crates/dock" }
|
||||
settings = { path = "crates/settings" }
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "metadata"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
signed_state = { path = "../signed_state" }
|
||||
utils = { path = "../utils" }
|
||||
|
||||
nostr-sdk.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
anyhow.workspace = true
|
||||
gpui.workspace = true
|
||||
log.workspace = true
|
||||
@@ -0,0 +1,9 @@
|
||||
mod profile;
|
||||
|
||||
use gpui::{App, AppContext};
|
||||
pub use profile::{Profile, ProfileDirectory, ProfileEvent, ProfileStore};
|
||||
|
||||
/// Initialize the global profile store.
|
||||
pub fn init(cx: &mut App) {
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use flume::Sender;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_state::{Backend, BackendEvent, sync_bootstrap_only};
|
||||
use utils::shorten_pubkey;
|
||||
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
const REQUEST_CHUNK: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Profile {
|
||||
public_key: PublicKey,
|
||||
metadata: Metadata,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
}
|
||||
|
||||
pub fn metadata(&self) -> &Metadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
// Falls back to `name`, then a shortened npub.
|
||||
pub fn name(&self) -> SharedString {
|
||||
if let Some(display_name) = self.metadata.display_name.as_ref()
|
||||
&& !display_name.is_empty()
|
||||
{
|
||||
return SharedString::from(display_name.trim().to_owned());
|
||||
}
|
||||
|
||||
if let Some(name) = self.metadata.name.as_ref()
|
||||
&& !name.is_empty()
|
||||
{
|
||||
return SharedString::from(name.trim().to_owned());
|
||||
}
|
||||
|
||||
SharedString::from(shorten_pubkey(self.public_key))
|
||||
}
|
||||
|
||||
pub fn picture(&self) -> Option<SharedString> {
|
||||
self.metadata
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| SharedString::from(p.clone()))
|
||||
}
|
||||
|
||||
/// The newest profile per author from metadata events.
|
||||
fn latest_profiles(events: impl IntoIterator<Item = Event>) -> Vec<Self> {
|
||||
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
||||
|
||||
for event in events {
|
||||
match latest.get(&event.pubkey) {
|
||||
Some((created_at, _)) if *created_at >= event.created_at => {}
|
||||
_ => {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
latest.insert(event.pubkey, (event.created_at, metadata));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
latest
|
||||
.into_iter()
|
||||
.map(|(public_key, (_, metadata))| Profile::new(public_key, metadata))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata for these authors may have changed in the local database.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ProfileEvent {
|
||||
Updated(Vec<PublicKey>),
|
||||
}
|
||||
|
||||
/// Batches metadata requests into relay syncs feeding the local database.
|
||||
pub struct ProfileStore {
|
||||
in_flight: RefCell<HashSet<PublicKey>>,
|
||||
sender: Sender<PublicKey>,
|
||||
_task: Task<Result<(), anyhow::Error>>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
struct GlobalProfileStore(Entity<ProfileStore>);
|
||||
|
||||
impl Global for GlobalProfileStore {}
|
||||
|
||||
impl EventEmitter<ProfileEvent> for ProfileStore {}
|
||||
|
||||
impl ProfileStore {
|
||||
pub(crate) fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalProfileStore>().0.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalProfileStore(entity));
|
||||
}
|
||||
|
||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
if let BackendEvent::ProfileUpdates(authors) = event {
|
||||
this.emit_updated(authors.clone(), cx);
|
||||
}
|
||||
});
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
|
||||
loop {
|
||||
match receiver.recv_async().await {
|
||||
Ok(public_key) => {
|
||||
batch.insert(public_key);
|
||||
}
|
||||
Err(_) => return Ok(()),
|
||||
}
|
||||
|
||||
// The channel has no async timeout, race the receive against a timer.
|
||||
let deadline = Instant::now() + BATCH_TIMEOUT;
|
||||
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
|
||||
if now >= deadline {
|
||||
break;
|
||||
}
|
||||
|
||||
let timer = cx.background_executor().timer(deadline - now);
|
||||
futures::pin_mut!(timer);
|
||||
|
||||
let recv = receiver.recv_async();
|
||||
futures::pin_mut!(recv);
|
||||
|
||||
match futures::future::select(recv, timer).await {
|
||||
futures::future::Either::Left((Ok(public_key), _)) => {
|
||||
batch.insert(public_key);
|
||||
}
|
||||
futures::future::Either::Left((Err(_), _)) => return Ok(()),
|
||||
futures::future::Either::Right(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let authors: Vec<PublicKey> = batch.drain().collect();
|
||||
|
||||
for chunk in authors.chunks(REQUEST_CHUNK) {
|
||||
let opts = SyncOptions::default();
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Metadata)
|
||||
.authors(chunk.iter().copied());
|
||||
|
||||
if let Err(e) = sync_bootstrap_only(&client, filter, opts).await {
|
||||
log::warn!("profile fetch failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Emit even when the sync failed, the database may hold newer data.
|
||||
this.update(cx, |this, cx| {
|
||||
this.emit_updated(authors, cx);
|
||||
})?;
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
in_flight: RefCell::new(HashSet::new()),
|
||||
sender,
|
||||
_task: task,
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a batched relay sync for keys without one in flight.
|
||||
pub fn fetch(&self, keys: impl IntoIterator<Item = PublicKey>) {
|
||||
let mut queue = Vec::new();
|
||||
{
|
||||
let mut in_flight = self.in_flight.borrow_mut();
|
||||
|
||||
for key in keys {
|
||||
if in_flight.insert(key) {
|
||||
queue.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
for public_key in queue {
|
||||
if let Err(e) = self.sender.send(public_key) {
|
||||
log::warn!("failed to queue profile fetch: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Releases the in-flight keys so future fetches can be queued again.
|
||||
fn emit_updated(&mut self, authors: Vec<PublicKey>, cx: &mut Context<Self>) {
|
||||
if authors.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let mut in_flight = self.in_flight.borrow_mut();
|
||||
for author in &authors {
|
||||
in_flight.remove(author);
|
||||
}
|
||||
}
|
||||
|
||||
cx.emit(ProfileEvent::Updated(authors));
|
||||
}
|
||||
}
|
||||
|
||||
/// Profile cache bound to one panel or screen.
|
||||
pub struct ProfileDirectory {
|
||||
profiles: HashMap<PublicKey, Profile>,
|
||||
pending: RefCell<HashSet<PublicKey>>,
|
||||
tasks: Vec<Task<()>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl ProfileDirectory {
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
let store = ProfileStore::global(cx);
|
||||
|
||||
cx.new(|cx| {
|
||||
let subscription =
|
||||
cx.subscribe(&store, |this: &mut Self, _store, event, cx| match event {
|
||||
ProfileEvent::Updated(authors) => this.refresh(authors, cx),
|
||||
});
|
||||
|
||||
Self {
|
||||
profiles: HashMap::new(),
|
||||
pending: RefCell::new(HashSet::new()),
|
||||
tasks: Vec::new(),
|
||||
_subscriptions: vec![subscription],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve keys from the local database, queue the rest for a relay sync.
|
||||
pub fn track<K>(&mut self, keys: K, cx: &mut Context<Self>)
|
||||
where
|
||||
K: IntoIterator<Item = PublicKey>,
|
||||
{
|
||||
let missing: Vec<PublicKey> = keys
|
||||
.into_iter()
|
||||
.filter(|key| {
|
||||
!self.profiles.contains_key(key)
|
||||
&& !self.with_pending(|pending| pending.contains(key))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if missing.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.load(missing, true, cx);
|
||||
}
|
||||
|
||||
/// The profile for a key, or a placeholder that queues a relay sync.
|
||||
pub fn profile(&self, public_key: &PublicKey, cx: &App) -> Profile {
|
||||
if let Some(profile) = self.profiles.get(public_key) {
|
||||
return profile.clone();
|
||||
}
|
||||
|
||||
if self.with_pending(|pending| pending.insert(*public_key)) {
|
||||
let store = ProfileStore::global(cx);
|
||||
store.read(cx).fetch([*public_key]);
|
||||
}
|
||||
|
||||
Profile::new(*public_key, Metadata::default())
|
||||
}
|
||||
|
||||
/// Refreshes profiles for keys that are already tracked.
|
||||
fn refresh(&mut self, authors: &[PublicKey], cx: &mut Context<Self>) {
|
||||
let tracked: Vec<PublicKey> = authors
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| {
|
||||
self.profiles.contains_key(key)
|
||||
|| self.with_pending(|pending| pending.contains(key))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if tracked.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.load(tracked, false, cx);
|
||||
}
|
||||
|
||||
/// Loads profiles from the local database, queueing a relay sync for any missing keys.
|
||||
fn load(&mut self, keys: Vec<PublicKey>, queue_sync: bool, cx: &mut Context<Self>) {
|
||||
// Accept events that arrive while the database query is running.
|
||||
self.with_pending(|pending| {
|
||||
pending.extend(keys.iter().copied());
|
||||
});
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
let query_keys = keys.clone();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let authors = query_keys.iter().copied();
|
||||
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
Ok::<_, anyhow::Error>(Profile::latest_profiles(events))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let loaded = work.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
let remaining = match loaded {
|
||||
Ok(profiles) => {
|
||||
let found: HashSet<PublicKey> =
|
||||
profiles.iter().map(Profile::public_key).collect();
|
||||
this.insert_loaded(profiles, cx);
|
||||
keys.into_iter()
|
||||
.filter(|key| !found.contains(key))
|
||||
.collect()
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("failed to load profiles from the database: {e}");
|
||||
keys
|
||||
}
|
||||
};
|
||||
|
||||
if queue_sync && !remaining.is_empty() {
|
||||
let store = ProfileStore::global(cx);
|
||||
store.read(cx).fetch(remaining);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
// Inserts database results, replacing anything loaded earlier.
|
||||
fn insert_loaded(&mut self, profiles: Vec<Profile>, cx: &mut Context<Self>) {
|
||||
let mut inserted = false;
|
||||
|
||||
for profile in profiles {
|
||||
let public_key = profile.public_key();
|
||||
self.profiles.insert(public_key, profile);
|
||||
self.with_pending(|pending| pending.remove(&public_key));
|
||||
inserted = true;
|
||||
}
|
||||
|
||||
if inserted {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn with_pending<R, T>(&self, then: T) -> R
|
||||
where
|
||||
T: FnOnce(&mut HashSet<PublicKey>) -> R,
|
||||
{
|
||||
let mut pending = self.pending.borrow_mut();
|
||||
then(&mut pending)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,6 @@ impl RepoAddr {
|
||||
.identifier(self.identifier())
|
||||
}
|
||||
|
||||
// Statuses may omit the `a` tag per NIP-34; those are not matched here.
|
||||
pub fn activity_filter(&self) -> Filter {
|
||||
Filter::new()
|
||||
.kinds(crate::filters::ACTIVITY_KINDS)
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::collections::HashSet;
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
use crate::upstream::Upstream;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Announcement {
|
||||
@@ -21,276 +22,8 @@ pub struct Announcement {
|
||||
pub hashtags: Vec<String>,
|
||||
}
|
||||
|
||||
// The `u` tag of a fork announcement, per NIP-34.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Upstream {
|
||||
pub raw: String,
|
||||
// `None` for the git-URL form.
|
||||
pub addr: Option<RepoAddr>,
|
||||
}
|
||||
|
||||
impl Upstream {
|
||||
fn parse(raw: &str) -> Self {
|
||||
let coordinate = raw.split('|').next().unwrap_or(raw);
|
||||
let addr = coordinate
|
||||
.parse::<Coordinate>()
|
||||
.ok()
|
||||
.filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement)
|
||||
.map(RepoAddr::from);
|
||||
Self {
|
||||
raw: raw.to_owned(),
|
||||
addr,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display(&self) -> String {
|
||||
match &self.addr {
|
||||
Some(addr) => addr.to_string(),
|
||||
None => self.raw.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait GitEvent {
|
||||
fn activity_subject(&self) -> String;
|
||||
|
||||
fn current_commit(&self) -> Option<String>;
|
||||
|
||||
fn merge_base(&self) -> Option<String>;
|
||||
|
||||
fn clone_urls(&self) -> Option<Vec<Url>>;
|
||||
|
||||
fn branch_name(&self) -> Option<String>;
|
||||
|
||||
fn is_git_activity(&self) -> bool;
|
||||
|
||||
// Matches both NIP-10 lowercase `e` and NIP-22 uppercase `E` root pointers.
|
||||
fn references_root(&self, root: &EventId) -> bool;
|
||||
}
|
||||
|
||||
impl GitEvent for Event {
|
||||
fn activity_subject(&self) -> String {
|
||||
let subject = self
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Subject(subject)) => Some(subject),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
subject
|
||||
.or_else(|| {
|
||||
self.content
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(|value| value.to_string())
|
||||
})
|
||||
.unwrap_or("Untitled".to_string())
|
||||
}
|
||||
|
||||
fn current_commit(&self) -> Option<String> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_base(&self) -> Option<String> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn clone_urls(&self) -> Option<Vec<Url>> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Clone(urls)) => Some(urls),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn branch_name(&self) -> Option<String> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::BranchName(name)) => Some(name),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_git_activity(&self) -> bool {
|
||||
match self.kind {
|
||||
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
|
||||
Kind::Comment => crate::filters::is_git_comment(self),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => crate::filters::is_git_status(self),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn references_root(&self, root: &EventId) -> bool {
|
||||
let root = root.to_hex();
|
||||
self.tags
|
||||
.iter()
|
||||
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: GitEvent + ?Sized> GitEvent for &T {
|
||||
fn activity_subject(&self) -> String {
|
||||
(*self).activity_subject()
|
||||
}
|
||||
|
||||
fn current_commit(&self) -> Option<String> {
|
||||
(*self).current_commit()
|
||||
}
|
||||
|
||||
fn merge_base(&self) -> Option<String> {
|
||||
(*self).merge_base()
|
||||
}
|
||||
|
||||
fn clone_urls(&self) -> Option<Vec<Url>> {
|
||||
(*self).clone_urls()
|
||||
}
|
||||
|
||||
fn branch_name(&self) -> Option<String> {
|
||||
(*self).branch_name()
|
||||
}
|
||||
|
||||
fn is_git_activity(&self) -> bool {
|
||||
(*self).is_git_activity()
|
||||
}
|
||||
|
||||
fn references_root(&self, root: &EventId) -> bool {
|
||||
(*self).references_root(root)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PullRequest<'a>(pub &'a Event);
|
||||
|
||||
impl<'a> PullRequest<'a> {
|
||||
pub fn new(event: &'a Event) -> Self {
|
||||
Self(event)
|
||||
}
|
||||
|
||||
// The PR references its root patch via an `e` tag.
|
||||
pub fn patches(&self, patches: impl IntoIterator<Item = &'a Event>) -> Vec<&'a Event> {
|
||||
let pr = self.0;
|
||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||
|
||||
if let Some(root_id) = pr.tags.event_ids().next()
|
||||
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
|
||||
{
|
||||
return Self::forward_series(root, &patches);
|
||||
}
|
||||
|
||||
let Some(tip) = pr.current_commit() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(last) = patches
|
||||
.iter()
|
||||
.filter(|patch| Self::patch_produces_commit(patch, &tip))
|
||||
.max_by_key(|patch| patch.created_at)
|
||||
.copied()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut series = vec![last];
|
||||
loop {
|
||||
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
|
||||
break;
|
||||
};
|
||||
let Some(prev) = patches
|
||||
.iter()
|
||||
.find(|patch| patch.id == prev_id && !series.contains(patch))
|
||||
.copied()
|
||||
else {
|
||||
break;
|
||||
};
|
||||
series.push(prev);
|
||||
}
|
||||
series.reverse();
|
||||
series
|
||||
}
|
||||
|
||||
// Falls back to the root event's content when no patch set is found.
|
||||
pub fn patch(&self, patches: impl IntoIterator<Item = &'a Event>) -> String {
|
||||
let pr = self.0;
|
||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||
let series = self.patches(patches.iter().copied());
|
||||
if series.is_empty() {
|
||||
return pr.content.clone();
|
||||
}
|
||||
series
|
||||
.iter()
|
||||
.map(|patch| patch.content.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
// A pull request's tip is only mutable by its author per NIP-34,
|
||||
// updates from anyone else are ignored even if they are newer.
|
||||
pub fn latest_update(
|
||||
events: impl Iterator<Item = &'a Event>,
|
||||
root: &Event,
|
||||
) -> Option<&'a Event> {
|
||||
let root_hex = root.id.to_hex();
|
||||
events
|
||||
.filter(|e| e.kind == Kind::GitPullRequestUpdate)
|
||||
.filter(|e| e.pubkey == root.pubkey)
|
||||
.filter(|e| {
|
||||
e.tags
|
||||
.iter()
|
||||
.any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str()))
|
||||
})
|
||||
.max_by_key(|e| e.created_at)
|
||||
}
|
||||
|
||||
fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
||||
let mut series = vec![root];
|
||||
loop {
|
||||
let next = patches
|
||||
.iter()
|
||||
.filter(|patch| !series.contains(patch))
|
||||
.filter(|patch| {
|
||||
patch
|
||||
.tags
|
||||
.event_ids()
|
||||
.any(|id| id == series.last().unwrap().id)
|
||||
})
|
||||
.max_by_key(|patch| patch.created_at);
|
||||
let Some(next) = next else {
|
||||
break;
|
||||
};
|
||||
series.push(next);
|
||||
}
|
||||
series
|
||||
}
|
||||
|
||||
// Lets clients find existing patches for a specific commit.
|
||||
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
||||
patch
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Announcement {
|
||||
// The user's own forks are listed first.
|
||||
/// Lists forks of the base, the user's own first.
|
||||
pub fn forks_in<'a>(
|
||||
announcements: &'a [Announcement],
|
||||
base: &RepoAddr,
|
||||
@@ -311,9 +44,7 @@ impl Announcement {
|
||||
own.into_iter().chain(others).collect()
|
||||
}
|
||||
|
||||
/// Parse a kind `30617` event.
|
||||
///
|
||||
/// Returns `None` when the kind is wrong or the `d` tag is missing.
|
||||
/// Parses a kind 30617 event, rejecting other kinds and missing `d` tags.
|
||||
pub fn from_event(event: &Event) -> Option<Self> {
|
||||
if event.kind != Kind::GitRepoAnnouncement {
|
||||
return None;
|
||||
@@ -345,8 +76,6 @@ impl Announcement {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// The SDK's `Nip34Tag` does not model the `u` tag, so parse it manually.
|
||||
// Only the first `u` tag is used.
|
||||
if upstream.is_none() && tag.kind() == "u" {
|
||||
let values = tag.as_slice();
|
||||
let raw = values.get(1).map(String::as_str).unwrap_or_default();
|
||||
@@ -373,15 +102,17 @@ impl Announcement {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the owner and identifier as a repository address.
|
||||
pub fn addr(&self) -> RepoAddr {
|
||||
RepoAddr::new(self.owner, self.id.clone())
|
||||
}
|
||||
|
||||
/// Returns the name, defaulting to "Untitled".
|
||||
pub fn name(&self) -> String {
|
||||
self.name.clone().unwrap_or("Untitled".into())
|
||||
}
|
||||
|
||||
// The `u` tag pointing at `base` also covers permanent forks whose EUC diverged.
|
||||
/// Matches a `u` tag pointing at the base or a shared earliest unique commit.
|
||||
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
|
||||
if self.addr() == *base {
|
||||
return false;
|
||||
@@ -392,14 +123,14 @@ impl Announcement {
|
||||
base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc))
|
||||
}
|
||||
|
||||
/// Returns the description, defaulting to "No description".
|
||||
pub fn description(&self) -> String {
|
||||
self.description
|
||||
.clone()
|
||||
.unwrap_or("No description".to_string())
|
||||
}
|
||||
|
||||
// A `u` tag marking the repository as a subordinate fork excludes the
|
||||
// announcement author from the maintainers, per NIP-34.
|
||||
/// Adds the owner to the maintainers unless a `u` tag marks this a subordinate fork.
|
||||
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
|
||||
let mut maintainers = self.maintainers.clone();
|
||||
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
|
||||
@@ -408,6 +139,7 @@ impl Announcement {
|
||||
maintainers
|
||||
}
|
||||
|
||||
/// Returns deduplicated `git clone` commands for the advertised URLs.
|
||||
pub fn clone_urls(&self) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
self.clone
|
||||
@@ -424,6 +156,7 @@ mod tests {
|
||||
|
||||
const MAINTAINER_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272";
|
||||
|
||||
/// Returns the test signer keys.
|
||||
fn keys() -> Keys {
|
||||
Keys::new(
|
||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||
@@ -431,6 +164,7 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds a signed repo announcement event from raw tags.
|
||||
fn announcement_event(tags: &[&[&str]]) -> Event {
|
||||
let tags: Vec<Tag> = tags
|
||||
.iter()
|
||||
@@ -443,6 +177,7 @@ mod tests {
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Parses an event carrying every supported tag.
|
||||
#[test]
|
||||
fn parses_full_announcement() {
|
||||
let event = announcement_event(&[
|
||||
@@ -490,6 +225,7 @@ mod tests {
|
||||
assert_eq!(announcement.hashtags, vec!["rust", "nostr"]);
|
||||
}
|
||||
|
||||
/// Skips malformed clone, relay, and maintainer values.
|
||||
#[test]
|
||||
fn drops_malformed_values() {
|
||||
let event = announcement_event(&[
|
||||
@@ -509,6 +245,7 @@ mod tests {
|
||||
assert!(announcement.maintainers.is_empty());
|
||||
}
|
||||
|
||||
/// Parses the `u` tag into an upstream coordinate.
|
||||
#[test]
|
||||
fn parses_upstream_tag() {
|
||||
let event = announcement_event(&[
|
||||
@@ -540,6 +277,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Matches a fork through its `u` tag coordinate.
|
||||
#[test]
|
||||
fn is_fork_of_matches_the_u_tag_coordinate() {
|
||||
let base = RepoAddr::new(
|
||||
@@ -552,6 +290,7 @@ mod tests {
|
||||
assert!(fork.is_fork_of(&base, None));
|
||||
}
|
||||
|
||||
/// Matches forks sharing the base's earliest unique commit.
|
||||
#[test]
|
||||
fn is_fork_of_matches_a_shared_euc() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
@@ -573,6 +312,7 @@ mod tests {
|
||||
assert!(!fork.is_fork_of(&base_addr, None));
|
||||
}
|
||||
|
||||
/// Matches permanent forks whose `u` tag points at the base despite a diverged EUC.
|
||||
#[test]
|
||||
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
|
||||
let base = RepoAddr::new(
|
||||
@@ -590,6 +330,7 @@ mod tests {
|
||||
assert!(fork.is_fork_of(&base, Some(base_euc)));
|
||||
}
|
||||
|
||||
/// Includes the owner among maintainers for primary repositories.
|
||||
#[test]
|
||||
fn effective_maintainers_include_owner_for_primary_repos() {
|
||||
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
|
||||
@@ -602,6 +343,7 @@ mod tests {
|
||||
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
|
||||
}
|
||||
|
||||
/// Excludes the owner from maintainers for subordinate forks.
|
||||
#[test]
|
||||
fn effective_maintainers_exclude_owner_for_subordinate_forks() {
|
||||
let event = announcement_event(&[
|
||||
@@ -620,151 +362,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
|
||||
EventBuilder::new(Kind::GitPullRequest, content)
|
||||
.tags(tags)
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn patch_event(content: &str, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(Kind::GitPatch, content)
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_patch_joins_the_whole_patch_set() {
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||
|
||||
assert_eq!(
|
||||
PullRequest::new(&pr).patch([&root, &second]),
|
||||
"patch-one\npatch-two"
|
||||
);
|
||||
assert_eq!(
|
||||
PullRequest::new(&pr).patches([&root, &second]),
|
||||
vec![&root, &second]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_patches_walks_the_reply_chain_in_order() {
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
||||
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
|
||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||
|
||||
let series = PullRequest::new(&pr).patches([&third, &root, &second]);
|
||||
assert_eq!(
|
||||
series
|
||||
.iter()
|
||||
.map(|p| p.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["patch-one", "patch-two", "patch-three"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let tip = "1111111111111111111111111111111111111111";
|
||||
let last = patch_event(
|
||||
"patch-two",
|
||||
vec![
|
||||
Tag::event(root.id),
|
||||
Tag::parse(["r", tip]).expect("valid tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
let pr = pr_event(
|
||||
"description",
|
||||
vec![Tag::parse(["c", tip]).expect("valid tag")],
|
||||
);
|
||||
|
||||
let series = PullRequest::new(&pr).patches([&root, &last]);
|
||||
assert_eq!(
|
||||
series
|
||||
.iter()
|
||||
.map(|p| p.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["patch-one", "patch-two"]
|
||||
);
|
||||
}
|
||||
|
||||
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
|
||||
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
|
||||
|
||||
fn signed_at(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn pr_root() -> Event {
|
||||
signed_at(
|
||||
Kind::GitPullRequest,
|
||||
vec![
|
||||
Tag::parse(["c", COMMIT_HEX]).expect("valid tag"),
|
||||
Tag::parse(["branch-name", "feature/x"]).expect("valid tag"),
|
||||
],
|
||||
100,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_update_picks_newest_revision_of_the_root() {
|
||||
let root = pr_root();
|
||||
let root_hex = root.id.to_hex();
|
||||
|
||||
let revision = |created_at: u64| {
|
||||
signed_at(
|
||||
Kind::GitPullRequestUpdate,
|
||||
vec![Tag::parse(["E", &root_hex]).expect("valid tag")],
|
||||
created_at,
|
||||
)
|
||||
};
|
||||
let unrelated = signed_at(
|
||||
Kind::GitPullRequestUpdate,
|
||||
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
|
||||
999,
|
||||
);
|
||||
|
||||
let events = [unrelated, revision(200), root.clone(), revision(300)];
|
||||
let latest = PullRequest::latest_update(events.iter(), &root).expect("an update");
|
||||
|
||||
assert_eq!(latest.created_at.as_secs(), 300);
|
||||
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_update_ignores_other_authors() {
|
||||
let root = pr_root();
|
||||
let root_hex = root.id.to_hex();
|
||||
let other = Keys::new(
|
||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
|
||||
.expect("valid secret key"),
|
||||
);
|
||||
let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "")
|
||||
.tags([Tag::parse(["E", &root_hex]).expect("valid tag")])
|
||||
.custom_created_at(Timestamp::from(999))
|
||||
.finalize(&other)
|
||||
.expect("signed event");
|
||||
|
||||
assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none());
|
||||
}
|
||||
|
||||
const OWNER_KEYS: [&str; 3] = [
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"0000000000000000000000000000000000000000000000000000000000000003",
|
||||
];
|
||||
|
||||
/// Builds an announcement event signed by the given owner key.
|
||||
fn owned_announcement_event(owner: &str, tags: &[&[&str]]) -> Event {
|
||||
let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key"));
|
||||
let tags: Vec<Tag> = tags
|
||||
@@ -777,6 +381,7 @@ mod tests {
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Builds one announcement owned by the owner key at the given index.
|
||||
fn owned_announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec<Announcement> {
|
||||
vec![
|
||||
Announcement::from_event(&owned_announcement_event(OWNER_KEYS[owner_ix], tags))
|
||||
@@ -784,6 +389,7 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
/// Places the user's own forks before everyone else's.
|
||||
#[test]
|
||||
fn fork_candidates_orders_own_forks_first() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
@@ -828,6 +434,7 @@ mod tests {
|
||||
assert_eq!(ids, vec!["my-fork", "their-fork"]);
|
||||
}
|
||||
|
||||
/// Excludes the base itself, unrelated repos, and repos without clone URLs.
|
||||
#[test]
|
||||
fn fork_candidates_excludes_base_unrelated_and_unfetchable() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
@@ -4,13 +4,16 @@ use nostr::prelude::*;
|
||||
|
||||
pub struct Deletions {
|
||||
ids: HashSet<(EventId, PublicKey)>,
|
||||
// All versions of the addressable event up to `cutoff` are deleted.
|
||||
coords: Vec<(Coordinate, PublicKey, Timestamp)>,
|
||||
vanished: Vec<(PublicKey, Timestamp)>,
|
||||
}
|
||||
|
||||
impl Deletions {
|
||||
pub fn from_events(events: impl IntoIterator<Item = Event>) -> Self {
|
||||
/// Builds a [`Deletions`] instance from a collection of events.
|
||||
pub fn from_events<E>(events: E) -> Self
|
||||
where
|
||||
E: IntoIterator<Item = Event>,
|
||||
{
|
||||
let mut ids = HashSet::new();
|
||||
let mut coords = Vec::new();
|
||||
let mut vanished = Vec::new();
|
||||
@@ -25,8 +28,6 @@ impl Deletions {
|
||||
.map(|c| (c, event.pubkey, event.created_at)),
|
||||
);
|
||||
} else if event.kind == Kind::RequestToVanish {
|
||||
// We can't verify which relay the request targeted, so any
|
||||
// vanish request is honored for the author's events.
|
||||
vanished.push((event.pubkey, event.created_at));
|
||||
}
|
||||
}
|
||||
@@ -38,8 +39,7 @@ impl Deletions {
|
||||
}
|
||||
}
|
||||
|
||||
// A request is valid when its author matches the deleted event's author,
|
||||
// per NIP-09. Addressable events are deleted up to the request's `created_at`.
|
||||
/// Returns whether the given event is deleted.
|
||||
pub fn is_deleted(&self, event: &Event) -> bool {
|
||||
if self
|
||||
.vanished
|
||||
|
||||
@@ -81,7 +81,7 @@ impl Filters {
|
||||
Filter::new().kind(Kind::Custom(10018)).author(public_key)
|
||||
}
|
||||
|
||||
// Replaceable events, so the latest of each kind is all we need.
|
||||
/// Replaceable events, so the latest of each kind is all we need.
|
||||
pub fn user_metadata(public_key: PublicKey) -> Vec<Filter> {
|
||||
vec![
|
||||
Self::grasp_list(public_key).limit(1),
|
||||
@@ -106,12 +106,13 @@ impl Filters {
|
||||
]
|
||||
}
|
||||
|
||||
// Two filters: combining `#E` and `#e` would AND the conditions.
|
||||
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
||||
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
||||
|
||||
if roots.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![
|
||||
Filter::new()
|
||||
.kind(Kind::Comment)
|
||||
@@ -129,7 +130,6 @@ impl Filters {
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
||||
}
|
||||
|
||||
// `Filter::pubkey` matches the git events' lowercase `p` tag.
|
||||
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
||||
vec![
|
||||
Self::notification_comments(me),
|
||||
@@ -137,8 +137,6 @@ impl Filters {
|
||||
]
|
||||
}
|
||||
|
||||
// A comment on an unrelated kind matches too, so results must be filtered
|
||||
// through `GitEvent::is_git_activity` before display.
|
||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||
Filter::new().kinds(ACTIVITY_KINDS).author(me)
|
||||
}
|
||||
@@ -151,13 +149,11 @@ impl Filters {
|
||||
Filter::new().kind(Kind::RepoState)
|
||||
}
|
||||
|
||||
// Quantized to whole days so identical filters hash the same.
|
||||
fn deletions_since() -> Timestamp {
|
||||
let now = Timestamp::now().as_secs();
|
||||
Timestamp::from_secs(now - now % 86_400) - Self::DELETIONS_LOOKBACK
|
||||
}
|
||||
|
||||
// Deletion requests must be known before any other event is shown.
|
||||
pub fn deletions() -> Filter {
|
||||
Filter::new()
|
||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
pub trait GitEvent {
|
||||
/// Returns the NIP-34 subject or the first non-empty content line.
|
||||
fn activity_subject(&self) -> String;
|
||||
|
||||
/// Returns the current commit id.
|
||||
fn current_commit(&self) -> Option<String>;
|
||||
|
||||
/// Returns the merge base commit id.
|
||||
fn merge_base(&self) -> Option<String>;
|
||||
|
||||
/// Returns the advertised clone URLs.
|
||||
fn clone_urls(&self) -> Option<Vec<Url>>;
|
||||
|
||||
/// Returns the branch name.
|
||||
fn branch_name(&self) -> Option<String>;
|
||||
|
||||
/// Returns whether the event is git activity.
|
||||
fn is_git_activity(&self) -> bool;
|
||||
|
||||
/// Returns whether an `e` or `E` tag points at the root event.
|
||||
fn references_root(&self, root: &EventId) -> bool;
|
||||
}
|
||||
|
||||
impl GitEvent for Event {
|
||||
/// Reads the subject tag, then the first content line, then "Untitled".
|
||||
fn activity_subject(&self) -> String {
|
||||
let subject = self
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Subject(subject)) => Some(subject),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
subject
|
||||
.or_else(|| {
|
||||
self.content
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(|value| value.to_string())
|
||||
})
|
||||
.unwrap_or("Untitled".to_string())
|
||||
}
|
||||
|
||||
/// Reads the `commit` tag.
|
||||
fn current_commit(&self) -> Option<String> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the `merge-base` tag.
|
||||
fn merge_base(&self) -> Option<String> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the `clone` tag.
|
||||
fn clone_urls(&self) -> Option<Vec<Url>> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Clone(urls)) => Some(urls),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the `branch-name` tag.
|
||||
fn branch_name(&self) -> Option<String> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::BranchName(name)) => Some(name),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Classifies git issue, patch, pull request, comment, and status kinds.
|
||||
fn is_git_activity(&self) -> bool {
|
||||
match self.kind {
|
||||
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
|
||||
Kind::Comment => crate::filters::is_git_comment(self),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => crate::filters::is_git_status(self),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares `e` and `E` tag contents against the root id.
|
||||
fn references_root(&self, root: &EventId) -> bool {
|
||||
let root = root.to_hex();
|
||||
self.tags
|
||||
.iter()
|
||||
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: GitEvent + ?Sized> GitEvent for &T {
|
||||
/// Delegates to the referenced event.
|
||||
fn activity_subject(&self) -> String {
|
||||
(*self).activity_subject()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn current_commit(&self) -> Option<String> {
|
||||
(*self).current_commit()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn merge_base(&self) -> Option<String> {
|
||||
(*self).merge_base()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn clone_urls(&self) -> Option<Vec<Url>> {
|
||||
(*self).clone_urls()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn branch_name(&self) -> Option<String> {
|
||||
(*self).branch_name()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn is_git_activity(&self) -> bool {
|
||||
(*self).is_git_activity()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn references_root(&self, root: &EventId) -> bool {
|
||||
(*self).references_root(root)
|
||||
}
|
||||
}
|
||||
@@ -1,561 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{GitEvent, RepoAddr};
|
||||
|
||||
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
||||
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InboxItem {
|
||||
pub root: EventId,
|
||||
pub root_event: Option<Event>,
|
||||
pub address: Option<RepoAddr>,
|
||||
pub events: Vec<Event>,
|
||||
pub own_events: Vec<Event>,
|
||||
pub unread_ids: Vec<EventId>,
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
impl InboxItem {
|
||||
pub fn title(&self) -> String {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(|event| event.activity_subject())
|
||||
.unwrap_or_else(|| "Untitled".to_string())
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> Option<Kind> {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(|event| event.kind)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
pub fn is_unread(&self) -> bool {
|
||||
!self.archived && !self.unread_ids.is_empty()
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ThreadResolver<'a, L: ?Sized> {
|
||||
lookup: &'a L,
|
||||
}
|
||||
|
||||
impl<'a, L> ThreadResolver<'a, L>
|
||||
where
|
||||
L: Fn(EventId) -> Option<Event> + ?Sized,
|
||||
{
|
||||
pub fn new(lookup: &'a L) -> Self {
|
||||
Self { lookup }
|
||||
}
|
||||
|
||||
// Kind → root mapping:
|
||||
// - 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): NIP-10 root `e`
|
||||
// Returns `None` when the event is not git-related, or when its root is a
|
||||
// coordinate rather than an event.
|
||||
pub fn notification_root(&self, event: &Event) -> Option<EventId> {
|
||||
match event.kind {
|
||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||
Kind::GitPatch => Some(match self.first_e_id(event) {
|
||||
Some(parent) => self.resolve_thread_root(parent),
|
||||
None => event.id,
|
||||
}),
|
||||
Kind::Comment => match nip22::extract_root(event) {
|
||||
Some(CommentTarget::Event { id, .. }) => Some(self.resolve_thread_root(id)),
|
||||
_ => None,
|
||||
},
|
||||
Kind::GitPullRequestUpdate => self
|
||||
.first_uppercase_e_id(event)
|
||||
.map(|root| self.resolve_thread_root(root)),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => self
|
||||
.nip10_root_id(event)
|
||||
.map(|root| self.resolve_thread_root(root)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Follow NIP-10/NIP-22 parent pointers until a root item is reached.
|
||||
pub fn resolve_thread_root(&self, id: EventId) -> EventId {
|
||||
let mut seen = HashSet::new();
|
||||
let mut root = id;
|
||||
|
||||
loop {
|
||||
if !seen.insert(root) {
|
||||
return id;
|
||||
}
|
||||
|
||||
let Some(event) = (self.lookup)(root) else {
|
||||
return root;
|
||||
};
|
||||
|
||||
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
|
||||
return root;
|
||||
}
|
||||
|
||||
match self.parent_id(&event) {
|
||||
Some(parent) => root = parent,
|
||||
None => return root,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors gitworkshop's `getParentId`.
|
||||
fn parent_id(&self, event: &Event) -> Option<EventId> {
|
||||
for marker in ["reply", "root"] {
|
||||
if let Some(id) = event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| self.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);
|
||||
}
|
||||
|
||||
self.first_uppercase_e_id(event)
|
||||
}
|
||||
|
||||
fn nip10_root_id(&self, event: &Event) -> Option<EventId> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| self.e_tag_with_marker(tag, "root"))
|
||||
.or_else(|| self.first_e_id(event))
|
||||
}
|
||||
|
||||
fn first_e_id(&self, event: &Event) -> Option<EventId> {
|
||||
self.first_tag_id(event, "e")
|
||||
}
|
||||
|
||||
fn first_uppercase_e_id(&self, event: &Event) -> Option<EventId> {
|
||||
self.first_tag_id(event, "E")
|
||||
}
|
||||
|
||||
fn first_tag_id(&self, 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())
|
||||
})
|
||||
}
|
||||
|
||||
fn e_tag_with_marker(&self, 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())
|
||||
}
|
||||
}
|
||||
|
||||
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 resolver = ThreadResolver::new(lookup);
|
||||
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||
for event in events {
|
||||
if event.pubkey == me {
|
||||
continue;
|
||||
}
|
||||
let Some(root) = resolver.notification_root(&event) 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 = resolver.notification_root(&event).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();
|
||||
utils::sort_newest_first(&mut events);
|
||||
utils::sort_newest_first(&mut own_events);
|
||||
|
||||
let root_event = lookup(root);
|
||||
|
||||
let mut item = InboxItem {
|
||||
root,
|
||||
address: root_event
|
||||
.as_ref()
|
||||
.and_then(|event| event.tags.coordinates().next())
|
||||
.map(RepoAddr::from),
|
||||
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
|
||||
}
|
||||
|
||||
// High-water-mark model: events at or before the cutoff
|
||||
// are covered without an entry in the id set.
|
||||
#[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 {
|
||||
pub fn is_read(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
pub fn is_archived(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
pub fn mark_read(&mut self, event: &Event) {
|
||||
if event.created_at > self.read_before {
|
||||
self.read_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_archived(&mut self, event: &Event) {
|
||||
if event.created_at > self.archived_before {
|
||||
self.archived_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
#[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 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 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!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&comment),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[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!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&child_patch),
|
||||
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!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&status),
|
||||
Some(issue.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!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&nested),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[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()]);
|
||||
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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nostr::prelude::*;
|
||||
use utils::sort_newest_first;
|
||||
|
||||
use super::{InboxItem, InboxReadState, ThreadResolver};
|
||||
use crate::RepoAddr;
|
||||
|
||||
/// Groups events into inbox threads, merging the user's own events.
|
||||
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 resolver = ThreadResolver::new(lookup);
|
||||
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||
for event in events {
|
||||
if event.pubkey == me {
|
||||
continue;
|
||||
}
|
||||
let Some(root) = resolver.notification_root(&event) 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 = resolver.notification_root(&event).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,
|
||||
address: root_event
|
||||
.as_ref()
|
||||
.and_then(|event| event.tags.coordinates().next())
|
||||
.map(RepoAddr::from),
|
||||
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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Derives deterministic signer keys from a seed byte.
|
||||
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"))
|
||||
}
|
||||
|
||||
/// Builds a signed event with a fixed timestamp.
|
||||
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")
|
||||
}
|
||||
|
||||
/// Builds an uppercase `E` tag for the event.
|
||||
fn uppercase_e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
|
||||
}
|
||||
|
||||
/// Builds an event lookup closure over a slice.
|
||||
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
|
||||
move |id| events.iter().find(|event| event.id == id).cloned()
|
||||
}
|
||||
|
||||
/// Builds a git issue event with a subject tag.
|
||||
fn titled_issue(author: &Keys, title: &str, at: u64) -> Event {
|
||||
signed(
|
||||
author,
|
||||
Kind::GitIssue,
|
||||
vec![Tag::parse(["subject", title]).expect("valid subject tag")],
|
||||
at,
|
||||
)
|
||||
}
|
||||
|
||||
/// Merges the user's own events into the matching thread.
|
||||
#[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()]);
|
||||
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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
use super::InboxReadState;
|
||||
use crate::{GitEvent, RepoAddr};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InboxItem {
|
||||
pub root: EventId,
|
||||
pub root_event: Option<Event>,
|
||||
pub address: Option<RepoAddr>,
|
||||
pub events: Vec<Event>,
|
||||
pub own_events: Vec<Event>,
|
||||
pub unread_ids: Vec<EventId>,
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
impl InboxItem {
|
||||
/// Returns the thread title from the root, own events, or events.
|
||||
pub fn title(&self) -> String {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(|event| event.activity_subject())
|
||||
.unwrap_or_else(|| "Untitled".to_string())
|
||||
}
|
||||
|
||||
/// Returns the kind of the thread's primary event.
|
||||
pub fn kind(&self) -> Option<Kind> {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(|event| event.kind)
|
||||
}
|
||||
|
||||
/// Returns the newest timestamp among the root, own, and other events.
|
||||
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()
|
||||
}
|
||||
|
||||
/// Builds a chronological timeline capped at `limit` events, root 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
|
||||
}
|
||||
|
||||
/// Returns whether the thread has unread events and is not archived.
|
||||
pub fn is_unread(&self) -> bool {
|
||||
!self.archived && !self.unread_ids.is_empty()
|
||||
}
|
||||
|
||||
/// Recomputes unread ids and the archived flag from the read 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();
|
||||
|
||||
self.archived =
|
||||
!self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod group;
|
||||
mod item;
|
||||
mod read_state;
|
||||
mod resolver;
|
||||
|
||||
pub use group::group;
|
||||
pub use item::InboxItem;
|
||||
pub use read_state::InboxReadState;
|
||||
pub use resolver::ThreadResolver;
|
||||
@@ -0,0 +1,109 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
||||
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
|
||||
|
||||
#[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 {
|
||||
/// Returns whether the event falls under the read high-water mark or id set.
|
||||
pub fn is_read(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
/// Returns whether the event falls under the archived high-water mark or id set.
|
||||
pub fn is_archived(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
/// Records the event as read when it is past the current cutoff.
|
||||
pub fn mark_read(&mut self, event: &Event) {
|
||||
if event.created_at > self.read_before {
|
||||
self.read_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the event as archived when it is past the current cutoff.
|
||||
pub fn mark_archived(&mut self, event: &Event) {
|
||||
if event.created_at > self.archived_before {
|
||||
self.archived_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks all events from other authors within the window as read.
|
||||
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();
|
||||
}
|
||||
|
||||
/// Advances the read cutoff as far as possible, then prunes 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);
|
||||
}
|
||||
|
||||
/// Advances the archived cutoff as far as possible, then prunes the id set.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the newest cutoff that leaves unmarked events uncovered.
|
||||
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)
|
||||
}
|
||||
|
||||
/// Drops ids whose events predate 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));
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
pub struct ThreadResolver<'a, L: ?Sized> {
|
||||
lookup: &'a L,
|
||||
}
|
||||
|
||||
impl<'a, L> ThreadResolver<'a, L>
|
||||
where
|
||||
L: Fn(EventId) -> Option<Event> + ?Sized,
|
||||
{
|
||||
/// Creates a resolver over the given event lookup.
|
||||
pub fn new(lookup: &'a L) -> Self {
|
||||
Self { lookup }
|
||||
}
|
||||
|
||||
/// Maps a git event kind to the thread root it notifies about.
|
||||
pub fn notification_root(&self, event: &Event) -> Option<EventId> {
|
||||
match event.kind {
|
||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||
Kind::GitPatch => Some(match self.first_e_id(event) {
|
||||
Some(parent) => self.resolve_thread_root(parent),
|
||||
None => event.id,
|
||||
}),
|
||||
Kind::Comment => match nip22::extract_root(event) {
|
||||
Some(CommentTarget::Event { id, .. }) => Some(self.resolve_thread_root(id)),
|
||||
_ => None,
|
||||
},
|
||||
Kind::GitPullRequestUpdate => self
|
||||
.first_uppercase_e_id(event)
|
||||
.map(|root| self.resolve_thread_root(root)),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => self
|
||||
.nip10_root_id(event)
|
||||
.map(|root| self.resolve_thread_root(root)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows parent pointers until a git issue or pull request root is reached.
|
||||
pub fn resolve_thread_root(&self, id: EventId) -> EventId {
|
||||
let mut seen = HashSet::new();
|
||||
let mut root = id;
|
||||
|
||||
loop {
|
||||
if !seen.insert(root) {
|
||||
return id;
|
||||
}
|
||||
|
||||
let Some(event) = (self.lookup)(root) else {
|
||||
return root;
|
||||
};
|
||||
|
||||
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
|
||||
return root;
|
||||
}
|
||||
|
||||
match self.parent_id(&event) {
|
||||
Some(parent) => root = parent,
|
||||
None => return root,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors gitworkshop's `getParentId` parent resolution.
|
||||
fn parent_id(&self, event: &Event) -> Option<EventId> {
|
||||
for marker in ["reply", "root"] {
|
||||
if let Some(id) = event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| self.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);
|
||||
}
|
||||
|
||||
self.first_uppercase_e_id(event)
|
||||
}
|
||||
|
||||
/// Reads the NIP-10 `root` marker, falling back to the first `e` tag.
|
||||
fn nip10_root_id(&self, event: &Event) -> Option<EventId> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| self.e_tag_with_marker(tag, "root"))
|
||||
.or_else(|| self.first_e_id(event))
|
||||
}
|
||||
|
||||
/// Reads the first lowercase `e` tag id.
|
||||
fn first_e_id(&self, event: &Event) -> Option<EventId> {
|
||||
self.first_tag_id(event, "e")
|
||||
}
|
||||
|
||||
/// Reads the first uppercase `E` tag id.
|
||||
fn first_uppercase_e_id(&self, event: &Event) -> Option<EventId> {
|
||||
self.first_tag_id(event, "E")
|
||||
}
|
||||
|
||||
/// Reads the first event id from tags of the given kind.
|
||||
fn first_tag_id(&self, 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())
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses a four-value `e` tag carrying the given marker.
|
||||
fn e_tag_with_marker(&self, 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::*;
|
||||
|
||||
/// Derives deterministic signer keys from a seed byte.
|
||||
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"))
|
||||
}
|
||||
|
||||
/// Builds a signed event with a fixed timestamp.
|
||||
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")
|
||||
}
|
||||
|
||||
/// Builds a plain lowercase `e` tag for the event.
|
||||
fn e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
|
||||
}
|
||||
|
||||
/// Builds a four-value `e` tag with the given marker.
|
||||
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")
|
||||
}
|
||||
|
||||
/// Builds an uppercase `E` tag for the event.
|
||||
fn uppercase_e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
|
||||
}
|
||||
|
||||
/// Builds an event lookup closure over a slice.
|
||||
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
|
||||
move |id| events.iter().find(|event| event.id == id).cloned()
|
||||
}
|
||||
|
||||
/// Builds a git issue event.
|
||||
fn issue(author: &Keys, at: u64) -> Event {
|
||||
signed(author, Kind::GitIssue, Vec::new(), at)
|
||||
}
|
||||
|
||||
/// Resolves a comment to its uppercase `E` root.
|
||||
#[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!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&comment),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolves a child patch to its root patch.
|
||||
#[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!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&child_patch),
|
||||
Some(root_patch.id)
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolves a status through its `root` marker tag.
|
||||
#[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!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&status),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
|
||||
/// Follows a nested comment chain to the issue root.
|
||||
#[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!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&nested),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
pub mod addr;
|
||||
pub mod announcement;
|
||||
pub mod deletions;
|
||||
pub mod filters;
|
||||
pub mod git_event;
|
||||
pub mod inbox;
|
||||
pub mod model;
|
||||
pub mod state;
|
||||
pub mod pull_request;
|
||||
pub mod status;
|
||||
pub mod upstream;
|
||||
|
||||
pub use addr::RepoAddr;
|
||||
pub use announcement::Announcement;
|
||||
pub use deletions::Deletions;
|
||||
pub use filters::Filters;
|
||||
pub use git_event::GitEvent;
|
||||
pub use inbox::{InboxItem, InboxReadState, ThreadResolver};
|
||||
pub use model::{Announcement, GitEvent, PullRequest};
|
||||
pub use state::RepoState;
|
||||
pub use pull_request::PullRequest;
|
||||
pub use status::RepoStatus;
|
||||
pub use upstream::Upstream;
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::git_event::GitEvent;
|
||||
|
||||
pub struct PullRequest<'a>(pub &'a Event);
|
||||
|
||||
impl<'a> PullRequest<'a> {
|
||||
/// Wraps a pull request root event.
|
||||
pub fn new(event: &'a Event) -> Self {
|
||||
Self(event)
|
||||
}
|
||||
|
||||
/// Returns the ordered patch series, found via the root reference or the tip commit.
|
||||
pub fn patches(&self, patches: impl IntoIterator<Item = &'a Event>) -> Vec<&'a Event> {
|
||||
let pr = self.0;
|
||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||
|
||||
if let Some(root_id) = pr.tags.event_ids().next()
|
||||
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
|
||||
{
|
||||
return Self::forward_series(root, &patches);
|
||||
}
|
||||
|
||||
let Some(tip) = pr.current_commit() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(last) = patches
|
||||
.iter()
|
||||
.filter(|patch| Self::patch_produces_commit(patch, &tip))
|
||||
.max_by_key(|patch| patch.created_at)
|
||||
.copied()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut series = vec![last];
|
||||
loop {
|
||||
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
|
||||
break;
|
||||
};
|
||||
let Some(prev) = patches
|
||||
.iter()
|
||||
.find(|patch| patch.id == prev_id && !series.contains(patch))
|
||||
.copied()
|
||||
else {
|
||||
break;
|
||||
};
|
||||
series.push(prev);
|
||||
}
|
||||
series.reverse();
|
||||
series
|
||||
}
|
||||
|
||||
/// Joins the patch series with newlines, falling back to the event content.
|
||||
pub fn patch(&self, patches: impl IntoIterator<Item = &'a Event>) -> String {
|
||||
let pr = self.0;
|
||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||
let series = self.patches(patches.iter().copied());
|
||||
if series.is_empty() {
|
||||
return pr.content.clone();
|
||||
}
|
||||
series
|
||||
.iter()
|
||||
.map(|patch| patch.content.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Returns the root author's newest update, since only the author may move the tip.
|
||||
pub fn latest_update(
|
||||
events: impl Iterator<Item = &'a Event>,
|
||||
root: &Event,
|
||||
) -> Option<&'a Event> {
|
||||
let root_hex = root.id.to_hex();
|
||||
events
|
||||
.filter(|e| e.kind == Kind::GitPullRequestUpdate)
|
||||
.filter(|e| e.pubkey == root.pubkey)
|
||||
.filter(|e| {
|
||||
e.tags
|
||||
.iter()
|
||||
.any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str()))
|
||||
})
|
||||
.max_by_key(|e| e.created_at)
|
||||
}
|
||||
|
||||
/// Walks reply links outward from the root patch, taking the newest child first.
|
||||
fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
||||
let mut series = vec![root];
|
||||
loop {
|
||||
let next = patches
|
||||
.iter()
|
||||
.filter(|patch| !series.contains(patch))
|
||||
.filter(|patch| {
|
||||
patch
|
||||
.tags
|
||||
.event_ids()
|
||||
.any(|id| id == series.last().unwrap().id)
|
||||
})
|
||||
.max_by_key(|patch| patch.created_at);
|
||||
let Some(next) = next else {
|
||||
break;
|
||||
};
|
||||
series.push(next);
|
||||
}
|
||||
series
|
||||
}
|
||||
|
||||
/// Checks the commit against the patch's `c` and `r` tags.
|
||||
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
||||
patch
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Returns the test signer keys.
|
||||
fn keys() -> Keys {
|
||||
Keys::new(
|
||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||
.expect("valid secret key"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds a signed pull request event.
|
||||
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
|
||||
EventBuilder::new(Kind::GitPullRequest, content)
|
||||
.tags(tags)
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Builds a signed patch event with a fixed timestamp.
|
||||
fn patch_event(content: &str, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(Kind::GitPatch, content)
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Joins the full patch series into one diff.
|
||||
#[test]
|
||||
fn pull_request_patch_joins_the_whole_patch_set() {
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||
|
||||
assert_eq!(
|
||||
PullRequest::new(&pr).patch([&root, &second]),
|
||||
"patch-one\npatch-two"
|
||||
);
|
||||
assert_eq!(
|
||||
PullRequest::new(&pr).patches([&root, &second]),
|
||||
vec![&root, &second]
|
||||
);
|
||||
}
|
||||
|
||||
/// Orders patches by walking the reply chain.
|
||||
#[test]
|
||||
fn pull_request_patches_walks_the_reply_chain_in_order() {
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
||||
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
|
||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||
|
||||
let series = PullRequest::new(&pr).patches([&third, &root, &second]);
|
||||
assert_eq!(
|
||||
series
|
||||
.iter()
|
||||
.map(|p| p.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["patch-one", "patch-two", "patch-three"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Finds the patch series through the declared tip commit.
|
||||
#[test]
|
||||
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let tip = "1111111111111111111111111111111111111111";
|
||||
let last = patch_event(
|
||||
"patch-two",
|
||||
vec![
|
||||
Tag::event(root.id),
|
||||
Tag::parse(["r", tip]).expect("valid tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
let pr = pr_event(
|
||||
"description",
|
||||
vec![Tag::parse(["c", tip]).expect("valid tag")],
|
||||
);
|
||||
|
||||
let series = PullRequest::new(&pr).patches([&root, &last]);
|
||||
assert_eq!(
|
||||
series
|
||||
.iter()
|
||||
.map(|p| p.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["patch-one", "patch-two"]
|
||||
);
|
||||
}
|
||||
|
||||
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
|
||||
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
|
||||
|
||||
/// Builds a signed event with a fixed timestamp.
|
||||
fn signed_at(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Builds the pull request root used by the update tests.
|
||||
fn pr_root() -> Event {
|
||||
signed_at(
|
||||
Kind::GitPullRequest,
|
||||
vec![
|
||||
Tag::parse(["c", COMMIT_HEX]).expect("valid tag"),
|
||||
Tag::parse(["branch-name", "feature/x"]).expect("valid tag"),
|
||||
],
|
||||
100,
|
||||
)
|
||||
}
|
||||
|
||||
/// Picks the newest update targeting the root.
|
||||
#[test]
|
||||
fn latest_update_picks_newest_revision_of_the_root() {
|
||||
let root = pr_root();
|
||||
let root_hex = root.id.to_hex();
|
||||
|
||||
let revision = |created_at: u64| {
|
||||
signed_at(
|
||||
Kind::GitPullRequestUpdate,
|
||||
vec![Tag::parse(["E", &root_hex]).expect("valid tag")],
|
||||
created_at,
|
||||
)
|
||||
};
|
||||
let unrelated = signed_at(
|
||||
Kind::GitPullRequestUpdate,
|
||||
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
|
||||
999,
|
||||
);
|
||||
|
||||
let events = [unrelated, revision(200), root.clone(), revision(300)];
|
||||
let latest = PullRequest::latest_update(events.iter(), &root).expect("an update");
|
||||
|
||||
assert_eq!(latest.created_at.as_secs(), 300);
|
||||
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
|
||||
}
|
||||
|
||||
/// Ignores updates from authors other than the root owner.
|
||||
#[test]
|
||||
fn latest_update_ignores_other_authors() {
|
||||
let root = pr_root();
|
||||
let root_hex = root.id.to_hex();
|
||||
let other = Keys::new(
|
||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
|
||||
.expect("valid secret key"),
|
||||
);
|
||||
let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "")
|
||||
.tags([Tag::parse(["E", &root_hex]).expect("valid tag")])
|
||||
.custom_created_at(Timestamp::from(999))
|
||||
.finalize(&other)
|
||||
.expect("signed event");
|
||||
|
||||
assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RepoState {
|
||||
pub refs: Vec<(String, String)>,
|
||||
pub head: Option<String>,
|
||||
}
|
||||
|
||||
impl RepoState {
|
||||
// The `d` tag matches the repository id.
|
||||
pub fn build(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
|
||||
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
|
||||
for (name, commit) in refs {
|
||||
tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag"));
|
||||
}
|
||||
if let Some(head) = head {
|
||||
tags.push(
|
||||
Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"),
|
||||
);
|
||||
}
|
||||
EventBuilder::new(Kind::RepoState, "").tags(tags)
|
||||
}
|
||||
|
||||
pub fn parse(event: &Event) -> Self {
|
||||
let mut refs = Vec::new();
|
||||
let mut head = None;
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Head(branch)) => head = Some(branch),
|
||||
Ok(Nip34Tag::RefHead { branch, commit }) => {
|
||||
refs.push((format!("refs/heads/{branch}"), commit.to_string()));
|
||||
}
|
||||
Ok(Nip34Tag::RefTag { name, commit }) => {
|
||||
refs.push((format!("refs/tags/{name}"), commit.to_string()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Self { refs, head }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const COMMIT_A: &str = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
const COMMIT_B: &str = "59429cfc6cb35b0a1ddace73b5a5c5ed57b8f5ca";
|
||||
|
||||
fn keys() -> Keys {
|
||||
Keys::new(
|
||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||
.expect("valid secret key"),
|
||||
)
|
||||
}
|
||||
|
||||
fn state_event(tags: &[&[&str]]) -> Event {
|
||||
let tags: Vec<Tag> = tags
|
||||
.iter()
|
||||
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
|
||||
.collect();
|
||||
|
||||
EventBuilder::new(Kind::RepoState, "")
|
||||
.tags(tags)
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_heads_and_tags() {
|
||||
let event = state_event(&[
|
||||
&["HEAD", "ref: refs/heads/main"],
|
||||
&["refs/heads/main", COMMIT_A],
|
||||
&["refs/heads/dev", COMMIT_B],
|
||||
&["refs/tags/v1.0", COMMIT_A],
|
||||
]);
|
||||
|
||||
let state = RepoState::parse(&event);
|
||||
|
||||
assert_eq!(state.head.as_deref(), Some("main"));
|
||||
assert_eq!(
|
||||
state.refs,
|
||||
vec![
|
||||
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
||||
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
||||
("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_round_trips_through_parse() {
|
||||
let refs = [
|
||||
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
||||
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
||||
("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()),
|
||||
];
|
||||
|
||||
let event = RepoState::build("my-repo", &refs, Some("main"))
|
||||
.finalize(&keys())
|
||||
.expect("signed event");
|
||||
|
||||
assert_eq!(event.kind, Kind::RepoState);
|
||||
assert_eq!(event.tags.identifier().as_deref(), Some("my-repo"));
|
||||
|
||||
let state = RepoState::parse(&event);
|
||||
assert_eq!(state.refs, refs);
|
||||
assert_eq!(state.head.as_deref(), Some("main"));
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ impl RepoStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// Defaults to Open when no authorized status event exists.
|
||||
pub fn resolve<'a, I>(
|
||||
status_events: I,
|
||||
root_author: &PublicKey,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Upstream {
|
||||
pub raw: String,
|
||||
pub addr: Option<RepoAddr>,
|
||||
}
|
||||
|
||||
impl Upstream {
|
||||
/// Parses a raw `u` tag value, extracting the coordinate when present.
|
||||
pub(crate) fn parse(raw: &str) -> Self {
|
||||
let coordinate = raw.split('|').next().unwrap_or(raw);
|
||||
let addr = coordinate
|
||||
.parse::<Coordinate>()
|
||||
.ok()
|
||||
.filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement)
|
||||
.map(RepoAddr::from);
|
||||
Self {
|
||||
raw: raw.to_owned(),
|
||||
addr,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the coordinate when parsed, otherwise the raw value.
|
||||
pub fn display(&self) -> String {
|
||||
match &self.addr {
|
||||
Some(addr) => addr.to_string(),
|
||||
None => self.raw.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,11 @@ signed_core = { path = "../signed_core" }
|
||||
nostr.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
log.workspace = true
|
||||
anyhow.workspace = true
|
||||
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
||||
gix-worktree = "0.57"
|
||||
gix-worktree-state = "0.35"
|
||||
anyhow.workspace = true
|
||||
diffy = "0.5"
|
||||
ignore = "0.4"
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::Result;
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
|
||||
use crate::repo::Repo;
|
||||
@@ -8,51 +10,80 @@ use crate::repo::Repo;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitCache {
|
||||
root: PathBuf,
|
||||
clone_locks: Arc<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>>,
|
||||
}
|
||||
|
||||
impl GitCache {
|
||||
/// Creates a cache rooted at `root`.
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
Self { root }
|
||||
Self {
|
||||
root,
|
||||
clone_locks: Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the cache root directory.
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// Returns the cache path for a repository address.
|
||||
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
|
||||
self.root
|
||||
.join(addr.public_key().to_hex())
|
||||
.join(Self::sanitize_path_component(addr.identifier()))
|
||||
}
|
||||
|
||||
/// Opens the cached repository, or `None` when it is not cloned yet.
|
||||
pub fn open(&self, addr: &RepoAddr) -> Result<Option<Repo>> {
|
||||
let path = self.repo_path(addr);
|
||||
match gix::open(&path) {
|
||||
Ok(repo) => Ok(Some(Repo { inner: repo })),
|
||||
// A missing path and a directory that is no repository are both classified `NotFound`.
|
||||
Ok(repo) => Ok(Some(Repo::new(repo))),
|
||||
Err(e) if e.is_not_found() => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the clone lock for `path`, creating it on first use.
|
||||
fn clone_lock(&self, path: &Path) -> Arc<Mutex<()>> {
|
||||
let mut locks = self
|
||||
.clone_locks
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
Arc::clone(locks.entry(path.to_path_buf()).or_default())
|
||||
}
|
||||
|
||||
/// Returns the cached repository, cloning from one of `clone_urls` on first use.
|
||||
pub fn ensure_clone<U: AsRef<str>>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result<Repo> {
|
||||
let path = self.repo_path(addr);
|
||||
|
||||
if let Some(repo) = self.open(addr)? {
|
||||
repo.fetch().ok();
|
||||
// Lock the clone lock for this path to prevent concurrent clones.
|
||||
let clone_lock = self.clone_lock(&path);
|
||||
let _clone_guard = clone_lock.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
|
||||
// Refreshes the cached repository if it is already cloned, or clones it from one of `clone_urls` otherwise.
|
||||
if let Ok(Some(repo)) = self.open(addr) {
|
||||
if let Err(error) = repo.fetch() {
|
||||
log::warn!("failed to refresh the cached repository: {error:#}");
|
||||
}
|
||||
return Ok(repo);
|
||||
}
|
||||
|
||||
// Create the parent directory if it does not exist.
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Remove the cached clone if it exists but is unusable.
|
||||
if path.exists() {
|
||||
log::warn!("removing unusable cached clone at {}", path.display());
|
||||
std::fs::remove_dir_all(&path)?;
|
||||
}
|
||||
|
||||
Repo::clone(clone_urls, &path)
|
||||
}
|
||||
|
||||
// `id` is untrusted relay content: it must never escape the cache root as
|
||||
// a single path component.
|
||||
/// Maps untrusted relay content onto a safe single path component.
|
||||
pub fn sanitize_path_component(id: &str) -> String {
|
||||
let sanitized: String = id
|
||||
.chars()
|
||||
@@ -72,6 +103,7 @@ impl GitCache {
|
||||
sanitized
|
||||
}
|
||||
|
||||
/// Returns the `owner/identifier` namespace used for fork checkouts.
|
||||
pub fn fork_namespace(announcement: &Announcement) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
|
||||
+159
-202
@@ -1,9 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
|
||||
|
||||
use crate::GixResultExt as _;
|
||||
use crate::repo::Repo;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiffLineKind {
|
||||
Context,
|
||||
@@ -19,6 +15,18 @@ pub struct DiffLine {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl DiffLine {
|
||||
/// Creates a diff line with its kind, optional line numbers, and text.
|
||||
pub fn new(kind: DiffLineKind, old: Option<u32>, new: Option<u32>, text: String) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
old,
|
||||
new,
|
||||
text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffHunk {
|
||||
pub old_start: u32,
|
||||
@@ -28,6 +36,25 @@ pub struct DiffHunk {
|
||||
pub lines: Vec<DiffLine>,
|
||||
}
|
||||
|
||||
impl DiffHunk {
|
||||
/// Creates a hunk spanning the given old and new ranges.
|
||||
pub fn new(
|
||||
old_start: u32,
|
||||
old_lines: u32,
|
||||
new_start: u32,
|
||||
new_lines: u32,
|
||||
lines: Vec<DiffLine>,
|
||||
) -> Self {
|
||||
Self {
|
||||
old_start,
|
||||
old_lines,
|
||||
new_start,
|
||||
new_lines,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiffStatus {
|
||||
Added,
|
||||
@@ -39,242 +66,172 @@ pub enum DiffStatus {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileDiff {
|
||||
// For renames and copies, the destination path.
|
||||
pub path: String,
|
||||
pub old_path: Option<String>,
|
||||
pub status: DiffStatus,
|
||||
pub insertions: usize,
|
||||
pub deletions: usize,
|
||||
// Binary files have empty `hunks`.
|
||||
pub binary: bool,
|
||||
pub hunks: Vec<DiffHunk>,
|
||||
}
|
||||
|
||||
impl FileDiff {
|
||||
/// Creates a file diff; `old_path` is the rename or copy source and `binary` files have no hunks.
|
||||
pub fn new(
|
||||
path: String,
|
||||
old_path: Option<String>,
|
||||
status: DiffStatus,
|
||||
insertions: usize,
|
||||
deletions: usize,
|
||||
binary: bool,
|
||||
hunks: Vec<DiffHunk>,
|
||||
) -> Self {
|
||||
Self {
|
||||
path,
|
||||
old_path,
|
||||
status,
|
||||
insertions,
|
||||
deletions,
|
||||
binary,
|
||||
hunks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommitDiff {
|
||||
pub files: Vec<FileDiff>,
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
// Compared against the first parent; the empty tree for the root commit.
|
||||
pub fn commit_diff(&self, id: &str) -> Result<CommitDiff> {
|
||||
let commit_id = self.inner.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,
|
||||
};
|
||||
Self::tree_diff(self, old_tree.as_ref(), &new_tree)
|
||||
}
|
||||
|
||||
// Directories and submodules are skipped, files are sorted by path.
|
||||
pub fn range_diff(&self, base: &str, tip: &str) -> Result<CommitDiff> {
|
||||
let base_tree = self
|
||||
.inner
|
||||
.rev_parse_single(base.as_bytes())?
|
||||
.object()?
|
||||
.into_commit()
|
||||
.tree()?;
|
||||
let tip_tree = self
|
||||
.inner
|
||||
.rev_parse_single(tip.as_bytes())?
|
||||
.object()?
|
||||
.into_commit()
|
||||
.tree()?;
|
||||
Self::tree_diff(self, Some(&base_tree), &tip_tree)
|
||||
}
|
||||
|
||||
fn tree_diff(
|
||||
repo: &Repo,
|
||||
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
|
||||
.inner
|
||||
.diff_tree_to_tree(old_tree, Some(new_tree), None)?;
|
||||
|
||||
let mut cache = repo.inner.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.inner, &repo.inner);
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
// 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().into_anyhow()?;
|
||||
|
||||
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 })
|
||||
impl CommitDiff {
|
||||
/// Creates a commit diff from its files.
|
||||
pub fn new(files: Vec<FileDiff>) -> Self {
|
||||
Self { files }
|
||||
}
|
||||
}
|
||||
|
||||
struct HunkCollector<'a> {
|
||||
pub(crate) struct HunkBuilder {
|
||||
old_start: u32,
|
||||
old_lines: u32,
|
||||
new_start: u32,
|
||||
new_lines: u32,
|
||||
old: u32,
|
||||
new: u32,
|
||||
lines: Vec<DiffLine>,
|
||||
insertions: usize,
|
||||
deletions: usize,
|
||||
}
|
||||
|
||||
impl HunkBuilder {
|
||||
/// Starts a hunk spanning the given old and new ranges.
|
||||
pub(crate) fn new(old_start: u32, old_lines: u32, new_start: u32, new_lines: u32) -> Self {
|
||||
Self {
|
||||
old_start,
|
||||
old_lines,
|
||||
new_start,
|
||||
new_lines,
|
||||
old: old_start,
|
||||
new: new_start,
|
||||
lines: Vec::new(),
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends a line, numbering it and counting insertions and deletions.
|
||||
pub(crate) fn push(&mut self, kind: DiffLineKind, text: impl Into<String>) {
|
||||
let (old_no, new_no) = match kind {
|
||||
DiffLineKind::Context => {
|
||||
let numbers = (Some(self.old), Some(self.new));
|
||||
self.old += 1;
|
||||
self.new += 1;
|
||||
numbers
|
||||
}
|
||||
DiffLineKind::Addition => {
|
||||
self.insertions += 1;
|
||||
let number = Some(self.new);
|
||||
self.new += 1;
|
||||
(None, number)
|
||||
}
|
||||
DiffLineKind::Deletion => {
|
||||
self.deletions += 1;
|
||||
let number = Some(self.old);
|
||||
self.old += 1;
|
||||
(number, None)
|
||||
}
|
||||
};
|
||||
|
||||
self.lines
|
||||
.push(DiffLine::new(kind, old_no, new_no, text.into()));
|
||||
}
|
||||
|
||||
/// Finishes the hunk and returns it with its insertion and deletion counts.
|
||||
pub(crate) fn finish(self) -> (DiffHunk, usize, usize) {
|
||||
let hunk = DiffHunk::new(
|
||||
self.old_start,
|
||||
self.old_lines,
|
||||
self.new_start,
|
||||
self.new_lines,
|
||||
self.lines,
|
||||
);
|
||||
(hunk, self.insertions, self.deletions)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct HunkCollector<'a> {
|
||||
hunks: &'a mut Vec<DiffHunk>,
|
||||
insertions: &'a mut usize,
|
||||
deletions: &'a mut usize,
|
||||
}
|
||||
|
||||
impl<'a> HunkCollector<'a> {
|
||||
/// Collects hunks into `hunks` while counting `insertions` and `deletions`.
|
||||
pub(crate) fn new(
|
||||
hunks: &'a mut Vec<DiffHunk>,
|
||||
insertions: &'a mut usize,
|
||||
deletions: &'a mut usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
hunks,
|
||||
insertions,
|
||||
deletions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConsumeHunk for HunkCollector<'_> {
|
||||
type Out = ();
|
||||
|
||||
/// Consumes one hunk, tracking line numbers and insertion/deletion counts.
|
||||
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());
|
||||
let mut builder = HunkBuilder::new(
|
||||
header.before_hunk_start,
|
||||
header.before_hunk_len,
|
||||
header.after_hunk_start,
|
||||
header.after_hunk_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
|
||||
}
|
||||
let kind = match kind {
|
||||
GixLineKind::Context => DiffLineKind::Context,
|
||||
GixLineKind::Remove => DiffLineKind::Deletion,
|
||||
GixLineKind::Add => DiffLineKind::Addition,
|
||||
};
|
||||
out.push(line);
|
||||
builder.push(kind, String::from_utf8_lossy(content).into_owned());
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
let (hunk, insertions, deletions) = builder.finish();
|
||||
*self.insertions += insertions;
|
||||
*self.deletions += deletions;
|
||||
self.hunks.push(hunk);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Marks the end of the unified diff stream.
|
||||
fn finish(self) {}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
pub const MAX_LISTED_COMMITS: usize = 20_000;
|
||||
|
||||
use anyhow::Result;
|
||||
pub struct CommitList {
|
||||
pub total: usize,
|
||||
pub commits: Vec<FileCommit>,
|
||||
}
|
||||
|
||||
use crate::GixResultExt as _;
|
||||
use crate::repo::Repo;
|
||||
impl CommitList {
|
||||
/// Creates a list holding `total` commits of which `commits` are materialized.
|
||||
pub fn new(total: usize, commits: Vec<FileCommit>) -> Self {
|
||||
Self { total, commits }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileCommit {
|
||||
@@ -16,27 +22,47 @@ pub struct FileCommit {
|
||||
}
|
||||
|
||||
impl FileCommit {
|
||||
fn from_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
/// Creates a commit entry from its id, message parts, author, and timestamp.
|
||||
pub fn new(
|
||||
id: String,
|
||||
summary: String,
|
||||
description: Option<String>,
|
||||
author: String,
|
||||
time: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
summary,
|
||||
description,
|
||||
author,
|
||||
time,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a gix commit including its message body.
|
||||
pub(crate) fn from_commit(commit: &gix::Commit<'_>) -> anyhow::Result<Self> {
|
||||
Self::from_commit_with_description(commit, true)
|
||||
}
|
||||
|
||||
// History lists never display the body,
|
||||
// skipping it saves an allocation per listed commit.
|
||||
fn from_commit_summary(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
/// Converts a gix commit, skipping the message body since lists never show it.
|
||||
pub(crate) fn from_commit_summary(commit: &gix::Commit<'_>) -> anyhow::Result<Self> {
|
||||
Self::from_commit_with_description(commit, false)
|
||||
}
|
||||
|
||||
/// Converts a gix commit, optionally skipping the message body.
|
||||
fn from_commit_with_description(
|
||||
commit: &gix::Commit<'_>,
|
||||
include_description: bool,
|
||||
) -> Result<FileCommit> {
|
||||
) -> anyhow::Result<Self> {
|
||||
use crate::GixResultExt as _;
|
||||
|
||||
let author = commit.author().into_anyhow()?;
|
||||
let message = commit.message().into_anyhow()?;
|
||||
|
||||
Ok(FileCommit {
|
||||
id: commit.id().shorten_or_id().to_string(),
|
||||
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
||||
description: if include_description {
|
||||
Ok(FileCommit::new(
|
||||
commit.id().shorten_or_id().to_string(),
|
||||
String::from_utf8_lossy(message.title).trim().to_string(),
|
||||
if include_description {
|
||||
message
|
||||
.body
|
||||
.map(|body| String::from_utf8_lossy(body).trim().to_string())
|
||||
@@ -44,154 +70,8 @@ impl FileCommit {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
||||
time: author.time().into_anyhow()?.seconds,
|
||||
})
|
||||
String::from_utf8_lossy(author.name).trim().to_string(),
|
||||
author.time().into_anyhow()?.seconds,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
// Paths without any commit, like untracked files, are absent from the result.
|
||||
pub fn last_commits(&self, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let Some(head) = self.inner.head_id().ok() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
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 = self
|
||||
.inner
|
||||
.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.
|
||||
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(), FileCommit::from_commit(&commit)?));
|
||||
pending.swap_remove(ix);
|
||||
} else {
|
||||
ix += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
pub fn all_commits(&self) -> Result<CommitList> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let Some(head) = self.inner.head_id().ok() else {
|
||||
return Ok(CommitList {
|
||||
total: 0,
|
||||
commits: Vec::new(),
|
||||
});
|
||||
};
|
||||
|
||||
let walk = self
|
||||
.inner
|
||||
.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(FileCommit::from_commit_summary(&info.object()?)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CommitList { total, commits })
|
||||
}
|
||||
|
||||
pub fn commit_range(&self, base: &str, tip: &str) -> Result<Vec<FileCommit>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let base_id = self.inner.rev_parse_single(base.as_bytes())?;
|
||||
let tip_id = self.inner.rev_parse_single(tip.as_bytes())?;
|
||||
let walk = self
|
||||
.inner
|
||||
.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(FileCommit::from_commit_summary(&info.object()?)?);
|
||||
}
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
// `Ok(None)` for an unborn HEAD.
|
||||
pub fn head_commit(&self) -> Result<Option<FileCommit>> {
|
||||
let Some(head) = self.inner.head_id().ok() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let commit = head.object()?.into_commit();
|
||||
Ok(Some(FileCommit::from_commit(&commit)?))
|
||||
}
|
||||
|
||||
// `Ok(None)` when the id cannot be resolved.
|
||||
pub fn commit(&self, id: &str) -> Result<Option<FileCommit>> {
|
||||
match self.inner.rev_parse_single(id.as_bytes()) {
|
||||
Ok(commit_id) => {
|
||||
let commit = commit_id.object()?.into_commit();
|
||||
Ok(Some(FileCommit::from_commit(&commit)?))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
pub struct CommitList {
|
||||
pub total: usize,
|
||||
pub commits: Vec<FileCommit>,
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ mod diff;
|
||||
mod history;
|
||||
mod nip34;
|
||||
mod patch;
|
||||
mod remote;
|
||||
mod repo;
|
||||
mod scan;
|
||||
mod sync;
|
||||
mod worktree;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -18,9 +18,11 @@ pub use nip34::{GraspSignals, Nip34Binding, Nip34Kind};
|
||||
pub use patch::PatchParser;
|
||||
pub use repo::{Repo, RepoRefState};
|
||||
pub use scan::{LocalRepo, find_git_repos};
|
||||
pub use sync::{RefSync, RepoSyncStatus};
|
||||
pub use worktree::WorktreeSnapshot;
|
||||
|
||||
pub(crate) trait GixResultExt<T> {
|
||||
/// Converts a gix exception result into an `anyhow` result.
|
||||
fn into_anyhow(self) -> anyhow::Result<T>;
|
||||
}
|
||||
|
||||
|
||||
+16
-414
@@ -1,18 +1,15 @@
|
||||
use anyhow::Result;
|
||||
use gix::bstr::ByteSlice;
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::GixResultExt as _;
|
||||
use crate::repo::Repo;
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(crate) struct Nip34Json {
|
||||
pub(crate) identifier: Option<String>,
|
||||
pub(crate) owner: Option<String>,
|
||||
}
|
||||
|
||||
/// The kind of NIP-34 relationship a local repository has on disk.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Nip34Kind {
|
||||
// By `nak`'s `nip34.json` or `ngit`'s `nostr.repo`.
|
||||
Initialized,
|
||||
// Cloned from a `nostr://` remote but never initialized locally.
|
||||
Cloned,
|
||||
// Nostr tooling touched the repository but no binding is recoverable.
|
||||
ToolingOnly,
|
||||
}
|
||||
|
||||
@@ -23,7 +20,6 @@ pub struct GraspSignals {
|
||||
pub nostr_repo_config: bool,
|
||||
pub nostr_remote: bool,
|
||||
pub grasp_remote: bool,
|
||||
// `nak`-specific `nip34/grasp/<host>` remote name.
|
||||
pub nip34_grasp_remote: bool,
|
||||
pub nip34_state_refs: bool,
|
||||
pub nostr_cache: bool,
|
||||
@@ -32,430 +28,36 @@ pub struct GraspSignals {
|
||||
}
|
||||
|
||||
impl GraspSignals {
|
||||
/// Returns whether any Nostr tooling marker was found.
|
||||
pub fn any(&self) -> bool {
|
||||
*self != Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a local repository's on-disk state says about its NIP-34 binding.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Nip34Binding {
|
||||
pub kind: Nip34Kind,
|
||||
pub signals: GraspSignals,
|
||||
// From `nip34.json` or `nostr.repo`.
|
||||
pub owner: Option<PublicKey>,
|
||||
pub identifier: Option<String>,
|
||||
pub grasp_urls: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Nip34Json {
|
||||
identifier: Option<String>,
|
||||
owner: Option<String>,
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
// `None` when no Nostr tooling left a marker.
|
||||
pub fn nip34_binding(&self) -> Option<Nip34Binding> {
|
||||
let common_dir = self.inner.common_dir().to_path_buf();
|
||||
let workdir = self.inner.workdir().map(std::path::Path::to_path_buf);
|
||||
|
||||
let mut signals = GraspSignals::default();
|
||||
let mut owner: Option<PublicKey> = None;
|
||||
let mut identifier: Option<String> = None;
|
||||
let mut grasp_urls: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(workdir) = &workdir {
|
||||
if let Ok(bytes) = std::fs::read(workdir.join("nip34.json"))
|
||||
&& let Ok(config) = serde_json::from_slice::<Nip34Json>(&bytes)
|
||||
{
|
||||
signals.nip34_json = true;
|
||||
identifier = config.identifier.and_then(Self::non_empty);
|
||||
owner = config
|
||||
.owner
|
||||
.as_deref()
|
||||
.and_then(|value| PublicKey::parse(value).ok());
|
||||
}
|
||||
|
||||
if workdir.join("maintainers.yaml").is_file() {
|
||||
signals.maintainers_yaml = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude"))
|
||||
&& exclude.contains("nip34.json")
|
||||
{
|
||||
signals.nip34_excluded = true;
|
||||
}
|
||||
|
||||
// `ngit` keeps its repository event cache in the Git common directory.
|
||||
if common_dir.join("nostr-cache.lmdb").is_file() {
|
||||
signals.nostr_cache = true;
|
||||
}
|
||||
|
||||
// `ngit` reads and writes `nostr.repo` at repository-local scope only.
|
||||
if let Ok(config) = gix::config::File::from_path_no_includes(
|
||||
common_dir.join("config"),
|
||||
gix::config::Source::Local,
|
||||
) {
|
||||
if let Some(value) = config.string("nostr.repo")
|
||||
&& let Some((key, id)) = Self::coordinate_from_naddr(&value.to_str_lossy())
|
||||
{
|
||||
signals.nostr_repo_config = true;
|
||||
owner = Some(key);
|
||||
identifier = Some(id);
|
||||
}
|
||||
|
||||
for key in ["nostr.repo-relay-only", "nostr.nostate", "nostr.private"] {
|
||||
if config.string(key).is_some() {
|
||||
signals.nostr_aux_config = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sections) = config.sections_by_name("remote") {
|
||||
for section in sections {
|
||||
let Some(name) = section.header().subsection_name() else {
|
||||
continue;
|
||||
};
|
||||
let nak_grasp_remote = name.to_str_lossy().starts_with("nip34/grasp/");
|
||||
|
||||
for url in section.values("url") {
|
||||
let url = url.to_str_lossy();
|
||||
|
||||
if url.starts_with("nostr://") {
|
||||
signals.nostr_remote = true;
|
||||
// Strong markers win; only fill an empty binding.
|
||||
if owner.is_none()
|
||||
&& identifier.is_none()
|
||||
&& let Some((key, id)) = Self::parse_nostr_url(&url)
|
||||
{
|
||||
owner = Some(key);
|
||||
identifier = Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
if Self::is_grasp_url(&url) {
|
||||
signals.grasp_remote = true;
|
||||
signals.nip34_grasp_remote |= nak_grasp_remote;
|
||||
grasp_urls.push(url.to_string());
|
||||
|
||||
if owner.is_none()
|
||||
&& identifier.is_none()
|
||||
&& let Some((key, id)) = Self::grasp_parts(&url)
|
||||
{
|
||||
owner = Some(key);
|
||||
identifier = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `nak` materializes a kind-30618 state as `refs/heads/nip34/state/*`.
|
||||
if let Ok(platform) = self.inner.references()
|
||||
&& let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/")
|
||||
&& refs.next().is_some()
|
||||
{
|
||||
signals.nip34_state_refs = true;
|
||||
}
|
||||
|
||||
if !signals.any() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let kind = if signals.nip34_json
|
||||
|| signals.nostr_repo_config
|
||||
|| signals.nip34_grasp_remote
|
||||
|| signals.nip34_state_refs
|
||||
{
|
||||
Nip34Kind::Initialized
|
||||
} else if signals.nostr_remote {
|
||||
Nip34Kind::Cloned
|
||||
} else {
|
||||
Nip34Kind::ToolingOnly
|
||||
};
|
||||
|
||||
Some(Nip34Binding {
|
||||
impl Nip34Binding {
|
||||
/// Creates a binding from its kind, detected signals, and recovered coordinates.
|
||||
pub fn new(
|
||||
kind: Nip34Kind,
|
||||
signals: GraspSignals,
|
||||
owner: Option<PublicKey>,
|
||||
identifier: Option<String>,
|
||||
grasp_urls: Vec<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
signals,
|
||||
owner,
|
||||
identifier,
|
||||
grasp_urls,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_nostr_repo(&self, naddr: &str) -> Result<()> {
|
||||
self.edit_local_config(|config| {
|
||||
config.set_raw_value("nostr.repo", naddr).into_anyhow()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
// Mirrors `nak`'s `IsGraspURL`: two path segments,
|
||||
// a path of at least 65 bytes, and a first segment that decodes as an `npub`.
|
||||
fn is_grasp_url(url: &str) -> bool {
|
||||
let Ok(parsed) = Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !matches!(parsed.scheme(), "http" | "https" | "grasp") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let path = parsed.path();
|
||||
if path.matches('/').count() != 2 || path.len() < 65 {
|
||||
return false;
|
||||
}
|
||||
|
||||
Self::grasp_parts(url).is_some()
|
||||
}
|
||||
|
||||
fn grasp_parts(url: &str) -> Option<(PublicKey, String)> {
|
||||
let parsed = Url::parse(url).ok()?;
|
||||
let mut segments = parsed.path_segments()?.filter(|part| !part.is_empty());
|
||||
|
||||
let owner = PublicKey::parse(segments.next()?).ok()?;
|
||||
let identifier = Self::non_empty(segments.next()?.trim_end_matches(".git"))?;
|
||||
|
||||
Some((owner, identifier))
|
||||
}
|
||||
|
||||
fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> {
|
||||
let coordinate = Nip19Coordinate::from_bech32(value).ok()?;
|
||||
if coordinate.kind != Kind::GitRepoAnnouncement {
|
||||
return None;
|
||||
}
|
||||
|
||||
let identifier = Self::non_empty(coordinate.identifier.clone())?;
|
||||
Some((coordinate.public_key, identifier))
|
||||
}
|
||||
|
||||
// Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`,
|
||||
// `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding.
|
||||
fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> {
|
||||
let rest = url.strip_prefix("nostr://")?;
|
||||
|
||||
if rest.starts_with("naddr1") {
|
||||
return Self::coordinate_from_naddr(rest);
|
||||
}
|
||||
|
||||
let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after);
|
||||
let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect();
|
||||
|
||||
if parts
|
||||
.first()
|
||||
.is_some_and(|first| matches!(*first, "ssh" | "https" | "http"))
|
||||
{
|
||||
parts.remove(0);
|
||||
}
|
||||
|
||||
if parts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let owner = PublicKey::parse(parts[0]).ok()?;
|
||||
let identifier = Self::non_empty(parts.last()?.trim_end_matches(".git"))?;
|
||||
|
||||
Some((owner, identifier))
|
||||
}
|
||||
|
||||
fn non_empty(value: impl Into<String>) -> Option<String> {
|
||||
let value = value.into();
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn init_repo() -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("repo");
|
||||
std::fs::create_dir_all(&path).expect("mkdir");
|
||||
git(&path, &["init", "-q"]);
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let status = Command::new("git")
|
||||
.current_dir(dir)
|
||||
.env("GIT_AUTHOR_NAME", "Test Author")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "Test Author")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@example.com")
|
||||
.env("GIT_EDITOR", "true")
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("spawn git");
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
}
|
||||
|
||||
fn key() -> PublicKey {
|
||||
Keys::generate().public_key()
|
||||
}
|
||||
|
||||
fn naddr(kind: Kind, owner: PublicKey, identifier: &str) -> String {
|
||||
let coordinate = Coordinate::new(kind, owner).identifier(identifier);
|
||||
Nip19Coordinate::new(coordinate, Vec::<RelayUrl>::new())
|
||||
.to_bech32()
|
||||
.expect("naddr")
|
||||
}
|
||||
|
||||
fn binding_of(path: &Path) -> Option<Nip34Binding> {
|
||||
Repo::open(path).ok()?.nip34_binding()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_repository_has_no_binding() {
|
||||
let (_dir, path) = init_repo();
|
||||
assert!(binding_of(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nip34_json_marks_a_repository_initialized() {
|
||||
let (_dir, path) = init_repo();
|
||||
let owner = key();
|
||||
let npub = owner.to_bech32().expect("npub");
|
||||
std::fs::write(
|
||||
path.join("nip34.json"),
|
||||
format!(r#"{{"identifier":"my-repo","owner":"{npub}"}}"#),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let binding = binding_of(&path).expect("binding");
|
||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||
assert!(binding.signals.nip34_json);
|
||||
assert_eq!(binding.owner, Some(owner));
|
||||
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_nip34_json_is_ignored() {
|
||||
let (_dir, path) = init_repo();
|
||||
std::fs::write(path.join("nip34.json"), b"not json").expect("write");
|
||||
|
||||
assert!(binding_of(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nak_exclude_and_state_refs_are_detected() {
|
||||
let (_dir, path) = init_repo();
|
||||
|
||||
std::fs::create_dir_all(path.join(".git/info")).expect("mkdir");
|
||||
std::fs::write(path.join(".git/info/exclude"), "nip34.json\n").expect("write");
|
||||
|
||||
git(&path, &["commit", "-q", "--allow-empty", "-m", "initial"]);
|
||||
git(
|
||||
&path,
|
||||
&["update-ref", "refs/heads/nip34/state/HEAD", "HEAD"],
|
||||
);
|
||||
|
||||
let binding = binding_of(&path).expect("binding");
|
||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||
assert!(binding.signals.nip34_excluded);
|
||||
assert!(binding.signals.nip34_state_refs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nostr_repo_config_marks_a_repository_initialized() {
|
||||
let (_dir, path) = init_repo();
|
||||
let owner = key();
|
||||
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
|
||||
git(&path, &["config", "nostr.repo", &naddr]);
|
||||
|
||||
let binding = binding_of(&path).expect("binding");
|
||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||
assert!(binding.signals.nostr_repo_config);
|
||||
assert_eq!(binding.owner, Some(owner));
|
||||
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_written_nostr_repo_marker_is_detected() {
|
||||
let (_dir, path) = init_repo();
|
||||
let owner = key();
|
||||
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
|
||||
|
||||
Repo::open(&path)
|
||||
.expect("open")
|
||||
.set_nostr_repo(&naddr)
|
||||
.expect("write marker");
|
||||
|
||||
let binding = binding_of(&path).expect("binding");
|
||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||
assert!(binding.signals.nostr_repo_config);
|
||||
assert_eq!(binding.owner, Some(owner));
|
||||
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nostr_remote_is_a_nip34_clone() {
|
||||
let (_dir, path) = init_repo();
|
||||
let owner = key();
|
||||
let npub = owner.to_bech32().expect("npub");
|
||||
let url = format!("nostr://{npub}/relay.ngit.dev/my-repo");
|
||||
git(&path, &["remote", "add", "origin", &url]);
|
||||
|
||||
let binding = binding_of(&path).expect("binding");
|
||||
assert_eq!(binding.kind, Nip34Kind::Cloned);
|
||||
assert!(binding.signals.nostr_remote);
|
||||
assert_eq!(binding.owner, Some(owner));
|
||||
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nak_grasp_remote_marks_a_repository_initialized() {
|
||||
let (_dir, path) = init_repo();
|
||||
let owner = key();
|
||||
let npub = owner.to_bech32().expect("npub");
|
||||
let url = format!("https://gitnostr.com/{npub}/my-repo.git");
|
||||
git(
|
||||
&path,
|
||||
&["config", "remote.nip34/grasp/gitnostr.com.url", &url],
|
||||
);
|
||||
|
||||
let binding = binding_of(&path).expect("binding");
|
||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||
assert!(binding.signals.nip34_grasp_remote);
|
||||
assert!(binding.signals.grasp_remote);
|
||||
assert_eq!(binding.grasp_urls, vec![url]);
|
||||
assert_eq!(binding.owner, Some(owner));
|
||||
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nostr_cache_alone_is_tooling_only() {
|
||||
let (_dir, path) = init_repo();
|
||||
std::fs::write(path.join(".git/nostr-cache.lmdb"), b"cache").expect("write");
|
||||
|
||||
let binding = binding_of(&path).expect("binding");
|
||||
assert_eq!(binding.kind, Nip34Kind::ToolingOnly);
|
||||
assert!(binding.signals.nostr_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grasp_urls_are_recognised_by_shape() {
|
||||
let owner = key();
|
||||
let npub = owner.to_bech32().expect("npub");
|
||||
|
||||
assert!(Repo::is_grasp_url(&format!(
|
||||
"https://gitnostr.com/{npub}/my-repo.git"
|
||||
)));
|
||||
assert!(Repo::is_grasp_url(&format!(
|
||||
"grasp://gitnostr.com/{npub}/my-repo.git"
|
||||
)));
|
||||
|
||||
assert!(!Repo::is_grasp_url("https://gitnostr.com/my-repo.git"));
|
||||
assert!(!Repo::is_grasp_url(
|
||||
"https://gitnostr.com/not-a-pubkey/my-repo.git"
|
||||
));
|
||||
assert!(!Repo::is_grasp_url(&format!(
|
||||
"ssh://gitnostr.com/{npub}/my-repo.git"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
+61
-157
@@ -1,17 +1,14 @@
|
||||
use std::io::Write;
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::Result;
|
||||
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
|
||||
use diffy::{Hunk, Line};
|
||||
|
||||
use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
|
||||
use crate::diff::{CommitDiff, DiffHunk, DiffLineKind, DiffStatus, FileDiff, HunkBuilder};
|
||||
use crate::history::FileCommit;
|
||||
use crate::repo::Repo;
|
||||
|
||||
pub struct PatchParser;
|
||||
|
||||
impl PatchParser {
|
||||
/// Splits an mbox series into the raw text of each message.
|
||||
pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
||||
Self::envelopes(patch)
|
||||
.into_iter()
|
||||
@@ -19,44 +16,49 @@ impl PatchParser {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parses a git diff into file diffs, returning an empty diff without one.
|
||||
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
||||
return Ok(CommitDiff { files: Vec::new() });
|
||||
return Ok(CommitDiff::new(Vec::new()));
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
|
||||
files.push(Self::file_diff(file?)?);
|
||||
files.push(Self::file_diff(file?));
|
||||
}
|
||||
|
||||
Ok(CommitDiff { files })
|
||||
Ok(CommitDiff::new(files))
|
||||
}
|
||||
|
||||
/// Lists the commits represented by the mbox messages that carry an id.
|
||||
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
||||
Self::envelopes(patch)
|
||||
.into_iter()
|
||||
.filter(|message| !message.id.is_empty())
|
||||
.map(|message| FileCommit {
|
||||
id: message.id.to_string(),
|
||||
summary: message
|
||||
.header("Subject")
|
||||
.map(Self::strip_patch_prefix)
|
||||
.unwrap_or_default(),
|
||||
description: None,
|
||||
author: message
|
||||
.header("From")
|
||||
.map(Self::name_from_address)
|
||||
.unwrap_or_default(),
|
||||
time: message
|
||||
.header("Date")
|
||||
.and_then(|value| gix::date::parse(value.trim(), None).ok())
|
||||
.map(|time| time.seconds)
|
||||
.unwrap_or(0),
|
||||
.map(|message| {
|
||||
FileCommit::new(
|
||||
message.id.to_string(),
|
||||
message
|
||||
.header("Subject")
|
||||
.map(Self::strip_patch_prefix)
|
||||
.unwrap_or_default(),
|
||||
None,
|
||||
message
|
||||
.header("From")
|
||||
.map(Self::name_from_address)
|
||||
.unwrap_or_default(),
|
||||
message
|
||||
.header("Date")
|
||||
.and_then(|value| gix::date::parse(value.trim(), None).ok())
|
||||
.map(|time| time.seconds)
|
||||
.unwrap_or(0),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Splits `patch` on `From <40-hex> <date>` envelopes, falling back to one message.
|
||||
fn envelopes(patch: &str) -> Vec<Envelope<'_>> {
|
||||
let mut messages: Vec<Envelope<'_>> = Vec::new();
|
||||
let mut current: Option<(usize, &str, Vec<&str>)> = None;
|
||||
@@ -74,11 +76,7 @@ impl PatchParser {
|
||||
|
||||
if is_envelope {
|
||||
if let Some((start, id, headers)) = current.take() {
|
||||
messages.push(Envelope {
|
||||
text: &patch[start..],
|
||||
id,
|
||||
headers,
|
||||
});
|
||||
messages.push(Envelope::new(&patch[start..], id, headers));
|
||||
}
|
||||
|
||||
let id = line
|
||||
@@ -104,24 +102,17 @@ impl PatchParser {
|
||||
}
|
||||
|
||||
if let Some((start, id, headers)) = current.take() {
|
||||
messages.push(Envelope {
|
||||
text: &patch[start..],
|
||||
id,
|
||||
headers,
|
||||
});
|
||||
messages.push(Envelope::new(&patch[start..], id, headers));
|
||||
}
|
||||
|
||||
if messages.is_empty() {
|
||||
messages.push(Envelope {
|
||||
text: patch,
|
||||
id: "",
|
||||
headers: Vec::new(),
|
||||
});
|
||||
messages.push(Envelope::new(patch, "", Vec::new()));
|
||||
}
|
||||
|
||||
messages
|
||||
}
|
||||
|
||||
/// Extracts the display name from a `From` header value.
|
||||
fn name_from_address(from: &str) -> String {
|
||||
match from.trim().find('<') {
|
||||
Some(ix) => from[..ix].trim().to_string(),
|
||||
@@ -129,7 +120,7 @@ impl PatchParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Matches `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]`, etc.
|
||||
/// Strips mailing list prefixes like `[PATCH]` or `[RFC PATCH 1/2]`.
|
||||
fn strip_patch_prefix(subject: &str) -> String {
|
||||
let trimmed = subject.trim();
|
||||
let Some(rest) = trimmed.strip_prefix('[') else {
|
||||
@@ -145,7 +136,8 @@ impl PatchParser {
|
||||
}
|
||||
}
|
||||
|
||||
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
|
||||
/// Converts one parsed file patch, keeping rename and copy paths unstripped.
|
||||
fn file_diff(file: FilePatch<'_, str>) -> FileDiff {
|
||||
let stripped;
|
||||
let operation = match file.operation() {
|
||||
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
|
||||
@@ -177,39 +169,35 @@ impl PatchParser {
|
||||
|
||||
if let Some(text) = patch.as_text() {
|
||||
for hunk in text.hunks() {
|
||||
let hunk = Self::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();
|
||||
let (hunk, hunk_insertions, hunk_deletions) = Self::hunk_diff(hunk);
|
||||
insertions += hunk_insertions;
|
||||
deletions += hunk_deletions;
|
||||
hunks.push(hunk);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(FileDiff {
|
||||
path: path.to_owned(),
|
||||
old_path: old_path.map(str::to_owned),
|
||||
FileDiff::new(
|
||||
path.to_owned(),
|
||||
old_path.map(str::to_owned),
|
||||
status,
|
||||
insertions,
|
||||
deletions,
|
||||
binary: patch.is_binary(),
|
||||
patch.is_binary(),
|
||||
hunks,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk {
|
||||
/// Converts a diffy hunk into a `DiffHunk` with its insertion and deletion counts.
|
||||
fn hunk_diff(hunk: &Hunk<'_, str>) -> (DiffHunk, usize, usize) {
|
||||
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());
|
||||
let mut builder = HunkBuilder::new(
|
||||
old_range.start() as u32,
|
||||
old_range.len() as u32,
|
||||
new_range.start() as u32,
|
||||
new_range.len() as u32,
|
||||
);
|
||||
|
||||
for line in hunk.lines() {
|
||||
let (kind, text) = match line {
|
||||
@@ -218,56 +206,28 @@ impl PatchParser {
|
||||
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: text
|
||||
.strip_suffix('\n')
|
||||
.unwrap_or(text)
|
||||
.strip_suffix('\r')
|
||||
.unwrap_or(text)
|
||||
.to_owned(),
|
||||
});
|
||||
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||
let text = text.strip_suffix('\r').unwrap_or(text);
|
||||
builder.push(kind, text.to_owned());
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
builder.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A `git format-patch` mbox message, split on its `From <40-hex> <date>` envelope.
|
||||
struct Envelope<'a> {
|
||||
text: &'a str,
|
||||
id: &'a str,
|
||||
headers: Vec<&'a str>,
|
||||
}
|
||||
|
||||
impl Envelope<'_> {
|
||||
impl<'a> Envelope<'a> {
|
||||
/// Creates an envelope from its text, commit id, and header lines.
|
||||
fn new(text: &'a str, id: &'a str, headers: Vec<&'a str>) -> Self {
|
||||
Self { text, id, headers }
|
||||
}
|
||||
|
||||
/// Returns the first header line matching `name: `.
|
||||
fn header(&self, name: &str) -> Option<&str> {
|
||||
let prefix = format!("{name}: ");
|
||||
self.headers
|
||||
@@ -276,59 +236,3 @@ impl Envelope<'_> {
|
||||
.map(|line| &line[prefix.len()..])
|
||||
}
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
pub fn apply_patch(&self, patch: &str) -> Result<()> {
|
||||
let workdir = self
|
||||
.inner
|
||||
.workdir()
|
||||
.context("repository has no worktree")?
|
||||
.to_path_buf();
|
||||
|
||||
let mut child = Repo::git_command(&workdir)
|
||||
.args(["am"])
|
||||
.stdin(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("failed to spawn `git am`")?;
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.context("git am has no stdin pipe")?
|
||||
.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(())
|
||||
}
|
||||
|
||||
// Fails when the range has no commits. The mbox is returned untrimmed;
|
||||
// trailing newlines are part of the format.
|
||||
pub fn format_patch_between(&self, base: &str, tip: &str) -> Result<String> {
|
||||
let output = Repo::run_git(
|
||||
self.workdir_or_dot(),
|
||||
&["format-patch", "--stdout", &format!("{base}..{tip}")],
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
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;
|
||||
|
||||
use crate::GixResultExt as _;
|
||||
use crate::repo::Repo;
|
||||
|
||||
impl Repo {
|
||||
pub fn fetch(&self) -> 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,
|
||||
)
|
||||
.into_anyhow()?
|
||||
.to_owned(),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
self.inner
|
||||
.find_remote("origin")?
|
||||
.connect(gix::remote::Direction::Fetch)?
|
||||
.prepare_fetch(Discard, options)?
|
||||
.receive(Discard, &IS_INTERRUPTED)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn push_ref(&self, url: &str, commit: &str, reference: &str) -> Result<()> {
|
||||
let output = Self::run_git(
|
||||
self.workdir_or_dot(),
|
||||
&["push", url, &format!("{commit}:{reference}")],
|
||||
"git push",
|
||||
)?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git push failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn push_main(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
self.push_refspecs(
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/main:refs/heads/main"],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
self.push_refspecs(
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
|
||||
)
|
||||
}
|
||||
|
||||
fn push_refspecs(
|
||||
&self,
|
||||
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 = Self::run_git(self.workdir_or_dot(), &args, "git push")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git push to {base_url} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remote_has_refs(&self, url: &str, expected: &[(String, String)]) -> Result<bool> {
|
||||
if expected.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let url = Self::transport_url(url);
|
||||
|
||||
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<_>, _>>()
|
||||
.into_anyhow()
|
||||
.context("invalid refspec")?;
|
||||
|
||||
let options = gix::remote::ref_map::Options {
|
||||
extra_refspecs: refspecs,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (refs, _) = self
|
||||
.inner
|
||||
.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,
|
||||
// matching `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)))
|
||||
}
|
||||
|
||||
pub fn ensure_origin(&self, url: &str) -> Result<()> {
|
||||
if self.inner.find_remote("origin").is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// `git remote add` also configures the default fetch refspec.
|
||||
self.edit_local_config(|config| {
|
||||
config
|
||||
.set_raw_value("remote.origin.url", url)
|
||||
.into_anyhow()?;
|
||||
config
|
||||
.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
|
||||
.into_anyhow()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
// A working copy cloned from a local mirror is re-targeted at the grasp server,
|
||||
// a pre-existing fetch refspec is left untouched.
|
||||
pub fn set_origin(&self, url: &str) -> Result<()> {
|
||||
let had_origin = self.inner.find_remote("origin").is_ok();
|
||||
|
||||
self.edit_local_config(|config| {
|
||||
config
|
||||
.set_raw_value("remote.origin.url", url)
|
||||
.into_anyhow()?;
|
||||
|
||||
if !had_origin {
|
||||
config
|
||||
.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
|
||||
.into_anyhow()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn origin_url(&self) -> Result<Option<String>> {
|
||||
let Ok(remote) = self.inner.find_remote("origin") else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(remote
|
||||
.url(gix::remote::Direction::Fetch)
|
||||
.map(|url| url.to_string()))
|
||||
}
|
||||
|
||||
pub fn fetch_refs<U: AsRef<str>>(&self, urls: &[U], refspec: &str) -> Result<()> {
|
||||
let refspec = gix::refspec::parse(
|
||||
gix::bstr::BStr::new(refspec),
|
||||
gix::refspec::parse::Operation::Fetch,
|
||||
)
|
||||
.into_anyhow()
|
||||
.context("invalid fetch refspec")?
|
||||
.to_owned();
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
for url in urls {
|
||||
let url = Self::transport_url(url.as_ref());
|
||||
let outcome = (|| -> Result<()> {
|
||||
let options = gix::remote::ref_map::Options {
|
||||
extra_refspecs: vec![refspec.clone()],
|
||||
..Default::default()
|
||||
};
|
||||
self.inner
|
||||
.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(())
|
||||
})();
|
||||
|
||||
match outcome {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
match last_error {
|
||||
Some(error) => Err(error).context("failed to fetch from any mirror"),
|
||||
None => bail!("no fetch URLs provided"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn edit_local_config(
|
||||
&self,
|
||||
edit: impl FnOnce(&mut gix::config::File) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let config_path = self.inner.common_dir().join("config");
|
||||
|
||||
let mut lock = gix::lock::File::acquire_to_update_resource(
|
||||
&config_path,
|
||||
gix::lock::acquire::Fail::Immediately,
|
||||
None,
|
||||
)
|
||||
.into_anyhow()
|
||||
.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,
|
||||
Err(error) if error.is_not_found() => gix::config::File::default(),
|
||||
Err(error) => {
|
||||
return Err(error)
|
||||
.into_anyhow()
|
||||
.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(())
|
||||
}
|
||||
|
||||
pub(crate) fn workdir_or_dot(&self) -> &Path {
|
||||
self.inner.workdir().unwrap_or_else(|| Path::new("."))
|
||||
}
|
||||
|
||||
pub(crate) fn run_git(dir: &Path, args: &[&str], what: &str) -> Result<std::process::Output> {
|
||||
Self::git_command(dir)
|
||||
.args(args)
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.with_context(|| format!("failed to spawn `{what}`"))
|
||||
}
|
||||
|
||||
pub(crate) fn git_command(dir: &Path) -> Command {
|
||||
let mut command = Command::new("git");
|
||||
command.arg("-C").arg(dir).env("GIT_TERMINAL_PROMPT", "0");
|
||||
command
|
||||
}
|
||||
}
|
||||
+1430
-31
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
use crate::nip34::Nip34Binding;
|
||||
use crate::repo::Repo;
|
||||
|
||||
/// Caps nesting so pathological trees can't stall the scan.
|
||||
const SCAN_MAX_DEPTH: usize = 12;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -14,41 +14,69 @@ pub struct LocalRepo {
|
||||
pub nip34: Option<Nip34Binding>,
|
||||
}
|
||||
|
||||
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
|
||||
if !root.is_dir() {
|
||||
return Vec::new();
|
||||
impl LocalRepo {
|
||||
/// Creates a local repository entry from its path and optional NIP-34 binding.
|
||||
pub fn new(path: PathBuf, nip34: Option<Nip34Binding>) -> Self {
|
||||
Self { path, nip34 }
|
||||
}
|
||||
}
|
||||
|
||||
let walker = WalkBuilder::new(root)
|
||||
/// Finds git repositories under `root` up to `SCAN_MAX_DEPTH` deep, without descending into them.
|
||||
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
|
||||
let Ok(root) = root.canonicalize() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let found = Arc::new(Mutex::new(Vec::<PathBuf>::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)
|
||||
.filter_entry({
|
||||
let found = found.clone();
|
||||
move |entry| {
|
||||
let Some(kind) = entry.file_type() else {
|
||||
return true;
|
||||
};
|
||||
if !kind.is_dir() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let dir = entry.path();
|
||||
let Ok(repo) = gix::discover(dir) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let Some(workdir) = repo.workdir() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if workdir != dir {
|
||||
return true;
|
||||
}
|
||||
|
||||
match found.lock() {
|
||||
Ok(mut repos) => repos.push(dir.to_path_buf()),
|
||||
Err(poisoned) => poisoned.into_inner().push(dir.to_path_buf()),
|
||||
}
|
||||
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();
|
||||
for _ in walker {}
|
||||
|
||||
let mut repos = match found.lock() {
|
||||
Ok(mut repos) => std::mem::take(&mut *repos),
|
||||
Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
|
||||
};
|
||||
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
|
||||
repos
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let nip34 = Repo::open(&path).ok().and_then(|repo| repo.nip34_binding());
|
||||
LocalRepo { path, nip34 }
|
||||
LocalRepo::new(path, nip34)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// How a single branch compares between local and Nostr.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RefSync {
|
||||
InSync,
|
||||
LocalAhead { ahead: usize },
|
||||
RemoteAhead { behind: usize },
|
||||
Diverged { ahead: usize, behind: usize },
|
||||
LocalOnly,
|
||||
RemoteOnly,
|
||||
}
|
||||
|
||||
/// Branch comparison between a local repository and its Nostr state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RepoSyncStatus {
|
||||
/// Branch short name and its classification.
|
||||
pub refs: Vec<(String, RefSync)>,
|
||||
pub ahead_total: usize,
|
||||
pub behind_total: usize,
|
||||
}
|
||||
|
||||
impl RepoSyncStatus {
|
||||
/// True when every branch matches the Nostr state.
|
||||
pub fn in_sync(&self) -> bool {
|
||||
self.refs.iter().all(|(_, sync)| *sync == RefSync::InSync)
|
||||
}
|
||||
|
||||
/// Maps branch short name to lowercase commit oid.
|
||||
pub(crate) fn branches(refs: &[(String, String)]) -> BTreeMap<&str, String> {
|
||||
refs.iter()
|
||||
.filter_map(|(name, commit)| {
|
||||
name.strip_prefix("refs/heads/")
|
||||
.map(|branch| (branch, commit.to_ascii_lowercase()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
+321
-23
@@ -1,6 +1,9 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use nostr::prelude::Keys;
|
||||
use signed_core::RepoAddr;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -11,6 +14,70 @@ fn blocks_parent_components() {
|
||||
assert_eq!(GitCache::sanitize_path_component("a/../b"), "a_.._b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_ensure_clone_succeeds_for_every_caller() {
|
||||
let server = tempfile::tempdir().expect("tempdir");
|
||||
let server_repo = bare_server(server.path(), "npub1test", "repo");
|
||||
|
||||
let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&work_repo, "initial");
|
||||
let work_dir = work_dir.path();
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
Repo::open(work_dir)
|
||||
.expect("open")
|
||||
.push_all(&base_url, "npub1test", "repo")
|
||||
.expect("push");
|
||||
|
||||
let cache_dir = tempfile::tempdir().expect("tempdir");
|
||||
let cache = GitCache::new(cache_dir.path().to_path_buf());
|
||||
let addr = RepoAddr::new(Keys::generate().public_key(), "repo");
|
||||
let clone_url = format!("file://{}", server_repo.display());
|
||||
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..4 {
|
||||
scope.spawn(|| {
|
||||
cache
|
||||
.ensure_clone(&addr, &[&clone_url])
|
||||
.expect("ensure_clone");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
assert!(cache.open(&addr).expect("open").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_clone_recovers_from_a_leftover_directory() {
|
||||
let server = tempfile::tempdir().expect("tempdir");
|
||||
let server_repo = bare_server(server.path(), "npub1test", "repo");
|
||||
|
||||
let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&work_repo, "initial");
|
||||
let work_dir = work_dir.path();
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
Repo::open(work_dir)
|
||||
.expect("open")
|
||||
.push_all(&base_url, "npub1test", "repo")
|
||||
.expect("push");
|
||||
|
||||
let cache_dir = tempfile::tempdir().expect("tempdir");
|
||||
let cache = GitCache::new(cache_dir.path().to_path_buf());
|
||||
let addr = RepoAddr::new(Keys::generate().public_key(), "repo");
|
||||
let clone_url = format!("file://{}", server_repo.display());
|
||||
|
||||
// Simulate the remains of an interrupted clone.
|
||||
let leftover = cache.repo_path(&addr);
|
||||
std::fs::create_dir_all(&leftover).expect("mkdir");
|
||||
|
||||
cache
|
||||
.ensure_clone(&addr, &[&clone_url])
|
||||
.expect("ensure_clone");
|
||||
|
||||
assert!(cache.open(&addr).expect("open").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_commit_reports_the_first_ancestor() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
@@ -56,39 +123,221 @@ fn push_all_mirrors_branches_and_tags() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_has_refs_reports_whether_pushed_refs_landed() {
|
||||
fn ls_remote_lists_branches_and_tags_only() {
|
||||
let server = tempfile::tempdir().unwrap();
|
||||
bare_server(server.path(), "npub1test", "my-repo");
|
||||
let server_repo = bare_server(server.path(), "npub1test", "my-repo");
|
||||
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
|
||||
git_run(dir, &["branch", "feature"]);
|
||||
git_run(dir, &["tag", "v1"]);
|
||||
git_run(dir, &["tag", "-a", "v2", "-m", "annotated"]);
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
let local = Repo::open(dir).expect("open");
|
||||
local
|
||||
.push_all(&base_url, "npub1test", "my-repo")
|
||||
.expect("push");
|
||||
|
||||
// Refs outside heads and tags, and the HEAD symref line, are not listed.
|
||||
let main = git_in(dir, &["rev-parse", "refs/heads/main"]).expect("main oid");
|
||||
let url = format!("file://{}/npub1test/my-repo.git", server.path().display());
|
||||
let expected = vec![("refs/heads/main".to_owned(), main.clone())];
|
||||
let repo = Repo::open(dir).expect("open");
|
||||
git_run(&server_repo, &["update-ref", "refs/nostr/abc", &main]);
|
||||
|
||||
assert!(!repo.remote_has_refs(&url, &expected).expect("probe"));
|
||||
let remote = local
|
||||
.ls_remote(&format!("file://{}", server_repo.display()))
|
||||
.expect("ls-remote");
|
||||
let names: Vec<String> = remote.iter().map(|(name, _)| name.clone()).collect();
|
||||
|
||||
repo.push_all(
|
||||
&format!("file://{}", server.path().display()),
|
||||
"npub1test",
|
||||
"my-repo",
|
||||
)
|
||||
.expect("push");
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"refs/heads/feature".to_owned(),
|
||||
"refs/heads/main".to_owned(),
|
||||
"refs/tags/v1".to_owned(),
|
||||
"refs/tags/v2".to_owned(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
remote
|
||||
.iter()
|
||||
.find(|(name, _)| name == "refs/heads/main")
|
||||
.map(|(_, oid)| oid.as_str()),
|
||||
Some(main.as_str())
|
||||
);
|
||||
// The annotated tag lists the tag object, matching the local ref state,
|
||||
// without a peeled `^{}` duplicate.
|
||||
let v2 = git_in(dir, &["rev-parse", "refs/tags/v2"]).expect("v2 oid");
|
||||
assert_eq!(
|
||||
remote
|
||||
.iter()
|
||||
.find(|(name, _)| name == "refs/tags/v2")
|
||||
.map(|(_, oid)| oid.as_str()),
|
||||
Some(v2.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
assert!(repo.remote_has_refs(&url, &expected).expect("probe"));
|
||||
#[test]
|
||||
fn push_all_force_updates_diverged_remote_branches() {
|
||||
let server = tempfile::tempdir().unwrap();
|
||||
let server_repo = bare_server(server.path(), "npub1test", "my-repo");
|
||||
|
||||
let stale = vec![("refs/heads/main".to_owned(), "0".repeat(40))];
|
||||
assert!(!repo.remote_has_refs(&url, &stale).expect("probe"));
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
std::fs::write(dir.path().join("b.txt"), b"two").expect("write");
|
||||
commit_all(&repo, "second");
|
||||
let dir = dir.path();
|
||||
|
||||
git_run(dir, &["tag", "v1.0"]);
|
||||
repo.push_all(
|
||||
&format!("file://{}", server.path().display()),
|
||||
"npub1test",
|
||||
"my-repo",
|
||||
)
|
||||
.expect("push");
|
||||
assert!(repo.remote_has_refs(&url, &expected).expect("probe"));
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
let local = Repo::open(dir).expect("open");
|
||||
local
|
||||
.push_all(&base_url, "npub1test", "my-repo")
|
||||
.expect("initial push");
|
||||
|
||||
// Diverge local main from the pushed state.
|
||||
git_run(dir, &["reset", "-q", "--hard", "HEAD~1"]);
|
||||
std::fs::write(dir.join("c.txt"), b"three").expect("write");
|
||||
commit_all(&repo, "diverged");
|
||||
|
||||
local
|
||||
.push_all(&base_url, "npub1test", "my-repo")
|
||||
.expect("force push");
|
||||
|
||||
assert_eq!(
|
||||
git_in(dir, &["rev-parse", "refs/heads/main"]).expect("local main"),
|
||||
git_in(&server_repo, &["rev-parse", "refs/heads/main"]).expect("server main")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_refspecs_deletes_server_refs_missing_locally() {
|
||||
let server = tempfile::tempdir().unwrap();
|
||||
let server_repo = bare_server(server.path(), "npub1test", "my-repo");
|
||||
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
|
||||
git_run(dir, &["branch", "feature"]);
|
||||
git_run(dir, &["tag", "v1"]);
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
let local = Repo::open(dir).expect("open");
|
||||
local
|
||||
.push_all(&base_url, "npub1test", "my-repo")
|
||||
.expect("initial push");
|
||||
|
||||
git_run(dir, &["branch", "-D", "feature"]);
|
||||
git_run(dir, &["tag", "-d", "v1"]);
|
||||
|
||||
local
|
||||
.push_refspecs(
|
||||
&base_url,
|
||||
"npub1test",
|
||||
"my-repo",
|
||||
&[
|
||||
"+refs/heads/*:refs/heads/*",
|
||||
"+refs/tags/*:refs/tags/*",
|
||||
":refs/heads/feature",
|
||||
":refs/tags/v1",
|
||||
],
|
||||
)
|
||||
.expect("push with deletions");
|
||||
|
||||
let refs = git_in(&server_repo, &["show-ref"]).expect("server refs");
|
||||
assert!(refs.contains("refs/heads/main"));
|
||||
assert!(!refs.contains("refs/heads/feature"));
|
||||
assert!(!refs.contains("refs/tags/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_refs_brings_server_only_branches_into_local() {
|
||||
let server = tempfile::tempdir().unwrap();
|
||||
bare_server(server.path(), "npub1test", "my-repo");
|
||||
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
git_run(dir, &["branch", "feature"]);
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
Repo::open(dir)
|
||||
.expect("open")
|
||||
.push_all(&base_url, "npub1test", "my-repo")
|
||||
.expect("push");
|
||||
let feature = git_in(dir, &["rev-parse", "refs/heads/feature"]).expect("feature oid");
|
||||
|
||||
// A repository that never had the branch fetches it by name.
|
||||
let (other_dir, _other_repo) = fixture(&[("unrelated.txt", b"x")]);
|
||||
let other = Repo::open(other_dir.path()).expect("open other");
|
||||
let server_url = format!("file://{}/npub1test/my-repo.git", server.path().display());
|
||||
other
|
||||
.fetch_refs(&[&server_url], "+refs/heads/feature:refs/heads/feature")
|
||||
.expect("fetch ref");
|
||||
|
||||
assert_eq!(
|
||||
git_in(other_dir.path(), &["rev-parse", "refs/heads/feature"]).expect("fetched feature"),
|
||||
feature
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_state_matches_the_git_cli_ref_listing() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
|
||||
git_run(dir, &["branch", "browse-group"]);
|
||||
git_run(dir, &["checkout", "-q", "-b", "new-search"]);
|
||||
std::fs::write(dir.join("b.txt"), b"two").expect("write");
|
||||
commit_all(&repo, "search work");
|
||||
git_run(dir, &["checkout", "-q", "-"]);
|
||||
git_run(dir, &["tag", "v1"]);
|
||||
|
||||
let listing = |repo: &Repo| {
|
||||
let mut refs = repo.ref_state().expect("ref state").refs;
|
||||
refs.sort();
|
||||
refs
|
||||
};
|
||||
|
||||
// Loose refs.
|
||||
let loose = listing(&Repo::open(dir).expect("open"));
|
||||
|
||||
// Packed refs.
|
||||
git_run(dir, &["pack-refs", "--all"]);
|
||||
let packed = listing(&Repo::open(dir).expect("open"));
|
||||
|
||||
let output = git_run(
|
||||
dir,
|
||||
&[
|
||||
"for-each-ref",
|
||||
"--format=%(refname) %(objectname)",
|
||||
"refs/heads",
|
||||
"refs/tags",
|
||||
],
|
||||
);
|
||||
let mut cli = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(|line| {
|
||||
let (name, oid) = line.split_once(' ').expect("for-each-ref format");
|
||||
(name.to_owned(), oid.to_owned())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
cli.sort();
|
||||
|
||||
assert_eq!(loose, cli);
|
||||
assert_eq!(packed, cli);
|
||||
assert!(
|
||||
loose
|
||||
.iter()
|
||||
.any(|(name, _)| name == "refs/heads/new-search")
|
||||
);
|
||||
assert!(
|
||||
loose
|
||||
.iter()
|
||||
.any(|(name, _)| name == "refs/heads/browse-group")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -130,6 +379,7 @@ fn repo_ref_state_lists_branches_tags_and_head() {
|
||||
assert_eq!(state.refs.len(), 3);
|
||||
}
|
||||
|
||||
/// Creates a repository fixture with the given files.
|
||||
fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
gix::init(&dir).expect("init");
|
||||
@@ -144,6 +394,7 @@ fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) {
|
||||
(dir, repo)
|
||||
}
|
||||
|
||||
/// Stages and commits every change in the repository.
|
||||
fn commit_all(repo: &Repo, message: &str) {
|
||||
git_run(repo.workdir().expect("workdir"), &["add", "-A"]);
|
||||
git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]);
|
||||
@@ -415,6 +666,7 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs `git` in `dir`, failing the test on a non-zero exit.
|
||||
fn git_run(dir: &Path, args: &[&str]) -> std::process::Output {
|
||||
let output = Command::new("git")
|
||||
.current_dir(dir)
|
||||
@@ -430,6 +682,52 @@ fn git_run(dir: &Path, args: &[&str]) -> std::process::Output {
|
||||
output
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_repos_reports_the_outermost_repository() {
|
||||
let root = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dir = root.path().join("project").join("sub");
|
||||
std::fs::create_dir_all(&repo_dir).expect("mkdir");
|
||||
git_run(&repo_dir, &["init", "-q"]);
|
||||
|
||||
let plain_dir = root.path().join("plain").join("deep");
|
||||
std::fs::create_dir_all(&plain_dir).expect("mkdir");
|
||||
|
||||
let repos = find_git_repos(root.path());
|
||||
|
||||
assert_eq!(repos.len(), 1);
|
||||
assert_eq!(repos[0].path, repo_dir.canonicalize().expect("canonical"));
|
||||
assert!(repos[0].nip34.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_repos_skips_repositories_nested_in_repositories() {
|
||||
let root = tempfile::tempdir().expect("tempdir");
|
||||
let outer = root.path().join("outer");
|
||||
std::fs::create_dir_all(&outer).expect("mkdir");
|
||||
git_run(&outer, &["init", "-q"]);
|
||||
|
||||
let nested = outer.join("nested");
|
||||
std::fs::create_dir_all(&nested).expect("mkdir");
|
||||
git_run(&nested, &["init", "-q"]);
|
||||
|
||||
let repos = find_git_repos(root.path());
|
||||
|
||||
assert_eq!(repos.len(), 1);
|
||||
assert_eq!(repos[0].path, outer.canonicalize().expect("canonical"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_repos_ignores_a_repository_above_the_scan_root() {
|
||||
let outer = tempfile::tempdir().expect("tempdir");
|
||||
git_run(outer.path(), &["init", "-q"]);
|
||||
|
||||
let root = outer.path().join("scan");
|
||||
std::fs::create_dir_all(&root).expect("mkdir");
|
||||
|
||||
assert!(find_git_repos(&root).is_empty());
|
||||
}
|
||||
|
||||
/// Creates an empty bare server repository for push tests.
|
||||
fn bare_server(base: &Path, owner: &str, name: &str) -> PathBuf {
|
||||
let repo = base.join(owner).join(format!("{name}.git"));
|
||||
std::fs::create_dir_all(repo.parent().expect("parent")).expect("mkdir");
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use gix::progress::Discard;
|
||||
|
||||
use crate::GixResultExt as _;
|
||||
use crate::history::FileCommit;
|
||||
use crate::repo::Repo;
|
||||
|
||||
pub struct WorktreeSnapshot {
|
||||
pub entries: Vec<PathBuf>,
|
||||
pub readme_path: Option<PathBuf>,
|
||||
pub readme: Option<Vec<u8>>,
|
||||
// `None` when detached, for example on a tag.
|
||||
pub current_branch: Option<String>,
|
||||
pub head_commit: Option<FileCommit>,
|
||||
pub branches: Vec<String>,
|
||||
@@ -20,7 +13,8 @@ pub struct WorktreeSnapshot {
|
||||
}
|
||||
|
||||
impl WorktreeSnapshot {
|
||||
fn new(
|
||||
/// Creates a snapshot of the worktree contents and ref state.
|
||||
pub(crate) fn new(
|
||||
entries: Vec<PathBuf>,
|
||||
readme_path: Option<PathBuf>,
|
||||
readme: Option<Vec<u8>>,
|
||||
@@ -40,287 +34,3 @@ impl WorktreeSnapshot {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
match self.inner.is_dirty() {
|
||||
Ok(true) => return true,
|
||||
Ok(false) => {}
|
||||
Err(_) => return false,
|
||||
}
|
||||
|
||||
let Ok(platform) = self.inner.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
|
||||
}
|
||||
|
||||
pub fn commits_ahead(&self, base: &str, branch: &str) -> u32 {
|
||||
let (Some(base), Some(branch)) = (self.resolve_commit(base), self.resolve_commit(branch))
|
||||
else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let Ok(walk) = self.inner.rev_walk([branch]).with_hidden([base]).all() else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32
|
||||
}
|
||||
|
||||
fn resolve_commit<'a>(&'a self, rev: &str) -> Option<gix::Id<'a>> {
|
||||
self.inner.rev_parse_single(rev.as_bytes()).ok()
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> Result<Vec<PathBuf>> {
|
||||
let workdir = self.inner.workdir().context("repository has no worktree")?;
|
||||
|
||||
let mut entries: Vec<(PathBuf, bool)> = Vec::new();
|
||||
Self::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())
|
||||
}
|
||||
|
||||
pub fn read(&self, rel: &Path) -> Result<Option<Vec<u8>>> {
|
||||
let workdir = self.inner.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())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_readme(&self) -> Result<Option<PathBuf>> {
|
||||
let Some(workdir) = self.inner.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)))
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Result<WorktreeSnapshot> {
|
||||
let readme_path = self.find_readme()?;
|
||||
let readme = match &readme_path {
|
||||
Some(path) => self.read(path)?,
|
||||
None => None,
|
||||
};
|
||||
Ok(WorktreeSnapshot::new(
|
||||
self.entries()?,
|
||||
readme_path,
|
||||
readme,
|
||||
self.current_branch(),
|
||||
self.head_commit().unwrap_or(None),
|
||||
self.branches().unwrap_or_default(),
|
||||
self.tags().unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn checkout_branch(&self, name: &str) -> Result<()> {
|
||||
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 = self.inner.find_reference(&full)?;
|
||||
let tree = reference.peel_to_tree()?.id;
|
||||
|
||||
let (signature, mut time_buf) = Self::repository_signature();
|
||||
let signature = signature.to_ref(&mut time_buf);
|
||||
|
||||
self.move_head(
|
||||
signature,
|
||||
gix::refs::Target::Symbolic(branch),
|
||||
&format!("checkout: moving to {name}"),
|
||||
)?;
|
||||
|
||||
self.force_checkout(&tree)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn checkout_tag(&self, name: &str) -> Result<()> {
|
||||
let full = format!("refs/tags/{name}");
|
||||
|
||||
let mut reference = self.inner.find_reference(&full)?;
|
||||
|
||||
let commit = reference.peel_to_id()?;
|
||||
let tree = reference.peel_to_tree()?.id;
|
||||
|
||||
let (signature, mut time_buf) = Self::repository_signature();
|
||||
let signature = signature.to_ref(&mut time_buf);
|
||||
|
||||
self.move_head(
|
||||
signature,
|
||||
gix::refs::Target::Object(commit.detach()),
|
||||
&format!("checkout: moving to {name}"),
|
||||
)?;
|
||||
|
||||
self.force_checkout(&tree)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn force_checkout(&self, tree: &gix::hash::oid) -> Result<()> {
|
||||
let workdir = self
|
||||
.inner
|
||||
.workdir()
|
||||
.context("repository has no worktree")?
|
||||
.to_path_buf();
|
||||
|
||||
let mut index = self.inner.index_from_tree(tree)?;
|
||||
|
||||
if let Ok(previous) = self.inner.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 = self
|
||||
.inner
|
||||
.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?;
|
||||
options.overwrite_existing = true;
|
||||
|
||||
let objects = self.inner.objects.clone().into_arc()?;
|
||||
let files = gix::progress::Discard;
|
||||
let bytes = gix::progress::Discard;
|
||||
|
||||
gix_worktree_state::checkout(
|
||||
&mut index,
|
||||
workdir,
|
||||
objects,
|
||||
&files,
|
||||
&bytes,
|
||||
&gix::interrupt::IS_INTERRUPTED,
|
||||
options,
|
||||
)
|
||||
.into_anyhow()?;
|
||||
|
||||
index
|
||||
.write(gix::index::write::Options::default())
|
||||
.into_anyhow()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_head(
|
||||
&self,
|
||||
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}"))?;
|
||||
|
||||
self.inner.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(())
|
||||
}
|
||||
|
||||
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 {
|
||||
Self::collect_entries(root, &path, out)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+201
-223
@@ -1,13 +1,12 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Error, anyhow, bail};
|
||||
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, Context, Entity, EventEmitter, Global, Task, TaskExt, WeakEntity,
|
||||
};
|
||||
use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, Global, Task, TaskExt};
|
||||
use nostr::event::IntoEventBuilder;
|
||||
use nostr::nips::nip19::Nip19Coordinate;
|
||||
use nostr_connect::prelude::*;
|
||||
@@ -21,12 +20,11 @@ use crate::bootstrap::{
|
||||
};
|
||||
use crate::git_store::Mirrors;
|
||||
use crate::inbox::Inbox;
|
||||
use crate::push::{GraspPush, PushOutcome, grasp_base_url, grasp_clone_url};
|
||||
use crate::push::{PushOutcome, grasp_base_url, grasp_clone_url};
|
||||
use crate::repos::RepoListStore;
|
||||
use crate::state::{GitPushMode, GitPushPolicy, GitRef, PushStepStatus, SyncState};
|
||||
|
||||
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||
|
||||
const PUMP_DEBOUNCE: Duration = Duration::from_millis(200);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -37,6 +35,10 @@ pub enum BackendEvent {
|
||||
ProfileUpdates(Vec<PublicKey>),
|
||||
RepoUpdates(Vec<Update>),
|
||||
Synced,
|
||||
PushProgress {
|
||||
addr: RepoAddr,
|
||||
status: PushStepStatus,
|
||||
},
|
||||
Error(String),
|
||||
}
|
||||
|
||||
@@ -194,15 +196,11 @@ impl Backend {
|
||||
this.set_signer(keys, cx);
|
||||
})?;
|
||||
} else if content.starts_with("bunker://") {
|
||||
let (base, keys) = extract_master_key(&content);
|
||||
let (base, keys) = extract_master_key(&content)?;
|
||||
let uri = NostrConnectUri::parse(base)?;
|
||||
let mut signer = NostrConnect::new(
|
||||
uri,
|
||||
keys,
|
||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||
let signer = NostrConnect::builder(uri, keys)
|
||||
.auth_url_handler(Arc::new(SignedAuthUrlHandler))
|
||||
.build()?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(signer, cx);
|
||||
@@ -363,13 +361,9 @@ impl Backend {
|
||||
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let mut signer = NostrConnect::new(
|
||||
connect_uri,
|
||||
keys,
|
||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||
let signer = NostrConnect::builder(connect_uri, keys)
|
||||
.auth_url_handler(Arc::new(SignedAuthUrlHandler))
|
||||
.build()?;
|
||||
|
||||
// Verify the bunker responds before persisting the credential.
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
@@ -452,16 +446,28 @@ impl Backend {
|
||||
.map(|url| RelayUrl::parse(url).expect("valid relay URL"))
|
||||
.collect();
|
||||
|
||||
let pusher = GraspPush::new(this.client.clone(), this.signer.clone());
|
||||
|
||||
for builder in [
|
||||
RelayList::new(relays).into_event_builder(),
|
||||
metadata,
|
||||
GitUserGraspList { grasp_servers }.into_event_builder(),
|
||||
] {
|
||||
let pusher = pusher.clone();
|
||||
let client = this.client.clone();
|
||||
let signer = this.signer.clone();
|
||||
cx.background_spawn(async move {
|
||||
pusher.publish_best_effort(builder).await;
|
||||
let event = builder.finalize_async(&signer).await?;
|
||||
let output = client.send_event(&event).broadcast().await?;
|
||||
|
||||
if output.success.is_empty() {
|
||||
let reasons = output
|
||||
.failed
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
bail!("event not accepted by any relay: {reasons}");
|
||||
}
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -513,8 +519,10 @@ impl Backend {
|
||||
}
|
||||
|
||||
let owner = public_key.to_bech32().unwrap();
|
||||
let addr = RepoAddr::new(public_key, repo_id.clone());
|
||||
let servers = grasp_servers.clone();
|
||||
let client = self.client.clone();
|
||||
let signer = self.signer.clone();
|
||||
|
||||
let destination = {
|
||||
let dir_name = GitCache::sanitize_path_component(&name);
|
||||
@@ -554,9 +562,7 @@ impl Backend {
|
||||
let commit = work.await?;
|
||||
let commit_sha = Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid id"))?;
|
||||
|
||||
let signer = this.update(cx, |this, _cx| this.signer.clone())?;
|
||||
|
||||
let announcement = repository_announcement(
|
||||
let announcement = build_announcement(
|
||||
&repo_id,
|
||||
&name,
|
||||
&description,
|
||||
@@ -565,21 +571,30 @@ impl Backend {
|
||||
Some(commit_sha),
|
||||
);
|
||||
|
||||
let event = announce_repository_and_push(
|
||||
&this,
|
||||
&client,
|
||||
&signer,
|
||||
announcement,
|
||||
&repo_id,
|
||||
&owner,
|
||||
&servers,
|
||||
vec![("refs/heads/main".to_owned(), commit)],
|
||||
Some("main".to_owned()),
|
||||
&destination,
|
||||
cx,
|
||||
|path, base, owner, repo_id| Repo::open(path)?.push_main(base, owner, repo_id),
|
||||
)
|
||||
.await?;
|
||||
let state = SyncState {
|
||||
refs: vec![GitRef {
|
||||
name: "refs/heads/main".to_owned(),
|
||||
commit: commit_sha,
|
||||
}],
|
||||
head: Some("main".to_owned()),
|
||||
path: Some(destination.clone()),
|
||||
mode: GitPushMode::Main,
|
||||
policy: GitPushPolicy::default(),
|
||||
};
|
||||
|
||||
let on_step = &mut |status: PushStepStatus, cx: &mut AsyncApp| {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(BackendEvent::PushProgress {
|
||||
addr: addr.clone(),
|
||||
status,
|
||||
});
|
||||
})?;
|
||||
Ok::<(), Error>(())
|
||||
};
|
||||
|
||||
let event =
|
||||
announce_and_push_or_retract(&client, &signer, &state, announcement, cx, on_step)
|
||||
.await?;
|
||||
|
||||
let announcement = Announcement::from_event(&event)
|
||||
.ok_or_else(|| anyhow!("failed to parse announcement"))?;
|
||||
@@ -594,6 +609,7 @@ impl Backend {
|
||||
name: &str,
|
||||
description: &str,
|
||||
grasp_servers: Vec<RelayUrl>,
|
||||
default_branch: Option<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Announcement, Error>> {
|
||||
let name = name.trim().to_owned();
|
||||
@@ -626,50 +642,60 @@ impl Backend {
|
||||
}
|
||||
|
||||
let owner = public_key.to_bech32().unwrap();
|
||||
let addr = RepoAddr::new(public_key, repo_id.clone());
|
||||
let servers = grasp_servers.clone();
|
||||
|
||||
let client = self.client.clone();
|
||||
let signer = self.signer.clone();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let work = cx.background_spawn({
|
||||
let path = path.clone();
|
||||
async move {
|
||||
let repo = Repo::open(&path)?;
|
||||
let state = repo.ref_state()?;
|
||||
let euc = repo.root_commit()?;
|
||||
Ok::<_, Error>((state, euc))
|
||||
let (mut state, euc) = cx
|
||||
.background_spawn({
|
||||
let path = path.clone();
|
||||
async move {
|
||||
let repo = Repo::open(&path)?;
|
||||
let euc = repo.root_commit()?;
|
||||
let state = SyncState::from_repo(&path)?;
|
||||
Ok::<_, Error>((state, euc))
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
let chosen = default_branch
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|branch| !branch.is_empty());
|
||||
|
||||
if let Some(branch) = chosen {
|
||||
let wanted = format!("refs/heads/{branch}");
|
||||
if state.refs.iter().any(|git_ref| git_ref.name == wanted) {
|
||||
state.head = Some(branch.to_owned());
|
||||
}
|
||||
});
|
||||
let (state, euc) = work.await?;
|
||||
}
|
||||
|
||||
let signer = this.update(cx, |this, _cx| this.signer.clone())?;
|
||||
let euc = euc.and_then(|commit: String| Sha1Hash::from_str(&commit).ok());
|
||||
let ann = build_announcement(&repo_id, &name, &description, &owner, &servers, euc);
|
||||
|
||||
let euc = euc.and_then(|commit| Sha1Hash::from_str(&commit).ok());
|
||||
let announcement =
|
||||
repository_announcement(&repo_id, &name, &description, &owner, &servers, euc);
|
||||
let on_step = &mut |status: PushStepStatus, cx: &mut AsyncApp| {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(BackendEvent::PushProgress {
|
||||
addr: addr.clone(),
|
||||
status,
|
||||
});
|
||||
})?;
|
||||
Ok::<(), Error>(())
|
||||
};
|
||||
|
||||
let event = announce_repository_and_push(
|
||||
&this,
|
||||
&client,
|
||||
&signer,
|
||||
announcement,
|
||||
&repo_id,
|
||||
&owner,
|
||||
&servers,
|
||||
state.refs.clone(),
|
||||
state.head.clone(),
|
||||
&path,
|
||||
cx,
|
||||
|path, base, owner, repo_id| Repo::open(path)?.push_all(base, owner, repo_id),
|
||||
)
|
||||
.await?;
|
||||
let event =
|
||||
announce_and_push_or_retract(&client, &signer, &state, ann, cx, on_step).await?;
|
||||
|
||||
if let Some(base) = servers.first().and_then(grasp_base_url) {
|
||||
let url = format!("{base}/{owner}/{repo_id}.git");
|
||||
let path = path.clone();
|
||||
cx.background_spawn(async move {
|
||||
Repo::open(&path)
|
||||
.and_then(|repo| repo.ensure_origin(&url))
|
||||
.ok();
|
||||
if let Err(e) = Repo::open(&path).and_then(|r| r.ensure_origin(&url)) {
|
||||
log::warn!("failed to ensure origin: {e}");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -681,13 +707,8 @@ impl Backend {
|
||||
Ok(naddr) => {
|
||||
let path = path.clone();
|
||||
cx.background_spawn(async move {
|
||||
if let Err(error) =
|
||||
Repo::open(&path).and_then(|repo| repo.set_nostr_repo(&naddr))
|
||||
{
|
||||
log::warn!(
|
||||
"failed to record the NIP-34 marker for {}: {error}",
|
||||
path.display()
|
||||
);
|
||||
if let Err(e) = Repo::open(&path).and_then(|r| r.set_nostr_repo(&naddr)) {
|
||||
log::warn!("failed to record the NIP-34 marker: {e}");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
@@ -706,7 +727,17 @@ impl Backend {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PushOutcome, Error>> {
|
||||
let path = Mirrors::path(&announcement.addr());
|
||||
self.push_repo_from(announcement, path, None, cx)
|
||||
self.push_repo_from(announcement, path, None, GitPushPolicy::Reconcile, cx)
|
||||
}
|
||||
|
||||
/// Republishes the mirror and deletes grasp refs the mirror does not have.
|
||||
pub fn reset_repository(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PushOutcome, Error>> {
|
||||
let path = Mirrors::path(&announcement.addr());
|
||||
self.push_repo_from(announcement, path, None, GitPushPolicy::ResetRemote, cx)
|
||||
}
|
||||
|
||||
pub fn push_checkout(
|
||||
@@ -716,7 +747,13 @@ impl Backend {
|
||||
announced_head: Option<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PushOutcome, Error>> {
|
||||
self.push_repo_from(announcement, checkout, announced_head, cx)
|
||||
self.push_repo_from(
|
||||
announcement,
|
||||
checkout,
|
||||
announced_head,
|
||||
GitPushPolicy::Reconcile,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
fn push_repo_from(
|
||||
@@ -724,6 +761,7 @@ impl Backend {
|
||||
announcement: Announcement,
|
||||
path: PathBuf,
|
||||
announced_head: Option<String>,
|
||||
policy: GitPushPolicy,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PushOutcome, Error>> {
|
||||
let addr = announcement.addr();
|
||||
@@ -739,9 +777,8 @@ impl Backend {
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let owner = announcement.owner.to_bech32().unwrap();
|
||||
let repo_id = announcement.id.clone();
|
||||
let relays = announcement.relays.clone();
|
||||
let client = self.client.clone();
|
||||
let signer = self.signer.clone();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let _guard = cx.on_drop(&this, {
|
||||
@@ -757,77 +794,55 @@ impl Backend {
|
||||
let mut state = {
|
||||
let work = cx.background_spawn({
|
||||
let path = path.clone();
|
||||
async move { Repo::open(&path).and_then(|repo| repo.ref_state()) }
|
||||
async move { SyncState::from_repo(&path) }
|
||||
});
|
||||
work.await?
|
||||
};
|
||||
state.policy = policy;
|
||||
|
||||
// Fetch grasp refs the local repository lacks before the state event is signed,
|
||||
// so the servers' full ref map is declared.
|
||||
state.reconcile(&announcement, cx).await?;
|
||||
|
||||
// Keep the announced default branch in `HEAD` when it is among the pushed refs.
|
||||
let heads: Vec<&str> = state
|
||||
.refs
|
||||
.iter()
|
||||
.filter_map(|(name, _)| name.strip_prefix("refs/heads/"))
|
||||
.collect();
|
||||
|
||||
if let Some(head) = announced_head
|
||||
&& heads.iter().any(|branch| *branch == head)
|
||||
&& state
|
||||
.refs
|
||||
.iter()
|
||||
.any(|git_ref| git_ref.name.strip_prefix("refs/heads/") == Some(head.as_str()))
|
||||
{
|
||||
state.head = Some(head);
|
||||
}
|
||||
|
||||
// Grasp servers authorize a push by the state event they hold in purgatory.
|
||||
let refs = state.refs.clone();
|
||||
let head = state.head.clone();
|
||||
|
||||
let (client, signer) =
|
||||
this.update(cx, |this, _cx| (this.client.clone(), this.signer.clone()))?;
|
||||
|
||||
let outcome = if refs.is_empty() {
|
||||
let outcome = if state.refs.is_empty() {
|
||||
PushOutcome::default()
|
||||
} else {
|
||||
let push = cx.background_spawn({
|
||||
let pusher = GraspPush::new(client.clone(), signer.clone());
|
||||
let path = path.clone();
|
||||
let owner = owner.clone();
|
||||
let repo_id = repo_id.clone();
|
||||
let relays = relays.clone();
|
||||
let refs = refs.clone();
|
||||
let head = head.clone();
|
||||
let executor = cx.background_executor().clone();
|
||||
async move {
|
||||
pusher
|
||||
.push_staged_to_grasps(
|
||||
&repo_id,
|
||||
&refs,
|
||||
head.as_deref(),
|
||||
&path,
|
||||
&owner,
|
||||
&relays,
|
||||
&executor,
|
||||
|path, base, owner, repo_id| {
|
||||
Repo::open(path)?.push_all(base, owner, repo_id)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
push.await
|
||||
// Grasp servers authorize a push by the state event they hold in purgatory.
|
||||
let builder = state.state_event(&announcement.id);
|
||||
let event = builder.finalize_async(&signer).await?;
|
||||
|
||||
let on_step = &mut |status: PushStepStatus, cx: &mut AsyncApp| {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(BackendEvent::PushProgress {
|
||||
addr: addr.clone(),
|
||||
status,
|
||||
});
|
||||
})?;
|
||||
Ok::<(), Error>(())
|
||||
};
|
||||
|
||||
state
|
||||
.push_to_grasps(&client, event, &announcement, cx, on_step)
|
||||
.await?
|
||||
};
|
||||
|
||||
if !refs.is_empty() && outcome.accepted() == 0 {
|
||||
if !state.refs.is_empty() && outcome.accepted() == 0 {
|
||||
bail!(
|
||||
"could not push the repository to any grasp server: {}",
|
||||
outcome.failure_summary()
|
||||
);
|
||||
}
|
||||
|
||||
// Fan the state out to the relays once a git server holds the objects.
|
||||
if let Some(state_event) = &outcome.state_event
|
||||
&& let Err(e) = client.send_event(state_event).broadcast().await
|
||||
{
|
||||
log::warn!("failed to broadcast repository state: {e}");
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
})
|
||||
}
|
||||
@@ -1130,22 +1145,27 @@ impl Backend {
|
||||
|
||||
// Each target gets its own deletion event.
|
||||
fn retract_events(&mut self, events: &[Event], cx: &mut Context<Self>) {
|
||||
let pusher = GraspPush::new(self.client.clone(), self.signer.clone());
|
||||
|
||||
for event in events.iter().cloned() {
|
||||
let pusher = pusher.clone();
|
||||
let client = self.client.clone();
|
||||
let signer = self.signer.clone();
|
||||
|
||||
cx.spawn(async move |_this, _cx| {
|
||||
if let Err(e) = pusher.retract_event(&event).await {
|
||||
log::warn!("failed to retract event {}: {e}", event.id);
|
||||
cx.background_spawn(async move {
|
||||
let builder = EventDeletionRequest::new().id(event.id);
|
||||
let deletion = builder.finalize_async(&signer).await?;
|
||||
let output = client.send_event(&deletion).broadcast().await?;
|
||||
|
||||
if output.success.is_empty() {
|
||||
bail!("deletion event not accepted by any relay");
|
||||
}
|
||||
|
||||
Ok::<(), Error>(())
|
||||
})
|
||||
.detach();
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_announcement(
|
||||
fn build_announcement(
|
||||
repo_id: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
@@ -1168,84 +1188,44 @@ fn repository_announcement(
|
||||
}
|
||||
}
|
||||
|
||||
// Announce the repository, then stage the state event and push.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn announce_repository_and_push(
|
||||
backend: &WeakEntity<Backend>,
|
||||
/// Announces a repository and pushes its state to grasp servers,
|
||||
/// or retracts the announcement if the push fails.
|
||||
async fn announce_and_push_or_retract<S, P>(
|
||||
client: &Client,
|
||||
signer: &UniversalSigner,
|
||||
signer: &S,
|
||||
state: &SyncState,
|
||||
announcement: GitRepositoryAnnouncement,
|
||||
repo_id: &str,
|
||||
owner: &str,
|
||||
servers: &[RelayUrl],
|
||||
refs: Vec<(String, String)>,
|
||||
head: Option<String>,
|
||||
path: &Path,
|
||||
cx: &mut AsyncApp,
|
||||
push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error> + Send + 'static,
|
||||
) -> Result<Event, Error> {
|
||||
for url in servers {
|
||||
client.add_relay(url).and_connect().await.ok();
|
||||
}
|
||||
on_step: &mut P,
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>,
|
||||
{
|
||||
let state_event = state
|
||||
.state_event(&announcement.id)
|
||||
.finalize_async(signer)
|
||||
.await?;
|
||||
let announcement_event = announcement.finalize_async(signer).await?;
|
||||
|
||||
let event = {
|
||||
let builder = announcement.into_event_builder();
|
||||
let event = builder.finalize_async(signer).await?;
|
||||
let output = client.send_event(&event).broadcast().await?;
|
||||
GraspPush::require_relay_accepted(output, event)?
|
||||
};
|
||||
|
||||
// The state event is the push authorization.
|
||||
let outcome = if refs.is_empty() {
|
||||
PushOutcome::default()
|
||||
} else {
|
||||
let pusher = GraspPush::new(client.clone(), signer.clone());
|
||||
let repo_id = repo_id.to_owned();
|
||||
let owner = owner.to_owned();
|
||||
let servers = servers.to_vec();
|
||||
let head = head.clone();
|
||||
let path = path.to_path_buf();
|
||||
let executor = cx.background_executor().clone();
|
||||
cx.background_spawn(async move {
|
||||
pusher
|
||||
.push_staged_to_grasps(
|
||||
&repo_id,
|
||||
&refs,
|
||||
head.as_deref(),
|
||||
&path,
|
||||
&owner,
|
||||
&servers,
|
||||
&executor,
|
||||
push,
|
||||
)
|
||||
.await
|
||||
})
|
||||
match state
|
||||
.announce_and_push(client, announcement_event.clone(), state_event, cx, on_step)
|
||||
.await
|
||||
};
|
||||
|
||||
if outcome.accepted() == 0 {
|
||||
// Retract the announcement so the repository is not left announced without content.
|
||||
backend
|
||||
.update(cx, |backend, cx| {
|
||||
backend.retract_events(std::slice::from_ref(&event), cx);
|
||||
})
|
||||
.ok();
|
||||
|
||||
return Err(anyhow!(
|
||||
"The repository was announced, but the push to every grasp server failed: {}. \
|
||||
The announcement has been retracted",
|
||||
outcome.failure_summary()
|
||||
));
|
||||
}
|
||||
|
||||
// Fan the state out to the relays once a git server holds the objects.
|
||||
if let Some(state_event) = &outcome.state_event
|
||||
&& let Err(e) = client.send_event(state_event).broadcast().await
|
||||
{
|
||||
log::warn!("failed to broadcast repository state: {e}");
|
||||
}
|
||||
Ok(event) => Ok(event),
|
||||
Err(e) => {
|
||||
let retracted =
|
||||
SyncState::retract_announcement(client, signer, announcement_event.id).await;
|
||||
let retraction = match retracted {
|
||||
Ok(()) => "The announcement has been retracted".to_owned(),
|
||||
Err(error) => format!(
|
||||
"Retracting the announcement failed: {error}; delete the repository to clean it up"
|
||||
),
|
||||
};
|
||||
|
||||
Ok(event)
|
||||
bail!("{e}. {retraction}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum UpdateEvent {
|
||||
@@ -1286,15 +1266,13 @@ impl UpdateEvent {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_master_key(credential: &str) -> (&str, Keys) {
|
||||
fn extract_master_key(credential: &str) -> Result<(&str, Keys), Error> {
|
||||
match credential.split_once("master=") {
|
||||
Some((base, nsec)) => {
|
||||
let keys = SecretKey::parse(nsec)
|
||||
.map(Keys::new)
|
||||
.unwrap_or_else(|_| Keys::generate());
|
||||
(base.trim_end_matches(['?', '&']), keys)
|
||||
let keys = Keys::new(SecretKey::parse(nsec)?);
|
||||
Ok((base.trim_end_matches(['?', '&']), keys))
|
||||
}
|
||||
None => (credential, Keys::generate()),
|
||||
None => Ok((credential, Keys::generate())),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ pub(crate) async fn subscribe_bootstrap_only(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_bootstrap_only(
|
||||
pub async fn sync_bootstrap_only(
|
||||
client: &Client,
|
||||
filter: Filter,
|
||||
opts: SyncOptions,
|
||||
@@ -78,16 +78,10 @@ pub async fn user_grasp_list_servers(
|
||||
) -> Result<Vec<RelayUrl>, Error> {
|
||||
let events: Vec<Event> = client
|
||||
.database()
|
||||
.query(Filters::grasp_list(user))
|
||||
.query(Filters::grasp_list(user).limit(1))
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let latest = events
|
||||
.into_iter()
|
||||
.max_by_key(|event| event.created_at)
|
||||
.map(|event| grasp_list_servers(&event))
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(latest)
|
||||
Ok(events.first().map(grasp_list_servers).unwrap_or_default())
|
||||
}
|
||||
|
||||
@@ -45,6 +45,53 @@ struct Remembered {
|
||||
last_used: u64,
|
||||
}
|
||||
|
||||
impl Remembered {
|
||||
fn resolve_associations<'a, A>(
|
||||
remembered: &[Remembered],
|
||||
scanned: &[(PathBuf, Option<String>, Option<String>)],
|
||||
announcements: A,
|
||||
) -> HashMap<RepoAddr, Vec<PathBuf>>
|
||||
where
|
||||
A: IntoIterator<Item = &'a Announcement>,
|
||||
{
|
||||
let announcements: Vec<&Announcement> = announcements.into_iter().collect();
|
||||
let mut out: HashMap<RepoAddr, Vec<PathBuf>> = HashMap::new();
|
||||
|
||||
let mut sorted: Vec<&Remembered> = remembered.iter().collect();
|
||||
sorted.sort_by_key(|record| std::cmp::Reverse(record.last_used));
|
||||
for record in sorted {
|
||||
let paths = out.entry(record.addr.clone()).or_default();
|
||||
if !paths.contains(&record.path) {
|
||||
paths.push(record.path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for (path, origin, root) in scanned {
|
||||
for announcement in &announcements {
|
||||
let url_match = origin.as_deref().is_some_and(|origin| {
|
||||
announcement
|
||||
.clone
|
||||
.iter()
|
||||
.any(|url| same_repo_url(origin, url.as_str()))
|
||||
});
|
||||
|
||||
let euc_match = root
|
||||
.as_deref()
|
||||
.is_some_and(|root| announcement.euc.as_deref() == Some(root));
|
||||
|
||||
if url_match || euc_match {
|
||||
let paths = out.entry(announcement.addr()).or_default();
|
||||
if !paths.contains(path) {
|
||||
paths.push(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CheckoutsStore {
|
||||
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
|
||||
statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
||||
@@ -117,9 +164,10 @@ impl CheckoutsStore {
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.refresh(cx)) {
|
||||
log::warn!("checkouts store dropped before initial refresh could run: {error}");
|
||||
}
|
||||
weak.update(cx, |this, cx| {
|
||||
this.refresh(cx);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,13 +191,15 @@ impl CheckoutsStore {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
|
||||
let settings = SettingsStore::global(cx);
|
||||
let addr_str = addr.to_string();
|
||||
|
||||
let last_used = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let addr_str = addr.to_string();
|
||||
|
||||
let settings = SettingsStore::global(cx);
|
||||
settings.update(cx, |settings, cx| {
|
||||
settings.edit(
|
||||
|s| {
|
||||
@@ -306,7 +356,7 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
let associations =
|
||||
CheckoutsStore::resolve_associations(&remembered, &facts, announcements.iter());
|
||||
Remembered::resolve_associations(&remembered, &facts, announcements.iter());
|
||||
|
||||
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
|
||||
.into_iter()
|
||||
@@ -435,7 +485,7 @@ impl CheckoutsStore {
|
||||
Ok::<_, Error>((statuses, push_statuses))
|
||||
});
|
||||
|
||||
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let Ok((statuses, push_statuses)) = work.await else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -456,53 +506,9 @@ impl CheckoutsStore {
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
task.detach();
|
||||
}
|
||||
}
|
||||
|
||||
impl CheckoutsStore {
|
||||
fn resolve_associations<'a>(
|
||||
remembered: &[Remembered],
|
||||
scanned: &[(PathBuf, Option<String>, Option<String>)],
|
||||
announcements: impl IntoIterator<Item = &'a Announcement>,
|
||||
) -> HashMap<RepoAddr, Vec<PathBuf>> {
|
||||
let announcements: Vec<&Announcement> = announcements.into_iter().collect();
|
||||
let mut out: HashMap<RepoAddr, Vec<PathBuf>> = HashMap::new();
|
||||
|
||||
let mut sorted: Vec<&Remembered> = remembered.iter().collect();
|
||||
sorted.sort_by_key(|record| std::cmp::Reverse(record.last_used));
|
||||
for record in sorted {
|
||||
let paths = out.entry(record.addr.clone()).or_default();
|
||||
if !paths.contains(&record.path) {
|
||||
paths.push(record.path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for (path, origin, root) in scanned {
|
||||
for announcement in &announcements {
|
||||
let url_match = origin.as_deref().is_some_and(|origin| {
|
||||
announcement
|
||||
.clone
|
||||
.iter()
|
||||
.any(|url| same_repo_url(origin, url.as_str()))
|
||||
});
|
||||
|
||||
let euc_match = root
|
||||
.as_deref()
|
||||
.is_some_and(|root| announcement.euc.as_deref() == Some(root));
|
||||
|
||||
if url_match || euc_match {
|
||||
let paths = out.entry(announcement.addr()).or_default();
|
||||
if !paths.contains(path) {
|
||||
paths.push(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
|
||||
@@ -535,7 +541,7 @@ impl CheckoutsStore {
|
||||
})
|
||||
}
|
||||
|
||||
// Never fetches the checked-out refs; reads the tracking refs as-is.
|
||||
/// Returns checkouts that are ahead of their remote tracking branch.
|
||||
fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
||||
let repo = Repo::try_open(path)?;
|
||||
if repo.is_dirty() {
|
||||
@@ -546,15 +552,13 @@ impl CheckoutsStore {
|
||||
let head = repo.head()?;
|
||||
let origin = repo.origin_url().ok().flatten()?;
|
||||
|
||||
if fetch {
|
||||
repo.fetch_refs(&[origin], "+refs/heads/*:refs/remotes/origin/*")
|
||||
.ok();
|
||||
if fetch && let Err(e) = repo.fetch_refs(&[origin], "+refs/heads/*:refs/remotes/origin/*") {
|
||||
log::warn!("failed to fetch refs for {path:?}: {e}");
|
||||
}
|
||||
|
||||
let remote = format!("refs/remotes/origin/{branch}");
|
||||
|
||||
// A branch never fetched or pushed yet compares against the remote
|
||||
// HEAD, the fork point in practice.
|
||||
// If the branch is new and has no remote tracking branch, fall back to origin/HEAD.
|
||||
let base = if repo.ref_exists(&remote) {
|
||||
remote
|
||||
} else if repo.ref_exists("refs/remotes/origin/HEAD") {
|
||||
@@ -648,146 +652,3 @@ impl CheckoutsStore {
|
||||
branch_matches || tip_matches
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::process::Command;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn same_repo_url_ignores_the_transport_scheme() {
|
||||
assert!(same_repo_url(
|
||||
"grasp://relay.ngit.dev/npub1test/repo",
|
||||
"https://relay.ngit.dev/npub1test/repo.git"
|
||||
));
|
||||
assert!(same_repo_url(
|
||||
"ws://localhost:8080/npub1test/repo",
|
||||
"http://localhost:8080/npub1test/repo"
|
||||
));
|
||||
assert!(!same_repo_url(
|
||||
"wss://localhost:8081/npub1test/repo",
|
||||
"wss://localhost:8080/npub1test/repo"
|
||||
));
|
||||
assert!(!same_repo_url(
|
||||
"wss://host/npub1test/repo",
|
||||
"wss://host/npub1other/repo"
|
||||
));
|
||||
assert!(same_repo_url("/local/path", "/local/path"));
|
||||
assert!(!same_repo_url("/local/path", "/local/other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_status_reports_ahead_branches_only() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("repo");
|
||||
let _initial = Repo::init(&path, "My Repo", "").expect("init");
|
||||
let run = |args: &[&str]| {
|
||||
let status = Command::new("git")
|
||||
.current_dir(&path)
|
||||
.env("GIT_AUTHOR_NAME", "Test Author")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "Test Author")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@example.com")
|
||||
.env("GIT_EDITOR", "true")
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("git");
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
};
|
||||
let commit = |message: &str| {
|
||||
run(&["add", "-A"]);
|
||||
run(&["commit", "-m", message]);
|
||||
};
|
||||
|
||||
run(&["checkout", "-b", "feature"]);
|
||||
std::fs::write(path.join("feature.txt"), "x\n").expect("write");
|
||||
commit("feature work");
|
||||
let status = CheckoutsStore::checkout_status(&path, Some("main")).expect("status");
|
||||
assert_eq!(status.branch, "feature");
|
||||
assert_eq!(status.base, "main");
|
||||
assert_eq!(status.ahead, 1);
|
||||
assert_eq!(status.head.len(), 40);
|
||||
|
||||
std::fs::write(path.join("uncommitted.txt"), "y\n").expect("write");
|
||||
assert!(CheckoutsStore::checkout_status(&path, Some("main")).is_none());
|
||||
run(&["checkout", "--", "."]);
|
||||
|
||||
run(&["checkout", "main"]);
|
||||
assert_eq!(CheckoutsStore::checkout_status(&path, Some("main")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_push_status_counts_unpushed_commits_only() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let remote = dir.path().join("remote");
|
||||
Repo::init(&remote, "My Repo", "").expect("init");
|
||||
let config = Command::new("git")
|
||||
.args(["config", "receive.denyCurrentBranch", "ignore"])
|
||||
.current_dir(&remote)
|
||||
.status()
|
||||
.expect("git config");
|
||||
assert!(config.success(), "git config failed");
|
||||
|
||||
let checkout = dir.path().join("checkout");
|
||||
let status = Command::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"-q",
|
||||
remote.to_str().unwrap(),
|
||||
checkout.to_str().unwrap(),
|
||||
])
|
||||
.status()
|
||||
.expect("git clone");
|
||||
assert!(status.success(), "git clone failed");
|
||||
|
||||
let run = |args: &[&str]| {
|
||||
let status = Command::new("git")
|
||||
.current_dir(&checkout)
|
||||
.env("GIT_AUTHOR_NAME", "Test Author")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "Test Author")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@example.com")
|
||||
.env("GIT_EDITOR", "true")
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("git");
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
};
|
||||
|
||||
// A fresh clone has nothing to push.
|
||||
assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None);
|
||||
|
||||
std::fs::write(checkout.join("work.txt"), "x\n").expect("write");
|
||||
run(&["add", "-A"]);
|
||||
run(&["commit", "-m", "local work"]);
|
||||
let status = CheckoutsStore::checkout_push_status(&checkout, true).expect("status");
|
||||
assert_eq!(status.branch, "main");
|
||||
assert_eq!(status.base, "refs/remotes/origin/main");
|
||||
assert_eq!(status.ahead, 1);
|
||||
assert_eq!(status.head.len(), 40);
|
||||
|
||||
let local = CheckoutsStore::checkout_push_status(&checkout, false).expect("local status");
|
||||
assert_eq!(local.ahead, 1);
|
||||
|
||||
run(&["push", "origin", "main"]);
|
||||
assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None);
|
||||
|
||||
let remote_run = |args: &[&str]| {
|
||||
let status = Command::new("git")
|
||||
.current_dir(&remote)
|
||||
.env("GIT_AUTHOR_NAME", "Other Author")
|
||||
.env("GIT_AUTHOR_EMAIL", "other@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "Other Author")
|
||||
.env("GIT_COMMITTER_EMAIL", "other@example.com")
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("git");
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
};
|
||||
std::fs::write(remote.join("other.txt"), "y\n").expect("write");
|
||||
remote_run(&["add", "-A"]);
|
||||
remote_run(&["commit", "-m", "remote work"]);
|
||||
assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,29 +4,32 @@ mod checkouts;
|
||||
mod git_store;
|
||||
mod inbox;
|
||||
mod local_repos;
|
||||
mod profile;
|
||||
mod patch_series;
|
||||
mod push;
|
||||
mod refresh;
|
||||
mod repo;
|
||||
mod repos;
|
||||
mod state;
|
||||
mod sync_status;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use backend::{Backend, BackendEvent};
|
||||
pub use bootstrap::user_grasp_list_servers;
|
||||
pub use bootstrap::{sync_bootstrap_only, user_grasp_list_servers};
|
||||
pub use checkouts::{CheckoutStatus, CheckoutsStore};
|
||||
pub use git_store::Mirrors;
|
||||
use gpui::{App, AppContext};
|
||||
pub use inbox::Inbox;
|
||||
pub use local_repos::{LocalReposStore, ResolvedLocalRepo};
|
||||
pub use nostr_sdk::prelude::Timestamp;
|
||||
pub use profile::{Profile, ProfileStore};
|
||||
pub use push::{GraspServer, PushOutcome};
|
||||
pub use refresh::{RefreshGate, RefreshRequest};
|
||||
pub use repo::RepoStore;
|
||||
pub use repos::RepoListStore;
|
||||
pub use signed_git::{Nip34Binding, Nip34Kind};
|
||||
use signed_nostr::NostrBackend;
|
||||
pub use state::{GitPushMode, GitPushPolicy, GitRef, PushStep, PushStepStatus, SyncState};
|
||||
pub use sync_status::{CheckoutSyncStatus, SyncEvent, SyncStatusDirectory, SyncStatusStore};
|
||||
|
||||
pub fn init(
|
||||
db_path: impl AsRef<Path>,
|
||||
@@ -46,8 +49,8 @@ pub fn init(
|
||||
Mirrors::install(repos_root);
|
||||
|
||||
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);
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||
SyncStatusStore::set_global(cx.new(SyncStatusStore::new), cx);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
||||
use signed_git::PatchParser;
|
||||
|
||||
/// NIP-34 suggests patches when each event is under 60kb.
|
||||
pub(crate) const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
|
||||
|
||||
pub(crate) struct PatchSeries {
|
||||
pub(crate) parts: Vec<String>,
|
||||
}
|
||||
|
||||
impl PatchSeries {
|
||||
pub(crate) fn parse(patch: &str) -> Self {
|
||||
Self {
|
||||
parts: PatchParser::split_patch_series(patch)
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the length of the first part that exceeds the NIP-34 recommended size.
|
||||
pub(crate) fn oversized_length(&self) -> Option<usize> {
|
||||
self.parts
|
||||
.iter()
|
||||
.map(String::len)
|
||||
.find(|length| *length > MAX_PATCH_EVENT_BYTES)
|
||||
}
|
||||
|
||||
/// The last commit of the series, which is the tip of the branch.
|
||||
pub(crate) fn tip_commit(&self) -> Option<Sha1Hash> {
|
||||
self.parts
|
||||
.last()
|
||||
.and_then(|part| patch_current_commit(part))
|
||||
.and_then(|hex| hex.parse::<Sha1Hash>().ok())
|
||||
}
|
||||
|
||||
pub(crate) fn commit_of(&self, index: usize) -> Option<&str> {
|
||||
self.parts
|
||||
.get(index)
|
||||
.and_then(|part| patch_current_commit(part))
|
||||
.filter(|hex| hex.len() == 40)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `From <commit>` header on the first line.
|
||||
fn patch_current_commit(patch: &str) -> Option<&str> {
|
||||
let line = patch.lines().next()?;
|
||||
let hex = line.strip_prefix("From ")?;
|
||||
hex.split_whitespace().next().filter(|hex| hex.len() == 40)
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Error;
|
||||
use flume::{Receiver, Sender};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, WeakEntity,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use utils::shorten_pubkey;
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
use crate::bootstrap::sync_bootstrap_only;
|
||||
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
const REQUEST_CHUNK: usize = 100;
|
||||
const WARM_LIMIT: usize = 500;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Profile {
|
||||
public_key: PublicKey,
|
||||
metadata: Metadata,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
}
|
||||
|
||||
pub fn metadata(&self) -> &Metadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
// Falls back to `name`, then a shortened npub.
|
||||
pub fn name(&self) -> SharedString {
|
||||
if let Some(display_name) = self.metadata.display_name.as_ref()
|
||||
&& !display_name.is_empty()
|
||||
{
|
||||
return SharedString::from(display_name.trim().to_owned());
|
||||
}
|
||||
|
||||
if let Some(name) = self.metadata.name.as_ref()
|
||||
&& !name.is_empty()
|
||||
{
|
||||
return SharedString::from(name.trim().to_owned());
|
||||
}
|
||||
|
||||
SharedString::from(shorten_pubkey(self.public_key))
|
||||
}
|
||||
|
||||
pub fn picture(&self) -> Option<SharedString> {
|
||||
self.metadata
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| SharedString::from(p.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProfileStore {
|
||||
profiles: HashMap<PublicKey, Profile>,
|
||||
seen: RefCell<HashSet<PublicKey>>,
|
||||
sender: Sender<PublicKey>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
struct GlobalProfileStore(Entity<ProfileStore>);
|
||||
|
||||
impl Global for GlobalProfileStore {}
|
||||
|
||||
impl ProfileStore {
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalProfileStore>().0.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalProfileStore(entity));
|
||||
}
|
||||
|
||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let entity = cx.entity().downgrade();
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
if let BackendEvent::ProfileUpdates(authors) = event {
|
||||
this.apply_authors(authors.clone(), cx);
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = Self::handle_requests(this, &client, &receiver, cx).await {
|
||||
log::error!("Failed to handle requests: {e}");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.defer(move |cx| {
|
||||
entity.update(cx, |this, cx| this.load(cx)).ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
profiles: HashMap::new(),
|
||||
seen: RefCell::new(HashSet::new()),
|
||||
sender,
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a placeholder until fetched; queues a fetch when uncached.
|
||||
pub fn get(&self, public_key: &PublicKey) -> Profile {
|
||||
if let Some(profile) = self.profiles.get(public_key) {
|
||||
return profile.clone();
|
||||
}
|
||||
|
||||
let public_key = *public_key;
|
||||
|
||||
if self.seen.borrow_mut().insert(public_key)
|
||||
&& let Err(e) = self.sender.send(public_key)
|
||||
{
|
||||
log::warn!("failed to queue profile fetch: {e}");
|
||||
}
|
||||
|
||||
Profile::new(public_key, Metadata::default())
|
||||
}
|
||||
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(WARM_LIMIT);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
let profiles: Vec<Profile> = events
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
Profile::new(event.pubkey, metadata)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok::<_, Error>(profiles)
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let profiles = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
for profile in profiles {
|
||||
this.profiles.insert(profile.public_key(), profile);
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok::<_, Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn apply_authors(&mut self, authors: Vec<PublicKey>, cx: &mut Context<Self>) {
|
||||
if authors.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
||||
let events = client.database().query(filter).await?;
|
||||
// Pick the latest metadata per author off the main thread.
|
||||
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
||||
|
||||
for event in events {
|
||||
match latest.get(&event.pubkey) {
|
||||
Some((ts, _)) if *ts >= event.created_at => {}
|
||||
_ => {
|
||||
latest.insert(
|
||||
event.pubkey,
|
||||
(
|
||||
event.created_at,
|
||||
Metadata::from_json(&event.content).unwrap_or_default(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let profiles: Vec<Profile> = latest
|
||||
.into_iter()
|
||||
.map(|(public_key, (_, metadata))| Profile::new(public_key, metadata))
|
||||
.collect();
|
||||
|
||||
Ok::<_, Error>(profiles)
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let profiles = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
for profile in profiles {
|
||||
this.profiles.insert(profile.public_key(), profile);
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok::<_, Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
||||
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
||||
self.apply_authors(authors, cx);
|
||||
}
|
||||
|
||||
async fn handle_requests(
|
||||
this: WeakEntity<ProfileStore>,
|
||||
client: &Client,
|
||||
receiver: &Receiver<PublicKey>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<(), Error> {
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
|
||||
loop {
|
||||
match receiver.recv_async().await {
|
||||
Ok(public_key) => {
|
||||
batch.insert(public_key);
|
||||
}
|
||||
Err(_) => return Ok(()),
|
||||
}
|
||||
|
||||
// The channel has no async timeout, race the receive against a timer.
|
||||
let deadline = Instant::now() + BATCH_TIMEOUT;
|
||||
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
|
||||
if now >= deadline {
|
||||
break;
|
||||
}
|
||||
|
||||
let timer = cx.background_executor().timer(deadline - now);
|
||||
futures::pin_mut!(timer);
|
||||
|
||||
let recv = receiver.recv_async();
|
||||
futures::pin_mut!(recv);
|
||||
|
||||
match futures::future::select(recv, timer).await {
|
||||
futures::future::Either::Left((Ok(public_key), _)) => {
|
||||
batch.insert(public_key);
|
||||
}
|
||||
futures::future::Either::Left((Err(_), _)) => return Ok(()),
|
||||
futures::future::Either::Right(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let authors: Vec<PublicKey> = batch.drain().collect();
|
||||
|
||||
for chunk in authors.chunks(REQUEST_CHUNK) {
|
||||
let opts = SyncOptions::default();
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Metadata)
|
||||
.authors(chunk.iter().copied());
|
||||
|
||||
if let Err(e) = sync_bootstrap_only(client, filter, opts).await {
|
||||
log::warn!("profile fetch failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
if this.update(cx, |this, cx| this.apply_seen(cx)).is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,14 @@
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Error, bail};
|
||||
use gpui::BackgroundExecutor;
|
||||
use nostr::event::IntoEventBuilder;
|
||||
use nostr::prelude::Url;
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::RepoState;
|
||||
use signed_git::Repo;
|
||||
use signed_nostr::UniversalSigner;
|
||||
|
||||
pub(crate) const GRASP_PUSH_ATTEMPTS: usize = 3;
|
||||
|
||||
const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1);
|
||||
|
||||
/// `ws://` grasp servers, like ngit, use `http(s)://<host>`.
|
||||
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
|
||||
let parsed = Url::parse(relay.as_str()).ok()?;
|
||||
let host = parsed.host_str()?;
|
||||
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
|
||||
// `ws://` grasp servers, e.g. local dev relays, speak plain HTTP.
|
||||
let port = parsed
|
||||
.port()
|
||||
.map(|port| format!(":{port}"))
|
||||
.unwrap_or_default();
|
||||
let scheme = if relay.scheme().is_secure() {
|
||||
"https"
|
||||
} else {
|
||||
@@ -57,16 +46,16 @@ pub struct GraspServer {
|
||||
}
|
||||
|
||||
impl GraspServer {
|
||||
fn ok(relay: RelayUrl) -> Self {
|
||||
pub(crate) fn ok(relay: RelayUrl) -> Self {
|
||||
Self {
|
||||
relay,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failed(relay: RelayUrl, reason: impl Into<String>) -> Self {
|
||||
pub(crate) fn failed(relay: &RelayUrl, reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
relay,
|
||||
relay: relay.to_owned(),
|
||||
reason: Some(reason.into()),
|
||||
}
|
||||
}
|
||||
@@ -110,7 +99,7 @@ impl PushOutcome {
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
// `None` when every server accepted the push or nothing was pushed.
|
||||
/// `None` when every server accepted the push or nothing was pushed.
|
||||
pub fn partial_warning(&self) -> Option<String> {
|
||||
let accepted = self.accepted();
|
||||
if self.servers.is_empty() || accepted == self.servers.len() {
|
||||
@@ -123,406 +112,3 @@ impl PushOutcome {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Reasons a push attempt should be retried with a freshly staged state event
|
||||
// and a fresh git advertisement. Two families are retried:
|
||||
//
|
||||
// - **Purgatory denials**: the grasp server sends these when the state event
|
||||
// for the push has not reached its purgatory yet. Re-staging a fresh event
|
||||
// resolves them.
|
||||
// - **Stale advertisement races**: `git receive-pack` compares each ref
|
||||
// update against the value it advertised when the push started. The grasp
|
||||
// server's own background sync can move a ref in between - typically by
|
||||
// aligning the repository to a parked state event once the objects of an
|
||||
// earlier attempt land - so the compare-and-swap fails with `cannot lock
|
||||
// ref` / `incorrect old value provided`. A retry against the fresh
|
||||
// advertisement converges, and when the race is lost the pushed data is
|
||||
// usually already on the server (see `is_stale_advertisement_race` and
|
||||
// the convergence probe in `push_staged_to_grasps`).
|
||||
//
|
||||
// Other rejections are not retried.
|
||||
fn is_transient_grasp_denial(stderr: &str) -> bool {
|
||||
let error = stderr.to_lowercase();
|
||||
[
|
||||
"no state events in purgatory",
|
||||
"no matching state event",
|
||||
"doesn't match push",
|
||||
"none from authorized publishers",
|
||||
"no repository announcement found",
|
||||
"cannot lock ref",
|
||||
"incorrect old value provided",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| error.contains(marker))
|
||||
}
|
||||
|
||||
// A push rejected because `git receive-pack`'s compare-and-swap lost to the
|
||||
// grasp server's own background ref alignment: the ref moved between this
|
||||
// push's advertisement and its ref transaction (`cannot lock ref ... is at
|
||||
// ... but expected ...` / `incorrect old value provided`). The pushed data
|
||||
// is usually already on the server by then.
|
||||
fn is_stale_advertisement_race(stderr: &str) -> bool {
|
||||
let error = stderr.to_lowercase();
|
||||
error.contains("cannot lock ref") || error.contains("incorrect old value provided")
|
||||
}
|
||||
|
||||
// All staged events carry the same refs; the newest timestamp wins on the relays.
|
||||
fn keep_newest(state_event: &mut Option<Event>, event: Event) {
|
||||
if state_event
|
||||
.as_ref()
|
||||
.is_none_or(|current| event.created_at > current.created_at)
|
||||
{
|
||||
*state_event = Some(event);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct GraspPush {
|
||||
client: Client,
|
||||
signer: UniversalSigner,
|
||||
}
|
||||
|
||||
impl GraspPush {
|
||||
pub(crate) fn new(client: Client, signer: UniversalSigner) -> Self {
|
||||
Self { client, signer }
|
||||
}
|
||||
|
||||
pub(crate) fn require_relay_accepted(
|
||||
output: SendEventOutput,
|
||||
event: Event,
|
||||
) -> Result<Event, Error> {
|
||||
if output.success.is_empty() && !output.failed.is_empty() {
|
||||
let reasons = output
|
||||
.failed
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
bail!("event not accepted by any relay: {reasons}");
|
||||
}
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
// A relay hiccup should not block sign-up, so failures are only logged.
|
||||
pub(crate) async fn publish_best_effort(&self, builder: EventBuilder) {
|
||||
let result: Result<(), Error> = async {
|
||||
let event = builder.finalize_async(&self.signer).await?;
|
||||
let output = self.client.send_event(&event).broadcast().await?;
|
||||
Self::require_relay_accepted(output, event)?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
log::warn!("failed to publish identity bootstrap event: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn publish_one(&self, builder: EventBuilder) -> Result<Event, Error> {
|
||||
let event = builder.finalize_async(&self.signer).await?;
|
||||
self.send_accepted(event).await
|
||||
}
|
||||
|
||||
pub(crate) async fn send_accepted(&self, event: Event) -> Result<Event, Error> {
|
||||
let output = self.client.send_event(&event).broadcast().await?;
|
||||
Self::require_relay_accepted(output, event)
|
||||
}
|
||||
|
||||
pub(crate) async fn retract_event(&self, event: &Event) -> Result<(), Error> {
|
||||
let builder = EventDeletionRequest::new()
|
||||
.id(event.id)
|
||||
.into_event_builder();
|
||||
|
||||
let deletion = builder.finalize_async(&self.signer).await?;
|
||||
self.client.send_event(&deletion).broadcast().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Retries within the same second get the next second: a grasp relay
|
||||
// treats a same-id resend as a duplicate and does not re-run its ingest,
|
||||
// so an identical resend cannot re-park a state event lost from its purgatory.
|
||||
async fn sign_state_event(
|
||||
&self,
|
||||
repo_id: &str,
|
||||
refs: &[(String, String)],
|
||||
head: Option<&str>,
|
||||
last_created_at: u64,
|
||||
) -> Result<(Event, u64), String> {
|
||||
let now = Timestamp::now().as_secs();
|
||||
let created_at = if now > last_created_at {
|
||||
now
|
||||
} else {
|
||||
last_created_at + 1
|
||||
};
|
||||
|
||||
let event = RepoState::build(repo_id, refs, head)
|
||||
.custom_created_at(Timestamp::from_secs(created_at))
|
||||
.finalize_async(&self.signer)
|
||||
.await
|
||||
.map_err(|e| format!("could not sign the state event: {e}"))?;
|
||||
|
||||
Ok((event, created_at))
|
||||
}
|
||||
|
||||
// `Ok` only when the relay confirmed the event. On a grasp relay the
|
||||
// accept parks the event in purgatory, which authorizes the paired git push.
|
||||
async fn stage_event_on_relay(&self, relay: &RelayUrl, event: &Event) -> Result<(), String> {
|
||||
self.client
|
||||
.add_relay(relay)
|
||||
.and_connect()
|
||||
.await
|
||||
.map_err(|e| format!("could not add relay {relay}: {e}"))?;
|
||||
|
||||
let output = self
|
||||
.client
|
||||
.send_event(event)
|
||||
.to([relay.clone()])
|
||||
.await
|
||||
.map_err(|e| format!("could not send the state event to {relay}: {e}"))?;
|
||||
|
||||
if output.success.contains_key(relay) {
|
||||
Ok(())
|
||||
} else {
|
||||
let reason = output
|
||||
.failed
|
||||
.get(relay)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "relay did not confirm the event".to_owned());
|
||||
Err(reason)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn push_staged_to_grasps(
|
||||
&self,
|
||||
repo_id: &str,
|
||||
refs: &[(String, String)],
|
||||
head: Option<&str>,
|
||||
path: &Path,
|
||||
owner: &str,
|
||||
servers: &[RelayUrl],
|
||||
executor: &BackgroundExecutor,
|
||||
push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error>,
|
||||
) -> PushOutcome {
|
||||
let mut outcome = PushOutcome::default();
|
||||
|
||||
for relay in servers {
|
||||
let Some(base) = grasp_base_url(relay) else {
|
||||
outcome
|
||||
.servers
|
||||
.push(GraspServer::failed(relay.clone(), "no domain"));
|
||||
continue;
|
||||
};
|
||||
let git_url = format!("{base}/{owner}/{repo_id}.git");
|
||||
|
||||
let mut reason = None;
|
||||
let mut last_created_at = 0;
|
||||
// For the convergence probe when every push attempt lost the
|
||||
// stale-ref race.
|
||||
let mut staged_event = None;
|
||||
|
||||
'server: for attempt in 1..=GRASP_PUSH_ATTEMPTS {
|
||||
if attempt > 1 {
|
||||
executor.timer(GRASP_RETRY_DELAY).await;
|
||||
}
|
||||
|
||||
let (event, created_at) = match self
|
||||
.sign_state_event(repo_id, refs, head, last_created_at)
|
||||
.await
|
||||
{
|
||||
Ok(signed) => signed,
|
||||
Err(e) => {
|
||||
reason = Some(e);
|
||||
break 'server;
|
||||
}
|
||||
};
|
||||
|
||||
last_created_at = created_at;
|
||||
|
||||
// A failed stage means the grasp never parked the state, so
|
||||
// the git push would be denied anyway: skip it.
|
||||
if let Err(e) = self.stage_event_on_relay(relay, &event).await {
|
||||
// One retry absorbs a relay connect blip, on the first
|
||||
// attempt only.
|
||||
if attempt == 1 && self.stage_event_on_relay(relay, &event).await.is_ok() {
|
||||
} else {
|
||||
reason = Some(e);
|
||||
break 'server;
|
||||
}
|
||||
}
|
||||
staged_event = Some(event.clone());
|
||||
|
||||
match push(path, &base, owner, repo_id) {
|
||||
Ok(()) => {
|
||||
keep_newest(&mut outcome.state_event, event);
|
||||
break 'server;
|
||||
}
|
||||
Err(e) => {
|
||||
let text = e.to_string();
|
||||
if attempt < GRASP_PUSH_ATTEMPTS && is_transient_grasp_denial(&text) {
|
||||
reason = Some(text);
|
||||
continue 'server;
|
||||
}
|
||||
reason = Some(text);
|
||||
break 'server;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The grasp's own background sync aligns refs to staged state
|
||||
// events as soon as the objects land, which can beat every push
|
||||
// attempt's compare-and-swap. When the last denial was that race
|
||||
// the sync has usually finished by now: verify the advertised refs
|
||||
// and accept the server when the pushed data is already there.
|
||||
if let Some(last_reason) = &reason
|
||||
&& is_stale_advertisement_race(last_reason)
|
||||
&& Repo::open(path)
|
||||
.and_then(|repo| repo.remote_has_refs(&git_url, refs))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Some(event) = staged_event {
|
||||
keep_newest(&mut outcome.state_event, event);
|
||||
}
|
||||
reason = None;
|
||||
}
|
||||
|
||||
match reason {
|
||||
Some(reason) => {
|
||||
log::warn!("grasp push failed: {relay}: {reason}");
|
||||
outcome
|
||||
.servers
|
||||
.push(GraspServer::failed(relay.clone(), reason));
|
||||
}
|
||||
None => outcome.servers.push(GraspServer::ok(relay.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
outcome
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grasp_base_url_maps_schemes_like_ngit() {
|
||||
let wss = RelayUrl::parse("wss://relay.ngit.dev").expect("url");
|
||||
assert_eq!(
|
||||
grasp_base_url(&wss).as_deref(),
|
||||
Some("https://relay.ngit.dev")
|
||||
);
|
||||
|
||||
let ws = RelayUrl::parse("ws://localhost:8080").expect("url");
|
||||
assert_eq!(
|
||||
grasp_base_url(&ws).as_deref(),
|
||||
Some("http://localhost:8080")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grasp_clone_url_matches_ngit_format() {
|
||||
let relay = RelayUrl::parse("wss://gitnostr.com").expect("url");
|
||||
let url = grasp_clone_url(&relay, "npub1test", "my-repo").expect("url");
|
||||
assert_eq!(
|
||||
url.to_string(),
|
||||
"https://gitnostr.com/npub1test/my-repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grasp06_prs_url_matches_ngit_format() {
|
||||
assert_eq!(
|
||||
grasp06_prs_url("https://relay.ngit.dev", "npub1author", "my-repo"),
|
||||
"https://relay.ngit.dev/prs/npub1author/my-repo.git"
|
||||
);
|
||||
assert_eq!(
|
||||
grasp06_prs_url("http://localhost:8080", "npub1author", "my-repo"),
|
||||
"http://localhost:8080/prs/npub1author/my-repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_clone_urls_orders_author_first_and_deduplicates() {
|
||||
let prs = vec![
|
||||
Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"),
|
||||
Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"),
|
||||
];
|
||||
let base = vec![
|
||||
Url::parse("https://a.example/npub1owner/repo.git").expect("url"),
|
||||
Url::parse("https://b.example/npub1owner/repo.git").expect("url"),
|
||||
Url::parse("https://b.example/npub1owner/repo.git").expect("url"),
|
||||
];
|
||||
|
||||
let urls = pr_clone_urls(prs, base);
|
||||
assert_eq!(
|
||||
urls.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
||||
vec![
|
||||
"https://a.example/prs/npub1me/repo.git",
|
||||
"https://a.example/npub1owner/repo.git",
|
||||
"https://b.example/npub1owner/repo.git",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_grasp_denials_are_classified() {
|
||||
let reported = "remote: ERR authorisation failed: No state events in purgatory\n\
|
||||
fatal: the remote end hung up unexpectedly\n\
|
||||
error: failed to push some refs to 'https://relay.ngit.dev/...git'";
|
||||
assert!(is_transient_grasp_denial(reported));
|
||||
|
||||
assert!(is_transient_grasp_denial(
|
||||
"remote: ERR authorisation failed: No matching state event found in purgatory"
|
||||
));
|
||||
assert!(is_transient_grasp_denial(
|
||||
"remote: ERR authorisation failed: 1 state event in purgatory from authorized \
|
||||
publisher but doesn't match push"
|
||||
));
|
||||
assert!(is_transient_grasp_denial(
|
||||
"remote: ERR authorisation failed: 2 state events in purgatory but none from \
|
||||
authorized publishers"
|
||||
));
|
||||
assert!(is_transient_grasp_denial(
|
||||
"remote: ERR authorisation failed: No repository announcement found"
|
||||
));
|
||||
|
||||
assert!(!is_transient_grasp_denial(
|
||||
"remote: ERR authorisation failed: not a maintainer of this repository"
|
||||
));
|
||||
assert!(!is_transient_grasp_denial(
|
||||
"fatal: unable to access 'https://relay.ngit.dev/...': The requested URL returned \
|
||||
error: 403"
|
||||
));
|
||||
assert!(!is_transient_grasp_denial(
|
||||
"fatal: unable to access 'https://relay.ngit.dev/...': Could not resolve host"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_ref_races_are_retried() {
|
||||
let reported = "remote: error: cannot lock ref 'refs/heads/main': is at \
|
||||
cac2ac91b6f5fb8dfcb6962785babc6e65350cb3 but expected \
|
||||
bc5e892aa84dc6240a5fbcd59367a4857d26f49b\n\
|
||||
To https://relay.ngit.dev/npub1owner/signed-test.git\n\
|
||||
! [remote rejected] main -> main (incorrect old value provided)\n\
|
||||
error: failed to push some refs to 'https://relay.ngit.dev/npub1owner/signed-test.git'";
|
||||
assert!(is_transient_grasp_denial(reported));
|
||||
assert!(is_stale_advertisement_race(reported));
|
||||
|
||||
assert!(is_stale_advertisement_race(
|
||||
"cannot lock ref 'refs/heads/main'"
|
||||
));
|
||||
assert!(is_stale_advertisement_race(
|
||||
"! [remote rejected] main -> main (incorrect old value provided)"
|
||||
));
|
||||
|
||||
assert!(!is_stale_advertisement_race("No state events in purgatory"));
|
||||
|
||||
assert!(!is_transient_grasp_denial(
|
||||
" ! [rejected] main -> main (non-fast-forward)"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+147
-182
@@ -3,26 +3,23 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Error, bail};
|
||||
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
||||
use gpui::{App, AppContext, Context, SharedString, Subscription, Task};
|
||||
use nostr::event::IntoEventBuilder;
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::{EventFetchingStrategy, SettingsStore};
|
||||
use signed_core::{
|
||||
Announcement, Deletions, Filters, GitEvent, PullRequest, RepoAddr, RepoState, RepoStatus,
|
||||
filters,
|
||||
Announcement, Deletions, Filters, GitEvent, PullRequest, RepoAddr, RepoStatus, filters,
|
||||
};
|
||||
use signed_git::{Nip34Binding, PatchParser, Repo};
|
||||
use signed_nostr::UniversalSigner;
|
||||
use signed_git::{Nip34Binding, Repo};
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
use crate::bootstrap::user_grasp_list_servers;
|
||||
use crate::checkouts::CheckoutsStore;
|
||||
use crate::push::{GraspPush, PushOutcome, grasp_base_url, grasp06_prs_url, pr_clone_urls};
|
||||
use crate::patch_series::{MAX_PATCH_EVENT_BYTES, PatchSeries};
|
||||
use crate::push::{PushOutcome, grasp_base_url, grasp06_prs_url, pr_clone_urls};
|
||||
use crate::repos::RepoListStore;
|
||||
|
||||
// NIP-34 suggests patches when each event is under 60kb.
|
||||
const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
|
||||
use crate::state::{GitRef, PushStepStatus, SyncState};
|
||||
use crate::sync_status::SyncStatusStore;
|
||||
|
||||
pub struct RepoStore {
|
||||
addr: Option<RepoAddr>,
|
||||
@@ -36,6 +33,9 @@ pub struct RepoStore {
|
||||
// Views distinguish "no data yet" from a genuinely empty repository with it.
|
||||
pub loaded: bool,
|
||||
pub head: Option<String>,
|
||||
// Refs and timestamp of the latest state event, for sync detection.
|
||||
pub state_refs: Vec<GitRef>,
|
||||
pub state_at: Option<Timestamp>,
|
||||
pub issues: Vec<Event>,
|
||||
pub patches: Vec<Event>,
|
||||
pub pull_requests: Vec<Event>,
|
||||
@@ -48,6 +48,7 @@ pub struct RepoStore {
|
||||
// The repository is out of sync on the rejected servers until republished.
|
||||
pub last_push_warning: Option<String>,
|
||||
pub pushing: bool,
|
||||
pub push_steps: Vec<PushStepStatus>,
|
||||
pub cloning: bool,
|
||||
// Avoids re-subscribing and re-fetching on every refresh.
|
||||
repo_relays: HashSet<RelayUrl>,
|
||||
@@ -88,6 +89,8 @@ impl RepoStore {
|
||||
nip34: None,
|
||||
loaded: false,
|
||||
head: None,
|
||||
state_refs: Vec::new(),
|
||||
state_at: None,
|
||||
issues: Vec::new(),
|
||||
patches: Vec::new(),
|
||||
pull_requests: Vec::new(),
|
||||
@@ -99,6 +102,7 @@ impl RepoStore {
|
||||
last_warning: None,
|
||||
last_push_warning: None,
|
||||
pushing: false,
|
||||
push_steps: Vec::new(),
|
||||
cloning: false,
|
||||
repo_relays: HashSet::new(),
|
||||
root_fetches: HashSet::new(),
|
||||
@@ -116,6 +120,8 @@ impl RepoStore {
|
||||
nip34,
|
||||
loaded: true,
|
||||
head: None,
|
||||
state_refs: Vec::new(),
|
||||
state_at: None,
|
||||
issues: Vec::new(),
|
||||
patches: Vec::new(),
|
||||
pull_requests: Vec::new(),
|
||||
@@ -127,6 +133,7 @@ impl RepoStore {
|
||||
last_warning: None,
|
||||
last_push_warning: None,
|
||||
pushing: false,
|
||||
push_steps: Vec::new(),
|
||||
cloning: false,
|
||||
repo_relays: HashSet::new(),
|
||||
root_fetches: HashSet::new(),
|
||||
@@ -165,6 +172,14 @@ impl RepoStore {
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
if let BackendEvent::PushProgress { addr, status } = event {
|
||||
if this.addr.as_ref() == Some(addr) {
|
||||
this.apply_push_step(status.clone(), cx);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(addr) = this.addr.as_ref() else {
|
||||
return;
|
||||
};
|
||||
@@ -199,6 +214,19 @@ impl RepoStore {
|
||||
self.addr.as_ref()
|
||||
}
|
||||
|
||||
fn apply_push_step(&mut self, status: PushStepStatus, cx: &mut Context<Self>) {
|
||||
match self
|
||||
.push_steps
|
||||
.iter_mut()
|
||||
.find(|entry| entry.step() == status.step())
|
||||
{
|
||||
Some(entry) => *entry = status,
|
||||
None => self.push_steps.push(status),
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn name(&self) -> SharedString {
|
||||
self.announcement
|
||||
.as_ref()
|
||||
@@ -349,7 +377,8 @@ impl RepoStore {
|
||||
.and_then(Announcement::from_event);
|
||||
|
||||
let all_states = states.into_iter().filter(|e| !deletions.is_deleted(e));
|
||||
let state = utils::latest(all_states).map(|state| RepoState::parse(&state));
|
||||
let state =
|
||||
utils::latest(all_states).map(|event| (SyncState::parse(&event), event.created_at));
|
||||
|
||||
let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) =
|
||||
(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||||
@@ -466,13 +495,13 @@ impl RepoStore {
|
||||
let keep_hint = announcement.is_none() && !this.loaded;
|
||||
let first_pass = !this.loaded;
|
||||
|
||||
let head_changed = state
|
||||
.as_ref()
|
||||
.is_some_and(|state| this.head.as_deref() != state.head.as_deref());
|
||||
let state_changed = state.as_ref().is_some_and(|(state, _)| {
|
||||
this.head.as_deref() != state.head.as_deref() || this.state_refs != state.refs
|
||||
});
|
||||
|
||||
let changed = first_pass
|
||||
|| (!keep_hint && this.announcement != announcement)
|
||||
|| head_changed
|
||||
|| state_changed
|
||||
|| this.issues != issues
|
||||
|| this.patches != patches
|
||||
|| this.pull_requests != pull_requests
|
||||
@@ -499,8 +528,10 @@ impl RepoStore {
|
||||
|
||||
this.sync_maintainer_relays(&maintainers, cx);
|
||||
|
||||
if let Some(state) = state {
|
||||
if let Some((state, created_at)) = state {
|
||||
this.head = state.head;
|
||||
this.state_refs = state.refs;
|
||||
this.state_at = Some(created_at);
|
||||
}
|
||||
|
||||
this.issues = issues;
|
||||
@@ -854,10 +885,22 @@ impl RepoStore {
|
||||
}
|
||||
}
|
||||
|
||||
let publish_result = {
|
||||
let pusher = GraspPush::new(client.clone(), signer.clone());
|
||||
pusher.send_accepted(event).await
|
||||
};
|
||||
let publish_result: Result<Event, Error> = async {
|
||||
let output = client.send_event(&event).broadcast().await?;
|
||||
|
||||
if output.success.is_empty() && !output.failed.is_empty() {
|
||||
let reasons = output
|
||||
.failed
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
bail!("event not accepted by any relay: {reasons}");
|
||||
}
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
.await;
|
||||
|
||||
let pr_event = match publish_result {
|
||||
Ok(event) => event,
|
||||
@@ -1030,10 +1073,23 @@ impl RepoStore {
|
||||
}
|
||||
};
|
||||
|
||||
let publish_result = {
|
||||
let pusher = GraspPush::new(client.clone(), signer.clone());
|
||||
pusher.publish_one(builder).await
|
||||
};
|
||||
let publish_result: Result<Event, Error> = async {
|
||||
let event = builder.finalize_async(&signer).await?;
|
||||
let output = client.send_event(&event).broadcast().await?;
|
||||
|
||||
if output.success.is_empty() && !output.failed.is_empty() {
|
||||
let reasons = output
|
||||
.failed
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
bail!("event not accepted by any relay: {reasons}");
|
||||
}
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = publish_result {
|
||||
return this.update(cx, |this, cx| {
|
||||
@@ -1116,6 +1172,24 @@ impl RepoStore {
|
||||
self.run_push(push, None, cx)
|
||||
}
|
||||
|
||||
/// Republishes the mirror and deletes grasp refs the mirror does not have.
|
||||
pub fn reset_repository(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
|
||||
if self.pushing {
|
||||
return Task::ready(Err(anyhow::anyhow!(
|
||||
"A push to this repository is already in progress"
|
||||
)));
|
||||
}
|
||||
|
||||
let Some(announcement) = self.action_announcement(cx) else {
|
||||
return self.action_error("Repository announcement is not loaded yet", cx);
|
||||
};
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let push = backend.update(cx, |backend, cx| backend.reset_repository(announcement, cx));
|
||||
|
||||
self.run_push(push, None, cx)
|
||||
}
|
||||
|
||||
pub fn push_checkout(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
@@ -1154,6 +1228,7 @@ impl RepoStore {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<(), Error>> {
|
||||
self.pushing = true;
|
||||
self.push_steps.clear();
|
||||
self.last_error = None;
|
||||
self.last_push_warning = None;
|
||||
cx.notify();
|
||||
@@ -1163,16 +1238,27 @@ impl RepoStore {
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.pushing = false;
|
||||
this.push_steps.clear();
|
||||
|
||||
match &result {
|
||||
Ok(outcome) => {
|
||||
this.last_error = None;
|
||||
this.last_push_warning = outcome.partial_warning();
|
||||
|
||||
if let Some((addr, path)) = &pushed_checkout {
|
||||
CheckoutsStore::global(cx).update(cx, |store, cx| {
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
checkouts.update(cx, |store, cx| {
|
||||
store.checkout_pushed(addr, path, cx);
|
||||
});
|
||||
}
|
||||
|
||||
// Re-probe so sync badges reflect the pushed state.
|
||||
let sync_status = SyncStatusStore::global(cx);
|
||||
let addr = pushed_checkout
|
||||
.as_ref()
|
||||
.map(|(addr, _)| addr.clone())
|
||||
.or_else(|| this.addr.clone());
|
||||
sync_status.read(cx).probe(addr);
|
||||
}
|
||||
Err(e) => {
|
||||
this.last_error = Some(format!("Push failed: {e}"));
|
||||
@@ -1282,8 +1368,7 @@ impl RepoStore {
|
||||
Task::ready(Err(anyhow::anyhow!("{message}")))
|
||||
}
|
||||
|
||||
// Every one-shot repository event (issue, comment, status) goes through
|
||||
// this; multi-step flows call the SDK directly instead.
|
||||
/// Publish an event to the repository's relays, and update the store with any error.
|
||||
fn publish(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
|
||||
self.last_error = None;
|
||||
|
||||
@@ -1294,8 +1379,23 @@ impl RepoStore {
|
||||
};
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let pusher = GraspPush::new(client, signer);
|
||||
let publish_result = pusher.publish_one(builder).await;
|
||||
let publish_result: Result<Event, Error> = async {
|
||||
let event = builder.finalize_async(&signer).await?;
|
||||
let output = client.send_event(&event).broadcast().await?;
|
||||
|
||||
if output.success.is_empty() && !output.failed.is_empty() {
|
||||
let reasons = output
|
||||
.failed
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
bail!("event not accepted by any relay: {reasons}");
|
||||
}
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = publish_result {
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -1348,65 +1448,21 @@ fn resolve_statuses(
|
||||
.collect()
|
||||
}
|
||||
|
||||
// The `From <commit>` header on the first line.
|
||||
fn patch_current_commit(patch: &str) -> Option<&str> {
|
||||
let line = patch.lines().next()?;
|
||||
let hex = line.strip_prefix("From ")?;
|
||||
hex.split_whitespace().next().filter(|hex| hex.len() == 40)
|
||||
}
|
||||
|
||||
struct PatchSeries {
|
||||
parts: Vec<String>,
|
||||
}
|
||||
|
||||
impl PatchSeries {
|
||||
fn parse(patch: &str) -> Self {
|
||||
Self {
|
||||
parts: PatchParser::split_patch_series(patch)
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
// The byte length of the first part over the NIP-34 size suggestion.
|
||||
fn oversized_length(&self) -> Option<usize> {
|
||||
self.parts
|
||||
.iter()
|
||||
.map(String::len)
|
||||
.find(|length| *length > MAX_PATCH_EVENT_BYTES)
|
||||
}
|
||||
|
||||
// The tip of the series is its last commit; `git format-patch` orders
|
||||
// patches oldest first.
|
||||
fn tip_commit(&self) -> Option<Sha1Hash> {
|
||||
self.parts
|
||||
.last()
|
||||
.and_then(|part| patch_current_commit(part))
|
||||
.and_then(|hex| hex.parse::<Sha1Hash>().ok())
|
||||
}
|
||||
|
||||
fn commit_of(&self, index: usize) -> Option<&str> {
|
||||
self.parts
|
||||
.get(index)
|
||||
.and_then(|part| patch_current_commit(part))
|
||||
.filter(|hex| hex.len() == 40)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the root event, the one a PR references.
|
||||
/// Publish a patch series as a sequence of `git/patch` events.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn publish_patch_series(
|
||||
async fn publish_patch_series<S>(
|
||||
client: &Client,
|
||||
signer: &UniversalSigner,
|
||||
signer: &S,
|
||||
addr: &RepoAddr,
|
||||
owner: PublicKey,
|
||||
euc: Option<&str>,
|
||||
series: &PatchSeries,
|
||||
first_marker: &str,
|
||||
reply_to: Option<EventId>,
|
||||
) -> Result<Event, Error> {
|
||||
let pusher = GraspPush::new(client.clone(), signer.clone());
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let mut root: Option<Event> = None;
|
||||
let mut previous = reply_to;
|
||||
|
||||
@@ -1453,7 +1509,18 @@ async fn publish_patch_series(
|
||||
}
|
||||
|
||||
let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags);
|
||||
let event = pusher.publish_one(builder).await?;
|
||||
let event = builder.finalize_async(signer).await?;
|
||||
let output = client.send_event(&event).broadcast().await?;
|
||||
|
||||
if output.success.is_empty() && !output.failed.is_empty() {
|
||||
let reasons = output
|
||||
.failed
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
bail!("event not accepted by any relay: {reasons}");
|
||||
}
|
||||
|
||||
if root.is_none() {
|
||||
root = Some(event.clone());
|
||||
@@ -1488,105 +1555,3 @@ fn comment_builder(
|
||||
.into_event_builder()
|
||||
.tags([Tag::coordinate(addr.clone().into(), None)])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::GitEvent;
|
||||
|
||||
use super::{RepoStore, comment_builder, patch_current_commit};
|
||||
|
||||
#[test]
|
||||
fn parses_format_patch_header() {
|
||||
let patch = "From 1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a Mon Sep 17 00:00:00 2001\nFrom: A <a@b.c>\nSubject: [PATCH] fix\n\n---\n";
|
||||
assert_eq!(
|
||||
patch_current_commit(patch),
|
||||
Some("1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_builder_follows_nip22() {
|
||||
let keys = Keys::generate();
|
||||
let root = EventBuilder::new(Kind::GitIssue, "issue body")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
let addr = signed_core::RepoAddr::new(root.pubkey, "my-repo");
|
||||
let relay = RelayUrl::parse("wss://relay.example.com").expect("valid relay URL");
|
||||
|
||||
let event = comment_builder(&root, None, Some(&relay), &addr, "hi".into())
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
|
||||
assert_eq!(event.kind, Kind::Comment);
|
||||
|
||||
let kinds: Vec<&str> = event.tags.iter().map(Tag::kind).collect();
|
||||
for expected in ["E", "K", "P", "e", "k", "p", "a"] {
|
||||
assert!(kinds.contains(&expected), "missing {expected} tag");
|
||||
}
|
||||
|
||||
let e = event.tags.iter().find(|t| t.kind() == "E").expect("E tag");
|
||||
let slice = e.as_slice();
|
||||
assert_eq!(slice[1], root.id.to_hex());
|
||||
assert_eq!(slice[2], relay.as_str());
|
||||
assert_eq!(slice[3], root.pubkey.to_hex());
|
||||
|
||||
let e = event.tags.iter().find(|t| t.kind() == "e").expect("e tag");
|
||||
assert_eq!(e.as_slice()[1], root.id.to_hex());
|
||||
|
||||
assert!(event.references_root(&root.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maintainer_filters_name_owner_and_maintainers() {
|
||||
let owner = Keys::generate().public_key();
|
||||
let maintainer = Keys::generate().public_key();
|
||||
let addr = signed_core::RepoAddr::new(owner, "my-repo");
|
||||
|
||||
let filters = RepoStore::maintainer_filters(&addr, &[maintainer]);
|
||||
assert_eq!(filters.len(), 4);
|
||||
|
||||
let expected = HashSet::from([owner, maintainer]);
|
||||
|
||||
let named = |filter: &Filter| -> HashSet<PublicKey> {
|
||||
let authors = filter.authors.iter().flatten().copied();
|
||||
let p_tag = filter
|
||||
.generic_tags
|
||||
.get(&SingleLetterTag::LOWERCASE_P)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|value| PublicKey::from_hex(value).ok());
|
||||
authors.chain(p_tag).collect()
|
||||
};
|
||||
|
||||
let announcement = &filters[0];
|
||||
assert_eq!(named(announcement), expected);
|
||||
assert!(
|
||||
announcement
|
||||
.generic_tags
|
||||
.contains_key(&SingleLetterTag::LOWERCASE_D)
|
||||
);
|
||||
|
||||
for filter in &filters[1..3] {
|
||||
assert_eq!(named(filter), expected);
|
||||
assert!(
|
||||
filter
|
||||
.generic_tags
|
||||
.contains_key(&SingleLetterTag::LOWERCASE_A)
|
||||
);
|
||||
}
|
||||
|
||||
let deletions = &filters[3];
|
||||
assert_eq!(
|
||||
deletions
|
||||
.authors
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Error, anyhow, bail};
|
||||
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
||||
use gpui::{AppContext, AsyncApp};
|
||||
use nostr::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::Announcement;
|
||||
use signed_git::Repo;
|
||||
|
||||
use crate::push::{GraspServer, PushOutcome, grasp_base_url};
|
||||
|
||||
/// A git ref with the commit it points at.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitRef {
|
||||
/// Full ref name, like `refs/heads/main` or `refs/tags/v1.0`.
|
||||
pub name: String,
|
||||
pub commit: Sha1Hash,
|
||||
}
|
||||
|
||||
impl GitRef {
|
||||
pub fn new(name: impl Into<String>, commit: &str) -> Result<Self, Error> {
|
||||
let commit = Sha1Hash::from_str(commit).map_err(|_| anyhow!("invalid commit: {commit}"))?;
|
||||
|
||||
Ok(Self {
|
||||
name: name.into(),
|
||||
commit,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// How much to push to the grasp servers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum GitPushMode {
|
||||
/// Mirror all branches and tags.
|
||||
#[default]
|
||||
All,
|
||||
/// Push `refs/heads/main` only (repo creation).
|
||||
Main,
|
||||
}
|
||||
|
||||
/// How to handle server-only refs when pushing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum GitPushPolicy {
|
||||
/// Reconcile the local refs with the grasp servers,
|
||||
/// fetching missing refs before pushing.
|
||||
#[default]
|
||||
Reconcile,
|
||||
/// Declare the local refs only and delete server-only refs while pushing.
|
||||
ResetRemote,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SyncState {
|
||||
pub refs: Vec<GitRef>,
|
||||
pub head: Option<String>,
|
||||
/// Local repository to push from; `None` for states parsed from events.
|
||||
pub path: Option<PathBuf>,
|
||||
pub mode: GitPushMode,
|
||||
pub policy: GitPushPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PushStep {
|
||||
AnnounceRepository,
|
||||
/// Publish the state event to one grasp server.
|
||||
StageStateEvent(RelayUrl),
|
||||
/// Git push to one grasp server.
|
||||
PushObjects(RelayUrl),
|
||||
/// Fan the state event out to the relays.
|
||||
BroadcastStateEvent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PushStepStatus {
|
||||
Running(PushStep),
|
||||
Done(PushStep),
|
||||
Failed(PushStep, String),
|
||||
}
|
||||
|
||||
impl PushStep {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Self::AnnounceRepository => "Announce repository".to_owned(),
|
||||
Self::StageStateEvent(relay) => format!("Publish state event -> {relay}"),
|
||||
Self::BroadcastStateEvent => "Publish state event to relays".to_owned(),
|
||||
Self::PushObjects(relay) => format!(
|
||||
"Push objects -> {}",
|
||||
grasp_base_url(relay).unwrap_or_else(|| relay.to_string())
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PushStepStatus {
|
||||
pub fn step(&self) -> &PushStep {
|
||||
match self {
|
||||
Self::Running(step) | Self::Done(step) | Self::Failed(step, _) => step,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncState {
|
||||
pub fn build(id: &str, refs: &[GitRef], head: Option<&str>) -> EventBuilder {
|
||||
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
|
||||
|
||||
for git_ref in refs {
|
||||
let commit = git_ref.commit.to_string();
|
||||
tags.push(Tag::parse([git_ref.name.as_str(), commit.as_str()]).expect("valid ref tag"));
|
||||
}
|
||||
|
||||
if let Some(head) = head {
|
||||
tags.push(
|
||||
Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"),
|
||||
);
|
||||
}
|
||||
|
||||
EventBuilder::new(Kind::RepoState, "").tags(tags)
|
||||
}
|
||||
|
||||
pub fn parse(event: &Event) -> Self {
|
||||
let mut refs = Vec::new();
|
||||
let mut head = None;
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Head(branch)) => head = Some(branch),
|
||||
Ok(Nip34Tag::RefHead { branch, commit }) => {
|
||||
refs.push(GitRef {
|
||||
name: format!("refs/heads/{branch}"),
|
||||
commit,
|
||||
});
|
||||
}
|
||||
Ok(Nip34Tag::RefTag { name, commit }) => {
|
||||
refs.push(GitRef {
|
||||
name: format!("refs/tags/{name}"),
|
||||
commit,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
refs,
|
||||
head,
|
||||
path: None,
|
||||
mode: GitPushMode::default(),
|
||||
policy: GitPushPolicy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the refs and head of a local repository, ready to push.
|
||||
pub fn from_repo(path: &Path) -> Result<Self, Error> {
|
||||
let repo = Repo::open(path)?;
|
||||
let state = repo.ref_state()?;
|
||||
|
||||
let refs = state
|
||||
.refs
|
||||
.iter()
|
||||
.map(|(name, commit)| GitRef::new(name, commit))
|
||||
.collect::<Result<Vec<_>, Error>>()?;
|
||||
|
||||
Ok(Self {
|
||||
refs,
|
||||
head: state.head,
|
||||
path: Some(path.to_path_buf()),
|
||||
mode: GitPushMode::default(),
|
||||
policy: GitPushPolicy::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_mode(mut self, mode: GitPushMode) -> Self {
|
||||
self.mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_policy(mut self, policy: GitPushPolicy) -> Self {
|
||||
self.policy = policy;
|
||||
self
|
||||
}
|
||||
|
||||
/// Reconciles the local repository with the grasp servers, fetching missing refs.
|
||||
pub async fn reconcile(
|
||||
&mut self,
|
||||
announcement: &Announcement,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<(), Error> {
|
||||
if self.policy == GitPushPolicy::ResetRemote || self.refs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(path) = self.path.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let local: Vec<(String, String)> = self
|
||||
.refs
|
||||
.iter()
|
||||
.map(|git_ref| (git_ref.name.clone(), git_ref.commit.to_string()))
|
||||
.collect();
|
||||
|
||||
let urls: Vec<String> = announcement
|
||||
.clone
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect();
|
||||
|
||||
let snapshot_path = path.clone();
|
||||
|
||||
let missing = cx
|
||||
.background_spawn(async move {
|
||||
let repo = Repo::open(&path)?;
|
||||
|
||||
// Refs the servers advertise that the local repository lacks
|
||||
let mut missing: Vec<(String, Vec<String>)> = Vec::new();
|
||||
// Diverged server tips that exist only on the servers
|
||||
let mut backups: Vec<(String, String, Vec<String>)> = Vec::new();
|
||||
|
||||
for url in &urls {
|
||||
let Ok(remote_refs) = repo.ls_remote(url) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for (name, oid) in remote_refs {
|
||||
let Some((_, local_oid)) =
|
||||
local.iter().find(|(local_name, _)| local_name == &name)
|
||||
else {
|
||||
match missing.iter_mut().find(|(known, _)| known == &name) {
|
||||
Some((_, sources)) => sources.push(url.clone()),
|
||||
None => missing.push((name, vec![url.clone()])),
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
if local_oid == &oid {
|
||||
continue;
|
||||
}
|
||||
|
||||
if repo.merge_base(local_oid, &oid).is_err() {
|
||||
match backups.iter_mut().find(|(known, _, _)| known == &name) {
|
||||
Some((_, _, sources)) => sources.push(url.clone()),
|
||||
None => {
|
||||
backups.push((name.clone(), oid.clone(), vec![url.clone()]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for (name, sources) in &missing {
|
||||
let refspec = format!("+{name}:{name}");
|
||||
if let Err(error) = repo.fetch_refs(sources, &refspec) {
|
||||
log::warn!("failed to fetch {name} from grasp servers: {error}");
|
||||
failures.push(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for (name, oid, sources) in &backups {
|
||||
let refspec = format!("+{name}:refs/nostr/backup/{oid}");
|
||||
if let Err(error) = repo.fetch_refs(sources, &refspec) {
|
||||
log::warn!("failed to back up {name} from grasp servers: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
match failures.is_empty() {
|
||||
true => Ok(missing
|
||||
.into_iter()
|
||||
.map(|(name, _)| name)
|
||||
.collect::<Vec<_>>()),
|
||||
false => bail!(
|
||||
"could not fetch {} from any grasp server",
|
||||
failures.join(", ")
|
||||
),
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
if !missing.is_empty() {
|
||||
let (mode, policy) = (self.mode, self.policy);
|
||||
*self = Self::from_repo(&snapshot_path)?;
|
||||
self.mode = mode;
|
||||
self.policy = policy;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// State event for these refs; the caller signs it.
|
||||
pub fn state_event(&self, repo_id: &str) -> EventBuilder {
|
||||
Self::build(repo_id, &self.refs, self.head.as_deref())
|
||||
}
|
||||
|
||||
/// Push the state event and objects to the grasp servers.
|
||||
///
|
||||
/// Calling `on_step` for each step.
|
||||
pub async fn push_to_grasps<P>(
|
||||
&self,
|
||||
client: &Client,
|
||||
event: Event,
|
||||
announcement: &Announcement,
|
||||
cx: &mut AsyncApp,
|
||||
on_step: &mut P,
|
||||
) -> Result<PushOutcome, Error>
|
||||
where
|
||||
P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>,
|
||||
{
|
||||
let path = self
|
||||
.path
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("push requires a local repository"))?;
|
||||
|
||||
let owner = announcement
|
||||
.owner
|
||||
.to_bech32()
|
||||
.map_err(|error| anyhow!("could not encode the repository owner: {error}"))?;
|
||||
|
||||
let mut outcome = PushOutcome::default();
|
||||
let policy = self.policy;
|
||||
let declared: Vec<String> = self
|
||||
.refs
|
||||
.iter()
|
||||
.map(|git_ref| git_ref.name.clone())
|
||||
.collect();
|
||||
|
||||
for relay in &announcement.relays {
|
||||
let step = PushStep::StageStateEvent(relay.clone());
|
||||
|
||||
// The relay must accept the state event before the git push is allowed.
|
||||
on_step(PushStepStatus::Running(step.clone()), cx)?;
|
||||
|
||||
// Get base URL for the git push
|
||||
let base = grasp_base_url(relay).unwrap_or_default();
|
||||
|
||||
// Ensure the relay is connected before sending the event.
|
||||
client.add_relay(relay).and_connect().await?;
|
||||
|
||||
let staging = client.send_event(&event).to([relay.clone()]).await;
|
||||
// Whether the relay accepted the event
|
||||
let sent_failed = !staging
|
||||
.as_ref()
|
||||
.is_ok_and(|output| output.success.contains_key(relay));
|
||||
|
||||
if sent_failed {
|
||||
let reason = match &staging {
|
||||
Ok(output) => output
|
||||
.failed
|
||||
.get(relay)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "relay did not confirm the event".to_owned()),
|
||||
Err(e) => format!("could not send the state event to {relay}: {e}"),
|
||||
};
|
||||
|
||||
// Run the callback to report the failure
|
||||
on_step(PushStepStatus::Failed(step, reason.clone()), cx)?;
|
||||
|
||||
// Record the failure in the outcome and continue to the next relay
|
||||
outcome.servers.push(GraspServer::failed(relay, reason));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Run the callback to report the success
|
||||
on_step(PushStepStatus::Done(step), cx)?;
|
||||
|
||||
// Run the git push in the background, reporting progress through the callback
|
||||
let step = PushStep::PushObjects(relay.clone());
|
||||
on_step(PushStepStatus::Running(step.clone()), cx)?;
|
||||
|
||||
let pushed = cx.background_spawn({
|
||||
let path = path.to_path_buf();
|
||||
let base = base.clone();
|
||||
let owner = owner.clone();
|
||||
let repo_id = announcement.id.clone();
|
||||
let mode = self.mode;
|
||||
let declared = declared.clone();
|
||||
|
||||
async move {
|
||||
let repo = Repo::open(&path)?;
|
||||
|
||||
match mode {
|
||||
GitPushMode::Main => repo.push_main(&base, &owner, &repo_id),
|
||||
GitPushMode::All => {
|
||||
let mut refspecs = force_refspecs();
|
||||
if policy == GitPushPolicy::ResetRemote {
|
||||
let url = format!("{base}/{owner}/{repo_id}.git");
|
||||
let server_refs = repo.ls_remote(&url)?;
|
||||
refspecs.extend(deletion_refspecs(&declared, &server_refs));
|
||||
}
|
||||
|
||||
let refspecs = refspecs.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
repo.push_refspecs(&base, &owner, &repo_id, &refspecs)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
match pushed.await {
|
||||
Ok(()) => {
|
||||
// Run the callback to report the success
|
||||
on_step(PushStepStatus::Done(step), cx)?;
|
||||
|
||||
outcome.state_event = Some(event.clone());
|
||||
outcome.servers.push(GraspServer::ok(relay.clone()));
|
||||
}
|
||||
Err(e) => {
|
||||
// Run the callback to report the failure
|
||||
on_step(PushStepStatus::Failed(step, e.to_string()), cx)?;
|
||||
|
||||
outcome
|
||||
.servers
|
||||
.push(GraspServer::failed(relay, e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fan the state out to the relays once a git server holds the objects.
|
||||
if outcome.state_event.is_some() {
|
||||
let step = PushStep::BroadcastStateEvent;
|
||||
on_step(PushStepStatus::Running(step.clone()), cx)?;
|
||||
|
||||
if let Err(e) = client.send_event(&event).broadcast().await {
|
||||
on_step(PushStepStatus::Failed(step, e.to_string()), cx)?;
|
||||
log::warn!("failed to broadcast repository state: {e}");
|
||||
} else {
|
||||
on_step(PushStepStatus::Done(step), cx)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Announce the repository and push the state and objects to the grasp servers.
|
||||
///
|
||||
/// Both events are signed by the caller, so a failure at the call site can retract the announcement by id.
|
||||
pub async fn announce_and_push<P>(
|
||||
&self,
|
||||
client: &Client,
|
||||
announcement: Event,
|
||||
state_event: Event,
|
||||
cx: &mut AsyncApp,
|
||||
on_step: &mut P,
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>,
|
||||
{
|
||||
let step = PushStep::AnnounceRepository;
|
||||
on_step(PushStepStatus::Running(step.clone()), cx)?;
|
||||
|
||||
let published = Announcement::from_event(&announcement)
|
||||
.ok_or_else(|| anyhow!("failed to parse announcement"))?;
|
||||
|
||||
// Ensure the relays are connected before sending the announcement event.
|
||||
for url in &published.relays {
|
||||
client.add_relay(url).and_connect().await.ok();
|
||||
}
|
||||
|
||||
let output = client.send_event(&announcement).broadcast().await?;
|
||||
|
||||
// Handle the case where no relay accepted the announcement event.
|
||||
if output.success.is_empty() {
|
||||
let reasons = output
|
||||
.failed
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
|
||||
// Run the callback to report the failure
|
||||
let step = PushStepStatus::Failed(step, format!("event not accepted: {reasons}"));
|
||||
on_step(step, cx)?;
|
||||
|
||||
bail!("event not accepted: {reasons}");
|
||||
}
|
||||
|
||||
on_step(PushStepStatus::Done(step), cx)?;
|
||||
|
||||
let outcome = if self.refs.is_empty() {
|
||||
PushOutcome::default()
|
||||
} else {
|
||||
self.push_to_grasps(client, state_event, &published, cx, on_step)
|
||||
.await?
|
||||
};
|
||||
|
||||
if outcome.accepted() == 0 {
|
||||
bail!(
|
||||
"Repository was announced, but the push to grasp servers failed: {}",
|
||||
outcome.failure_summary()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(announcement)
|
||||
}
|
||||
|
||||
/// Retracts a live announcement; call when `announce_and_push` fails.
|
||||
pub async fn retract_announcement<S>(
|
||||
client: &Client,
|
||||
signer: &S,
|
||||
id: EventId,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let builder = EventDeletionRequest::new().id(id);
|
||||
let deletion = builder.finalize_async(signer).await?;
|
||||
let output = client.send_event(&deletion).broadcast().await?;
|
||||
|
||||
// Without relay acceptance the deletion request stays live.
|
||||
if output.success.is_empty() {
|
||||
bail!("deletion event not accepted by any relay");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Refspecs to force push all heads and tags to the grasp servers.
|
||||
fn force_refspecs() -> Vec<String> {
|
||||
vec![
|
||||
"+refs/heads/*:refs/heads/*".to_owned(),
|
||||
"+refs/tags/*:refs/tags/*".to_owned(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Refspecs to delete server-only refs from the grasp servers.
|
||||
fn deletion_refspecs(declared: &[String], server_refs: &[(String, String)]) -> Vec<String> {
|
||||
server_refs
|
||||
.iter()
|
||||
.filter(|(name, _)| !declared.contains(name))
|
||||
.map(|(name, _)| format!(":{name}"))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use flume::Sender;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||
use signed_core::{Deletions, Filters, RepoAddr};
|
||||
use signed_git::{Repo, RepoSyncStatus};
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
use crate::checkouts::CheckoutsStore;
|
||||
use crate::state::SyncState;
|
||||
|
||||
const SYNC_POLL: Duration = Duration::from_secs(180);
|
||||
const MAX_SYNC_CHECKOUTS: usize = 8;
|
||||
|
||||
struct GlobalSyncStatusStore(Entity<SyncStatusStore>);
|
||||
|
||||
impl Global for GlobalSyncStatusStore {}
|
||||
|
||||
/// A checkout compared against the repository state published on Nostr.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CheckoutSyncStatus {
|
||||
pub path: PathBuf,
|
||||
pub status: RepoSyncStatus,
|
||||
}
|
||||
|
||||
/// Statuses for these repositories may have changed; empty on signer change.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SyncEvent {
|
||||
Updated(Vec<(RepoAddr, Vec<CheckoutSyncStatus>)>),
|
||||
}
|
||||
|
||||
/// Probes tracked repositories and broadcasts results. Holds no statuses.
|
||||
pub struct SyncStatusStore {
|
||||
active: RefCell<HashSet<RepoAddr>>,
|
||||
in_flight: bool,
|
||||
pass_pending: bool,
|
||||
poll_pending: bool,
|
||||
sender: Sender<()>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl EventEmitter<SyncEvent> for SyncStatusStore {}
|
||||
|
||||
impl SyncStatusStore {
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalSyncStatusStore>().0.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalSyncStatusStore(entity));
|
||||
}
|
||||
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let (sender, receiver) = flume::unbounded::<()>();
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
let backend = Backend::global(cx);
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
|
||||
// Checkout associations decide which paths get compared.
|
||||
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
|
||||
this.run_pass(cx);
|
||||
}));
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
||||
BackendEvent::SignerChanged => this.clear(cx),
|
||||
BackendEvent::RepoUpdates(_) | BackendEvent::Synced => this.run_pass(cx),
|
||||
_ => {}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
receiver.recv_async().await?;
|
||||
|
||||
// One pass covers every signal queued since the wake.
|
||||
while receiver.try_recv().is_ok() {}
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.run_pass(cx);
|
||||
})?;
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
active: RefCell::new(HashSet::new()),
|
||||
sender,
|
||||
in_flight: false,
|
||||
pass_pending: false,
|
||||
poll_pending: false,
|
||||
tasks: vec![task],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a probe pass; folded into any pass already in flight.
|
||||
pub fn probe(&self, addrs: impl IntoIterator<Item = RepoAddr>) {
|
||||
self.active.borrow_mut().extend(addrs);
|
||||
|
||||
if let Err(e) = self.sender.send(()) {
|
||||
log::warn!("failed to queue sync status probe: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Keeps the in-flight pass so its completion releases the slot exactly once.
|
||||
fn clear(&mut self, cx: &mut Context<Self>) {
|
||||
let had_addrs = !self.active.borrow().is_empty();
|
||||
self.active.borrow_mut().clear();
|
||||
self.pass_pending = false;
|
||||
|
||||
if had_addrs {
|
||||
cx.emit(SyncEvent::Updated(Vec::new()));
|
||||
}
|
||||
}
|
||||
|
||||
fn run_pass(&mut self, cx: &mut Context<Self>) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.in_flight {
|
||||
self.pass_pending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
if backend.read(cx).current_user().is_none() {
|
||||
self.clear(cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if self.active.borrow().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.in_flight = true;
|
||||
self.schedule_poll(cx);
|
||||
|
||||
let targets: Vec<(RepoAddr, Vec<PathBuf>)> = {
|
||||
let active = self.active.borrow();
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
|
||||
active
|
||||
.iter()
|
||||
.map(|addr| {
|
||||
let paths = checkouts.read(cx).associations_of(addr);
|
||||
(addr.clone(), paths)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let db = client.database();
|
||||
let deletion_events = db.query(Filters::deletions()).await?;
|
||||
let deletions = Deletions::from_events(deletion_events);
|
||||
|
||||
let mut updates: Vec<(RepoAddr, Vec<CheckoutSyncStatus>)> = Vec::new();
|
||||
|
||||
for (addr, paths) in &targets {
|
||||
let events = db.query(addr.state_filter()).await?;
|
||||
let latest = utils::latest(
|
||||
events
|
||||
.into_iter()
|
||||
.filter(|event| !deletions.is_deleted(event)),
|
||||
);
|
||||
|
||||
let Some(event) = latest else {
|
||||
updates.push((addr.clone(), Vec::new()));
|
||||
continue;
|
||||
};
|
||||
|
||||
let remote_refs: Vec<(String, String)> = SyncState::parse(&event)
|
||||
.refs
|
||||
.iter()
|
||||
.map(|git_ref| (git_ref.name.clone(), git_ref.commit.to_string()))
|
||||
.collect();
|
||||
|
||||
let mut list = Vec::new();
|
||||
for path in paths.iter().take(MAX_SYNC_CHECKOUTS) {
|
||||
let synced =
|
||||
Repo::try_open(path).and_then(|repo| repo.sync_status(&remote_refs).ok());
|
||||
|
||||
if let Some(status) = synced {
|
||||
list.push(CheckoutSyncStatus {
|
||||
path: path.clone(),
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updates.push((addr.clone(), list));
|
||||
}
|
||||
|
||||
Ok::<_, Error>(updates)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
// Reset the slot even on failure, or every later pass folds forever.
|
||||
let updates = match work.await {
|
||||
Ok(updates) => updates,
|
||||
Err(error) => {
|
||||
log::warn!("failed to compute nostr sync statuses: {error}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let mut again = false;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.in_flight = false;
|
||||
if !updates.is_empty() {
|
||||
cx.emit(SyncEvent::Updated(updates));
|
||||
}
|
||||
again = std::mem::take(&mut this.pass_pending);
|
||||
})?;
|
||||
|
||||
if again {
|
||||
this.update(cx, |this, cx| {
|
||||
this.run_pass(cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn schedule_poll(&mut self, cx: &mut Context<Self>) {
|
||||
if self.poll_pending {
|
||||
return;
|
||||
}
|
||||
self.poll_pending = true;
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(SYNC_POLL).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.poll_pending = false;
|
||||
this.run_pass(cx);
|
||||
})?;
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync status cache bound to one panel or screen.
|
||||
pub struct SyncStatusDirectory {
|
||||
statuses: HashMap<RepoAddr, Vec<CheckoutSyncStatus>>,
|
||||
pending: RefCell<HashSet<RepoAddr>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl SyncStatusDirectory {
|
||||
/// Create a directory, following the sync store's events.
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
let store = SyncStatusStore::global(cx);
|
||||
|
||||
cx.new(|cx| {
|
||||
let subscription =
|
||||
cx.subscribe(&store, |this: &mut Self, _store, event, cx| match event {
|
||||
SyncEvent::Updated(updates) => this.apply(updates.clone(), cx),
|
||||
});
|
||||
|
||||
Self {
|
||||
statuses: HashMap::new(),
|
||||
pending: RefCell::new(HashSet::new()),
|
||||
_subscriptions: vec![subscription],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Queue a probe for addrs this surface renders.
|
||||
pub fn track(&mut self, addrs: impl IntoIterator<Item = RepoAddr>, cx: &mut Context<Self>) {
|
||||
let missing: Vec<RepoAddr> = addrs
|
||||
.into_iter()
|
||||
.filter(|addr| {
|
||||
!self.statuses.contains_key(addr)
|
||||
&& !self.with_pending(|pending| pending.contains(addr))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if missing.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.with_pending(|pending| pending.extend(missing.iter().cloned()));
|
||||
|
||||
let store = SyncStatusStore::global(cx);
|
||||
store.read(cx).probe(missing);
|
||||
}
|
||||
|
||||
/// Statuses for a repo, or empty; a miss queues a probe.
|
||||
pub fn statuses_of(&self, addr: &RepoAddr, cx: &App) -> Vec<CheckoutSyncStatus> {
|
||||
self.queue(addr, cx);
|
||||
self.statuses.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Commits available locally but missing on Nostr.
|
||||
pub fn unsynced(&self, addr: &RepoAddr, cx: &App) -> usize {
|
||||
self.queue(addr, cx);
|
||||
|
||||
self.statuses
|
||||
.get(addr)
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.map(|checkout| checkout.status.ahead_total)
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn queue(&self, addr: &RepoAddr, cx: &App) {
|
||||
if self.with_pending(|pending| pending.insert(addr.clone())) {
|
||||
let store = SyncStatusStore::global(cx);
|
||||
store.read(cx).probe([addr.clone()]);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&mut self, updates: Vec<(RepoAddr, Vec<CheckoutSyncStatus>)>, cx: &mut Context<Self>) {
|
||||
if updates.is_empty() {
|
||||
let changed = !self.statuses.is_empty();
|
||||
self.statuses.clear();
|
||||
self.with_pending(|pending| pending.clear());
|
||||
|
||||
if changed {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
|
||||
for (addr, list) in updates {
|
||||
let relevant = self.statuses.contains_key(&addr)
|
||||
|| self.with_pending(|pending| pending.remove(&addr));
|
||||
|
||||
if !relevant {
|
||||
continue;
|
||||
}
|
||||
|
||||
if self.statuses.get(&addr) != Some(&list) {
|
||||
self.statuses.insert(addr, list);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn with_pending<R>(&self, then: impl FnOnce(&mut HashSet<RepoAddr>) -> R) -> R {
|
||||
let mut pending = self.pending.borrow_mut();
|
||||
then(&mut pending)
|
||||
}
|
||||
}
|
||||
+206
-25
@@ -1,37 +1,62 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, SharedString, StyleRefinement, Window};
|
||||
use gpui_base::{Avatar as BaseAvatar, AvatarFallback, AvatarImage};
|
||||
use gpui_component::{ActiveTheme, Sizable, Size, StyledExt};
|
||||
use gpui::{App, Pixels, SharedString, StyleRefinement, Window, div, px};
|
||||
use gpui_base::{AvatarImage, StyledExt};
|
||||
use gpui_component::{ActiveTheme, Colorize, Sizable, Size};
|
||||
|
||||
use crate::pixel_avatar::{PixelAvatar, side_length};
|
||||
/// Number of rows and columns in the pixel grid.
|
||||
const GRID_SIZE: usize = 8;
|
||||
/// Empty cells kept between the pattern and the avatar edge.
|
||||
const MARGIN: usize = 1;
|
||||
/// Probability that a cell in the left half of the pattern area is filled.
|
||||
const FILL_PROBABILITY: f32 = 0.42;
|
||||
/// Probability that a filled cell uses the accent shade instead of the main color.
|
||||
const ACCENT_PROBABILITY: f32 = 0.25;
|
||||
/// Minimum number of filled left-half cells.
|
||||
const MIN_FILLED: usize = 5;
|
||||
|
||||
/// A user avatar built on the unstyled [`gpui_base::Avatar`].
|
||||
///
|
||||
/// It shows the user's picture when one is set, and falls back to the
|
||||
/// deterministic pixel avatar seeded from the name otherwise.
|
||||
/// An avatar for a user or other entity.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Avatar {
|
||||
name: SharedString,
|
||||
id: SharedString,
|
||||
name: Option<SharedString>,
|
||||
picture: Option<SharedString>,
|
||||
size: Size,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl Avatar {
|
||||
/// Create an avatar for `name`.
|
||||
/// Create an avatar for `id`, a public key or any other stable string.
|
||||
///
|
||||
/// The name seeds the pixel fallback shown when no picture is set.
|
||||
pub fn new(name: impl Into<SharedString>) -> Self {
|
||||
/// The id seeds the pixel fallback shown when no picture is set.
|
||||
pub fn new<T>(id: T) -> Self
|
||||
where
|
||||
T: Display,
|
||||
{
|
||||
Self {
|
||||
name: name.into(),
|
||||
id: id.to_string().into(),
|
||||
name: None,
|
||||
picture: None,
|
||||
size: Size::Small,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The display name of the avatar's subject, if known.
|
||||
pub fn name<T>(mut self, name: T) -> Self
|
||||
where
|
||||
T: Into<Option<SharedString>>,
|
||||
{
|
||||
self.name = name.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// The user's picture URL, if known.
|
||||
pub fn picture(mut self, picture: Option<impl Into<SharedString>>) -> Self {
|
||||
pub fn picture<T>(mut self, picture: Option<T>) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
self.picture = picture.map(Into::into);
|
||||
self
|
||||
}
|
||||
@@ -52,20 +77,176 @@ impl Styled for Avatar {
|
||||
|
||||
impl RenderOnce for Avatar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let fallback = AvatarFallback::new()
|
||||
.size_full()
|
||||
.child(PixelAvatar::new(self.name.clone()).size_full());
|
||||
let radius = cx.theme().radius;
|
||||
|
||||
BaseAvatar::new()
|
||||
.size(side_length(self.size))
|
||||
match self.picture {
|
||||
Some(url) => div()
|
||||
.size(side_length(self.size))
|
||||
.flex_shrink_0()
|
||||
.rounded(radius)
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().secondary)
|
||||
.child(AvatarImage::new(url).size_full().rounded(radius))
|
||||
.refine_style(&self.style)
|
||||
.into_any_element(),
|
||||
None => PixelAvatar {
|
||||
seed: fnv1a(self.id.as_bytes()),
|
||||
side: side_length(self.size),
|
||||
style: self.style,
|
||||
}
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic, offline pixel-art avatar.
|
||||
#[derive(IntoElement)]
|
||||
struct PixelAvatar {
|
||||
seed: u64,
|
||||
side: Pixels,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl Styled for PixelAvatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for PixelAvatar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let pattern = pattern(self.seed);
|
||||
let mut cells = Vec::new();
|
||||
|
||||
let hue = self.seed as f32 / u64::MAX as f32;
|
||||
let main = theme.blue.hue(hue);
|
||||
|
||||
let shade = if theme.is_dark() {
|
||||
main.lightness((main.l * 1.6).min(0.95))
|
||||
} else {
|
||||
main.lightness((main.l * 0.45).max(0.18))
|
||||
};
|
||||
|
||||
for row in 0..GRID_SIZE {
|
||||
for col in 0..GRID_SIZE {
|
||||
let value = pattern[row * GRID_SIZE + col];
|
||||
if value != 0 {
|
||||
let color = if value == 2 { shade } else { main };
|
||||
cells.push(
|
||||
div()
|
||||
.row_start(row as i16 + 1)
|
||||
.row_end(row as i16 + 2)
|
||||
.col_start(col as i16 + 1)
|
||||
.col_end(col as i16 + 2)
|
||||
.bg(color),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div()
|
||||
.grid()
|
||||
.grid_cols(GRID_SIZE as u16)
|
||||
.grid_rows(GRID_SIZE as u16)
|
||||
.size(self.side)
|
||||
.flex_shrink_0()
|
||||
.rounded(cx.theme().radius)
|
||||
.rounded(theme.radius)
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().secondary)
|
||||
.when_some(self.picture, |this, url| {
|
||||
this.image(AvatarImage::new(url).size_full().rounded(cx.theme().radius))
|
||||
})
|
||||
.fallback(fallback)
|
||||
.bg(main.opacity(0.16))
|
||||
.children(cells)
|
||||
.refine_style(&self.style)
|
||||
}
|
||||
}
|
||||
|
||||
/// The rendered side length of an avatar at `size`.
|
||||
fn side_length(size: Size) -> Pixels {
|
||||
match size {
|
||||
Size::XSmall => px(16.),
|
||||
Size::Small => px(24.),
|
||||
Size::Medium => px(48.),
|
||||
Size::Large => px(80.),
|
||||
Size::Size(size) => size,
|
||||
}
|
||||
}
|
||||
|
||||
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||
let mut rng = PixelRng::new(seed);
|
||||
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
||||
let mut filled = 0usize;
|
||||
|
||||
// Only the inner rows and the inner left half are candidates; mirroring
|
||||
// then keeps the art within the same inset, leaving the outer ring empty.
|
||||
let art_rows = GRID_SIZE - 2 * MARGIN;
|
||||
let art_columns = GRID_SIZE / 2 - MARGIN;
|
||||
|
||||
for row in MARGIN..GRID_SIZE - MARGIN {
|
||||
for col in MARGIN..GRID_SIZE / 2 {
|
||||
if rng.chance(FILL_PROBABILITY) {
|
||||
let accent = rng.chance(ACCENT_PROBABILITY);
|
||||
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
||||
filled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if filled < MIN_FILLED {
|
||||
let total = art_rows * art_columns;
|
||||
let start = (rng.next() % total as u64) as usize;
|
||||
|
||||
for offset in 0..total {
|
||||
if filled >= MIN_FILLED {
|
||||
break;
|
||||
}
|
||||
|
||||
let ix = (start + offset) % total;
|
||||
let row = MARGIN + ix / art_columns;
|
||||
let col = MARGIN + ix % art_columns;
|
||||
|
||||
if pattern[row * GRID_SIZE + col] == 0 {
|
||||
set_cell(&mut pattern, row, col, 1);
|
||||
filled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
/// Fill `cell (row, col)` and its horizontal mirror.
|
||||
fn set_cell(pattern: &mut [u8; GRID_SIZE * GRID_SIZE], row: usize, col: usize, value: u8) {
|
||||
pattern[row * GRID_SIZE + col] = value;
|
||||
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value;
|
||||
}
|
||||
|
||||
/// FNV-1a 64-bit hash, stable across platforms and runs.
|
||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325u64;
|
||||
for &byte in bytes {
|
||||
hash ^= byte as u64;
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// Tiny xorshift64* PRNG for deriving the pattern from the seed.
|
||||
struct PixelRng(u64);
|
||||
|
||||
impl PixelRng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self(seed.max(1))
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
|
||||
}
|
||||
|
||||
fn chance(&mut self, probability: f32) -> bool {
|
||||
self.next() as f32 / (u64::MAX as f32) < probability
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
mod avatar;
|
||||
mod dropdown_button;
|
||||
mod nav_item;
|
||||
mod pixel_avatar;
|
||||
mod placeholder;
|
||||
mod ref_selector;
|
||||
mod segment_button;
|
||||
@@ -16,7 +15,6 @@ pub use avatar::Avatar;
|
||||
pub use copy_row::{copy_row, menu_copy_row};
|
||||
pub use dropdown_button::DropdownButton;
|
||||
pub use nav_item::NavItem;
|
||||
pub use pixel_avatar::PixelAvatar;
|
||||
pub use placeholder::placeholder;
|
||||
pub use ref_selector::ref_selector_trigger;
|
||||
pub use segment_button::{CountBadge, SegmentButton};
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Pixels, StyleRefinement, Window, div, px};
|
||||
use gpui_base::StyledExt;
|
||||
use gpui_component::{ActiveTheme, Colorize, Sizable, Size};
|
||||
|
||||
/// Number of rows and columns in the pixel grid.
|
||||
const GRID_SIZE: usize = 8;
|
||||
/// Empty cells kept between the pattern and the avatar edge, so the art
|
||||
/// gathers in the center instead of filling the whole avatar.
|
||||
const MARGIN: usize = 1;
|
||||
/// Probability that a cell in the left half of the pattern area is filled.
|
||||
const FILL_PROBABILITY: f32 = 0.42;
|
||||
/// Probability that a filled cell uses the accent shade instead of the main color.
|
||||
const ACCENT_PROBABILITY: f32 = 0.25;
|
||||
/// Minimum number of filled left-half cells.
|
||||
const MIN_FILLED: usize = 5;
|
||||
|
||||
/// A deterministic, offline pixel-art avatar.
|
||||
#[derive(IntoElement)]
|
||||
pub struct PixelAvatar {
|
||||
seed: u64,
|
||||
size: Size,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl PixelAvatar {
|
||||
/// Create an avatar seeded from `seed`.
|
||||
pub fn new(seed: impl AsRef<str>) -> Self {
|
||||
Self {
|
||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
||||
size: Size::Small,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for PixelAvatar {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for PixelAvatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for PixelAvatar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let pattern = pattern(self.seed);
|
||||
let mut cells = Vec::new();
|
||||
|
||||
let hue = self.seed as f32 / u64::MAX as f32;
|
||||
let main = theme.blue.hue(hue);
|
||||
|
||||
let shade = if theme.is_dark() {
|
||||
main.lightness((main.l * 1.6).min(0.95))
|
||||
} else {
|
||||
main.lightness((main.l * 0.45).max(0.18))
|
||||
};
|
||||
|
||||
for row in 0..GRID_SIZE {
|
||||
for col in 0..GRID_SIZE {
|
||||
let value = pattern[row * GRID_SIZE + col];
|
||||
if value != 0 {
|
||||
let color = if value == 2 { shade } else { main };
|
||||
cells.push(
|
||||
div()
|
||||
.row_start(row as i16 + 1)
|
||||
.row_end(row as i16 + 2)
|
||||
.col_start(col as i16 + 1)
|
||||
.col_end(col as i16 + 2)
|
||||
.bg(color),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div()
|
||||
.grid()
|
||||
.grid_cols(GRID_SIZE as u16)
|
||||
.grid_rows(GRID_SIZE as u16)
|
||||
.size(side_length(self.size))
|
||||
.flex_shrink_0()
|
||||
.rounded(theme.radius)
|
||||
.overflow_hidden()
|
||||
.bg(main.opacity(0.16))
|
||||
.children(cells)
|
||||
.refine_style(&self.style)
|
||||
}
|
||||
}
|
||||
|
||||
/// The rendered side length of an avatar at `size`, shared with [`Avatar`].
|
||||
pub(crate) fn side_length(size: Size) -> Pixels {
|
||||
match size {
|
||||
Size::XSmall => px(16.),
|
||||
Size::Small => px(24.),
|
||||
Size::Medium => px(48.),
|
||||
Size::Large => px(80.),
|
||||
Size::Size(size) => size,
|
||||
}
|
||||
}
|
||||
|
||||
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||
let mut rng = PixelRng::new(seed);
|
||||
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
||||
let mut filled = 0usize;
|
||||
|
||||
// Only the inner rows and the inner left half are candidates; mirroring
|
||||
// then keeps the art within the same inset, leaving the outer ring empty.
|
||||
let art_rows = GRID_SIZE - 2 * MARGIN;
|
||||
let art_columns = GRID_SIZE / 2 - MARGIN;
|
||||
|
||||
for row in MARGIN..GRID_SIZE - MARGIN {
|
||||
for col in MARGIN..GRID_SIZE / 2 {
|
||||
if rng.chance(FILL_PROBABILITY) {
|
||||
let accent = rng.chance(ACCENT_PROBABILITY);
|
||||
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
||||
filled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if filled < MIN_FILLED {
|
||||
let total = art_rows * art_columns;
|
||||
let start = (rng.next() % total as u64) as usize;
|
||||
|
||||
for offset in 0..total {
|
||||
if filled >= MIN_FILLED {
|
||||
break;
|
||||
}
|
||||
|
||||
let ix = (start + offset) % total;
|
||||
let row = MARGIN + ix / art_columns;
|
||||
let col = MARGIN + ix % art_columns;
|
||||
|
||||
if pattern[row * GRID_SIZE + col] == 0 {
|
||||
set_cell(&mut pattern, row, col, 1);
|
||||
filled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
/// Fill `cell (row, col)` and its horizontal mirror.
|
||||
fn set_cell(pattern: &mut [u8; GRID_SIZE * GRID_SIZE], row: usize, col: usize, value: u8) {
|
||||
pattern[row * GRID_SIZE + col] = value;
|
||||
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value;
|
||||
}
|
||||
|
||||
/// FNV-1a 64-bit hash, stable across platforms and runs.
|
||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325u64;
|
||||
for &byte in bytes {
|
||||
hash ^= byte as u64;
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// Tiny xorshift64* PRNG for deriving the pattern from the seed.
|
||||
struct PixelRng(u64);
|
||||
|
||||
impl PixelRng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self(seed.max(1))
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
|
||||
}
|
||||
|
||||
fn chance(&mut self, probability: f32) -> bool {
|
||||
self.next() as f32 / (u64::MAX as f32) < probability
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn count_filled(pattern: &[u8; GRID_SIZE * GRID_SIZE]) -> usize {
|
||||
pattern.iter().filter(|&&cell| cell != 0).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_properties() {
|
||||
for seed in 0..50 {
|
||||
let pattern = pattern(seed);
|
||||
assert!(
|
||||
count_filled(&pattern) >= MIN_FILLED * 2,
|
||||
"pattern too sparse for seed {seed}"
|
||||
);
|
||||
for row in 0..GRID_SIZE {
|
||||
for col in 0..GRID_SIZE {
|
||||
assert_eq!(
|
||||
pattern[row * GRID_SIZE + col],
|
||||
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)],
|
||||
"asymmetric pattern for seed {seed} at ({row}, {col})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for seed in [0, 1, 42, u64::MAX] {
|
||||
assert_eq!(pattern(seed), pattern(seed));
|
||||
}
|
||||
assert_ne!(pattern(42), pattern(43));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
assets = { path = "../assets" }
|
||||
dock = { workspace = true }
|
||||
metadata = { path = "../metadata" }
|
||||
paths = { path = "../paths" }
|
||||
settings = { path = "../settings" }
|
||||
signed_core = { path = "../signed_core" }
|
||||
|
||||
@@ -17,6 +17,7 @@ use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree};
|
||||
use gpui_component::{
|
||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||
};
|
||||
use metadata::ProfileDirectory;
|
||||
use signed_git::{
|
||||
CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo,
|
||||
};
|
||||
@@ -305,6 +306,8 @@ pub struct CommitDiffView {
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
pane: Entity<DiffPane>,
|
||||
/// Profile for the repository owner in the tab title.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||
}
|
||||
|
||||
@@ -318,6 +321,13 @@ impl CommitDiffView {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let pane = cx.new(DiffPane::new);
|
||||
let directory = ProfileDirectory::new(cx);
|
||||
|
||||
if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) {
|
||||
directory.update(cx, |directory, cx| {
|
||||
directory.track([owner], cx);
|
||||
});
|
||||
}
|
||||
|
||||
// Defer until the window is ready, like the repository detail view.
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
@@ -339,6 +349,7 @@ impl CommitDiffView {
|
||||
loading: true,
|
||||
error: None,
|
||||
pane,
|
||||
directory,
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -475,7 +486,7 @@ impl BasePanel for CommitDiffView {
|
||||
|
||||
impl Panel for CommitDiffView {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||
let label = SharedString::from(format!("{}/{}", self.repo_name, self.commit.id));
|
||||
|
||||
tab_title(avatar, label)
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, SharedString, div};
|
||||
use gpui_component::ActiveTheme;
|
||||
use gpui::{AnyElement, App, Context, SharedString, Subscription, div};
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
||||
use signed_core::RepoAddr;
|
||||
use signed_state::{Backend, BackendEvent, PushStepStatus};
|
||||
|
||||
/// Progress of an async dialog action: a busy flag that disables the form and an error shown below it.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DialogProgress {
|
||||
pub busy: bool,
|
||||
pub error: Option<SharedString>,
|
||||
// The repository whose push checklist is shown; `None` outside a publish.
|
||||
push_addr: Option<RepoAddr>,
|
||||
pub push_steps: Vec<PushStepStatus>,
|
||||
_push_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl DialogProgress {
|
||||
@@ -16,6 +23,37 @@ impl DialogProgress {
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
/// Starts a publish for the repository at `addr` and listens for its push steps.
|
||||
pub fn begin_push(&mut self, addr: RepoAddr, cx: &mut Context<Self>) {
|
||||
self.begin();
|
||||
self.push_addr = Some(addr);
|
||||
self.push_steps.clear();
|
||||
|
||||
if self._push_subscription.is_none() {
|
||||
let backend = Backend::global(cx);
|
||||
self._push_subscription = Some(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let BackendEvent::PushProgress { addr, status } = event else {
|
||||
return;
|
||||
};
|
||||
|
||||
if this.push_addr.as_ref() != Some(addr) {
|
||||
return;
|
||||
}
|
||||
|
||||
match this
|
||||
.push_steps
|
||||
.iter_mut()
|
||||
.find(|step| step.step() == status.step())
|
||||
{
|
||||
Some(step) => *step = status.clone(),
|
||||
None => this.push_steps.push(status.clone()),
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks an action as failed, enabling the form and showing `message`.
|
||||
pub fn fail(&mut self, message: impl Into<SharedString>) {
|
||||
self.busy = false;
|
||||
@@ -33,3 +71,46 @@ pub fn error_row(error: &Option<SharedString>, cx: &App) -> Option<AnyElement> {
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
|
||||
/// The push checklist of a running publish, `None` before any step is reported.
|
||||
pub fn push_steps_row(steps: &[PushStepStatus], cx: &App) -> Option<AnyElement> {
|
||||
if steps.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rows = steps
|
||||
.iter()
|
||||
.map(|status| {
|
||||
let label = SharedString::from(status.step().label());
|
||||
|
||||
match status {
|
||||
PushStepStatus::Running(_) => h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Spinner::new().small())
|
||||
.child(label)
|
||||
.into_any_element(),
|
||||
PushStepStatus::Done(_) => h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(Icon::new(IconName::CircleCheck).small())
|
||||
.child(label)
|
||||
.into_any_element(),
|
||||
PushStepStatus::Failed(_, reason) => h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(Icon::new(IconName::Close).small())
|
||||
.child(SharedString::from(format!(
|
||||
"{}: {reason}",
|
||||
status.step().label()
|
||||
)))
|
||||
.into_any_element(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Some(v_flex().gap_1().children(rows).into_any_element())
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::{Textarea, TextareaState};
|
||||
use gpui_component::tag::Tag;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{Event, EventId, PublicKey};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_state::RepoStore;
|
||||
use signed_ui::Avatar;
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -32,6 +33,7 @@ pub(crate) fn sidebar_section(
|
||||
id: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
top_gap: bool,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
@@ -39,7 +41,6 @@ pub(crate) fn sidebar_section(
|
||||
// The caller bails out when the root is missing.
|
||||
return div().into_any_element();
|
||||
};
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
|
||||
// Participants, the root author plus everyone who commented.
|
||||
let mut participants: Vec<PublicKey> = vec![root.pubkey];
|
||||
@@ -64,14 +65,14 @@ pub(crate) fn sidebar_section(
|
||||
.gap_2()
|
||||
.child(sidebar_title("Participants", cx))
|
||||
.children(participants.iter().map(|pubkey| {
|
||||
let profile = profile_store.read(cx).get(pubkey);
|
||||
let profile = directory.read(cx).profile(pubkey, cx);
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(Avatar::new(name.clone()).picture(picture))
|
||||
.child(Avatar::new(*pubkey).name(name.clone()).picture(picture))
|
||||
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
||||
.into_any_element()
|
||||
})),
|
||||
@@ -109,7 +110,12 @@ pub(crate) fn sidebar_section(
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &App) -> AnyElement {
|
||||
pub(crate) fn comments_section(
|
||||
store: &Entity<RepoStore>,
|
||||
root: EventId,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
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()));
|
||||
@@ -118,7 +124,7 @@ pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &Ap
|
||||
.gap_4()
|
||||
.child(div().text_xs().font_semibold().child(title))
|
||||
.children(comments.iter().map(|comment| {
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
|
||||
let profile = directory.read(cx).profile(&comment.pubkey, cx);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(comment.created_at);
|
||||
@@ -137,7 +143,11 @@ pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &Ap
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(Avatar::new(author.clone()).picture(picture))
|
||||
.child(
|
||||
Avatar::new(comment.pubkey)
|
||||
.name(author.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
|
||||
@@ -5,16 +5,16 @@ 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,
|
||||
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment,
|
||||
ListState, Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div,
|
||||
list, px,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
|
||||
use signed_core::{InboxItem, InboxReadState, RepoAddr};
|
||||
use signed_state::{
|
||||
Backend, BackendEvent, Inbox, ProfileStore, RefreshGate, RefreshRequest, RepoListStore,
|
||||
};
|
||||
use signed_state::{Backend, BackendEvent, Inbox, RefreshGate, RefreshRequest, RepoListStore};
|
||||
use signed_ui::{Avatar, CountBadge};
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -54,6 +54,8 @@ pub struct InboxView {
|
||||
refresh: RefreshGate,
|
||||
list: ListState,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
/// Profiles for activity authors.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
@@ -101,6 +103,7 @@ impl InboxView {
|
||||
refresh: RefreshGate::default(),
|
||||
list,
|
||||
tasks: vec![],
|
||||
directory: ProfileDirectory::new(cx),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
@@ -272,6 +275,19 @@ impl InboxView {
|
||||
let rows = self.flatten_rows(§ions);
|
||||
self.sections = Arc::new(sections);
|
||||
self.rows = Arc::new(rows);
|
||||
|
||||
let mut keys: Vec<PublicKey> = self
|
||||
.threads
|
||||
.iter()
|
||||
.flat_map(|item| item.events.iter().map(|event| event.pubkey))
|
||||
.collect();
|
||||
|
||||
keys.sort_by_key(PublicKey::to_hex);
|
||||
keys.dedup();
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(keys, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn group_sections(&self) -> Vec<InboxSection> {
|
||||
@@ -397,8 +413,9 @@ impl InboxView {
|
||||
let address = section.address.clone();
|
||||
let first = entry_ix == 0;
|
||||
let last = entry_ix + 1 == section.entries.len();
|
||||
let directory = self.directory.clone();
|
||||
|
||||
thread("inbox-row", ix, item, first, last, cx)
|
||||
thread("inbox-row", ix, item, first, last, &directory, cx)
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.open(root, kind, address.clone(), window, cx)
|
||||
}))
|
||||
@@ -461,6 +478,7 @@ fn thread(
|
||||
item: &InboxItem,
|
||||
first: bool,
|
||||
last: bool,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> Stateful<Div> {
|
||||
let title = SharedString::from(item.title());
|
||||
@@ -472,7 +490,7 @@ fn thread(
|
||||
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));
|
||||
timeline = timeline.child(sub_activity(&event, me, directory, cx));
|
||||
}
|
||||
|
||||
v_flex()
|
||||
@@ -521,9 +539,13 @@ fn thread(
|
||||
.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);
|
||||
fn sub_activity(
|
||||
event: &Event,
|
||||
me: Option<PublicKey>,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let profile = directory.read(cx).profile(&event.pubkey, cx);
|
||||
|
||||
let name = if Some(event.pubkey) == me {
|
||||
SharedString::from("You")
|
||||
@@ -544,7 +566,8 @@ fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
|
||||
.items_center()
|
||||
.text_xs()
|
||||
.child(
|
||||
Avatar::new(name.clone())
|
||||
Avatar::new(event.pubkey)
|
||||
.name(name.clone())
|
||||
.picture(profile.picture())
|
||||
.xsmall(),
|
||||
)
|
||||
|
||||
@@ -7,9 +7,10 @@ use gpui::{
|
||||
use gpui_component::input::TextareaState;
|
||||
use gpui_component::scroll::ScrollableElement;
|
||||
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
||||
use nostr::prelude::EventId;
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{EventId, PublicKey};
|
||||
use signed_core::GitEvent;
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_state::RepoStore;
|
||||
use signed_ui::{Avatar, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -21,6 +22,8 @@ pub struct IssueDetailView {
|
||||
store: Entity<RepoStore>,
|
||||
issue_id: EventId,
|
||||
comment_input: Entity<TextareaState>,
|
||||
/// Profiles for the author, commenters and the repository owner.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
@@ -34,16 +37,48 @@ impl IssueDetailView {
|
||||
let comment_input =
|
||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
|
||||
|
||||
let subscription = cx.observe(&store, |_this, _store, cx| cx.notify());
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.track_participants(cx);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let directory = ProfileDirectory::new(cx);
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
store,
|
||||
issue_id,
|
||||
comment_input,
|
||||
directory,
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Track the issue author, everyone who commented and the repository owner.
|
||||
fn track_participants(&mut self, cx: &mut Context<Self>) {
|
||||
let store = self.store.read(cx);
|
||||
let mut keys: Vec<PublicKey> = store
|
||||
.issues
|
||||
.iter()
|
||||
.find(|issue| issue.id == self.issue_id)
|
||||
.map(|issue| issue.pubkey)
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
keys.extend(
|
||||
store
|
||||
.comments_of(&self.issue_id)
|
||||
.map(|comment| comment.pubkey),
|
||||
);
|
||||
|
||||
if let Some(owner) = store.announcement.as_ref().map(|a| a.owner) {
|
||||
keys.push(owner);
|
||||
}
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(keys, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for IssueDetailView {
|
||||
@@ -66,7 +101,7 @@ impl Panel for IssueDetailView {
|
||||
})
|
||||
.unwrap_or_else(|| SharedString::from("Issue"));
|
||||
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||
|
||||
tab_title(avatar, short_id)
|
||||
}
|
||||
@@ -93,9 +128,8 @@ impl Render for IssueDetailView {
|
||||
};
|
||||
};
|
||||
|
||||
let (title, author, picture, status, age, issue_id, content) = {
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
let profile = profile_store.read(cx).get(&issue.pubkey);
|
||||
let (title, author, picture, status, age, issue_id, content, pubkey) = {
|
||||
let profile = self.directory.read(cx).profile(&issue.pubkey, cx);
|
||||
let content = if issue.content.is_empty() {
|
||||
SharedString::from("No description provided.")
|
||||
} else {
|
||||
@@ -110,6 +144,7 @@ impl Render for IssueDetailView {
|
||||
relative_time(issue.created_at),
|
||||
issue.id,
|
||||
content,
|
||||
issue.pubkey,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -153,7 +188,11 @@ impl Render for IssueDetailView {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(Avatar::new(&author).picture(picture))
|
||||
.child(
|
||||
Avatar::new(pubkey)
|
||||
.name(author.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
@@ -169,7 +208,7 @@ impl Render for IssueDetailView {
|
||||
)
|
||||
.child(div().text_sm().child(content)),
|
||||
)
|
||||
.child(comments_section(&self.store, issue_id, cx))
|
||||
.child(comments_section(&self.store, issue_id, &self.directory, cx))
|
||||
.child(comment_form(
|
||||
&self.store,
|
||||
issue_id,
|
||||
@@ -185,6 +224,7 @@ impl Render for IssueDetailView {
|
||||
issue_id,
|
||||
issue_roots,
|
||||
false,
|
||||
&self.directory,
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
|
||||
@@ -15,9 +15,10 @@ use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::{
|
||||
ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
|
||||
};
|
||||
use nostr::prelude::EventId;
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{EventId, PublicKey};
|
||||
use signed_core::{GitEvent, RepoStatus};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_state::RepoStore;
|
||||
use signed_ui::{Avatar, SegmentButton, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -59,6 +60,8 @@ pub struct IssuesView {
|
||||
// e.g. switching between two empty filters.
|
||||
synced_filter: IssueFilter,
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
/// Profiles for issue authors and the repository owner.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
@@ -90,6 +93,7 @@ impl IssuesView {
|
||||
counts: StatusCounts::default(),
|
||||
synced_filter: IssueFilter::Open,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
directory: ProfileDirectory::new(cx),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
@@ -106,6 +110,21 @@ impl IssuesView {
|
||||
)
|
||||
};
|
||||
|
||||
let store = self.store.read(cx);
|
||||
let mut keys: Vec<PublicKey> = store.issues.iter().map(|issue| issue.pubkey).collect();
|
||||
|
||||
if let Some(owner) = store
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|announcement| announcement.owner)
|
||||
{
|
||||
keys.push(owner);
|
||||
}
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(keys, cx);
|
||||
});
|
||||
|
||||
let filter_changed = self.synced_filter != filter;
|
||||
let visible_issues_changed = self.visible_issues != visible_issues;
|
||||
let counts_changed = self.counts != counts;
|
||||
@@ -148,7 +167,7 @@ impl IssuesView {
|
||||
let issue = &self.store.read(cx).issues[issue_ix];
|
||||
let title = issue.activity_subject();
|
||||
let id_hex = issue.id.to_hex();
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey);
|
||||
let profile = self.directory.read(cx).profile(&issue.pubkey, cx);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(issue.created_at);
|
||||
@@ -189,7 +208,11 @@ impl IssuesView {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(Avatar::new(author.clone()).picture(picture))
|
||||
.child(
|
||||
Avatar::new(issue.pubkey)
|
||||
.name(author.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(div().child(author)),
|
||||
)
|
||||
.child(SharedString::from("opened"))
|
||||
@@ -335,7 +358,7 @@ impl BasePanel for IssuesView {
|
||||
|
||||
impl Panel for IssuesView {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||
let label = SharedString::from(format!("{}/issues", self.repo_name));
|
||||
|
||||
tab_title(avatar, label)
|
||||
|
||||
@@ -12,17 +12,20 @@ pub(crate) mod sidebar;
|
||||
mod status_list;
|
||||
pub(crate) mod tree;
|
||||
|
||||
use std::fmt::Display;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, div};
|
||||
use gpui::{AnyElement, App, Entity, div};
|
||||
use gpui_component::{Sizable as _, h_flex};
|
||||
pub use inbox::InboxView;
|
||||
use metadata::ProfileDirectory;
|
||||
pub(crate) use placeholder_panel::PlaceholderPanel;
|
||||
pub use repo::RepoDetailView;
|
||||
pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel};
|
||||
pub use repo_list::RepoListView;
|
||||
pub use sidebar::SidebarPanel;
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{Avatar, PixelAvatar};
|
||||
use signed_state::RepoStore;
|
||||
use signed_ui::Avatar;
|
||||
|
||||
pub(crate) fn tab_title(avatar: AnyElement, label: impl IntoElement) -> impl IntoElement {
|
||||
h_flex()
|
||||
@@ -32,11 +35,15 @@ pub(crate) fn tab_title(avatar: AnyElement, label: impl IntoElement) -> impl Int
|
||||
.child(label)
|
||||
}
|
||||
|
||||
pub(crate) fn panel_avatar(seed: impl AsRef<str>) -> AnyElement {
|
||||
PixelAvatar::new(seed).xsmall().into_any_element()
|
||||
pub(crate) fn panel_avatar(seed: impl Display) -> AnyElement {
|
||||
Avatar::new(seed).xsmall().into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn repo_tab_avatar(store: &RepoStore, cx: &App) -> AnyElement {
|
||||
pub(crate) fn repo_tab_avatar(
|
||||
store: &RepoStore,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let seed = store
|
||||
.announcement
|
||||
.as_ref()
|
||||
@@ -53,13 +60,15 @@ pub(crate) fn repo_tab_avatar(store: &RepoStore, cx: &App) -> AnyElement {
|
||||
let owner = store
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|announcement| ProfileStore::global(cx).read(cx).get(&announcement.owner));
|
||||
.map(|announcement| directory.read(cx).profile(&announcement.owner, cx));
|
||||
|
||||
match owner.and_then(|profile| profile.picture().map(|picture| (profile.name(), picture))) {
|
||||
Some((name, picture)) => Avatar::new(name)
|
||||
.picture(Some(picture))
|
||||
.xsmall()
|
||||
.into_any_element(),
|
||||
None => PixelAvatar::new(seed).xsmall().into_any_element(),
|
||||
}
|
||||
let (name, picture) = owner
|
||||
.map(|profile| (Some(profile.name()), profile.picture()))
|
||||
.unwrap_or((None, None));
|
||||
|
||||
Avatar::new(seed)
|
||||
.name(name)
|
||||
.picture(picture)
|
||||
.xsmall()
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -20,10 +20,11 @@ use gpui_component::{
|
||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
|
||||
v_virtual_list,
|
||||
};
|
||||
use nostr::prelude::{Event, EventId, Kind, Url};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{Event, EventId, Kind, PublicKey, Url};
|
||||
use signed_core::{GitEvent, PullRequest, RepoAddr};
|
||||
use signed_git::{FileCommit, PatchParser};
|
||||
use signed_state::{Backend, Mirrors, ProfileStore, RepoStore};
|
||||
use signed_state::{Backend, Mirrors, RepoStore};
|
||||
use signed_ui::{Avatar, CountBadge, placeholder, status_badge};
|
||||
use utils::{relative_time, relative_time_secs};
|
||||
|
||||
@@ -77,6 +78,8 @@ pub struct PullRequestDetailView {
|
||||
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
commit_scroll_handle: VirtualListScrollHandle,
|
||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||
/// Profiles for the author, commenters and the repository owner.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
@@ -121,16 +124,37 @@ impl PullRequestDetailView {
|
||||
commit_item_sizes: Rc::new(Vec::new()),
|
||||
commit_scroll_handle: VirtualListScrollHandle::new(),
|
||||
tasks: Vec::new(),
|
||||
directory: ProfileDirectory::new(cx),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Track the PR author, everyone who commented and the repository owner.
|
||||
fn track_participants(&mut self, cx: &mut Context<Self>) {
|
||||
let store = self.store.read(cx);
|
||||
|
||||
let mut keys: Vec<PublicKey> = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||
.map(|root| vec![root.pubkey])
|
||||
.unwrap_or_default();
|
||||
|
||||
keys.extend(store.comments_of(&self.pr_id).map(|comment| comment.pubkey));
|
||||
|
||||
if let Some(owner) = store.announcement.as_ref().map(|a| a.owner) {
|
||||
keys.push(owner);
|
||||
}
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(keys, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Snapshot the root PR from the store and reload the diff when it changed.
|
||||
///
|
||||
/// Re-runs on construction and on every store refresh. Item panels are
|
||||
/// cached by the dock, so this is the only way a panel opened before the
|
||||
/// store's first pass learns about its PR.
|
||||
fn sync(&mut self, cx: &mut Context<Self>) {
|
||||
self.track_participants(cx);
|
||||
|
||||
let loaded = self.store.read(cx).loaded;
|
||||
|
||||
let binding = {
|
||||
@@ -407,7 +431,7 @@ impl PullRequestDetailView {
|
||||
return placeholder(&error, cx);
|
||||
}
|
||||
|
||||
let (author, picture, age, root_id) = {
|
||||
let (author, picture, age, root_id, pubkey) = {
|
||||
let store = self.store.read(cx);
|
||||
let Some(root) = store
|
||||
.pull_requests
|
||||
@@ -416,12 +440,13 @@ impl PullRequestDetailView {
|
||||
else {
|
||||
return placeholder("Pull request not found", cx);
|
||||
};
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&root.pubkey);
|
||||
let profile = self.directory.read(cx).profile(&root.pubkey, cx);
|
||||
(
|
||||
profile.name(),
|
||||
profile.picture(),
|
||||
relative_time(root.created_at),
|
||||
root.id,
|
||||
root.pubkey,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -451,7 +476,8 @@ impl PullRequestDetailView {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(author.clone())
|
||||
Avatar::new(pubkey)
|
||||
.name(author.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(author),
|
||||
@@ -467,7 +493,7 @@ impl PullRequestDetailView {
|
||||
this.child(div().text_sm().child(self.description.clone()))
|
||||
}),
|
||||
)
|
||||
.child(comments_section(&self.store, root_id, cx))
|
||||
.child(comments_section(&self.store, root_id, &self.directory, cx))
|
||||
.child(comment_form(
|
||||
&self.store,
|
||||
root_id,
|
||||
@@ -478,7 +504,14 @@ impl PullRequestDetailView {
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(sidebar_section(&self.store, root_id, pr_roots, true, cx))
|
||||
.child(sidebar_section(
|
||||
&self.store,
|
||||
root_id,
|
||||
pr_roots,
|
||||
true,
|
||||
&self.directory,
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -789,7 +822,7 @@ impl BasePanel for PullRequestDetailView {
|
||||
|
||||
impl Panel for PullRequestDetailView {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||
let hex = self.pr_id.to_hex();
|
||||
let id = SharedString::from(&hex[..8]);
|
||||
let label = if self.repo_name.is_empty() {
|
||||
|
||||
@@ -13,9 +13,10 @@ use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::{
|
||||
ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||
};
|
||||
use nostr::prelude::{EventId, Kind};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{EventId, Kind, PublicKey};
|
||||
use signed_core::{GitEvent, RepoStatus};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_state::RepoStore;
|
||||
use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -66,6 +67,8 @@ pub struct PullRequestsView {
|
||||
// e.g. switching between two empty filters.
|
||||
synced_filter: PullRequestFilter,
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
/// Profiles for PR authors and the repository owner.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
@@ -97,11 +100,13 @@ impl PullRequestsView {
|
||||
counts: StatusCounts::default(),
|
||||
synced_filter: PullRequestFilter::Open,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
directory: ProfileDirectory::new(cx),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild(&mut self, cx: &mut Context<Self>) {
|
||||
let store = self.store.read(cx);
|
||||
let filter = self.filter;
|
||||
|
||||
let (visible_prs, counts) = {
|
||||
@@ -117,6 +122,25 @@ impl PullRequestsView {
|
||||
)
|
||||
};
|
||||
|
||||
let mut keys: Vec<PublicKey> = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.filter(|pr| pr.kind == Kind::GitPullRequest)
|
||||
.map(|pr| pr.pubkey)
|
||||
.collect();
|
||||
|
||||
if let Some(owner) = store
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|announcement| announcement.owner)
|
||||
{
|
||||
keys.push(owner);
|
||||
}
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(keys, cx);
|
||||
});
|
||||
|
||||
let filter_changed = self.synced_filter != filter;
|
||||
|
||||
if !filter_changed && self.visible_prs == visible_prs && self.counts == counts {
|
||||
@@ -165,7 +189,7 @@ impl PullRequestsView {
|
||||
let age = relative_time(pr.created_at);
|
||||
let status = self.store.read(cx).status_of(pr);
|
||||
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&pr.pubkey);
|
||||
let profile = self.directory.read(cx).profile(&pr.pubkey, cx);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
@@ -203,7 +227,11 @@ impl PullRequestsView {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(Avatar::new(author.clone()).picture(picture))
|
||||
.child(
|
||||
Avatar::new(pr.pubkey)
|
||||
.name(author.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(div().child(author)),
|
||||
)
|
||||
.child(SharedString::from("opened"))
|
||||
@@ -335,7 +363,7 @@ impl BasePanel for PullRequestsView {
|
||||
|
||||
impl Panel for PullRequestsView {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||
let label = SharedString::from(format!("{}/pulls", self.repo_name));
|
||||
|
||||
tab_title(avatar, label)
|
||||
|
||||
@@ -20,6 +20,7 @@ use gpui_component::{
|
||||
ActiveTheme, Disableable, Icon, IconName, Sizable, VirtualListScrollHandle, h_flex, v_flex,
|
||||
v_virtual_list,
|
||||
};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::*;
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
use signed_git::{GitCache, Repo};
|
||||
@@ -65,6 +66,8 @@ pub struct NewPullRequestView {
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||
/// Profile for the repository owner in the tab title.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
@@ -222,6 +225,7 @@ impl NewPullRequestView {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let directory = ProfileDirectory::new(cx);
|
||||
let repo_name = store.read(cx).name();
|
||||
let pane = cx.new(DiffPane::new);
|
||||
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
|
||||
@@ -270,6 +274,12 @@ impl NewPullRequestView {
|
||||
),
|
||||
];
|
||||
|
||||
if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) {
|
||||
directory.update(cx, |directory, cx| {
|
||||
directory.track([owner], cx);
|
||||
});
|
||||
}
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
let Some(addr) = this.store.read(cx).addr().cloned() else {
|
||||
return;
|
||||
@@ -312,6 +322,7 @@ impl NewPullRequestView {
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
tasks: Vec::new(),
|
||||
directory,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
@@ -1313,7 +1324,7 @@ impl BasePanel for NewPullRequestView {
|
||||
|
||||
impl Panel for NewPullRequestView {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||
let label = SharedString::from(format!("{}/new-pull-request", self.repo_name));
|
||||
|
||||
tab_title(avatar, label)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, SharedString, Window, div, px};
|
||||
use gpui::{AnyElement, App, Entity, SharedString, Window, div, px};
|
||||
use gpui_component::clipboard::Clipboard;
|
||||
use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::PublicKey;
|
||||
use signed_core::Announcement;
|
||||
use signed_state::ProfileStore;
|
||||
use signed_ui::Avatar;
|
||||
use utils::middle_truncate;
|
||||
|
||||
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
|
||||
pub(super) fn open_about_dialog(
|
||||
announcement: Announcement,
|
||||
directory: Entity<ProfileDirectory>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
window.open_dialog(cx, move |dialog, _window, cx| {
|
||||
let announcement = announcement.clone();
|
||||
dialog
|
||||
@@ -17,11 +22,15 @@ pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window,
|
||||
.keyboard(true)
|
||||
.close_button(true)
|
||||
.title("About")
|
||||
.child(announcement_rows(&announcement, cx))
|
||||
.child(announcement_rows(&announcement, &directory, cx))
|
||||
});
|
||||
}
|
||||
|
||||
fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||
fn announcement_rows(
|
||||
announcement: &Announcement,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let mut rows: Vec<AnyElement> = Vec::new();
|
||||
|
||||
rows.push(row(
|
||||
@@ -107,7 +116,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||
if !announcement.maintainers.is_empty() {
|
||||
rows.push(row(
|
||||
"Maintainers",
|
||||
maintainers(&announcement.maintainers, cx),
|
||||
maintainers(&announcement.maintainers, directory, cx),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
@@ -160,13 +169,16 @@ fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
fn maintainers(
|
||||
maintainers: &[PublicKey],
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.min_w_0()
|
||||
.children(maintainers.iter().map(|pubkey| {
|
||||
let profile = profile_store.read(cx).get(pubkey);
|
||||
let profile = directory.read(cx).profile(pubkey, cx);
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
@@ -174,7 +186,7 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.min_w_0()
|
||||
.child(Avatar::new(name.clone()).picture(picture))
|
||||
.child(Avatar::new(*pubkey).name(name.clone()).picture(picture))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use signed_state::CheckoutStatus;
|
||||
use signed_state::{CheckoutStatus, CheckoutSyncStatus};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Banners {
|
||||
dismissed: HashSet<(PathBuf, String)>,
|
||||
sync_dismissed: HashSet<PathBuf>,
|
||||
ready_requested: bool,
|
||||
/// Re-requested only when the announced HEAD or the base default changes.
|
||||
ready_head: Option<String>,
|
||||
ready_statuses: Vec<CheckoutStatus>,
|
||||
push_statuses: Vec<CheckoutStatus>,
|
||||
@@ -24,6 +24,14 @@ impl Banners {
|
||||
.insert((status.path.clone(), status.branch.clone()));
|
||||
}
|
||||
|
||||
pub(super) fn sync_dismissal(&self, checkout: &CheckoutSyncStatus) -> bool {
|
||||
self.sync_dismissed.contains(&checkout.path)
|
||||
}
|
||||
|
||||
pub(super) fn sync_dismiss(&mut self, checkout: &CheckoutSyncStatus) {
|
||||
self.sync_dismissed.insert(checkout.path.clone());
|
||||
}
|
||||
|
||||
pub(super) fn ready_requested_at(&self) -> (bool, &Option<String>) {
|
||||
(self.ready_requested, &self.ready_head)
|
||||
}
|
||||
|
||||
@@ -2,19 +2,23 @@ use std::path::PathBuf;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, WeakEntity, Window, px};
|
||||
use gpui::{App, Entity, SharedString, WeakEntity, Window, px};
|
||||
use gpui_base::h_flex;
|
||||
use gpui_base::input::TextareaState;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputState, Textarea};
|
||||
use gpui_component::select::{Select, SelectState};
|
||||
use gpui_component::{ActiveTheme, Disableable, WindowExt};
|
||||
use settings::SettingsStore;
|
||||
use signed_core::RepoAddr;
|
||||
use signed_git::Repo;
|
||||
use signed_state::Backend;
|
||||
use signed_ui::SelectOption;
|
||||
|
||||
use super::RepoDetailView;
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
use crate::views::dialog_state::{DialogProgress, error_row, push_steps_row};
|
||||
use crate::views::sidebar::grasp_servers::{
|
||||
GraspServersState, grasp_servers_field, load_user_grasp_servers,
|
||||
};
|
||||
@@ -48,8 +52,11 @@ pub fn open(
|
||||
.auto_grow(3, 5)
|
||||
.placeholder("Short description")
|
||||
});
|
||||
let branch_select: Entity<SelectState<Vec<SelectOption>>> =
|
||||
cx.new(|cx| SelectState::new(Vec::new(), None, window, cx));
|
||||
|
||||
load_user_grasp_servers(grasp_state.clone(), window, cx);
|
||||
load_branches(local_path.clone(), branch_select.clone(), window, cx);
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
const DESC: &str = "Publish this local repository to Nostr.";
|
||||
@@ -57,6 +64,7 @@ pub fn open(
|
||||
let name_input = name_input.clone();
|
||||
let desc_input = desc_input.clone();
|
||||
let relay_input = relay_input.clone();
|
||||
let branch_select = branch_select.clone();
|
||||
let state = state.clone();
|
||||
let grasp_state = grasp_state.clone();
|
||||
let local_path = local_path.clone();
|
||||
@@ -105,8 +113,15 @@ pub fn open(
|
||||
.child(local_path.display().to_string()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
field()
|
||||
.label("Default branch")
|
||||
.description("The local branch to announce as the default")
|
||||
.child(Select::new(&branch_select).w_full()),
|
||||
)
|
||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
|
||||
)
|
||||
.children(push_steps_row(&state.read(cx).push_steps, cx))
|
||||
.children(error_row(&error, cx))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
@@ -120,6 +135,7 @@ pub fn open(
|
||||
.on_click({
|
||||
let name_input = name_input.clone();
|
||||
let desc_input = desc_input.clone();
|
||||
let branch_select = branch_select.clone();
|
||||
let state = state.clone();
|
||||
let grasp_state = grasp_state.clone();
|
||||
let local_path = local_path.clone();
|
||||
@@ -129,6 +145,7 @@ pub fn open(
|
||||
init_repository(
|
||||
local_path.clone(),
|
||||
(name_input.clone(), desc_input.clone()),
|
||||
branch_select.clone(),
|
||||
state.clone(),
|
||||
grasp_state.clone(),
|
||||
view.clone(),
|
||||
@@ -143,9 +160,11 @@ pub fn open(
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn init_repository(
|
||||
local_path: PathBuf,
|
||||
inputs: (Entity<InputState>, Entity<TextareaState>),
|
||||
branch_select: Entity<SelectState<Vec<SelectOption>>>,
|
||||
state: Entity<InitRepoState>,
|
||||
grasp_state: Entity<GraspServersState>,
|
||||
view: WeakEntity<RepoDetailView>,
|
||||
@@ -156,6 +175,11 @@ fn init_repository(
|
||||
let name = name_input.read(cx).value().trim().to_owned();
|
||||
let description = desc_input.read(cx).value().trim().to_owned();
|
||||
let servers = grasp_state.read(cx).grasp_servers.clone();
|
||||
let default_branch = branch_select
|
||||
.read(cx)
|
||||
.selected_value()
|
||||
.map(|value| value.trim().to_owned())
|
||||
.filter(|branch| !branch.is_empty());
|
||||
|
||||
if name.is_empty() {
|
||||
state.update(cx, |state, _| state.fail("Repository name is required"));
|
||||
@@ -167,11 +191,24 @@ fn init_repository(
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, _| state.begin());
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let Some(user) = backend.read(cx).current_user() else {
|
||||
state.update(cx, |state, _| state.fail("Sign in to publish a repository"));
|
||||
return;
|
||||
};
|
||||
|
||||
let addr = RepoAddr::new(user, RepoAddr::identifier_from_name(&name));
|
||||
state.update(cx, |state, cx| state.begin_push(addr, cx));
|
||||
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.publish_local_repo(local_path.clone(), &name, &description, servers, cx)
|
||||
backend.publish_local_repo(
|
||||
local_path.clone(),
|
||||
&name,
|
||||
&description,
|
||||
servers,
|
||||
default_branch,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let handle = window.window_handle();
|
||||
@@ -199,3 +236,45 @@ fn init_repository(
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Fills the default-branch selector with the repository's local branches,
|
||||
/// preselecting the checked-out one.
|
||||
fn load_branches(
|
||||
local_path: PathBuf,
|
||||
select: Entity<SelectState<Vec<SelectOption>>>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let handle = window.window_handle();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let loaded = cx
|
||||
.background_spawn(async move {
|
||||
let repo = Repo::open(&local_path)?;
|
||||
let current: Option<SharedString> = repo.current_branch().map(Into::into);
|
||||
Ok::<_, anyhow::Error>((repo.branches()?, current))
|
||||
})
|
||||
.await;
|
||||
|
||||
let _ = cx.update_window(handle, |_, window, cx| {
|
||||
let Ok((branches, current)) = loaded.map_err(|error| {
|
||||
log::warn!("failed to list branches for the default branch selector: {error:#}")
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let options: Vec<SelectOption> = branches
|
||||
.into_iter()
|
||||
.map(|branch| SelectOption::new(branch.clone(), branch))
|
||||
.collect();
|
||||
|
||||
select.update(cx, |state, cx| {
|
||||
state.set_items(options, window, cx);
|
||||
if let Some(current) = current.as_ref() {
|
||||
state.set_selected_value(current, window, cx);
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
@@ -15,21 +15,24 @@ use gpui_base::{Button as BaseButton, Disableable, Popover};
|
||||
use gpui_component::alert::Alert;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::combobox::{Combobox, ComboboxEvent};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::menu::{DropdownMenu, PopupMenu};
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{
|
||||
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex,
|
||||
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, WindowExt, h_flex,
|
||||
v_flex,
|
||||
};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::nips::nip19::Nip19Coordinate;
|
||||
use nostr::prelude::{RelayUrl, ToBech32, Url};
|
||||
use nostr::prelude::{PublicKey, RelayUrl, ToBech32, Url};
|
||||
use signed_core::{Announcement, RepoAddr, RepoStatus};
|
||||
use signed_git::{FileCommit, GitCache, Repo};
|
||||
use signed_git::{FileCommit, GitCache, RefSync, Repo};
|
||||
use signed_state::{
|
||||
Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Mirrors, Nip34Binding, Nip34Kind,
|
||||
ProfileStore, RepoListStore, RepoStore,
|
||||
Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors,
|
||||
Nip34Binding, Nip34Kind, PushStepStatus, RepoListStore, RepoStore, SyncStatusDirectory,
|
||||
};
|
||||
use signed_ui::{
|
||||
Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, ref_selector_trigger,
|
||||
Avatar, CountBadge, DropdownButton, copy_row, menu_copy_row, ref_selector_trigger,
|
||||
};
|
||||
use utils::middle_truncate;
|
||||
|
||||
@@ -71,6 +74,7 @@ pub(crate) enum RepoAction {
|
||||
SendPatch,
|
||||
About,
|
||||
Push,
|
||||
ResetRemote,
|
||||
Delete,
|
||||
}
|
||||
|
||||
@@ -93,6 +97,10 @@ pub struct RepoDetailView {
|
||||
refs: RefSwitcher,
|
||||
banners: Banners,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
/// Profiles rendered by the header, maintainers and tab title.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
/// Sync status feeding the sync-to-Nostr banner.
|
||||
sync_directory: Entity<SyncStatusDirectory>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
@@ -209,6 +217,8 @@ impl RepoDetailView {
|
||||
tasks: Vec::new(),
|
||||
banners: Banners::default(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
directory: ProfileDirectory::new(cx),
|
||||
sync_directory: SyncStatusDirectory::new(cx),
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
|
||||
@@ -244,6 +254,8 @@ impl RepoDetailView {
|
||||
self._subscriptions
|
||||
.push(cx.observe_in(store, window, |this, store, window, cx| {
|
||||
this.refresh_ready_statuses(cx);
|
||||
this.track_profiles(cx);
|
||||
this.track_sync_status(cx);
|
||||
if !this.repo_started && store.read(cx).announcement.is_some() {
|
||||
this.load_repo(window, cx);
|
||||
}
|
||||
@@ -252,6 +264,46 @@ impl RepoDetailView {
|
||||
self.refresh_ready_statuses(cx);
|
||||
}
|
||||
|
||||
/// Track the repository owner, maintainers and the local binding owner.
|
||||
fn track_profiles(&mut self, cx: &mut Context<Self>) {
|
||||
let store = self.store.read(cx);
|
||||
|
||||
let mut keys: Vec<PublicKey> = store
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|announcement| {
|
||||
let mut keys = vec![announcement.owner];
|
||||
keys.extend(
|
||||
announcement
|
||||
.maintainers
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| *key != announcement.owner),
|
||||
);
|
||||
keys
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(owner) = store.nip34.as_ref().and_then(|binding| binding.owner) {
|
||||
keys.push(owner);
|
||||
}
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(keys, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Track the repository address so the sync banner stays current.
|
||||
fn track_sync_status(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(addr) = self.store.read(cx).addr().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.sync_directory.update(cx, |directory, cx| {
|
||||
directory.track([addr], cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn refresh_ready_statuses(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(addr) = self.store.read(cx).addr().cloned() else {
|
||||
return;
|
||||
@@ -308,6 +360,8 @@ impl RepoDetailView {
|
||||
pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
self.track_profiles(cx);
|
||||
self.track_sync_status(cx);
|
||||
cx.notify();
|
||||
|
||||
let (announcement, local_path) = {
|
||||
@@ -315,9 +369,6 @@ impl RepoDetailView {
|
||||
(store.announcement.clone(), store.path.clone())
|
||||
};
|
||||
|
||||
// A repository with a local worktree shows it directly. An announced one
|
||||
// still loads its announcement and activity from the store, which is
|
||||
// subscribed to the relays independently.
|
||||
if let Some(local_path) = local_path {
|
||||
self.repo_started = true;
|
||||
|
||||
@@ -352,6 +403,7 @@ impl RepoDetailView {
|
||||
|
||||
self.repo_started = true;
|
||||
|
||||
let store = self.store.downgrade();
|
||||
let addr = announcement.addr();
|
||||
let clone_urls: Vec<Url> = announcement.clone.clone();
|
||||
|
||||
@@ -393,64 +445,71 @@ impl RepoDetailView {
|
||||
})?;
|
||||
|
||||
// Refresh the clone from the network in the background.
|
||||
// When it completes, update the refs and commit list.
|
||||
if !had_clone {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let refresh = {
|
||||
let addr = addr.clone();
|
||||
// Whether a push is in progress
|
||||
let pushing = store.read_with(cx, |this, _| this.pushing)?;
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let Some(repo) = Mirrors::open(&addr)? else {
|
||||
return Ok::<_, Error>(None);
|
||||
};
|
||||
if !pushing {
|
||||
let refresh = {
|
||||
let addr = addr.clone();
|
||||
|
||||
// Best-effort, a fetch failure, e.g. offline, keeps the cached state.
|
||||
repo.fetch().ok();
|
||||
cx.background_spawn(async move {
|
||||
let Some(repo) = Mirrors::open(&addr)? else {
|
||||
return Ok::<_, Error>(None);
|
||||
};
|
||||
|
||||
let moved = repo.fast_forward_branches().unwrap_or(false);
|
||||
// Best-effort, a fetch failure, e.g. offline, keeps the cached state.
|
||||
repo.fetch().ok();
|
||||
|
||||
Ok::<_, Error>(Some((moved, repo.snapshot()?)))
|
||||
})
|
||||
}
|
||||
.await;
|
||||
let moved = repo.fast_forward_branches().unwrap_or(false);
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
if let Ok(Some((moved, snapshot))) = refresh {
|
||||
let branches: Vec<SharedString> =
|
||||
snapshot.branches.iter().map(Into::into).collect();
|
||||
let tags: Vec<SharedString> = snapshot.tags.iter().map(Into::into).collect();
|
||||
let current_branch = snapshot.current_branch.clone();
|
||||
let head_commit = snapshot.head_commit.clone();
|
||||
|
||||
let branches_changed = this.refs.set_branches(
|
||||
branches,
|
||||
current_branch.map(Into::into),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
let tags_changed = this.refs.set_tags(tags, 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;
|
||||
|
||||
if head_changed {
|
||||
this.history.update(cx, |history, cx| history.reload(cx));
|
||||
}
|
||||
|
||||
if moved {
|
||||
this.catch_up_worktree(cx);
|
||||
}
|
||||
|
||||
if branches_changed || tags_changed || head_changed {
|
||||
cx.notify();
|
||||
}
|
||||
Ok::<_, Error>(Some((moved, repo.snapshot()?)))
|
||||
})
|
||||
}
|
||||
})?;
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
if let Ok(Some((moved, snapshot))) = refresh {
|
||||
let branches: Vec<SharedString> =
|
||||
snapshot.branches.iter().map(Into::into).collect();
|
||||
|
||||
let tags: Vec<SharedString> =
|
||||
snapshot.tags.iter().map(Into::into).collect();
|
||||
|
||||
let current_branch = snapshot.current_branch.clone();
|
||||
let head_commit = snapshot.head_commit.clone();
|
||||
|
||||
let branches_changed = this.refs.set_branches(
|
||||
branches,
|
||||
current_branch.map(Into::into),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
let tags_changed = this.refs.set_tags(tags, 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;
|
||||
|
||||
if head_changed {
|
||||
this.history.update(cx, |history, cx| history.reload(cx));
|
||||
}
|
||||
|
||||
if moved {
|
||||
this.catch_up_worktree(cx);
|
||||
}
|
||||
|
||||
if branches_changed || tags_changed || head_changed {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
@@ -785,6 +844,63 @@ impl RepoDetailView {
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
pub(super) fn reset_remote(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(announcement) = self.announcement(cx).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let store = self.store.clone();
|
||||
let mirror = Mirrors::path(&announcement.addr());
|
||||
|
||||
let urls: Vec<String> = announcement
|
||||
.clone
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect();
|
||||
|
||||
let probe = cx.background_spawn(async move {
|
||||
let mut deleted: Vec<String> = Vec::new();
|
||||
let repo = Repo::open(&mirror)?;
|
||||
|
||||
let local: HashSet<String> = repo
|
||||
.ref_state()?
|
||||
.refs
|
||||
.into_iter()
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
|
||||
for url in &urls {
|
||||
let Ok(remote_refs) = repo.ls_remote(url) else {
|
||||
continue;
|
||||
};
|
||||
for (name, _) in remote_refs {
|
||||
if !local.contains(&name) && !deleted.contains(&name) {
|
||||
deleted.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deleted.sort();
|
||||
|
||||
Ok::<_, Error>(deleted)
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let deleted = probe.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| match deleted {
|
||||
Ok(deleted) => open_reset_remote_dialog(store, deleted, window, cx),
|
||||
Err(error) => {
|
||||
this.error = Some(error.to_string().into());
|
||||
cx.notify();
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok::<(), Error>(())
|
||||
})
|
||||
.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 task = self
|
||||
@@ -828,10 +944,6 @@ impl RepoDetailView {
|
||||
}
|
||||
|
||||
/// Open the upstream repository, the `u` tag of this fork's announcement.
|
||||
///
|
||||
/// The announcement may not be in the local database yet. The panel opens
|
||||
/// from the address and fills in when the store loads it; the store's
|
||||
/// `subscribe_remote` fetches it from the bootstrap relays.
|
||||
pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(addr) = self
|
||||
.announcement(cx)
|
||||
@@ -870,9 +982,10 @@ impl RepoDetailView {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let nip05 = ProfileStore::global(cx)
|
||||
let nip05 = self
|
||||
.directory
|
||||
.read(cx)
|
||||
.get(&source.owner)
|
||||
.profile(&source.owner, cx)
|
||||
.metadata()
|
||||
.nip05
|
||||
.clone()
|
||||
@@ -888,7 +1001,7 @@ impl RepoDetailView {
|
||||
|
||||
let name = repo_display_name(self.store.read(cx));
|
||||
let description = announcement.description();
|
||||
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
|
||||
let avatar = Avatar::new(format!("{}:{}", announcement.owner, announcement.id));
|
||||
|
||||
v_flex()
|
||||
.on_action(
|
||||
@@ -909,10 +1022,16 @@ impl RepoDetailView {
|
||||
}
|
||||
RepoAction::About => {
|
||||
if let Some(announcement) = this.announcement(cx) {
|
||||
open_about_dialog(announcement.clone(), window, cx);
|
||||
open_about_dialog(
|
||||
announcement.clone(),
|
||||
this.directory.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
RepoAction::Push => this.push_repository(window, cx),
|
||||
RepoAction::ResetRemote => this.reset_remote(window, cx),
|
||||
RepoAction::Delete => this.delete_repository(window, cx),
|
||||
}),
|
||||
)
|
||||
@@ -1115,6 +1234,16 @@ impl RepoDetailView {
|
||||
.child(Icon::new(CustomIconName::Init))
|
||||
.child("Republish")
|
||||
})
|
||||
.menu_element(
|
||||
Box::new(RepoAction::ResetRemote),
|
||||
|_, _| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(Icon::new(IconName::RefreshCw))
|
||||
.child("Reset remote to local")
|
||||
},
|
||||
)
|
||||
.separator()
|
||||
.menu_element(Box::new(RepoAction::Delete), |_, cx| {
|
||||
h_flex()
|
||||
@@ -1245,7 +1374,7 @@ impl RepoDetailView {
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_default();
|
||||
let avatar = PixelAvatar::new(path.clone());
|
||||
let avatar = Avatar::new(path.clone());
|
||||
|
||||
// A repository already bound to a coordinate is not offered for publishing again.
|
||||
let bound = self.store.read(cx).nip34.clone();
|
||||
@@ -1253,7 +1382,7 @@ impl RepoDetailView {
|
||||
.as_ref()
|
||||
.filter(|binding| binding.kind == Nip34Kind::Initialized)
|
||||
{
|
||||
Some(binding) => bound_repo_label(binding, cx),
|
||||
Some(binding) => bound_repo_label(binding, &self.directory, cx),
|
||||
None => Button::new("init")
|
||||
.icon(CustomIconName::Init)
|
||||
.label("Initialize on Nostr")
|
||||
@@ -1449,7 +1578,6 @@ impl RepoDetailView {
|
||||
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
|
||||
@@ -1459,7 +1587,7 @@ impl RepoDetailView {
|
||||
.filter(|key| key != &announcement.owner && seen.insert(*key))
|
||||
.collect();
|
||||
|
||||
let owner = profile_store.read(cx).get(&announcement.owner);
|
||||
let owner = self.directory.read(cx).profile(&announcement.owner, cx);
|
||||
let owner_name = owner.name();
|
||||
let owner_picture = owner.picture();
|
||||
|
||||
@@ -1473,7 +1601,11 @@ impl RepoDetailView {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(Avatar::new(owner_name.clone()).picture(owner_picture))
|
||||
.child(
|
||||
Avatar::new(announcement.owner)
|
||||
.name(owner_name.clone())
|
||||
.picture(owner_picture),
|
||||
)
|
||||
.child(div().text_xs().whitespace_nowrap().child(owner_name)),
|
||||
)
|
||||
.when(!rest.is_empty(), |this| {
|
||||
@@ -1539,6 +1671,25 @@ impl RepoDetailView {
|
||||
.find(|status| !self.banners.dismissal(status))
|
||||
}
|
||||
|
||||
/// The first checkout of this owned repository with commits not on Nostr.
|
||||
///
|
||||
/// Not dismissed in this panel.
|
||||
fn sync_suggestion(&self, cx: &App) -> Option<CheckoutSyncStatus> {
|
||||
let store = self.store.read(cx);
|
||||
let addr = store.addr()?;
|
||||
let user = Backend::global(cx).read(cx).current_user()?;
|
||||
|
||||
if !store.is_author(&user) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let statuses = self.sync_directory.read(cx).statuses_of(addr, cx);
|
||||
|
||||
statuses.into_iter().find(|checkout| {
|
||||
checkout.status.ahead_total > 0 && !self.banners.sync_dismissal(checkout)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn render_push_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
|
||||
let status = self.push_suggestion(cx)?;
|
||||
let path = status.path.clone();
|
||||
@@ -1624,6 +1775,155 @@ impl RepoDetailView {
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn render_sync_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
|
||||
let checkout = self.sync_suggestion(cx)?;
|
||||
let path = checkout.path.clone();
|
||||
// The push busy flag lives on the store; it disables the banner's triggers.
|
||||
let pushing = self.store.read(cx).pushing;
|
||||
|
||||
let (branch, ahead) = checkout
|
||||
.status
|
||||
.refs
|
||||
.iter()
|
||||
.filter_map(|(name, sync)| match sync {
|
||||
RefSync::LocalAhead { ahead } => Some((name, *ahead)),
|
||||
RefSync::Diverged { ahead, .. } => Some((name, *ahead)),
|
||||
_ => None,
|
||||
})
|
||||
.max_by_key(|(_, ahead)| *ahead)?;
|
||||
|
||||
let branch = SharedString::from(branch.clone());
|
||||
let commits = if ahead == 1 {
|
||||
SharedString::from("1 commit")
|
||||
} else {
|
||||
SharedString::from(format!("{} commits", 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(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 Nostr"),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("sync-banner")
|
||||
.icon(CustomIconName::Refresh)
|
||||
.label("Sync now")
|
||||
.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("sync-banner-dismiss")
|
||||
.icon(IconName::Close)
|
||||
.tooltip("Dismiss")
|
||||
.small()
|
||||
.ghost()
|
||||
.disabled(pushing)
|
||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||
this.banners.sync_dismiss(&checkout);
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn render_push_progress_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
|
||||
let store = self.store.read(cx);
|
||||
if !store.pushing || store.push_steps.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let steps = store
|
||||
.push_steps
|
||||
.iter()
|
||||
.map(|status| {
|
||||
let label = SharedString::from(status.step().label());
|
||||
|
||||
match status {
|
||||
PushStepStatus::Running(_) => h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Spinner::new().small())
|
||||
.child(label)
|
||||
.into_any_element(),
|
||||
PushStepStatus::Done(_) => h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(Icon::new(IconName::CircleCheck).small())
|
||||
.child(label)
|
||||
.into_any_element(),
|
||||
PushStepStatus::Failed(_, reason) => h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(Icon::new(IconName::Close).small())
|
||||
.child(SharedString::from(format!(
|
||||
"{}: {reason}",
|
||||
status.step().label()
|
||||
)))
|
||||
.into_any_element(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Some(
|
||||
v_flex()
|
||||
.p_4()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.bg(cx.theme().muted)
|
||||
.child(div().text_sm().child("Pushing"))
|
||||
.children(steps)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn render_push_warning_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
|
||||
let store = self.store.read(cx);
|
||||
let warning = store.last_push_warning.clone()?;
|
||||
@@ -1788,6 +2088,12 @@ impl RepoDetailView {
|
||||
/// The Files tab body, or the clone/initial-load spinner.
|
||||
fn render_files_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
let label = if self.store.read(cx).path.is_some() {
|
||||
"Loading repository..."
|
||||
} else {
|
||||
"Cloning repository..."
|
||||
};
|
||||
|
||||
return v_flex()
|
||||
.flex_1()
|
||||
.size_full()
|
||||
@@ -1799,7 +2105,7 @@ impl RepoDetailView {
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Cloning repository..."),
|
||||
.child(label),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
@@ -1808,6 +2114,75 @@ impl RepoDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirmation for the remote reset, listing the server refs it deletes.
|
||||
fn open_reset_remote_dialog(
|
||||
store: Entity<RepoStore>,
|
||||
deleted: Vec<String>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
const DESC: &str = "Grasp servers are overwritten to match the local mirror. \
|
||||
Refs that do not exist locally are deleted from the servers. \
|
||||
This cannot be undone.";
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
let deleted = deleted.clone();
|
||||
let store = store.clone();
|
||||
|
||||
dialog
|
||||
.w(px(480.))
|
||||
.keyboard(true)
|
||||
.close_button(true)
|
||||
.content(move |body, _window, cx| {
|
||||
let deleted = deleted.clone();
|
||||
let store = store.clone();
|
||||
|
||||
body.child(
|
||||
DialogHeader::new()
|
||||
.child(DialogTitle::new().child("Reset remote to local"))
|
||||
.child(DialogDescription::new().child(DESC)),
|
||||
)
|
||||
.child(if deleted.is_empty() {
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("No server refs will be deleted.")
|
||||
.into_any_element()
|
||||
} else {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.children(deleted.iter().map(|name| {
|
||||
div()
|
||||
.text_sm()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.child(name.clone())
|
||||
}))
|
||||
.into_any_element()
|
||||
})
|
||||
.child(
|
||||
DialogFooter::new()
|
||||
.justify_end()
|
||||
.child(Button::new("cancel-reset-remote").label("Cancel").on_click(
|
||||
|_, window, cx| {
|
||||
window.close_dialog(cx);
|
||||
},
|
||||
))
|
||||
.child(
|
||||
Button::new("confirm-reset-remote")
|
||||
.danger()
|
||||
.label("Reset remote")
|
||||
.on_click(move |_, window, cx| {
|
||||
store.update(cx, |store, cx| {
|
||||
store.reset_repository(cx).detach_and_log_err(cx);
|
||||
});
|
||||
window.close_dialog(cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString {
|
||||
let owner = announcement.owner;
|
||||
let user = nip05
|
||||
@@ -1986,9 +2361,12 @@ pub(super) fn repo_display_name(store: &RepoStore) -> SharedString {
|
||||
}
|
||||
|
||||
/// The owner and identifier a local repository is already bound to.
|
||||
fn bound_repo_label(binding: &Nip34Binding, cx: &App) -> AnyElement {
|
||||
let store = ProfileStore::global(cx);
|
||||
let profile = binding.owner.map(|pk| store.read(cx).get(&pk));
|
||||
fn bound_repo_label(
|
||||
binding: &Nip34Binding,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let profile = binding.owner.map(|pk| directory.read(cx).profile(&pk, cx));
|
||||
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
@@ -1997,12 +2375,17 @@ fn bound_repo_label(binding: &Nip34Binding, cx: &App) -> AnyElement {
|
||||
.child("Initialized by")
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.when_some(profile, |this, profile| {
|
||||
let name = profile.name();
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(Avatar::new(profile.name()).picture(profile.picture()))
|
||||
.child(profile.name()),
|
||||
.child(
|
||||
Avatar::new(profile.public_key())
|
||||
.name(name.clone())
|
||||
.picture(profile.picture()),
|
||||
)
|
||||
.child(name),
|
||||
)
|
||||
})
|
||||
.when_some(binding.identifier.as_deref(), |this, ident| {
|
||||
@@ -2026,7 +2409,7 @@ impl Panel for RepoDetailView {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let store = self.store.read(cx);
|
||||
let name = repo_display_name(store);
|
||||
let avatar = repo_tab_avatar(store, cx);
|
||||
let avatar = repo_tab_avatar(store, &self.directory, cx);
|
||||
|
||||
tab_title(avatar, name)
|
||||
}
|
||||
@@ -2044,7 +2427,8 @@ impl Render for RepoDetailView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let banner = self
|
||||
.render_ready_banner(cx)
|
||||
.or_else(|| self.render_push_banner(cx));
|
||||
.or_else(|| self.render_push_banner(cx))
|
||||
.or_else(|| self.render_sync_banner(cx));
|
||||
|
||||
let error = self.error.clone().or_else(|| {
|
||||
self.store
|
||||
@@ -2059,6 +2443,9 @@ impl Render for RepoDetailView {
|
||||
.id("repo")
|
||||
.size_full()
|
||||
.when_some(banner, |this, banner| this.child(banner))
|
||||
.when_some(self.render_push_progress_banner(cx), |this, banner| {
|
||||
this.child(banner)
|
||||
})
|
||||
.when_some(self.render_push_warning_banner(cx), |this, banner| {
|
||||
this.child(banner)
|
||||
})
|
||||
|
||||
@@ -14,8 +14,10 @@ use gpui_component::{
|
||||
ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex,
|
||||
v_virtual_list,
|
||||
};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::PublicKey;
|
||||
use signed_core::Announcement;
|
||||
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
||||
use signed_state::{RepoListStore, Timestamp};
|
||||
use signed_ui::{Avatar, SegmentButton};
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -107,6 +109,8 @@ pub struct RepoListView {
|
||||
/// Indices matching [`Self::filter`] into the store's `announcements`.
|
||||
visible: Vec<usize>,
|
||||
search: Entity<InputState>,
|
||||
/// Profiles for the card owners.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_search_subscription: Subscription,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
@@ -118,6 +122,7 @@ impl RepoListView {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let store = RepoListStore::global(cx);
|
||||
let directory = ProfileDirectory::new(cx);
|
||||
|
||||
let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search..."));
|
||||
let search_subscription = cx.subscribe(&search, |this, _search, event, cx| {
|
||||
@@ -126,8 +131,7 @@ impl RepoListView {
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the visible slice and row sizes in sync with the store.
|
||||
// Newly announced repositories appear without waiting for a click.
|
||||
// Rebuild the rows when the store changes.
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.rebuild_rows(cx);
|
||||
});
|
||||
@@ -146,6 +150,7 @@ impl RepoListView {
|
||||
repo_len: 0,
|
||||
visible: Vec::new(),
|
||||
search,
|
||||
directory,
|
||||
_search_subscription: search_subscription,
|
||||
_subscription: subscription,
|
||||
}
|
||||
@@ -158,6 +163,17 @@ impl RepoListView {
|
||||
|
||||
self.visible = filter.visible(store, &query);
|
||||
|
||||
let owners: Vec<PublicKey> = self
|
||||
.visible
|
||||
.iter()
|
||||
.filter_map(|ix| store.announcements.get(*ix))
|
||||
.map(|announcement| announcement.owner)
|
||||
.collect();
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(owners, cx);
|
||||
});
|
||||
|
||||
// Each virtual list row holds `COLUMNS` repo cards.
|
||||
let rows = self.visible.len().div_ceil(COLUMNS);
|
||||
|
||||
@@ -176,8 +192,7 @@ impl RepoListView {
|
||||
last_activity: Option<Timestamp>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
let owner = profile_store.read(cx).get(&announcement.owner);
|
||||
let owner = self.directory.read(cx).profile(&announcement.owner, cx);
|
||||
|
||||
let name = announcement
|
||||
.name
|
||||
@@ -274,7 +289,11 @@ impl RepoListView {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(Avatar::new(owner.name()).picture(owner.picture()))
|
||||
.child(
|
||||
Avatar::new(announcement.owner)
|
||||
.name(owner.name())
|
||||
.picture(owner.picture()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
|
||||
@@ -9,6 +9,7 @@ use gpui_component::input::{Input, InputState, Textarea, TextareaState};
|
||||
use gpui_component::scroll::ScrollableElement;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
||||
use metadata::ProfileDirectory;
|
||||
use signed_state::RepoStore;
|
||||
|
||||
use crate::views::{repo_tab_avatar, tab_title};
|
||||
@@ -24,6 +25,8 @@ pub struct SendPatchView {
|
||||
submitting: bool,
|
||||
/// Error of the last submit attempt, it keeps the panel open.
|
||||
error: Option<SharedString>,
|
||||
/// Profile for the repository owner in the tab title.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
}
|
||||
|
||||
impl SendPatchView {
|
||||
@@ -34,11 +37,17 @@ impl SendPatchView {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
let directory = ProfileDirectory::new(cx);
|
||||
|
||||
if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) {
|
||||
directory.update(cx, |directory, cx| {
|
||||
directory.track([owner], cx);
|
||||
});
|
||||
}
|
||||
|
||||
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
|
||||
|
||||
let description = cx
|
||||
.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)"));
|
||||
|
||||
let description =
|
||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change"));
|
||||
let patch = cx.new(|cx| {
|
||||
TextareaState::new(window, cx).placeholder("diff --git a/file.txt b/file.txt\nindex 1234567..abcdefg 100644\n--- a/file.txt\n+++ b/file.txt")
|
||||
});
|
||||
@@ -53,6 +62,7 @@ impl SendPatchView {
|
||||
patch,
|
||||
submitting: false,
|
||||
error: None,
|
||||
directory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +218,7 @@ impl BasePanel for SendPatchView {
|
||||
|
||||
impl Panel for SendPatchView {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||
let label = SharedString::from(format!("{}/send-patch", self.repo_name));
|
||||
|
||||
tab_title(avatar, label)
|
||||
|
||||
@@ -10,11 +10,12 @@ use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputState, Textarea};
|
||||
use gpui_component::{Disableable, IconName, WindowExt, h_flex};
|
||||
use settings::SettingsStore;
|
||||
use signed_core::RepoAddr;
|
||||
use signed_state::{Backend, CheckoutsStore};
|
||||
|
||||
use super::super::open_repo_panel;
|
||||
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
use crate::views::dialog_state::{DialogProgress, error_row, push_steps_row};
|
||||
|
||||
/// Progress of the create-repository flow, so async results can be rendered.
|
||||
pub type CreateRepoState = DialogProgress;
|
||||
@@ -112,6 +113,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
|
||||
)
|
||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
|
||||
)
|
||||
.children(push_steps_row(&state.read(cx).push_steps, cx))
|
||||
.children(error_row(&error, cx))
|
||||
.child(
|
||||
DialogFooter::new().justify_end().child(
|
||||
@@ -209,9 +211,15 @@ fn create_repository(
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, _| state.begin());
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let Some(user) = backend.read(cx).current_user() else {
|
||||
state.update(cx, |state, _| state.fail("Sign in to create a repository"));
|
||||
return;
|
||||
};
|
||||
|
||||
let addr = RepoAddr::new(user, RepoAddr::identifier_from_name(&name));
|
||||
state.update(cx, |state, cx| state.begin_push(addr, cx));
|
||||
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.create_repository(&name, &description, folder, servers, cx)
|
||||
});
|
||||
|
||||
@@ -10,8 +10,9 @@ use dock::{
|
||||
};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render,
|
||||
SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, white,
|
||||
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ObjectFit,
|
||||
Render, SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list,
|
||||
white,
|
||||
};
|
||||
use gpui_base::Button as BaseButton;
|
||||
use gpui_component::button::{Button, ButtonVariant, ButtonVariants};
|
||||
@@ -19,13 +20,14 @@ use gpui_component::dialog::DialogButtonProps;
|
||||
use gpui_component::input::InputState;
|
||||
use gpui_component::menu::{DropdownMenu, PopupMenuItem};
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, WindowExt, h_flex, v_flex};
|
||||
use nostr::prelude::RelayUrl;
|
||||
use metadata::{Profile, ProfileDirectory};
|
||||
use nostr::prelude::{PublicKey, RelayUrl};
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
use signed_state::{
|
||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile,
|
||||
ProfileStore, RepoListStore, ResolvedLocalRepo,
|
||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, RepoListStore,
|
||||
ResolvedLocalRepo, SyncStatusDirectory,
|
||||
};
|
||||
use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers};
|
||||
use signed_ui::{Avatar, NavItem, title_bar_drag_handlers};
|
||||
|
||||
use super::{InboxView, PlaceholderPanel, RepoDetailView, RepoListView, open_repo_panel};
|
||||
|
||||
@@ -59,13 +61,17 @@ pub struct SidebarPanel {
|
||||
profile: Option<WeakEntity<PlaceholderPanel>>,
|
||||
relays: Option<WeakEntity<PlaceholderPanel>>,
|
||||
banner: SharedString,
|
||||
scanning: bool,
|
||||
/// User's announced repositories.
|
||||
announcements: Arc<Vec<Announcement>>,
|
||||
/// Local repositories found by the scan that are not announced yet.
|
||||
local_repos: Arc<Vec<ResolvedLocalRepo>>,
|
||||
scanning: bool,
|
||||
/// Unpushed commit counts per announced repository.
|
||||
unpushed: HashMap<RepoAddr, usize>,
|
||||
/// Profiles rendered by the sidebar.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
/// Sync statuses for the announced repositories.
|
||||
sync_directory: Entity<SyncStatusDirectory>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
@@ -124,6 +130,8 @@ impl SidebarPanel {
|
||||
local_repos: Arc::new(Vec::new()),
|
||||
scanning: false,
|
||||
unpushed: HashMap::new(),
|
||||
directory: ProfileDirectory::new(cx),
|
||||
sync_directory: SyncStatusDirectory::new(cx),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
@@ -155,6 +163,35 @@ impl SidebarPanel {
|
||||
self.local_repos = Arc::new(local_repos);
|
||||
self.scanning = scanning;
|
||||
|
||||
let mut profiles: Vec<PublicKey> = user.into_iter().collect();
|
||||
let owners: Vec<PublicKey> = self
|
||||
.local_repos
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
entry
|
||||
.nip34
|
||||
.as_ref()
|
||||
.and_then(|binding| binding.owner)
|
||||
.filter(|owner| !profiles.contains(owner))
|
||||
})
|
||||
.collect();
|
||||
|
||||
profiles.extend(owners);
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(profiles, cx);
|
||||
});
|
||||
|
||||
let addrs: Vec<RepoAddr> = self
|
||||
.announcements
|
||||
.iter()
|
||||
.map(|announcement| announcement.addr())
|
||||
.collect();
|
||||
|
||||
self.sync_directory.update(cx, |directory, cx| {
|
||||
directory.track(addrs, cx);
|
||||
});
|
||||
|
||||
if announcements_changed {
|
||||
self.request_push_watches(cx);
|
||||
self.unpushed.clear();
|
||||
@@ -478,32 +515,31 @@ impl SidebarPanel {
|
||||
) -> impl IntoElement {
|
||||
let name = announcement.name().map(SharedString::from);
|
||||
// The sidebar rows are compact, so the avatar matches the 16px nav icons.
|
||||
let avatar =
|
||||
PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall();
|
||||
let avatar = Avatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall();
|
||||
let announcement = announcement.clone();
|
||||
|
||||
let unpushed = self
|
||||
.unpushed
|
||||
.get(&announcement.addr())
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
let addr = announcement.addr();
|
||||
let unpushed = self.unpushed.get(&addr).copied().unwrap_or(0);
|
||||
let unsynced = self.sync_directory.read(cx).unsynced(&addr, cx);
|
||||
|
||||
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
|
||||
|
||||
if unpushed > 0 {
|
||||
row = row.suffix(
|
||||
v_flex()
|
||||
.flex_shrink_0()
|
||||
.size_4()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.line_height(relative(1.))
|
||||
.bg(cx.theme().red_light)
|
||||
.text_color(white())
|
||||
.text_size(px(8.))
|
||||
.child(SharedString::from(unpushed.to_string())),
|
||||
);
|
||||
if unpushed > 0 || unsynced > 0 {
|
||||
let badges = h_flex().gap_0p5().flex_shrink_0();
|
||||
|
||||
let badges = if unpushed > 0 {
|
||||
badges.child(Self::count_badge(unpushed, cx.theme().red_light))
|
||||
} else {
|
||||
badges
|
||||
};
|
||||
|
||||
let badges = if unsynced > 0 {
|
||||
badges.child(Self::count_badge(unsynced, cx.theme().info))
|
||||
} else {
|
||||
badges
|
||||
};
|
||||
|
||||
row = row.suffix(badges);
|
||||
}
|
||||
|
||||
row.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
@@ -517,13 +553,27 @@ impl SidebarPanel {
|
||||
}))
|
||||
}
|
||||
|
||||
fn count_badge(count: usize, color: Hsla) -> Div {
|
||||
v_flex()
|
||||
.flex_shrink_0()
|
||||
.size_4()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.line_height(relative(1.))
|
||||
.bg(color)
|
||||
.text_color(white())
|
||||
.text_size(px(8.))
|
||||
.child(SharedString::from(count.to_string()))
|
||||
}
|
||||
|
||||
fn render_local_row(
|
||||
&self,
|
||||
entry: &ResolvedLocalRepo,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let name = entry.name();
|
||||
let avatar = local_avatar(entry, cx);
|
||||
let avatar = local_avatar(entry, &self.directory, cx);
|
||||
|
||||
let id = format!("local-repo:{}", entry.path.display());
|
||||
let entry = entry.clone();
|
||||
@@ -590,7 +640,11 @@ impl SidebarPanel {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(Avatar::new(name.clone()).picture(picture))
|
||||
.child(
|
||||
Avatar::new(profile.public_key())
|
||||
.name(name.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(div().text_xs().font_semibold().child(name)),
|
||||
)
|
||||
.dropdown_menu(move |menu, _, _| {
|
||||
@@ -720,23 +774,27 @@ pub(super) fn server_host(relay: &RelayUrl) -> SharedString {
|
||||
}
|
||||
|
||||
/// The repository's pixel avatar, with the bound owner's avatar at its bottom right.
|
||||
fn local_avatar(entry: &ResolvedLocalRepo, cx: &App) -> AnyElement {
|
||||
fn local_avatar(
|
||||
entry: &ResolvedLocalRepo,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
// Matches the other sidebar rows, and the badge below sits at its corner.
|
||||
let avatar = PixelAvatar::new(entry.path.to_string_lossy()).xsmall();
|
||||
let avatar = Avatar::new(entry.path.to_string_lossy()).xsmall();
|
||||
|
||||
let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else {
|
||||
return avatar.into_any_element();
|
||||
};
|
||||
|
||||
let store = ProfileStore::global(cx);
|
||||
let profile = store.read(cx).get(&owner);
|
||||
let profile = directory.read(cx).profile(&owner, cx);
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.child(avatar)
|
||||
.child(
|
||||
div().absolute().bottom_neg_0p5().right_neg_0p5().child(
|
||||
Avatar::new(profile.name())
|
||||
Avatar::new(owner)
|
||||
.name(profile.name())
|
||||
.picture(profile.picture())
|
||||
.size(px(14.)),
|
||||
),
|
||||
@@ -781,12 +839,11 @@ impl Focusable for SidebarPanel {
|
||||
impl Render for SidebarPanel {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let backend = Backend::global(cx);
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
|
||||
let profile = backend
|
||||
.read(cx)
|
||||
.current_user()
|
||||
.map(|public_key| profile_store.read(cx).get(&public_key));
|
||||
.map(|public_key| self.directory.read(cx).profile(&public_key, cx));
|
||||
|
||||
if profile.is_none() {
|
||||
return self.render_sign_in(window, cx);
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
use std::cell::Cell;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyWindowHandle, App, Entity, PathPromptOptions, SharedString, Subscription, Window, div, px,
|
||||
};
|
||||
use gpui::{App, Entity, PathPromptOptions, SharedString, Subscription, Window, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::{
|
||||
Input, InputEvent, InputState, NumberInput, NumberInputEvent, StepAction,
|
||||
};
|
||||
use gpui_component::input::{Input, InputState};
|
||||
use gpui_component::select::{Select, SelectEvent, SelectState};
|
||||
use gpui_component::separator::Separator;
|
||||
use gpui_component::setting::NumberFieldOptions;
|
||||
use gpui_component::switch::Switch;
|
||||
use gpui_component::{
|
||||
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex,
|
||||
v_flex,
|
||||
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, WindowExt, h_flex, v_flex,
|
||||
};
|
||||
use settings::{AppearanceMode, EventFetchingStrategy, Settings, SettingsStore};
|
||||
use signed_ui::{SelectOption, setting_block, setting_row};
|
||||
@@ -31,34 +24,10 @@ fn selected_index(options: &[SelectOption], value: &str) -> Option<IndexPath> {
|
||||
.map(|row| IndexPath::default().row(row))
|
||||
}
|
||||
|
||||
/// Registered themes split into light and dark options, light first.
|
||||
fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
|
||||
let registry = ThemeRegistry::global(cx);
|
||||
let mut light = Vec::new();
|
||||
let mut dark = Vec::new();
|
||||
for config in registry.themes().values() {
|
||||
let name = config.name.clone();
|
||||
if config.mode.is_dark() {
|
||||
dark.push(SelectOption::new(name.clone(), name));
|
||||
} else {
|
||||
light.push(SelectOption::new(name.clone(), name));
|
||||
}
|
||||
}
|
||||
light.sort_by(|a, b| a.label().as_ref().cmp(b.label().as_ref()));
|
||||
dark.sort_by(|a, b| a.label().as_ref().cmp(b.label().as_ref()));
|
||||
(light, dark)
|
||||
}
|
||||
|
||||
/// Created once when the dialog opens, so control state survives re-renders.
|
||||
struct SettingsControls {
|
||||
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
||||
event_fetching: Entity<SelectState<Vec<SelectOption>>>,
|
||||
light_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||
dark_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||
font_size: Entity<InputState>,
|
||||
mono_font_size: Entity<InputState>,
|
||||
radius: Entity<InputState>,
|
||||
radius_lg: Entity<InputState>,
|
||||
grasp_server_input: Entity<InputState>,
|
||||
/// The effective create-repository folder, shown in a disabled input.
|
||||
default_folder: Entity<InputState>,
|
||||
@@ -107,38 +76,9 @@ impl SettingsControls {
|
||||
)
|
||||
});
|
||||
|
||||
let (light_options, dark_options) = theme_options(cx);
|
||||
let light_theme = cx.new(|cx| {
|
||||
SelectState::new(
|
||||
light_options.clone(),
|
||||
selected_index(&light_options, &settings.theme.light_theme),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let dark_theme = cx.new(|cx| {
|
||||
SelectState::new(
|
||||
dark_options.clone(),
|
||||
selected_index(&dark_options, &settings.theme.dark_theme),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let font_size = cx.new(|cx| {
|
||||
InputState::new(window, cx).default_value(settings.theme.font_size.to_string())
|
||||
});
|
||||
let mono_font_size = cx.new(|cx| {
|
||||
InputState::new(window, cx).default_value(settings.theme.mono_font_size.to_string())
|
||||
});
|
||||
let radius = cx
|
||||
.new(|cx| InputState::new(window, cx).default_value(settings.theme.radius.to_string()));
|
||||
let radius_lg = cx.new(|cx| {
|
||||
InputState::new(window, cx).default_value(settings.theme.radius_lg.to_string())
|
||||
});
|
||||
|
||||
let grasp_server_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("wss://relay.example.com"));
|
||||
|
||||
let default_folder = cx.new(|cx| {
|
||||
let folder = settings
|
||||
.create_repository
|
||||
@@ -178,118 +118,9 @@ impl SettingsControls {
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&light_theme, |_, event, cx| {
|
||||
if let SelectEvent::Confirm(Some(value)) = event {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| settings.theme.light_theme = value.to_string(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
apply_theme(cx);
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&dark_theme, |_, event, cx| {
|
||||
if let SelectEvent::Confirm(Some(value)) = event {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.dark_theme = value.to_string(), cx);
|
||||
});
|
||||
apply_theme(cx);
|
||||
}
|
||||
}));
|
||||
|
||||
wire_number_input(
|
||||
&font_size,
|
||||
&mut subscriptions,
|
||||
window.window_handle(),
|
||||
cx,
|
||||
NumberFieldOptions {
|
||||
min: 10.,
|
||||
max: 32.,
|
||||
..Default::default()
|
||||
},
|
||||
settings.theme.font_size as f64,
|
||||
|value, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.font_size = value as f32, cx);
|
||||
});
|
||||
apply_theme(cx);
|
||||
},
|
||||
);
|
||||
|
||||
wire_number_input(
|
||||
&mono_font_size,
|
||||
&mut subscriptions,
|
||||
window.window_handle(),
|
||||
cx,
|
||||
NumberFieldOptions {
|
||||
min: 8.,
|
||||
max: 32.,
|
||||
..Default::default()
|
||||
},
|
||||
settings.theme.mono_font_size as f64,
|
||||
|value, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.mono_font_size = value as f32, cx);
|
||||
});
|
||||
apply_theme(cx);
|
||||
},
|
||||
);
|
||||
|
||||
wire_number_input(
|
||||
&radius,
|
||||
&mut subscriptions,
|
||||
window.window_handle(),
|
||||
cx,
|
||||
NumberFieldOptions {
|
||||
min: 0.,
|
||||
max: 24.,
|
||||
..Default::default()
|
||||
},
|
||||
settings.theme.radius as f64,
|
||||
|value, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.radius = value as f32, cx);
|
||||
});
|
||||
apply_theme(cx);
|
||||
},
|
||||
);
|
||||
|
||||
wire_number_input(
|
||||
&radius_lg,
|
||||
&mut subscriptions,
|
||||
window.window_handle(),
|
||||
cx,
|
||||
NumberFieldOptions {
|
||||
min: 0.,
|
||||
max: 24.,
|
||||
..Default::default()
|
||||
},
|
||||
settings.theme.radius_lg as f64,
|
||||
|value, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.radius_lg = value as f32, cx);
|
||||
});
|
||||
apply_theme(cx);
|
||||
},
|
||||
);
|
||||
|
||||
Self {
|
||||
appearance,
|
||||
event_fetching,
|
||||
light_theme,
|
||||
dark_theme,
|
||||
font_size,
|
||||
mono_font_size,
|
||||
radius,
|
||||
radius_lg,
|
||||
grasp_server_input,
|
||||
default_folder,
|
||||
_subscriptions: subscriptions,
|
||||
@@ -320,8 +151,6 @@ fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement
|
||||
.w_full()
|
||||
.child(appearance_section(controls, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(theme_section(&settings, controls, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(tab_bar_section(&settings, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(grasp_servers_section(&settings, controls, cx))
|
||||
@@ -350,76 +179,6 @@ fn event_fetching_section(controls: &SettingsControls, cx: &App) -> impl IntoEle
|
||||
))
|
||||
}
|
||||
|
||||
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"Light Theme",
|
||||
"The theme to use when the appearance is light.",
|
||||
Select::new(&controls.light_theme).w_full(),
|
||||
))
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"Dark Theme",
|
||||
"The theme to use when the appearance is dark.",
|
||||
Select::new(&controls.dark_theme).w_full(),
|
||||
))
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"UI Font Size",
|
||||
"Font size for the UI.",
|
||||
NumberInput::new(&controls.font_size).w_full(),
|
||||
))
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"Editor Font Size",
|
||||
"Font size for editor text.",
|
||||
NumberInput::new(&controls.mono_font_size).w_full(),
|
||||
))
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"Corner Radius",
|
||||
"The corner radius for UI elements.",
|
||||
NumberInput::new(&controls.radius).w_full(),
|
||||
))
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"Large Corner Radius",
|
||||
"The corner radius for large UI elements (dialogs, notifications).",
|
||||
NumberInput::new(&controls.radius_lg).w_full(),
|
||||
))
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"Focus Ring",
|
||||
"Draw a ring around focused controls.",
|
||||
Switch::new("focus-ring")
|
||||
.checked(settings.theme.focus_ring)
|
||||
.on_click(move |checked: &bool, _window, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.focus_ring = *checked, cx);
|
||||
});
|
||||
apply_theme(cx);
|
||||
}),
|
||||
))
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"Shadows",
|
||||
"The shadow effect for UI elements.",
|
||||
Switch::new("shadows")
|
||||
.checked(settings.theme.shadow)
|
||||
.on_click(move |checked: &bool, _window, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.shadow = *checked, cx);
|
||||
});
|
||||
apply_theme(cx);
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Tab bar chrome, such as its next/previous tab buttons.
|
||||
fn tab_bar_section(settings: &Settings, cx: &App) -> impl IntoElement {
|
||||
v_flex().w_full().gap_3().child(setting_row(
|
||||
@@ -724,79 +483,6 @@ fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Windo
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Step actions clamp and persist the value; typed changes parse, clamp and persist.
|
||||
fn wire_number_input(
|
||||
state: &Entity<InputState>,
|
||||
subscriptions: &mut Vec<Subscription>,
|
||||
window_handle: AnyWindowHandle,
|
||||
cx: &mut App,
|
||||
options: NumberFieldOptions,
|
||||
initial: f64,
|
||||
on_change: impl Fn(f64, &mut App) + 'static,
|
||||
) {
|
||||
let on_change = Rc::new(on_change);
|
||||
let initial_value = Rc::new(Cell::new(initial));
|
||||
let (min, max, step) = (options.min, options.max, options.step);
|
||||
|
||||
subscriptions.push(cx.subscribe(&state.clone(), {
|
||||
let state = state.clone();
|
||||
let initial_value = initial_value.clone();
|
||||
let on_change = on_change.clone();
|
||||
move |_, event: &NumberInputEvent, cx| {
|
||||
let NumberInputEvent::Step(action) = event;
|
||||
let value = state.read(cx).value();
|
||||
if let Ok(value) = value.parse::<f64>() {
|
||||
let new_value = match action {
|
||||
StepAction::Increment => value + step,
|
||||
StepAction::Decrement => value - step,
|
||||
};
|
||||
let clamped = new_value.clamp(min, max);
|
||||
window_handle
|
||||
.update(cx, |_, window, cx| {
|
||||
state.update(cx, |input, cx| {
|
||||
input.set_value(SharedString::from(clamped.to_string()), window, cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
initial_value.set(clamped);
|
||||
on_change(clamped, cx);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&state.clone(), {
|
||||
let state = state.clone();
|
||||
let initial_value = initial_value.clone();
|
||||
let on_change = on_change.clone();
|
||||
move |_, event: &InputEvent, cx| {
|
||||
if matches!(event, InputEvent::Change) {
|
||||
let value = state.read(cx).value();
|
||||
if value == initial_value.get().to_string() {
|
||||
return;
|
||||
}
|
||||
if let Ok(parsed) = value.parse::<f64>() {
|
||||
let clamped = parsed.clamp(min, max);
|
||||
initial_value.set(clamped);
|
||||
on_change(clamped, cx);
|
||||
if clamped != parsed {
|
||||
window_handle
|
||||
.update(cx, |_, window, cx| {
|
||||
state.update(cx, |input, cx| {
|
||||
input.set_value(
|
||||
SharedString::from(clamped.to_string()),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
|
||||
match appearance {
|
||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||
@@ -804,33 +490,3 @@ fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
|
||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_theme(cx: &mut App) {
|
||||
let store = SettingsStore::global(cx);
|
||||
let settings = store.read(cx).settings().theme.clone();
|
||||
|
||||
let registry = ThemeRegistry::global(cx);
|
||||
let light_config = registry
|
||||
.themes()
|
||||
.get(settings.light_theme.as_str())
|
||||
.cloned();
|
||||
let dark_config = registry.themes().get(settings.dark_theme.as_str()).cloned();
|
||||
let mode = Theme::global(cx).mode;
|
||||
|
||||
let theme = Theme::global_mut(cx);
|
||||
theme.radius = px(settings.radius);
|
||||
theme.radius_lg = px(settings.radius_lg);
|
||||
theme.focus_ring = settings.focus_ring;
|
||||
theme.shadow = settings.shadow;
|
||||
theme.font_size = px(settings.font_size);
|
||||
theme.mono_font_size = px(settings.mono_font_size);
|
||||
if let Some(config) = light_config {
|
||||
theme.light_theme = config;
|
||||
}
|
||||
if let Some(config) = dark_config {
|
||||
theme.dark_theme = config;
|
||||
}
|
||||
|
||||
// Re-apply the active mode so the updated configs take effect.
|
||||
Theme::change(mode, None, cx);
|
||||
}
|
||||
|
||||
+5
-1
@@ -14,7 +14,7 @@ product-name = "Signed"
|
||||
description = "Nostr Git client for browsing repositories and collaborating"
|
||||
identifier = "info.reya.signed"
|
||||
category = "DeveloperTool"
|
||||
version = "0.2.0-alpha"
|
||||
version = "0.2.0"
|
||||
out-dir = "../dist"
|
||||
before-packaging-command = "cargo build --release"
|
||||
resources = ["Cargo.toml", "src"]
|
||||
@@ -28,6 +28,7 @@ icons = [
|
||||
|
||||
[dependencies]
|
||||
assets = { path = "../crates/assets" }
|
||||
metadata = { path = "../crates/metadata" }
|
||||
paths = { path = "../crates/paths" }
|
||||
settings = { path = "../crates/settings" }
|
||||
signed_state = { path = "../crates/signed_state" }
|
||||
@@ -37,6 +38,9 @@ gpui.workspace = true
|
||||
gpui_platform.workspace = true
|
||||
dock = { workspace = true }
|
||||
gpui-component.workspace = true
|
||||
native-theme.workspace = true
|
||||
native-theme-gpui.workspace = true
|
||||
flume.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
+110
-41
@@ -5,7 +5,9 @@ use dock::TAB_BAR_HEIGHT;
|
||||
use gpui::*;
|
||||
use gpui_component::{Theme, ThemeMode, ThemeRegistry, theme};
|
||||
use gpui_platform::application;
|
||||
use settings::{AppearanceMode, SettingsStore};
|
||||
use native_theme::watch::ThemeSubscription;
|
||||
use native_theme_gpui::{SystemTheme, apply_system_theme};
|
||||
use settings::{AppearanceMode, Settings, SettingsStore};
|
||||
|
||||
actions!(signed, [Quit]);
|
||||
|
||||
@@ -25,63 +27,71 @@ fn main() {
|
||||
// The persisted settings must load before the theme is applied.
|
||||
let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx));
|
||||
SettingsStore::set_global(store.clone(), cx);
|
||||
let settings = store.read(cx).settings().clone();
|
||||
|
||||
let registry = ThemeRegistry::global_mut(cx);
|
||||
for (name, content) in Assets.themes() {
|
||||
if let Err(err) = registry.load_themes_from_str(&content) {
|
||||
tracing::error!("Failed to load theme {name}: {err}");
|
||||
let settings = store.read(cx).settings().clone();
|
||||
{
|
||||
let registry = ThemeRegistry::global_mut(cx);
|
||||
for (name, content) in Assets.themes() {
|
||||
if let Err(err) = registry.load_themes_from_str(&content) {
|
||||
tracing::error!("Failed to load theme {name}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let light_theme = registry
|
||||
.themes()
|
||||
.get(settings.theme.light_theme.as_str())
|
||||
.cloned();
|
||||
let dark_theme = registry
|
||||
.themes()
|
||||
.get(settings.theme.dark_theme.as_str())
|
||||
.cloned();
|
||||
|
||||
let theme = Theme::global_mut(cx);
|
||||
theme.radius = px(settings.theme.radius);
|
||||
theme.radius_lg = px(settings.theme.radius_lg);
|
||||
theme.focus_ring = settings.theme.focus_ring;
|
||||
theme.shadow = settings.theme.shadow;
|
||||
theme.font_size = px(settings.theme.font_size);
|
||||
theme.mono_font_size = px(settings.theme.mono_font_size);
|
||||
match apply_native_theme(&settings, cx) {
|
||||
Ok(()) => start_theme_watcher(cx),
|
||||
Err(error) => {
|
||||
tracing::warn!("native theme unavailable ({error})");
|
||||
|
||||
if let Some(light) = light_theme {
|
||||
theme.light_theme = light;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"{} theme is missing from the registry",
|
||||
settings.theme.light_theme
|
||||
);
|
||||
}
|
||||
let registry = ThemeRegistry::global_mut(cx);
|
||||
let light_theme = registry
|
||||
.themes()
|
||||
.get(settings.theme.light_theme.as_str())
|
||||
.cloned();
|
||||
|
||||
if let Some(dark) = dark_theme {
|
||||
theme.dark_theme = dark;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"{} theme is missing from the registry",
|
||||
settings.theme.dark_theme
|
||||
);
|
||||
}
|
||||
let dark_theme = registry
|
||||
.themes()
|
||||
.get(settings.theme.dark_theme.as_str())
|
||||
.cloned();
|
||||
|
||||
match settings.appearance {
|
||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||
let theme = Theme::global_mut(cx);
|
||||
theme.radius = px(settings.theme.radius);
|
||||
theme.radius_lg = px(settings.theme.radius_lg);
|
||||
theme.focus_ring = settings.theme.focus_ring;
|
||||
theme.shadow = settings.theme.shadow;
|
||||
theme.font_size = px(settings.theme.font_size);
|
||||
theme.mono_font_size = px(settings.theme.mono_font_size);
|
||||
|
||||
if let Some(light) = light_theme {
|
||||
theme.light_theme = light;
|
||||
} else {
|
||||
tracing::warn!("{} theme is missing", settings.theme.light_theme);
|
||||
}
|
||||
|
||||
if let Some(dark) = dark_theme {
|
||||
theme.dark_theme = dark;
|
||||
} else {
|
||||
tracing::warn!("{} theme is missing", settings.theme.dark_theme);
|
||||
}
|
||||
|
||||
match settings.appearance {
|
||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(paths::nostr_dir()).ok();
|
||||
std::fs::create_dir_all(paths::repos_dir()).ok();
|
||||
|
||||
signed_state::init(
|
||||
paths::nostr_dir(),
|
||||
paths::repos_dir().clone(),
|
||||
settings.local_repos.scan_paths,
|
||||
cx,
|
||||
);
|
||||
metadata::init(cx);
|
||||
|
||||
cx.set_app_identity("info.reya.signed", "Signed");
|
||||
|
||||
@@ -114,3 +124,62 @@ fn main() {
|
||||
cx.activate(true);
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply the OS theme to the app, scaling the font size to match the system font size.
|
||||
fn apply_native_theme(settings: &Settings, cx: &mut App) -> native_theme::Result<()> {
|
||||
let mut system = SystemTheme::from_system()?;
|
||||
let native = system.light.defaults.font.size;
|
||||
if native > 0.0 {
|
||||
let scaling = settings.theme.font_size / native;
|
||||
system.accessibility.text_scaling_factor *= scaling;
|
||||
}
|
||||
|
||||
apply_system_theme(&system, cx);
|
||||
|
||||
match settings.appearance {
|
||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Holds the watcher subscription so it lives as long as the app.
|
||||
struct ThemeWatcher {
|
||||
_subscription: ThemeSubscription,
|
||||
}
|
||||
|
||||
impl Global for ThemeWatcher {}
|
||||
|
||||
/// Re-apply the OS theme whenever it changes at runtime.
|
||||
fn start_theme_watcher(cx: &mut App) {
|
||||
let (sender, receiver) = flume::unbounded();
|
||||
match native_theme::watch::on_theme_change(move |event| {
|
||||
if let Err(error) = sender.send(event) {
|
||||
tracing::warn!("theme change dropped ({error})");
|
||||
}
|
||||
}) {
|
||||
Ok(subscription) => {
|
||||
cx.set_global(ThemeWatcher {
|
||||
_subscription: subscription,
|
||||
});
|
||||
cx.spawn(async move |cx| {
|
||||
while receiver.recv_async().await.is_ok() {
|
||||
tracing::info!("OS theme changed; re-applying");
|
||||
cx.update(|cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
let settings = store.read(cx).settings().clone();
|
||||
if let Err(error) = apply_native_theme(&settings, cx) {
|
||||
tracing::warn!("failed to apply updated OS theme ({error})");
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!("OS theme watching unavailable ({error})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
### Milestone 1: release the first out-of-box experience version
|
||||
Alpha version has been released, I'm working toward the first stable release for the desktop version, which will include a fully git workflow, repository management and collaboration. The stable version will maximum compatible with other nostr git (grasp) client
|
||||
|
||||
### Milestone 2: release the web version and mobile apps
|
||||
Release the web for browsing and repository management, full git workflow and collaboration still require the desktop app. About the mobile apps, it not only a hub for repositories but also control the agents which will be add later
|
||||
|
||||
### Milestone 3: social coding and web of trust
|
||||
Implement social layer, each repository can become a community (NIP-29), sharing patch snippets or napplet (NIP-5D) like sharing posts. The web of trust layer will help me user focus on works and support from their circle, provide more information to help user make decision, prevent bad actor can affect the software or supply chain.
|
||||
|
||||
### Milestone 4: agent collaboration and workspace
|
||||
Support users to plug their coding agents to resolve or review issues, patches, and pull requests. Workspace is a specific environment, allowing users to try new ideas, test a patch in a parallel branch without affecting the main tree.
|
||||
|
||||
### Milestone 5: private repository and team collaboration
|
||||
Support multiple teammates, channel and communication. Automate turning the discussion into plans and pull requests without hopping between multiple tools.
|
||||
+49
-2
@@ -1,4 +1,51 @@
|
||||
# TODO
|
||||
|
||||
- Profile panel: the sidebar user menu's "View profile" opens an empty placeholder panel; build the actual profile view.
|
||||
- Relays panel: the sidebar user menu's "View relays" opens an empty placeholder panel; build the actual relays view.
|
||||
## UI
|
||||
|
||||
- [ ] Add profile panel, replace the empty placeholder behind the sidebar user menu's "View profile".
|
||||
- [ ] Show the user's profile (metadata, avatar).
|
||||
- [ ] List their published repositories.
|
||||
- [ ] List their relays.
|
||||
- [ ] Show their recent activity (issues, PRs, patches, comments).
|
||||
- [ ] Add relay panel, replace the empty placeholder behind the sidebar user menu's "View relays".
|
||||
- [ ] Configure lookup relays.
|
||||
- [ ] Configure outbox and inbox relays.
|
||||
- [ ] Configure bootstrap relays.
|
||||
- [ ] Configure grasp servers.
|
||||
|
||||
## Maintainer workflow
|
||||
|
||||
- [ ] Add close issue / close PR / mark applied-merged actions, `RepoStore::set_status` exists but is only called for drafts.
|
||||
- [ ] Carry `applied-as-commits` / `merge-commit` tags on status events (nak publishes these).
|
||||
- [ ] Wire up `Repo::apply_patch` (`git am`) so patches can be applied in-app.
|
||||
|
||||
## Repository management
|
||||
|
||||
- [ ] Set `maintainers` when announcing a new repo, multi-maintainer repos cannot be created today and maintainer support is read-only.
|
||||
- [ ] Re-publish announcements to relays that missed the latest version (nak `git sync` reconciles this).
|
||||
- [ ] Support creating fork announcements (upstream `u` tag), currently parse-only.
|
||||
- [ ] Publish `web` urls and hashtags; currently parse-only.
|
||||
|
||||
## Push
|
||||
|
||||
- [ ] Add a fast-forward check and a force option (nak refuses non-FF without `--force`), today it relies entirely on grasp CAS.
|
||||
- [ ] Allow NIP-34 maintainers to push, not just the owner.
|
||||
|
||||
## Contributor workflow
|
||||
|
||||
- [ ] Check PRs out into a worktree to build/test, diffs are view-only in the mirror (ngit `pr checkout`, `pr/` branches).
|
||||
|
||||
## Identity
|
||||
|
||||
- [ ] Support multiple identities with non-destructive switching (ngit: multi-account keyring, aliases, `--signer`); today sign-out deletes the single key.
|
||||
|
||||
## Ecosystem
|
||||
|
||||
- [ ] Private repository support (GRASP-08, encrypted private relay lists).
|
||||
- [ ] Git remote helper so plain `git clone/push` with `nostr://` urls works outside the app.
|
||||
|
||||
## Minor
|
||||
|
||||
- [ ] Open a repo by address (naddr, `npub/identifier`, NIP-05), discovery is currently Explore/Sidebar only.
|
||||
- [ ] Per-grasp-server announcement freshness view (nak `git status`).
|
||||
- [ ] Publish PR labels, they are always empty today.
|
||||
|
||||
Reference in New Issue
Block a user