Compare commits
6
Commits
v0.2.0
..
56d3924e46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56d3924e46 | ||
|
|
3c307691aa | ||
|
|
f02f699ace | ||
|
|
0e3a5ed865 | ||
|
|
b4c3565484 | ||
|
|
4a57a01f4a |
@@ -129,12 +129,6 @@ 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 {}`.
|
||||
|
||||
+5
-17
@@ -1,22 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## v0.2.0 - 2026/10/21
|
||||
## Unreleased
|
||||
|
||||
### Breaking changes
|
||||
|
||||
### 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
|
||||
@@ -25,25 +23,15 @@
|
||||
- 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
+201
-449
File diff suppressed because it is too large
Load Diff
+4
-8
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.2.0-alpha"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
@@ -13,13 +13,9 @@ 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; 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"] }
|
||||
# GPUI Kit
|
||||
gpui-component = { version = "0.7.1", features = ["tree-sitter-languages"] }
|
||||
gpui-base = "0.7.1"
|
||||
|
||||
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.
@@ -1,16 +0,0 @@
|
||||
[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
|
||||
@@ -1,9 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod filters;
|
||||
pub mod git_event;
|
||||
pub mod inbox;
|
||||
pub mod pull_request;
|
||||
pub mod state;
|
||||
pub mod status;
|
||||
pub mod upstream;
|
||||
|
||||
@@ -15,5 +16,6 @@ pub use filters::Filters;
|
||||
pub use git_event::GitEvent;
|
||||
pub use inbox::{InboxItem, InboxReadState, ThreadResolver};
|
||||
pub use pull_request::PullRequest;
|
||||
pub use state::RepoState;
|
||||
pub use status::RepoStatus;
|
||||
pub use upstream::Upstream;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RepoState {
|
||||
pub refs: Vec<(String, String)>,
|
||||
pub head: Option<String>,
|
||||
}
|
||||
|
||||
impl RepoState {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
|
||||
use crate::repo::Repo;
|
||||
@@ -10,16 +8,12 @@ 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,
|
||||
clone_locks: Arc::default(),
|
||||
}
|
||||
Self { root }
|
||||
}
|
||||
|
||||
/// Returns the cache root directory.
|
||||
@@ -44,40 +38,20 @@ impl GitCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
// 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 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)?;
|
||||
}
|
||||
|
||||
// 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)?;
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
|
||||
Repo::clone(clone_urls, &path)
|
||||
|
||||
@@ -1222,25 +1222,22 @@ impl Repo {
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["+refs/heads/main:refs/heads/main"],
|
||||
&["refs/heads/main:refs/heads/main"],
|
||||
)
|
||||
}
|
||||
|
||||
/// Mirrors all branches and tags to the grasp repository at `base_url`.
|
||||
///
|
||||
/// Force refspecs keep the server ref map following the local repository,
|
||||
/// grasp authorizes pushes by ref map equality, not fast-forwards.
|
||||
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/*"],
|
||||
&["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
|
||||
)
|
||||
}
|
||||
|
||||
/// Pushes explicit refspecs to the grasp repository at `base_url`.
|
||||
pub fn push_refspecs(
|
||||
fn push_refspecs(
|
||||
&self,
|
||||
base_url: &str,
|
||||
owner: &str,
|
||||
@@ -1265,33 +1262,6 @@ impl Repo {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lists branches and tags advertised by the remote at `url`.
|
||||
pub fn ls_remote(&self, url: &str) -> Result<Vec<(String, String)>> {
|
||||
let url = Self::transport_url(url);
|
||||
let output = Self::run_git(self.workdir_or_dot(), &["ls-remote", &url], "git ls-remote")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git ls-remote on {url} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let refs = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (oid, name) = line.split_once('\t')?;
|
||||
let name = name.trim();
|
||||
|
||||
(name.starts_with("refs/heads/") || name.starts_with("refs/tags/"))
|
||||
.then(|| (name.to_owned(), oid.to_owned()))
|
||||
.filter(|(name, _)| !name.ends_with("^{}"))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(refs)
|
||||
}
|
||||
|
||||
/// Sets the `origin` URL when the repository has no remote yet.
|
||||
pub fn ensure_origin(&self, url: &str) -> Result<()> {
|
||||
if self.inner.find_remote("origin").is_ok() {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use nostr::prelude::Keys;
|
||||
use signed_core::RepoAddr;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -14,70 +11,6 @@ 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")]);
|
||||
@@ -122,166 +55,6 @@ fn push_all_mirrors_branches_and_tags() {
|
||||
assert!(refs.contains("refs/tags/v1.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ls_remote_lists_branches_and_tags_only() {
|
||||
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"]);
|
||||
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");
|
||||
git_run(&server_repo, &["update-ref", "refs/nostr/abc", &main]);
|
||||
|
||||
let remote = local
|
||||
.ls_remote(&format!("file://{}", server_repo.display()))
|
||||
.expect("ls-remote");
|
||||
let names: Vec<String> = remote.iter().map(|(name, _)| name.clone()).collect();
|
||||
|
||||
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())
|
||||
);
|
||||
}
|
||||
|
||||
#[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 (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();
|
||||
|
||||
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")]);
|
||||
|
||||
+195
-127
@@ -1,7 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Error, anyhow, bail};
|
||||
@@ -20,11 +19,12 @@ use crate::bootstrap::{
|
||||
};
|
||||
use crate::git_store::Mirrors;
|
||||
use crate::inbox::Inbox;
|
||||
use crate::push::{PushOutcome, grasp_base_url, grasp_clone_url};
|
||||
use crate::push::{PushOutcome, grasp_base_url, grasp_clone_url, push_staged_to_grasps};
|
||||
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)]
|
||||
@@ -35,10 +35,6 @@ pub enum BackendEvent {
|
||||
ProfileUpdates(Vec<PublicKey>),
|
||||
RepoUpdates(Vec<Update>),
|
||||
Synced,
|
||||
PushProgress {
|
||||
addr: RepoAddr,
|
||||
status: PushStepStatus,
|
||||
},
|
||||
Error(String),
|
||||
}
|
||||
|
||||
@@ -198,9 +194,13 @@ impl Backend {
|
||||
} else if content.starts_with("bunker://") {
|
||||
let (base, keys) = extract_master_key(&content)?;
|
||||
let uri = NostrConnectUri::parse(base)?;
|
||||
let signer = NostrConnect::builder(uri, keys)
|
||||
.auth_url_handler(Arc::new(SignedAuthUrlHandler))
|
||||
.build()?;
|
||||
let mut signer = NostrConnect::new(
|
||||
uri,
|
||||
keys,
|
||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(signer, cx);
|
||||
@@ -361,9 +361,13 @@ impl Backend {
|
||||
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let signer = NostrConnect::builder(connect_uri, keys)
|
||||
.auth_url_handler(Arc::new(SignedAuthUrlHandler))
|
||||
.build()?;
|
||||
let mut signer = NostrConnect::new(
|
||||
connect_uri,
|
||||
keys,
|
||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||
|
||||
// Verify the bunker responds before persisting the credential.
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
@@ -519,7 +523,6 @@ 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();
|
||||
@@ -534,7 +537,7 @@ impl Backend {
|
||||
folder.join(dir_name)
|
||||
};
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.spawn(async move |_this, cx| {
|
||||
let work = cx.background_spawn({
|
||||
let destination = destination.clone();
|
||||
let name = name.clone();
|
||||
@@ -571,29 +574,19 @@ impl Backend {
|
||||
Some(commit_sha),
|
||||
);
|
||||
|
||||
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)
|
||||
let event = announce_repository_and_push(
|
||||
&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 announcement = Announcement::from_event(&event)
|
||||
@@ -642,20 +635,19 @@ 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| {
|
||||
cx.spawn(async move |_this, cx| {
|
||||
let (mut state, euc) = cx
|
||||
.background_spawn({
|
||||
let path = path.clone();
|
||||
async move {
|
||||
let repo = Repo::open(&path)?;
|
||||
let state = repo.ref_state()?;
|
||||
let euc = repo.root_commit()?;
|
||||
let state = SyncState::from_repo(&path)?;
|
||||
Ok::<_, Error>((state, euc))
|
||||
}
|
||||
})
|
||||
@@ -668,26 +660,28 @@ impl Backend {
|
||||
|
||||
if let Some(branch) = chosen {
|
||||
let wanted = format!("refs/heads/{branch}");
|
||||
if state.refs.iter().any(|git_ref| git_ref.name == wanted) {
|
||||
if state.refs.iter().any(|(name, _)| name == &wanted) {
|
||||
state.head = Some(branch.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
let euc = euc.and_then(|commit: String| Sha1Hash::from_str(&commit).ok());
|
||||
let euc = euc.and_then(|commit| Sha1Hash::from_str(&commit).ok());
|
||||
let ann = build_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_and_push_or_retract(&client, &signer, &state, ann, cx, on_step).await?;
|
||||
let event = announce_repository_and_push(
|
||||
&client,
|
||||
&signer,
|
||||
ann,
|
||||
&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?;
|
||||
|
||||
if let Some(base) = servers.first().and_then(grasp_base_url) {
|
||||
let url = format!("{base}/{owner}/{repo_id}.git");
|
||||
@@ -727,17 +721,7 @@ impl Backend {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PushOutcome, Error>> {
|
||||
let path = Mirrors::path(&announcement.addr());
|
||||
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)
|
||||
self.push_repo_from(announcement, path, None, cx)
|
||||
}
|
||||
|
||||
pub fn push_checkout(
|
||||
@@ -747,13 +731,7 @@ impl Backend {
|
||||
announced_head: Option<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PushOutcome, Error>> {
|
||||
self.push_repo_from(
|
||||
announcement,
|
||||
checkout,
|
||||
announced_head,
|
||||
GitPushPolicy::Reconcile,
|
||||
cx,
|
||||
)
|
||||
self.push_repo_from(announcement, checkout, announced_head, cx)
|
||||
}
|
||||
|
||||
fn push_repo_from(
|
||||
@@ -761,7 +739,6 @@ impl Backend {
|
||||
announcement: Announcement,
|
||||
path: PathBuf,
|
||||
announced_head: Option<String>,
|
||||
policy: GitPushPolicy,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PushOutcome, Error>> {
|
||||
let addr = announcement.addr();
|
||||
@@ -777,6 +754,9 @@ 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();
|
||||
|
||||
@@ -794,55 +774,74 @@ impl Backend {
|
||||
let mut state = {
|
||||
let work = cx.background_spawn({
|
||||
let path = path.clone();
|
||||
async move { SyncState::from_repo(&path) }
|
||||
async move { Repo::open(&path).and_then(|repo| repo.ref_state()) }
|
||||
});
|
||||
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.
|
||||
if let Some(head) = announced_head
|
||||
&& state
|
||||
let heads: Vec<&str> = state
|
||||
.refs
|
||||
.iter()
|
||||
.any(|git_ref| git_ref.name.strip_prefix("refs/heads/") == Some(head.as_str()))
|
||||
.filter_map(|(name, _)| name.strip_prefix("refs/heads/"))
|
||||
.collect();
|
||||
|
||||
if let Some(head) = announced_head
|
||||
&& heads.iter().any(|branch| *branch == head)
|
||||
{
|
||||
state.head = Some(head);
|
||||
}
|
||||
|
||||
let outcome = if state.refs.is_empty() {
|
||||
// Grasp servers authorize a push by the state event they hold in purgatory.
|
||||
let refs = state.refs.clone();
|
||||
let head = state.head.clone();
|
||||
|
||||
let outcome = if refs.is_empty() {
|
||||
PushOutcome::default()
|
||||
} else {
|
||||
// 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,
|
||||
let push = cx.background_spawn({
|
||||
let client = client.clone();
|
||||
let signer = 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();
|
||||
async move {
|
||||
push_staged_to_grasps(
|
||||
&client,
|
||||
&signer,
|
||||
&repo_id,
|
||||
&refs,
|
||||
head.as_deref(),
|
||||
&path,
|
||||
&owner,
|
||||
&relays,
|
||||
|path, base, owner, repo_id| {
|
||||
Repo::open(path)?.push_all(base, owner, repo_id)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
})?;
|
||||
Ok::<(), Error>(())
|
||||
push.await
|
||||
};
|
||||
|
||||
state
|
||||
.push_to_grasps(&client, event, &announcement, cx, on_step)
|
||||
.await?
|
||||
};
|
||||
|
||||
if !state.refs.is_empty() && outcome.accepted() == 0 {
|
||||
if !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)
|
||||
})
|
||||
}
|
||||
@@ -1188,44 +1187,113 @@ fn build_announcement(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>(
|
||||
// Announce the repository, then stage the state event and push.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn announce_repository_and_push<S>(
|
||||
client: &Client,
|
||||
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,
|
||||
on_step: &mut P,
|
||||
push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error> + Send + 'static,
|
||||
) -> Result<Event, Error>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>,
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + Clone,
|
||||
{
|
||||
let state_event = state
|
||||
.state_event(&announcement.id)
|
||||
.finalize_async(signer)
|
||||
.await?;
|
||||
let announcement_event = announcement.finalize_async(signer).await?;
|
||||
for url in servers {
|
||||
if let Err(e) = client.add_relay(url).and_connect().await {
|
||||
log::warn!("failed to connect grasp relay {url}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
match state
|
||||
.announce_and_push(client, announcement_event.clone(), state_event, cx, on_step)
|
||||
let event = announcement.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}");
|
||||
}
|
||||
|
||||
// The state event is the push authorization.
|
||||
let outcome = if refs.is_empty() {
|
||||
PushOutcome::default()
|
||||
} else {
|
||||
let push = {
|
||||
let client = client.clone();
|
||||
let signer = S::clone(signer);
|
||||
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();
|
||||
cx.background_spawn(async move {
|
||||
push_staged_to_grasps(
|
||||
&client,
|
||||
&signer,
|
||||
&repo_id,
|
||||
&refs,
|
||||
head.as_deref(),
|
||||
&path,
|
||||
&owner,
|
||||
&servers,
|
||||
push,
|
||||
)
|
||||
.await
|
||||
{
|
||||
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"
|
||||
),
|
||||
})
|
||||
};
|
||||
push.await
|
||||
};
|
||||
|
||||
bail!("{e}. {retraction}");
|
||||
if outcome.accepted() == 0 {
|
||||
// Retract the announcement so the repository is not left announced without content.
|
||||
let retracted = async {
|
||||
let deletion = EventDeletionRequest::new()
|
||||
.id(event.id)
|
||||
.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::<(), Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
return Err(match retracted {
|
||||
Ok(()) => anyhow!(
|
||||
"The repository was announced, but the push to every grasp server failed: {}. \
|
||||
The announcement has been retracted",
|
||||
outcome.failure_summary()
|
||||
),
|
||||
Err(e) => anyhow!(
|
||||
"The repository was announced, but the push to every grasp server failed: {}. \
|
||||
Retracting the announcement failed: {e}; delete the repository to clean it up",
|
||||
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)
|
||||
}
|
||||
|
||||
enum UpdateEvent {
|
||||
|
||||
@@ -46,7 +46,7 @@ pub(crate) async fn subscribe_bootstrap_only(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_bootstrap_only(
|
||||
pub(crate) async fn sync_bootstrap_only(
|
||||
client: &Client,
|
||||
filter: Filter,
|
||||
opts: SyncOptions,
|
||||
|
||||
@@ -5,31 +5,31 @@ mod git_store;
|
||||
mod inbox;
|
||||
mod local_repos;
|
||||
mod patch_series;
|
||||
mod profile;
|
||||
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::{sync_bootstrap_only, user_grasp_list_servers};
|
||||
pub use bootstrap::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 use sync_status::{CheckoutSyncStatus, SyncStatusStore};
|
||||
|
||||
pub fn init(
|
||||
db_path: impl AsRef<Path>,
|
||||
@@ -49,6 +49,7 @@ 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);
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
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,5 +1,9 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Error;
|
||||
use nostr::prelude::Url;
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::RepoState;
|
||||
|
||||
/// `ws://` grasp servers, like ngit, use `http(s)://<host>`.
|
||||
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
|
||||
@@ -46,16 +50,16 @@ pub struct GraspServer {
|
||||
}
|
||||
|
||||
impl GraspServer {
|
||||
pub(crate) fn ok(relay: RelayUrl) -> Self {
|
||||
fn ok(relay: RelayUrl) -> Self {
|
||||
Self {
|
||||
relay,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn failed(relay: &RelayUrl, reason: impl Into<String>) -> Self {
|
||||
fn failed(relay: RelayUrl, reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
relay: relay.to_owned(),
|
||||
relay,
|
||||
reason: Some(reason.into()),
|
||||
}
|
||||
}
|
||||
@@ -111,4 +115,93 @@ impl PushOutcome {
|
||||
self.failure_summary()
|
||||
))
|
||||
}
|
||||
|
||||
/// All staged events carry the same refs; the newest timestamp wins on the relays.
|
||||
fn keep_newest(&mut self, event: Event) {
|
||||
if self
|
||||
.state_event
|
||||
.as_ref()
|
||||
.is_none_or(|current| event.created_at > current.created_at)
|
||||
{
|
||||
self.state_event = Some(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn push_staged_to_grasps<S>(
|
||||
client: &Client,
|
||||
signer: &S,
|
||||
repo_id: &str,
|
||||
refs: &[(String, String)],
|
||||
head: Option<&str>,
|
||||
path: &Path,
|
||||
owner: &str,
|
||||
servers: &[RelayUrl],
|
||||
push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error>,
|
||||
) -> PushOutcome
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
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 event = match RepoState::build(repo_id, refs, head)
|
||||
.finalize_async(signer)
|
||||
.await
|
||||
{
|
||||
Ok(event) => event,
|
||||
Err(e) => {
|
||||
let reason = format!("could not sign the state event: {e}");
|
||||
outcome
|
||||
.servers
|
||||
.push(GraspServer::failed(relay.clone(), reason));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// The relay parks the accepted event in purgatory, authorizing the paired git push.
|
||||
let staging = match client.add_relay(relay).and_connect().await {
|
||||
Ok(_) => match client.send_event(&event).to([relay.clone()]).await {
|
||||
Ok(output) if output.success.contains_key(relay) => Ok(()),
|
||||
Ok(output) => Err(output
|
||||
.failed
|
||||
.get(relay)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "relay did not confirm the event".to_owned())),
|
||||
Err(e) => Err(format!("could not send the state event to {relay}: {e}")),
|
||||
},
|
||||
Err(e) => Err(format!("could not add relay {relay}: {e}")),
|
||||
};
|
||||
|
||||
if let Err(reason) = staging {
|
||||
log::warn!("grasp push failed: {relay}: {reason}");
|
||||
outcome
|
||||
.servers
|
||||
.push(GraspServer::failed(relay.clone(), reason));
|
||||
continue;
|
||||
}
|
||||
|
||||
match push(path, &base, owner, repo_id) {
|
||||
Ok(()) => {
|
||||
outcome.keep_newest(event);
|
||||
outcome.servers.push(GraspServer::ok(relay.clone()));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("grasp push failed: {relay}: {e}");
|
||||
outcome
|
||||
.servers
|
||||
.push(GraspServer::failed(relay.clone(), e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outcome
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ use nostr::event::IntoEventBuilder;
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::{EventFetchingStrategy, SettingsStore};
|
||||
use signed_core::{
|
||||
Announcement, Deletions, Filters, GitEvent, PullRequest, RepoAddr, RepoStatus, filters,
|
||||
Announcement, Deletions, Filters, GitEvent, PullRequest, RepoAddr, RepoState, RepoStatus,
|
||||
filters,
|
||||
};
|
||||
use signed_git::{Nip34Binding, Repo};
|
||||
|
||||
@@ -18,7 +19,6 @@ use crate::checkouts::CheckoutsStore;
|
||||
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;
|
||||
use crate::state::{GitRef, PushStepStatus, SyncState};
|
||||
use crate::sync_status::SyncStatusStore;
|
||||
|
||||
pub struct RepoStore {
|
||||
@@ -34,7 +34,7 @@ pub struct RepoStore {
|
||||
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_refs: Vec<(String, String)>,
|
||||
pub state_at: Option<Timestamp>,
|
||||
pub issues: Vec<Event>,
|
||||
pub patches: Vec<Event>,
|
||||
@@ -48,7 +48,6 @@ 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>,
|
||||
@@ -102,7 +101,6 @@ 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(),
|
||||
@@ -133,7 +131,6 @@ 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(),
|
||||
@@ -172,14 +169,6 @@ 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;
|
||||
};
|
||||
@@ -214,19 +203,6 @@ 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()
|
||||
@@ -378,7 +354,7 @@ impl RepoStore {
|
||||
|
||||
let all_states = states.into_iter().filter(|e| !deletions.is_deleted(e));
|
||||
let state =
|
||||
utils::latest(all_states).map(|event| (SyncState::parse(&event), event.created_at));
|
||||
utils::latest(all_states).map(|event| (RepoState::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());
|
||||
@@ -1172,24 +1148,6 @@ 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,
|
||||
@@ -1228,7 +1186,6 @@ 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();
|
||||
@@ -1238,13 +1195,11 @@ 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 {
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
checkouts.update(cx, |store, cx| {
|
||||
@@ -1252,13 +1207,8 @@ impl RepoStore {
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
sync_status.update(cx, |store, cx| store.refresh(cx));
|
||||
}
|
||||
Err(e) => {
|
||||
this.last_error = Some(format!("Push failed: {e}"));
|
||||
|
||||
@@ -1,534 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
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 gpui::{App, AppContext, Context, Entity, Global, Subscription};
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::{Deletions, Filters, RepoAddr, RepoState};
|
||||
use signed_git::{Repo, RepoSyncStatus};
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
use crate::checkouts::CheckoutsStore;
|
||||
use crate::state::SyncState;
|
||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||
use crate::repos::RepoListStore;
|
||||
|
||||
const SYNC_POLL: Duration = Duration::from_secs(180);
|
||||
const MAX_SYNC_CHECKOUTS: usize = 8;
|
||||
@@ -27,25 +27,13 @@ pub struct CheckoutSyncStatus {
|
||||
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,
|
||||
statuses: HashMap<RepoAddr, Vec<CheckoutSyncStatus>>,
|
||||
refresh: RefreshGate,
|
||||
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()
|
||||
@@ -56,259 +44,57 @@ impl SyncStatusStore {
|
||||
}
|
||||
|
||||
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 repos = RepoListStore::global(cx);
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||
this.refresh(cx);
|
||||
}));
|
||||
|
||||
// Checkout associations decide which paths get compared.
|
||||
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
|
||||
this.run_pass(cx);
|
||||
this.refresh(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),
|
||||
BackendEvent::SignerChanged => {
|
||||
this.statuses.clear();
|
||||
cx.notify();
|
||||
this.refresh(cx);
|
||||
}
|
||||
BackendEvent::RepoUpdates(_) | BackendEvent::Synced => this.refresh(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);
|
||||
})?;
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.refresh(cx)) {
|
||||
log::warn!("sync status store dropped before the initial refresh: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
active: RefCell::new(HashSet::new()),
|
||||
sender,
|
||||
in_flight: false,
|
||||
pass_pending: false,
|
||||
statuses: HashMap::new(),
|
||||
refresh: RefreshGate::default(),
|
||||
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);
|
||||
/// Latest sync status per associated checkout.
|
||||
pub fn statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutSyncStatus> {
|
||||
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);
|
||||
|
||||
pub fn unsynced(&self, addr: &RepoAddr) -> usize {
|
||||
self.statuses
|
||||
.get(addr)
|
||||
.map(|list| {
|
||||
@@ -319,49 +105,136 @@ impl SyncStatusDirectory {
|
||||
.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()]);
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
if self.refresh.request() != RefreshRequest::Schedule {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let Some(user) = backend.read(cx).current_user() else {
|
||||
let changed = !self.statuses.is_empty();
|
||||
self.statuses.clear();
|
||||
if changed {
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
self.schedule_poll(cx);
|
||||
self.run_refresh(user, cx);
|
||||
}
|
||||
|
||||
fn with_pending<R>(&self, then: impl FnOnce(&mut HashSet<RepoAddr>) -> R) -> R {
|
||||
let mut pending = self.pending.borrow_mut();
|
||||
then(&mut pending)
|
||||
fn schedule_poll(&mut self, cx: &mut Context<Self>) {
|
||||
if self.poll_pending {
|
||||
return;
|
||||
}
|
||||
self.poll_pending = true;
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(SYNC_POLL).await;
|
||||
this.update(cx, |this, cx| {
|
||||
this.poll_pending = false;
|
||||
this.refresh(cx);
|
||||
})
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn run_refresh(&mut self, user: PublicKey, cx: &mut Context<Self>) {
|
||||
self.refresh.begin();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let repos = RepoListStore::global(cx);
|
||||
let announcements = repos.read(cx).announcements_of(&user);
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
|
||||
let targets: Vec<(RepoAddr, Vec<PathBuf>)> = announcements
|
||||
.iter()
|
||||
.map(|announcement| {
|
||||
let addr = announcement.addr();
|
||||
let paths = checkouts.read(cx).associations_of(&addr);
|
||||
(addr, 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 states: HashMap<RepoAddr, Vec<(String, String)>> = HashMap::new();
|
||||
for (addr, _) 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 {
|
||||
continue;
|
||||
};
|
||||
states.insert(addr.clone(), RepoState::parse(&event).refs);
|
||||
}
|
||||
|
||||
let mut statuses: HashMap<RepoAddr, Vec<CheckoutSyncStatus>> = HashMap::new();
|
||||
for (addr, paths) in &targets {
|
||||
let Some(nostr_refs) = states.get(addr) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
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(nostr_refs).ok());
|
||||
|
||||
if let Some(status) = synced {
|
||||
list.push(CheckoutSyncStatus {
|
||||
path: path.clone(),
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !list.is_empty() {
|
||||
statuses.insert(addr.clone(), list);
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<_, Error>(statuses)
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let statuses = match work.await {
|
||||
Ok(statuses) => statuses,
|
||||
Err(error) => {
|
||||
log::warn!("failed to compute nostr sync statuses: {error}");
|
||||
return this.update(cx, |this, _cx| {
|
||||
this.refresh.abort();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let again = this.update(cx, |this, cx| {
|
||||
if this.statuses != statuses {
|
||||
this.statuses = statuses;
|
||||
cx.notify();
|
||||
}
|
||||
this.refresh.finish()
|
||||
})?;
|
||||
|
||||
if again {
|
||||
this.update(cx, |this, cx| {
|
||||
this.refresh(cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok::<_, Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
+23
-204
@@ -1,62 +1,37 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Pixels, SharedString, StyleRefinement, Window, div, px};
|
||||
use gpui_base::{AvatarImage, StyledExt};
|
||||
use gpui_component::{ActiveTheme, Colorize, Sizable, Size};
|
||||
use gpui::{App, SharedString, StyleRefinement, Window};
|
||||
use gpui_base::{Avatar as BaseAvatar, AvatarFallback, AvatarImage};
|
||||
use gpui_component::{ActiveTheme, Sizable, Size, StyledExt};
|
||||
|
||||
/// 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;
|
||||
use crate::pixel_avatar::{PixelAvatar, side_length};
|
||||
|
||||
/// An avatar for a user or other entity.
|
||||
/// 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.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Avatar {
|
||||
id: SharedString,
|
||||
name: Option<SharedString>,
|
||||
name: SharedString,
|
||||
picture: Option<SharedString>,
|
||||
size: Size,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl Avatar {
|
||||
/// Create an avatar for `id`, a public key or any other stable string.
|
||||
/// Create an avatar for `name`.
|
||||
///
|
||||
/// The id seeds the pixel fallback shown when no picture is set.
|
||||
pub fn new<T>(id: T) -> Self
|
||||
where
|
||||
T: Display,
|
||||
{
|
||||
/// The name seeds the pixel fallback shown when no picture is set.
|
||||
pub fn new(name: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.to_string().into(),
|
||||
name: None,
|
||||
name: name.into(),
|
||||
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<T>(mut self, picture: Option<T>) -> Self
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
pub fn picture(mut self, picture: Option<impl Into<SharedString>>) -> Self {
|
||||
self.picture = picture.map(Into::into);
|
||||
self
|
||||
}
|
||||
@@ -77,176 +52,20 @@ impl Styled for Avatar {
|
||||
|
||||
impl RenderOnce for Avatar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let radius = cx.theme().radius;
|
||||
let fallback = AvatarFallback::new()
|
||||
.size_full()
|
||||
.child(PixelAvatar::new(self.name.clone()).size_full());
|
||||
|
||||
match self.picture {
|
||||
Some(url) => div()
|
||||
BaseAvatar::new()
|
||||
.size(side_length(self.size))
|
||||
.flex_shrink_0()
|
||||
.rounded(radius)
|
||||
.rounded(cx.theme().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(theme.radius)
|
||||
.overflow_hidden()
|
||||
.bg(main.opacity(0.16))
|
||||
.children(cells)
|
||||
.when_some(self.picture, |this, url| {
|
||||
this.image(AvatarImage::new(url).size_full().rounded(cx.theme().radius))
|
||||
})
|
||||
.fallback(fallback)
|
||||
.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,6 +1,7 @@
|
||||
mod avatar;
|
||||
mod dropdown_button;
|
||||
mod nav_item;
|
||||
mod pixel_avatar;
|
||||
mod placeholder;
|
||||
mod ref_selector;
|
||||
mod segment_button;
|
||||
@@ -15,6 +16,7 @@ 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};
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
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,7 +7,6 @@ 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,7 +17,6 @@ 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,
|
||||
};
|
||||
@@ -306,8 +305,6 @@ 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>>>,
|
||||
}
|
||||
|
||||
@@ -321,13 +318,6 @@ 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| {
|
||||
@@ -349,7 +339,6 @@ impl CommitDiffView {
|
||||
loading: true,
|
||||
error: None,
|
||||
pane,
|
||||
directory,
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -486,7 +475,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), &self.directory, cx);
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let label = SharedString::from(format!("{}/{}", self.repo_name, self.commit.id));
|
||||
|
||||
tab_title(avatar, label)
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
use gpui::prelude::*;
|
||||
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};
|
||||
use gpui::{AnyElement, App, SharedString, div};
|
||||
use gpui_component::ActiveTheme;
|
||||
|
||||
/// 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 {
|
||||
@@ -23,37 +16,6 @@ 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;
|
||||
@@ -71,46 +33,3 @@ 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,9 +5,8 @@ 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::RepoStore;
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::Avatar;
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -33,7 +32,6 @@ 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);
|
||||
@@ -41,6 +39,7 @@ 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];
|
||||
@@ -65,14 +64,14 @@ pub(crate) fn sidebar_section(
|
||||
.gap_2()
|
||||
.child(sidebar_title("Participants", cx))
|
||||
.children(participants.iter().map(|pubkey| {
|
||||
let profile = directory.read(cx).profile(pubkey, cx);
|
||||
let profile = profile_store.read(cx).get(pubkey);
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(Avatar::new(*pubkey).name(name.clone()).picture(picture))
|
||||
.child(Avatar::new(name.clone()).picture(picture))
|
||||
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
||||
.into_any_element()
|
||||
})),
|
||||
@@ -110,12 +109,7 @@ pub(crate) fn sidebar_section(
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn comments_section(
|
||||
store: &Entity<RepoStore>,
|
||||
root: EventId,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &App) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let comments: Vec<&Event> = store.comments_of(&root).collect();
|
||||
let title = SharedString::from(format!("Discussions {}", comments.len()));
|
||||
@@ -124,7 +118,7 @@ pub(crate) fn comments_section(
|
||||
.gap_4()
|
||||
.child(div().text_xs().font_semibold().child(title))
|
||||
.children(comments.iter().map(|comment| {
|
||||
let profile = directory.read(cx).profile(&comment.pubkey, cx);
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(comment.created_at);
|
||||
@@ -143,11 +137,7 @@ pub(crate) fn comments_section(
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(comment.pubkey)
|
||||
.name(author.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(Avatar::new(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, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment,
|
||||
ListState, Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div,
|
||||
list, px,
|
||||
AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState,
|
||||
Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div, list, px,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
|
||||
use signed_core::{InboxItem, InboxReadState, RepoAddr};
|
||||
use signed_state::{Backend, BackendEvent, Inbox, RefreshGate, RefreshRequest, RepoListStore};
|
||||
use signed_state::{
|
||||
Backend, BackendEvent, Inbox, ProfileStore, RefreshGate, RefreshRequest, RepoListStore,
|
||||
};
|
||||
use signed_ui::{Avatar, CountBadge};
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -54,8 +54,6 @@ pub struct InboxView {
|
||||
refresh: RefreshGate,
|
||||
list: ListState,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
/// Profiles for activity authors.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
@@ -103,7 +101,6 @@ impl InboxView {
|
||||
refresh: RefreshGate::default(),
|
||||
list,
|
||||
tasks: vec![],
|
||||
directory: ProfileDirectory::new(cx),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
@@ -275,19 +272,6 @@ 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> {
|
||||
@@ -413,9 +397,8 @@ 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, &directory, cx)
|
||||
thread("inbox-row", ix, item, first, last, cx)
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.open(root, kind, address.clone(), window, cx)
|
||||
}))
|
||||
@@ -478,7 +461,6 @@ fn thread(
|
||||
item: &InboxItem,
|
||||
first: bool,
|
||||
last: bool,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> Stateful<Div> {
|
||||
let title = SharedString::from(item.title());
|
||||
@@ -490,7 +472,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, directory, cx));
|
||||
timeline = timeline.child(sub_activity(&event, me, cx));
|
||||
}
|
||||
|
||||
v_flex()
|
||||
@@ -539,13 +521,9 @@ fn thread(
|
||||
.child(timeline)
|
||||
}
|
||||
|
||||
fn sub_activity(
|
||||
event: &Event,
|
||||
me: Option<PublicKey>,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let profile = directory.read(cx).profile(&event.pubkey, cx);
|
||||
fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
|
||||
let profile_store = ProfileStore::global(cx).read(cx);
|
||||
let profile = profile_store.get(&event.pubkey);
|
||||
|
||||
let name = if Some(event.pubkey) == me {
|
||||
SharedString::from("You")
|
||||
@@ -566,8 +544,7 @@ fn sub_activity(
|
||||
.items_center()
|
||||
.text_xs()
|
||||
.child(
|
||||
Avatar::new(event.pubkey)
|
||||
.name(name.clone())
|
||||
Avatar::new(name.clone())
|
||||
.picture(profile.picture())
|
||||
.xsmall(),
|
||||
)
|
||||
|
||||
@@ -7,10 +7,9 @@ use gpui::{
|
||||
use gpui_component::input::TextareaState;
|
||||
use gpui_component::scroll::ScrollableElement;
|
||||
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{EventId, PublicKey};
|
||||
use nostr::prelude::EventId;
|
||||
use signed_core::GitEvent;
|
||||
use signed_state::RepoStore;
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{Avatar, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -22,8 +21,6 @@ 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,
|
||||
}
|
||||
|
||||
@@ -37,48 +34,16 @@ impl IssueDetailView {
|
||||
let comment_input =
|
||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
|
||||
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.track_participants(cx);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let directory = ProfileDirectory::new(cx);
|
||||
let subscription = cx.observe(&store, |_this, _store, cx| cx.notify());
|
||||
|
||||
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 {
|
||||
@@ -101,7 +66,7 @@ impl Panel for IssueDetailView {
|
||||
})
|
||||
.unwrap_or_else(|| SharedString::from("Issue"));
|
||||
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
|
||||
tab_title(avatar, short_id)
|
||||
}
|
||||
@@ -128,8 +93,9 @@ impl Render for IssueDetailView {
|
||||
};
|
||||
};
|
||||
|
||||
let (title, author, picture, status, age, issue_id, content, pubkey) = {
|
||||
let profile = self.directory.read(cx).profile(&issue.pubkey, cx);
|
||||
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 content = if issue.content.is_empty() {
|
||||
SharedString::from("No description provided.")
|
||||
} else {
|
||||
@@ -144,7 +110,6 @@ impl Render for IssueDetailView {
|
||||
relative_time(issue.created_at),
|
||||
issue.id,
|
||||
content,
|
||||
issue.pubkey,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -188,11 +153,7 @@ impl Render for IssueDetailView {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(pubkey)
|
||||
.name(author.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(Avatar::new(&author).picture(picture))
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
@@ -208,7 +169,7 @@ impl Render for IssueDetailView {
|
||||
)
|
||||
.child(div().text_sm().child(content)),
|
||||
)
|
||||
.child(comments_section(&self.store, issue_id, &self.directory, cx))
|
||||
.child(comments_section(&self.store, issue_id, cx))
|
||||
.child(comment_form(
|
||||
&self.store,
|
||||
issue_id,
|
||||
@@ -224,7 +185,6 @@ impl Render for IssueDetailView {
|
||||
issue_id,
|
||||
issue_roots,
|
||||
false,
|
||||
&self.directory,
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
|
||||
@@ -15,10 +15,9 @@ use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::{
|
||||
ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
|
||||
};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{EventId, PublicKey};
|
||||
use nostr::prelude::EventId;
|
||||
use signed_core::{GitEvent, RepoStatus};
|
||||
use signed_state::RepoStore;
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{Avatar, SegmentButton, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -60,8 +59,6 @@ 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,
|
||||
}
|
||||
|
||||
@@ -93,7 +90,6 @@ impl IssuesView {
|
||||
counts: StatusCounts::default(),
|
||||
synced_filter: IssueFilter::Open,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
directory: ProfileDirectory::new(cx),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
@@ -110,21 +106,6 @@ 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;
|
||||
@@ -167,7 +148,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 = self.directory.read(cx).profile(&issue.pubkey, cx);
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(issue.created_at);
|
||||
@@ -208,11 +189,7 @@ impl IssuesView {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(issue.pubkey)
|
||||
.name(author.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(Avatar::new(author.clone()).picture(picture))
|
||||
.child(div().child(author)),
|
||||
)
|
||||
.child(SharedString::from("opened"))
|
||||
@@ -358,7 +335,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), &self.directory, cx);
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let label = SharedString::from(format!("{}/issues", self.repo_name));
|
||||
|
||||
tab_title(avatar, label)
|
||||
|
||||
@@ -12,20 +12,17 @@ pub(crate) mod sidebar;
|
||||
mod status_list;
|
||||
pub(crate) mod tree;
|
||||
|
||||
use std::fmt::Display;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Entity, div};
|
||||
use gpui::{AnyElement, App, 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::RepoStore;
|
||||
use signed_ui::Avatar;
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{Avatar, PixelAvatar};
|
||||
|
||||
pub(crate) fn tab_title(avatar: AnyElement, label: impl IntoElement) -> impl IntoElement {
|
||||
h_flex()
|
||||
@@ -35,15 +32,11 @@ pub(crate) fn tab_title(avatar: AnyElement, label: impl IntoElement) -> impl Int
|
||||
.child(label)
|
||||
}
|
||||
|
||||
pub(crate) fn panel_avatar(seed: impl Display) -> AnyElement {
|
||||
Avatar::new(seed).xsmall().into_any_element()
|
||||
pub(crate) fn panel_avatar(seed: impl AsRef<str>) -> AnyElement {
|
||||
PixelAvatar::new(seed).xsmall().into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn repo_tab_avatar(
|
||||
store: &RepoStore,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
pub(crate) fn repo_tab_avatar(store: &RepoStore, cx: &App) -> AnyElement {
|
||||
let seed = store
|
||||
.announcement
|
||||
.as_ref()
|
||||
@@ -60,15 +53,13 @@ pub(crate) fn repo_tab_avatar(
|
||||
let owner = store
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|announcement| directory.read(cx).profile(&announcement.owner, cx));
|
||||
.map(|announcement| ProfileStore::global(cx).read(cx).get(&announcement.owner));
|
||||
|
||||
let (name, picture) = owner
|
||||
.map(|profile| (Some(profile.name()), profile.picture()))
|
||||
.unwrap_or((None, None));
|
||||
|
||||
Avatar::new(seed)
|
||||
.name(name)
|
||||
.picture(picture)
|
||||
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()
|
||||
.into_any_element(),
|
||||
None => PixelAvatar::new(seed).xsmall().into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,10 @@ use gpui_component::{
|
||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
|
||||
v_virtual_list,
|
||||
};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{Event, EventId, Kind, PublicKey, Url};
|
||||
use nostr::prelude::{Event, EventId, Kind, Url};
|
||||
use signed_core::{GitEvent, PullRequest, RepoAddr};
|
||||
use signed_git::{FileCommit, PatchParser};
|
||||
use signed_state::{Backend, Mirrors, RepoStore};
|
||||
use signed_state::{Backend, Mirrors, ProfileStore, RepoStore};
|
||||
use signed_ui::{Avatar, CountBadge, placeholder, status_badge};
|
||||
use utils::{relative_time, relative_time_secs};
|
||||
|
||||
@@ -78,8 +77,6 @@ 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,
|
||||
}
|
||||
|
||||
@@ -124,37 +121,16 @@ 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 = {
|
||||
@@ -431,7 +407,7 @@ impl PullRequestDetailView {
|
||||
return placeholder(&error, cx);
|
||||
}
|
||||
|
||||
let (author, picture, age, root_id, pubkey) = {
|
||||
let (author, picture, age, root_id) = {
|
||||
let store = self.store.read(cx);
|
||||
let Some(root) = store
|
||||
.pull_requests
|
||||
@@ -440,13 +416,12 @@ impl PullRequestDetailView {
|
||||
else {
|
||||
return placeholder("Pull request not found", cx);
|
||||
};
|
||||
let profile = self.directory.read(cx).profile(&root.pubkey, cx);
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&root.pubkey);
|
||||
(
|
||||
profile.name(),
|
||||
profile.picture(),
|
||||
relative_time(root.created_at),
|
||||
root.id,
|
||||
root.pubkey,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -476,8 +451,7 @@ impl PullRequestDetailView {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(pubkey)
|
||||
.name(author.clone())
|
||||
Avatar::new(author.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(author),
|
||||
@@ -493,7 +467,7 @@ impl PullRequestDetailView {
|
||||
this.child(div().text_sm().child(self.description.clone()))
|
||||
}),
|
||||
)
|
||||
.child(comments_section(&self.store, root_id, &self.directory, cx))
|
||||
.child(comments_section(&self.store, root_id, cx))
|
||||
.child(comment_form(
|
||||
&self.store,
|
||||
root_id,
|
||||
@@ -504,14 +478,7 @@ impl PullRequestDetailView {
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(sidebar_section(
|
||||
&self.store,
|
||||
root_id,
|
||||
pr_roots,
|
||||
true,
|
||||
&self.directory,
|
||||
cx,
|
||||
))
|
||||
.child(sidebar_section(&self.store, root_id, pr_roots, true, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -822,7 +789,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), &self.directory, cx);
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let hex = self.pr_id.to_hex();
|
||||
let id = SharedString::from(&hex[..8]);
|
||||
let label = if self.repo_name.is_empty() {
|
||||
|
||||
@@ -13,10 +13,9 @@ use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::{
|
||||
ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||
};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{EventId, Kind, PublicKey};
|
||||
use nostr::prelude::{EventId, Kind};
|
||||
use signed_core::{GitEvent, RepoStatus};
|
||||
use signed_state::RepoStore;
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -67,8 +66,6 @@ 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,
|
||||
}
|
||||
|
||||
@@ -100,13 +97,11 @@ 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) = {
|
||||
@@ -122,25 +117,6 @@ 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 {
|
||||
@@ -189,7 +165,7 @@ impl PullRequestsView {
|
||||
let age = relative_time(pr.created_at);
|
||||
let status = self.store.read(cx).status_of(pr);
|
||||
|
||||
let profile = self.directory.read(cx).profile(&pr.pubkey, cx);
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&pr.pubkey);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
@@ -227,11 +203,7 @@ impl PullRequestsView {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(pr.pubkey)
|
||||
.name(author.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(Avatar::new(author.clone()).picture(picture))
|
||||
.child(div().child(author)),
|
||||
)
|
||||
.child(SharedString::from("opened"))
|
||||
@@ -363,7 +335,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), &self.directory, cx);
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let label = SharedString::from(format!("{}/pulls", self.repo_name));
|
||||
|
||||
tab_title(avatar, label)
|
||||
|
||||
@@ -20,7 +20,6 @@ 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};
|
||||
@@ -66,8 +65,6 @@ 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>,
|
||||
}
|
||||
|
||||
@@ -225,7 +222,6 @@ 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"));
|
||||
@@ -274,12 +270,6 @@ 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;
|
||||
@@ -322,7 +312,6 @@ impl NewPullRequestView {
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
tasks: Vec::new(),
|
||||
directory,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
@@ -1324,7 +1313,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), &self.directory, cx);
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let label = SharedString::from(format!("{}/new-pull-request", self.repo_name));
|
||||
|
||||
tab_title(avatar, label)
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Entity, SharedString, Window, div, px};
|
||||
use gpui::{AnyElement, App, 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,
|
||||
directory: Entity<ProfileDirectory>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
|
||||
window.open_dialog(cx, move |dialog, _window, cx| {
|
||||
let announcement = announcement.clone();
|
||||
dialog
|
||||
@@ -22,15 +17,11 @@ pub(super) fn open_about_dialog(
|
||||
.keyboard(true)
|
||||
.close_button(true)
|
||||
.title("About")
|
||||
.child(announcement_rows(&announcement, &directory, cx))
|
||||
.child(announcement_rows(&announcement, cx))
|
||||
});
|
||||
}
|
||||
|
||||
fn announcement_rows(
|
||||
announcement: &Announcement,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||
let mut rows: Vec<AnyElement> = Vec::new();
|
||||
|
||||
rows.push(row(
|
||||
@@ -116,7 +107,7 @@ fn announcement_rows(
|
||||
if !announcement.maintainers.is_empty() {
|
||||
rows.push(row(
|
||||
"Maintainers",
|
||||
maintainers(&announcement.maintainers, directory, cx),
|
||||
maintainers(&announcement.maintainers, cx),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
@@ -169,16 +160,13 @@ fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn maintainers(
|
||||
maintainers: &[PublicKey],
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.min_w_0()
|
||||
.children(maintainers.iter().map(|pubkey| {
|
||||
let profile = directory.read(cx).profile(pubkey, cx);
|
||||
let profile = profile_store.read(cx).get(pubkey);
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
|
||||
@@ -186,7 +174,7 @@ fn maintainers(
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.min_w_0()
|
||||
.child(Avatar::new(*pubkey).name(name.clone()).picture(picture))
|
||||
.child(Avatar::new(name.clone()).picture(picture))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
|
||||
@@ -12,13 +12,12 @@ 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, push_steps_row};
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
use crate::views::sidebar::grasp_servers::{
|
||||
GraspServersState, grasp_servers_field, load_user_grasp_servers,
|
||||
};
|
||||
@@ -121,7 +120,6 @@ pub fn open(
|
||||
)
|
||||
.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(
|
||||
@@ -191,15 +189,9 @@ 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(),
|
||||
|
||||
@@ -15,24 +15,21 @@ 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, WindowExt, h_flex,
|
||||
v_flex,
|
||||
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex,
|
||||
};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::nips::nip19::Nip19Coordinate;
|
||||
use nostr::prelude::{PublicKey, RelayUrl, ToBech32, Url};
|
||||
use nostr::prelude::{RelayUrl, ToBech32, Url};
|
||||
use signed_core::{Announcement, RepoAddr, RepoStatus};
|
||||
use signed_git::{FileCommit, GitCache, RefSync, Repo};
|
||||
use signed_state::{
|
||||
Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors,
|
||||
Nip34Binding, Nip34Kind, PushStepStatus, RepoListStore, RepoStore, SyncStatusDirectory,
|
||||
Nip34Binding, Nip34Kind, ProfileStore, RepoListStore, RepoStore, SyncStatusStore,
|
||||
};
|
||||
use signed_ui::{
|
||||
Avatar, CountBadge, DropdownButton, copy_row, menu_copy_row, ref_selector_trigger,
|
||||
Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, ref_selector_trigger,
|
||||
};
|
||||
use utils::middle_truncate;
|
||||
|
||||
@@ -74,7 +71,6 @@ pub(crate) enum RepoAction {
|
||||
SendPatch,
|
||||
About,
|
||||
Push,
|
||||
ResetRemote,
|
||||
Delete,
|
||||
}
|
||||
|
||||
@@ -97,10 +93,6 @@ 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>,
|
||||
}
|
||||
|
||||
@@ -161,6 +153,7 @@ impl RepoDetailView {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
let sync_status = SyncStatusStore::global(cx);
|
||||
let files = cx.new(RepoFilesView::new);
|
||||
let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone()));
|
||||
let refs = RefSwitcher::new(window, cx);
|
||||
@@ -197,6 +190,11 @@ impl RepoDetailView {
|
||||
}
|
||||
}));
|
||||
|
||||
// The sync-to-Nostr banner reads the global sync store directly.
|
||||
subscriptions.push(cx.observe(&sync_status, |_this, _sync, cx| {
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
// Defer loading the repository until the window is ready.
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load_repo(window, cx);
|
||||
@@ -217,8 +215,6 @@ impl RepoDetailView {
|
||||
tasks: Vec::new(),
|
||||
banners: Banners::default(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
directory: ProfileDirectory::new(cx),
|
||||
sync_directory: SyncStatusDirectory::new(cx),
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
|
||||
@@ -254,8 +250,6 @@ 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);
|
||||
}
|
||||
@@ -264,46 +258,6 @@ 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;
|
||||
@@ -360,8 +314,6 @@ 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) = {
|
||||
@@ -844,63 +796,6 @@ 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
|
||||
@@ -982,10 +877,9 @@ impl RepoDetailView {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let nip05 = self
|
||||
.directory
|
||||
let nip05 = ProfileStore::global(cx)
|
||||
.read(cx)
|
||||
.profile(&source.owner, cx)
|
||||
.get(&source.owner)
|
||||
.metadata()
|
||||
.nip05
|
||||
.clone()
|
||||
@@ -1001,7 +895,7 @@ impl RepoDetailView {
|
||||
|
||||
let name = repo_display_name(self.store.read(cx));
|
||||
let description = announcement.description();
|
||||
let avatar = Avatar::new(format!("{}:{}", announcement.owner, announcement.id));
|
||||
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
|
||||
|
||||
v_flex()
|
||||
.on_action(
|
||||
@@ -1022,16 +916,10 @@ impl RepoDetailView {
|
||||
}
|
||||
RepoAction::About => {
|
||||
if let Some(announcement) = this.announcement(cx) {
|
||||
open_about_dialog(
|
||||
announcement.clone(),
|
||||
this.directory.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
open_about_dialog(announcement.clone(), window, cx);
|
||||
}
|
||||
}
|
||||
RepoAction::Push => this.push_repository(window, cx),
|
||||
RepoAction::ResetRemote => this.reset_remote(window, cx),
|
||||
RepoAction::Delete => this.delete_repository(window, cx),
|
||||
}),
|
||||
)
|
||||
@@ -1234,16 +1122,6 @@ 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()
|
||||
@@ -1374,7 +1252,7 @@ impl RepoDetailView {
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_default();
|
||||
let avatar = Avatar::new(path.clone());
|
||||
let avatar = PixelAvatar::new(path.clone());
|
||||
|
||||
// A repository already bound to a coordinate is not offered for publishing again.
|
||||
let bound = self.store.read(cx).nip34.clone();
|
||||
@@ -1382,7 +1260,7 @@ impl RepoDetailView {
|
||||
.as_ref()
|
||||
.filter(|binding| binding.kind == Nip34Kind::Initialized)
|
||||
{
|
||||
Some(binding) => bound_repo_label(binding, &self.directory, cx),
|
||||
Some(binding) => bound_repo_label(binding, cx),
|
||||
None => Button::new("init")
|
||||
.icon(CustomIconName::Init)
|
||||
.label("Initialize on Nostr")
|
||||
@@ -1578,6 +1456,7 @@ 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
|
||||
@@ -1587,7 +1466,7 @@ impl RepoDetailView {
|
||||
.filter(|key| key != &announcement.owner && seen.insert(*key))
|
||||
.collect();
|
||||
|
||||
let owner = self.directory.read(cx).profile(&announcement.owner, cx);
|
||||
let owner = profile_store.read(cx).get(&announcement.owner);
|
||||
let owner_name = owner.name();
|
||||
let owner_picture = owner.picture();
|
||||
|
||||
@@ -1601,11 +1480,7 @@ impl RepoDetailView {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(announcement.owner)
|
||||
.name(owner_name.clone())
|
||||
.picture(owner_picture),
|
||||
)
|
||||
.child(Avatar::new(owner_name.clone()).picture(owner_picture))
|
||||
.child(div().text_xs().whitespace_nowrap().child(owner_name)),
|
||||
)
|
||||
.when(!rest.is_empty(), |this| {
|
||||
@@ -1683,7 +1558,8 @@ impl RepoDetailView {
|
||||
return None;
|
||||
}
|
||||
|
||||
let statuses = self.sync_directory.read(cx).statuses_of(addr, cx);
|
||||
let sync_status = SyncStatusStore::global(cx);
|
||||
let statuses = sync_status.read(cx).statuses_of(addr);
|
||||
|
||||
statuses.into_iter().find(|checkout| {
|
||||
checkout.status.ahead_total > 0 && !self.banners.sync_dismissal(checkout)
|
||||
@@ -1871,59 +1747,6 @@ impl RepoDetailView {
|
||||
)
|
||||
}
|
||||
|
||||
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()?;
|
||||
@@ -2114,75 +1937,6 @@ 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
|
||||
@@ -2361,12 +2115,9 @@ 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,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let profile = binding.owner.map(|pk| directory.read(cx).profile(&pk, cx));
|
||||
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));
|
||||
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
@@ -2375,17 +2126,12 @@ fn bound_repo_label(
|
||||
.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.public_key())
|
||||
.name(name.clone())
|
||||
.picture(profile.picture()),
|
||||
)
|
||||
.child(name),
|
||||
.child(Avatar::new(profile.name()).picture(profile.picture()))
|
||||
.child(profile.name()),
|
||||
)
|
||||
})
|
||||
.when_some(binding.identifier.as_deref(), |this, ident| {
|
||||
@@ -2409,7 +2155,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, &self.directory, cx);
|
||||
let avatar = repo_tab_avatar(store, cx);
|
||||
|
||||
tab_title(avatar, name)
|
||||
}
|
||||
@@ -2443,9 +2189,6 @@ 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,10 +14,8 @@ 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::{RepoListStore, Timestamp};
|
||||
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
||||
use signed_ui::{Avatar, SegmentButton};
|
||||
use utils::relative_time;
|
||||
|
||||
@@ -109,8 +107,6 @@ 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,
|
||||
}
|
||||
@@ -122,7 +118,6 @@ 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| {
|
||||
@@ -131,7 +126,8 @@ impl RepoListView {
|
||||
}
|
||||
});
|
||||
|
||||
// Rebuild the rows when the store changes.
|
||||
// Keep the visible slice and row sizes in sync with the store.
|
||||
// Newly announced repositories appear without waiting for a click.
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.rebuild_rows(cx);
|
||||
});
|
||||
@@ -150,7 +146,6 @@ impl RepoListView {
|
||||
repo_len: 0,
|
||||
visible: Vec::new(),
|
||||
search,
|
||||
directory,
|
||||
_search_subscription: search_subscription,
|
||||
_subscription: subscription,
|
||||
}
|
||||
@@ -163,17 +158,6 @@ 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);
|
||||
|
||||
@@ -192,7 +176,8 @@ impl RepoListView {
|
||||
last_activity: Option<Timestamp>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let owner = self.directory.read(cx).profile(&announcement.owner, cx);
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
let owner = profile_store.read(cx).get(&announcement.owner);
|
||||
|
||||
let name = announcement
|
||||
.name
|
||||
@@ -289,11 +274,7 @@ impl RepoListView {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
Avatar::new(announcement.owner)
|
||||
.name(owner.name())
|
||||
.picture(owner.picture()),
|
||||
)
|
||||
.child(Avatar::new(owner.name()).picture(owner.picture()))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
|
||||
@@ -9,7 +9,6 @@ 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};
|
||||
@@ -25,8 +24,6 @@ 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 {
|
||||
@@ -37,17 +34,11 @@ 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"));
|
||||
|
||||
let description = cx
|
||||
.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)"));
|
||||
|
||||
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")
|
||||
});
|
||||
@@ -62,7 +53,6 @@ impl SendPatchView {
|
||||
patch,
|
||||
submitting: false,
|
||||
error: None,
|
||||
directory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +208,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), &self.directory, cx);
|
||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
||||
let label = SharedString::from(format!("{}/send-patch", self.repo_name));
|
||||
|
||||
tab_title(avatar, label)
|
||||
|
||||
@@ -10,12 +10,11 @@ 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, push_steps_row};
|
||||
use crate::views::dialog_state::{DialogProgress, error_row};
|
||||
|
||||
/// Progress of the create-repository flow, so async results can be rendered.
|
||||
pub type CreateRepoState = DialogProgress;
|
||||
@@ -113,7 +112,6 @@ 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(
|
||||
@@ -211,15 +209,9 @@ 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)
|
||||
});
|
||||
|
||||
@@ -20,14 +20,13 @@ 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 metadata::{Profile, ProfileDirectory};
|
||||
use nostr::prelude::{PublicKey, RelayUrl};
|
||||
use nostr::prelude::RelayUrl;
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
use signed_state::{
|
||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, RepoListStore,
|
||||
ResolvedLocalRepo, SyncStatusDirectory,
|
||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile,
|
||||
ProfileStore, RepoListStore, ResolvedLocalRepo, SyncStatusStore,
|
||||
};
|
||||
use signed_ui::{Avatar, NavItem, title_bar_drag_handlers};
|
||||
use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers};
|
||||
|
||||
use super::{InboxView, PlaceholderPanel, RepoDetailView, RepoListView, open_repo_panel};
|
||||
|
||||
@@ -68,10 +67,8 @@ pub struct SidebarPanel {
|
||||
local_repos: Arc<Vec<ResolvedLocalRepo>>,
|
||||
/// 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>,
|
||||
/// Commits not on Nostr per announced repository.
|
||||
unsynced: HashMap<RepoAddr, usize>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
@@ -81,6 +78,7 @@ impl SidebarPanel {
|
||||
let repos = RepoListStore::global(cx);
|
||||
let local = LocalReposStore::global(cx);
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
let sync_status = SyncStatusStore::global(cx);
|
||||
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
@@ -118,6 +116,12 @@ impl SidebarPanel {
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.observe(&sync_status, |this, _sync, cx| {
|
||||
if this.refresh_unsynced(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
@@ -130,8 +134,7 @@ impl SidebarPanel {
|
||||
local_repos: Arc::new(Vec::new()),
|
||||
scanning: false,
|
||||
unpushed: HashMap::new(),
|
||||
directory: ProfileDirectory::new(cx),
|
||||
sync_directory: SyncStatusDirectory::new(cx),
|
||||
unsynced: HashMap::new(),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
@@ -163,38 +166,10 @@ 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();
|
||||
self.unsynced.clear();
|
||||
}
|
||||
|
||||
announcements_changed || local_changed || scanning_changed
|
||||
@@ -220,6 +195,26 @@ impl SidebarPanel {
|
||||
true
|
||||
}
|
||||
|
||||
fn refresh_unsynced(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let sync_status = SyncStatusStore::global(cx);
|
||||
let mut unsynced = HashMap::with_capacity(self.announcements.len());
|
||||
|
||||
for announcement in self.announcements.iter() {
|
||||
let addr = announcement.addr();
|
||||
let count = sync_status.read(cx).unsynced(&addr);
|
||||
if count > 0 {
|
||||
unsynced.insert(addr, count);
|
||||
}
|
||||
}
|
||||
|
||||
if unsynced == self.unsynced {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.unsynced = unsynced;
|
||||
true
|
||||
}
|
||||
|
||||
fn request_push_watches(&self, cx: &mut Context<Self>) {
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
checkouts.update(cx, |checkouts, cx| {
|
||||
@@ -515,12 +510,13 @@ 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 = Avatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall();
|
||||
let avatar =
|
||||
PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall();
|
||||
let announcement = announcement.clone();
|
||||
|
||||
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 unsynced = self.unsynced.get(&addr).copied().unwrap_or(0);
|
||||
|
||||
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
|
||||
|
||||
@@ -573,7 +569,7 @@ impl SidebarPanel {
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let name = entry.name();
|
||||
let avatar = local_avatar(entry, &self.directory, cx);
|
||||
let avatar = local_avatar(entry, cx);
|
||||
|
||||
let id = format!("local-repo:{}", entry.path.display());
|
||||
let entry = entry.clone();
|
||||
@@ -640,11 +636,7 @@ impl SidebarPanel {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(profile.public_key())
|
||||
.name(name.clone())
|
||||
.picture(picture),
|
||||
)
|
||||
.child(Avatar::new(name.clone()).picture(picture))
|
||||
.child(div().text_xs().font_semibold().child(name)),
|
||||
)
|
||||
.dropdown_menu(move |menu, _, _| {
|
||||
@@ -774,27 +766,23 @@ 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,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
fn local_avatar(entry: &ResolvedLocalRepo, cx: &App) -> AnyElement {
|
||||
// Matches the other sidebar rows, and the badge below sits at its corner.
|
||||
let avatar = Avatar::new(entry.path.to_string_lossy()).xsmall();
|
||||
let avatar = PixelAvatar::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 profile = directory.read(cx).profile(&owner, cx);
|
||||
let store = ProfileStore::global(cx);
|
||||
let profile = store.read(cx).get(&owner);
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.child(avatar)
|
||||
.child(
|
||||
div().absolute().bottom_neg_0p5().right_neg_0p5().child(
|
||||
Avatar::new(owner)
|
||||
.name(profile.name())
|
||||
Avatar::new(profile.name())
|
||||
.picture(profile.picture())
|
||||
.size(px(14.)),
|
||||
),
|
||||
@@ -839,11 +827,12 @@ 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| self.directory.read(cx).profile(&public_key, cx));
|
||||
.map(|public_key| profile_store.read(cx).get(&public_key));
|
||||
|
||||
if profile.is_none() {
|
||||
return self.render_sign_in(window, cx);
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
use std::cell::Cell;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, PathPromptOptions, SharedString, Subscription, Window, div, px};
|
||||
use gpui::{
|
||||
AnyWindowHandle, App, Entity, PathPromptOptions, SharedString, Subscription, Window, div, px,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::{Input, InputState};
|
||||
use gpui_component::input::{
|
||||
Input, InputEvent, InputState, NumberInput, NumberInputEvent, StepAction,
|
||||
};
|
||||
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, WindowExt, h_flex, v_flex,
|
||||
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex,
|
||||
v_flex,
|
||||
};
|
||||
use settings::{AppearanceMode, EventFetchingStrategy, Settings, SettingsStore};
|
||||
use signed_ui::{SelectOption, setting_block, setting_row};
|
||||
@@ -24,10 +31,34 @@ 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>,
|
||||
@@ -76,9 +107,38 @@ 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
|
||||
@@ -118,9 +178,118 @@ 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,
|
||||
@@ -151,6 +320,8 @@ 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))
|
||||
@@ -179,6 +350,76 @@ 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(
|
||||
@@ -483,6 +724,79 @@ 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),
|
||||
@@ -490,3 +804,33 @@ 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);
|
||||
}
|
||||
|
||||
+1
-5
@@ -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"
|
||||
version = "0.2.0-alpha"
|
||||
out-dir = "../dist"
|
||||
before-packaging-command = "cargo build --release"
|
||||
resources = ["Cargo.toml", "src"]
|
||||
@@ -28,7 +28,6 @@ icons = [
|
||||
|
||||
[dependencies]
|
||||
assets = { path = "../crates/assets" }
|
||||
metadata = { path = "../crates/metadata" }
|
||||
paths = { path = "../crates/paths" }
|
||||
settings = { path = "../crates/settings" }
|
||||
signed_state = { path = "../crates/signed_state" }
|
||||
@@ -38,9 +37,6 @@ 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
|
||||
|
||||
+10
-79
@@ -5,9 +5,7 @@ use dock::TAB_BAR_HEIGHT;
|
||||
use gpui::*;
|
||||
use gpui_component::{Theme, ThemeMode, ThemeRegistry, theme};
|
||||
use gpui_platform::application;
|
||||
use native_theme::watch::ThemeSubscription;
|
||||
use native_theme_gpui::{SystemTheme, apply_system_theme};
|
||||
use settings::{AppearanceMode, Settings, SettingsStore};
|
||||
use settings::{AppearanceMode, SettingsStore};
|
||||
|
||||
actions!(signed, [Quit]);
|
||||
|
||||
@@ -27,28 +25,18 @@ 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match apply_native_theme(&settings, cx) {
|
||||
Ok(()) => start_theme_watcher(cx),
|
||||
Err(error) => {
|
||||
tracing::warn!("native theme unavailable ({error})");
|
||||
|
||||
let registry = ThemeRegistry::global_mut(cx);
|
||||
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())
|
||||
@@ -65,13 +53,19 @@ fn main() {
|
||||
if let Some(light) = light_theme {
|
||||
theme.light_theme = light;
|
||||
} else {
|
||||
tracing::warn!("{} theme is missing", settings.theme.light_theme);
|
||||
tracing::warn!(
|
||||
"{} theme is missing from the registry",
|
||||
settings.theme.light_theme
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(dark) = dark_theme {
|
||||
theme.dark_theme = dark;
|
||||
} else {
|
||||
tracing::warn!("{} theme is missing", settings.theme.dark_theme);
|
||||
tracing::warn!(
|
||||
"{} theme is missing from the registry",
|
||||
settings.theme.dark_theme
|
||||
);
|
||||
}
|
||||
|
||||
match settings.appearance {
|
||||
@@ -79,19 +73,15 @@ fn main() {
|
||||
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");
|
||||
|
||||
@@ -124,62 +114,3 @@ 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})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
### 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.
|
||||
Reference in New Issue
Block a user