Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2124b00590 | ||
|
|
a249174026 | ||
|
|
4f81501668 | ||
|
|
d6850d60ea | ||
|
|
4b4b8b5cf2 | ||
|
|
a2e7907193 | ||
|
|
946128433b | ||
|
|
30ff255372 | ||
|
|
cd61a09320 | ||
|
|
e2edb9dd12 | ||
|
|
5e061cf860 | ||
|
|
4c252dc589 | ||
|
|
e72e04c471 |
@@ -129,6 +129,12 @@ Action handlers can be registered on an element via the event handler `.on_actio
|
|||||||
|
|
||||||
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
|
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
|
## 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 {}`.
|
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 {}`.
|
||||||
|
|||||||
+17
-5
@@ -1,20 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## Unreleased
|
## v0.2.0 - 2026/10/21
|
||||||
|
|
||||||
### Breaking changes
|
|
||||||
|
|
||||||
### Added
|
### 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 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 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 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)
|
- Add a quit action, bound to `secondary-q` (cmd on macOS, ctrl on others)
|
||||||
- Allow choosing the default branch when publishing a repository
|
- 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
|
- 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
|
### 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
|
- 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
|
- 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
|
- Break ties in repository activity lists by event id, so same-second events order deterministically
|
||||||
@@ -23,15 +25,25 @@
|
|||||||
- Connect to grasp relays and load the inbox only after the user's grasp list event arrives
|
- 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
|
- 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
|
- 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
|
### 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
|
### 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
|
- 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
|
## v0.2.0-alpha - 2026/09/27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+461
-213
File diff suppressed because it is too large
Load Diff
+8
-4
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop"]
|
|||||||
default-members = ["desktop"]
|
default-members = ["desktop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.0-alpha"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
@@ -13,9 +13,13 @@ gpui = { package = "gpui-pre", version = "0.3.8" }
|
|||||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
|
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" }
|
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
|
||||||
|
|
||||||
# GPUI Kit
|
# GPUI Kit; exact pins keep the graph on the gpui-pre 0.3.8 closure that
|
||||||
gpui-component = { version = "0.7.1", features = ["tree-sitter-languages"] }
|
# native-theme-gpui 0.6.1 is verified against (0.7.3 and 0.8 pin 0.3.9).
|
||||||
gpui-base = "0.7.1"
|
gpui-component = { version = "=0.7.1", features = ["tree-sitter-languages"] }
|
||||||
|
gpui-base = "=0.7.1"
|
||||||
|
|
||||||
|
native-theme = { version = "0.6.1", features = ["watch"] }
|
||||||
|
native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] }
|
||||||
|
|
||||||
dock = { path = "crates/dock" }
|
dock = { path = "crates/dock" }
|
||||||
settings = { path = "crates/settings" }
|
settings = { path = "crates/settings" }
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "metadata"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
signed_state = { path = "../signed_state" }
|
||||||
|
utils = { path = "../utils" }
|
||||||
|
|
||||||
|
nostr-sdk.workspace = true
|
||||||
|
flume.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
gpui.workspace = true
|
||||||
|
log.workspace = true
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
mod profile;
|
||||||
|
|
||||||
|
use gpui::{App, AppContext};
|
||||||
|
pub use profile::{Profile, ProfileDirectory, ProfileEvent, ProfileStore};
|
||||||
|
|
||||||
|
/// Initialize the global profile store.
|
||||||
|
pub fn init(cx: &mut App) {
|
||||||
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use flume::Sender;
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
||||||
|
};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use signed_state::{Backend, BackendEvent, sync_bootstrap_only};
|
||||||
|
use utils::shorten_pubkey;
|
||||||
|
|
||||||
|
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||||
|
const REQUEST_CHUNK: usize = 100;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Profile {
|
||||||
|
public_key: PublicKey,
|
||||||
|
metadata: Metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Profile {
|
||||||
|
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
|
||||||
|
Self {
|
||||||
|
public_key,
|
||||||
|
metadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_key(&self) -> PublicKey {
|
||||||
|
self.public_key
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn metadata(&self) -> &Metadata {
|
||||||
|
&self.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falls back to `name`, then a shortened npub.
|
||||||
|
pub fn name(&self) -> SharedString {
|
||||||
|
if let Some(display_name) = self.metadata.display_name.as_ref()
|
||||||
|
&& !display_name.is_empty()
|
||||||
|
{
|
||||||
|
return SharedString::from(display_name.trim().to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = self.metadata.name.as_ref()
|
||||||
|
&& !name.is_empty()
|
||||||
|
{
|
||||||
|
return SharedString::from(name.trim().to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
SharedString::from(shorten_pubkey(self.public_key))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn picture(&self) -> Option<SharedString> {
|
||||||
|
self.metadata
|
||||||
|
.picture
|
||||||
|
.as_ref()
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.map(|p| SharedString::from(p.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newest profile per author from metadata events.
|
||||||
|
fn latest_profiles(events: impl IntoIterator<Item = Event>) -> Vec<Self> {
|
||||||
|
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
match latest.get(&event.pubkey) {
|
||||||
|
Some((created_at, _)) if *created_at >= event.created_at => {}
|
||||||
|
_ => {
|
||||||
|
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||||
|
latest.insert(event.pubkey, (event.created_at, metadata));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
latest
|
||||||
|
.into_iter()
|
||||||
|
.map(|(public_key, (_, metadata))| Profile::new(public_key, metadata))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata for these authors may have changed in the local database.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ProfileEvent {
|
||||||
|
Updated(Vec<PublicKey>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Batches metadata requests into relay syncs feeding the local database.
|
||||||
|
pub struct ProfileStore {
|
||||||
|
in_flight: RefCell<HashSet<PublicKey>>,
|
||||||
|
sender: Sender<PublicKey>,
|
||||||
|
_task: Task<Result<(), anyhow::Error>>,
|
||||||
|
_subscription: Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlobalProfileStore(Entity<ProfileStore>);
|
||||||
|
|
||||||
|
impl Global for GlobalProfileStore {}
|
||||||
|
|
||||||
|
impl EventEmitter<ProfileEvent> for ProfileStore {}
|
||||||
|
|
||||||
|
impl ProfileStore {
|
||||||
|
pub(crate) fn global(cx: &App) -> Entity<Self> {
|
||||||
|
cx.global::<GlobalProfileStore>().0.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||||
|
cx.set_global(GlobalProfileStore(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
|
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||||
|
|
||||||
|
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
|
if let BackendEvent::ProfileUpdates(authors) = event {
|
||||||
|
this.emit_updated(authors.clone(), cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let task = cx.spawn(async move |this, cx| {
|
||||||
|
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match receiver.recv_async().await {
|
||||||
|
Ok(public_key) => {
|
||||||
|
batch.insert(public_key);
|
||||||
|
}
|
||||||
|
Err(_) => return Ok(()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// The channel has no async timeout, race the receive against a timer.
|
||||||
|
let deadline = Instant::now() + BATCH_TIMEOUT;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
if now >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer = cx.background_executor().timer(deadline - now);
|
||||||
|
futures::pin_mut!(timer);
|
||||||
|
|
||||||
|
let recv = receiver.recv_async();
|
||||||
|
futures::pin_mut!(recv);
|
||||||
|
|
||||||
|
match futures::future::select(recv, timer).await {
|
||||||
|
futures::future::Either::Left((Ok(public_key), _)) => {
|
||||||
|
batch.insert(public_key);
|
||||||
|
}
|
||||||
|
futures::future::Either::Left((Err(_), _)) => return Ok(()),
|
||||||
|
futures::future::Either::Right(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let authors: Vec<PublicKey> = batch.drain().collect();
|
||||||
|
|
||||||
|
for chunk in authors.chunks(REQUEST_CHUNK) {
|
||||||
|
let opts = SyncOptions::default();
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::Metadata)
|
||||||
|
.authors(chunk.iter().copied());
|
||||||
|
|
||||||
|
if let Err(e) = sync_bootstrap_only(&client, filter, opts).await {
|
||||||
|
log::warn!("profile fetch failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit even when the sync failed, the database may hold newer data.
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.emit_updated(authors, cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
in_flight: RefCell::new(HashSet::new()),
|
||||||
|
sender,
|
||||||
|
_task: task,
|
||||||
|
_subscription: subscription,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue a batched relay sync for keys without one in flight.
|
||||||
|
pub fn fetch(&self, keys: impl IntoIterator<Item = PublicKey>) {
|
||||||
|
let mut queue = Vec::new();
|
||||||
|
{
|
||||||
|
let mut in_flight = self.in_flight.borrow_mut();
|
||||||
|
|
||||||
|
for key in keys {
|
||||||
|
if in_flight.insert(key) {
|
||||||
|
queue.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for public_key in queue {
|
||||||
|
if let Err(e) = self.sender.send(public_key) {
|
||||||
|
log::warn!("failed to queue profile fetch: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Releases the in-flight keys so future fetches can be queued again.
|
||||||
|
fn emit_updated(&mut self, authors: Vec<PublicKey>, cx: &mut Context<Self>) {
|
||||||
|
if authors.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut in_flight = self.in_flight.borrow_mut();
|
||||||
|
for author in &authors {
|
||||||
|
in_flight.remove(author);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.emit(ProfileEvent::Updated(authors));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Profile cache bound to one panel or screen.
|
||||||
|
pub struct ProfileDirectory {
|
||||||
|
profiles: HashMap<PublicKey, Profile>,
|
||||||
|
pending: RefCell<HashSet<PublicKey>>,
|
||||||
|
tasks: Vec<Task<()>>,
|
||||||
|
_subscriptions: Vec<Subscription>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileDirectory {
|
||||||
|
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||||
|
let store = ProfileStore::global(cx);
|
||||||
|
|
||||||
|
cx.new(|cx| {
|
||||||
|
let subscription =
|
||||||
|
cx.subscribe(&store, |this: &mut Self, _store, event, cx| match event {
|
||||||
|
ProfileEvent::Updated(authors) => this.refresh(authors, cx),
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
profiles: HashMap::new(),
|
||||||
|
pending: RefCell::new(HashSet::new()),
|
||||||
|
tasks: Vec::new(),
|
||||||
|
_subscriptions: vec![subscription],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve keys from the local database, queue the rest for a relay sync.
|
||||||
|
pub fn track<K>(&mut self, keys: K, cx: &mut Context<Self>)
|
||||||
|
where
|
||||||
|
K: IntoIterator<Item = PublicKey>,
|
||||||
|
{
|
||||||
|
let missing: Vec<PublicKey> = keys
|
||||||
|
.into_iter()
|
||||||
|
.filter(|key| {
|
||||||
|
!self.profiles.contains_key(key)
|
||||||
|
&& !self.with_pending(|pending| pending.contains(key))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if missing.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.load(missing, true, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The profile for a key, or a placeholder that queues a relay sync.
|
||||||
|
pub fn profile(&self, public_key: &PublicKey, cx: &App) -> Profile {
|
||||||
|
if let Some(profile) = self.profiles.get(public_key) {
|
||||||
|
return profile.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.with_pending(|pending| pending.insert(*public_key)) {
|
||||||
|
let store = ProfileStore::global(cx);
|
||||||
|
store.read(cx).fetch([*public_key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Profile::new(*public_key, Metadata::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes profiles for keys that are already tracked.
|
||||||
|
fn refresh(&mut self, authors: &[PublicKey], cx: &mut Context<Self>) {
|
||||||
|
let tracked: Vec<PublicKey> = authors
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|key| {
|
||||||
|
self.profiles.contains_key(key)
|
||||||
|
|| self.with_pending(|pending| pending.contains(key))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if tracked.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.load(tracked, false, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads profiles from the local database, queueing a relay sync for any missing keys.
|
||||||
|
fn load(&mut self, keys: Vec<PublicKey>, queue_sync: bool, cx: &mut Context<Self>) {
|
||||||
|
// Accept events that arrive while the database query is running.
|
||||||
|
self.with_pending(|pending| {
|
||||||
|
pending.extend(keys.iter().copied());
|
||||||
|
});
|
||||||
|
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let client = backend.read(cx).client();
|
||||||
|
let query_keys = keys.clone();
|
||||||
|
|
||||||
|
let work = cx.background_spawn(async move {
|
||||||
|
let authors = query_keys.iter().copied();
|
||||||
|
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
||||||
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
|
Ok::<_, anyhow::Error>(Profile::latest_profiles(events))
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let loaded = work.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
let remaining = match loaded {
|
||||||
|
Ok(profiles) => {
|
||||||
|
let found: HashSet<PublicKey> =
|
||||||
|
profiles.iter().map(Profile::public_key).collect();
|
||||||
|
this.insert_loaded(profiles, cx);
|
||||||
|
keys.into_iter()
|
||||||
|
.filter(|key| !found.contains(key))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("failed to load profiles from the database: {e}");
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if queue_sync && !remaining.is_empty() {
|
||||||
|
let store = ProfileStore::global(cx);
|
||||||
|
store.read(cx).fetch(remaining);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inserts database results, replacing anything loaded earlier.
|
||||||
|
fn insert_loaded(&mut self, profiles: Vec<Profile>, cx: &mut Context<Self>) {
|
||||||
|
let mut inserted = false;
|
||||||
|
|
||||||
|
for profile in profiles {
|
||||||
|
let public_key = profile.public_key();
|
||||||
|
self.profiles.insert(public_key, profile);
|
||||||
|
self.with_pending(|pending| pending.remove(&public_key));
|
||||||
|
inserted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if inserted {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_pending<R, T>(&self, then: T) -> R
|
||||||
|
where
|
||||||
|
T: FnOnce(&mut HashSet<PublicKey>) -> R,
|
||||||
|
{
|
||||||
|
let mut pending = self.pending.borrow_mut();
|
||||||
|
then(&mut pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex, PoisonError};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::Result;
|
||||||
use signed_core::{Announcement, RepoAddr};
|
use signed_core::{Announcement, RepoAddr};
|
||||||
|
|
||||||
use crate::repo::Repo;
|
use crate::repo::Repo;
|
||||||
@@ -8,12 +10,16 @@ use crate::repo::Repo;
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GitCache {
|
pub struct GitCache {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
|
clone_locks: Arc<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GitCache {
|
impl GitCache {
|
||||||
/// Creates a cache rooted at `root`.
|
/// Creates a cache rooted at `root`.
|
||||||
pub fn new(root: PathBuf) -> Self {
|
pub fn new(root: PathBuf) -> Self {
|
||||||
Self { root }
|
Self {
|
||||||
|
root,
|
||||||
|
clone_locks: Arc::default(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the cache root directory.
|
/// Returns the cache root directory.
|
||||||
@@ -38,20 +44,40 @@ 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.
|
/// 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> {
|
pub fn ensure_clone<U: AsRef<str>>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result<Repo> {
|
||||||
let path = self.repo_path(addr);
|
let path = self.repo_path(addr);
|
||||||
|
|
||||||
if let Some(repo) = self.open(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 Err(error) = repo.fetch() {
|
if let Err(error) = repo.fetch() {
|
||||||
log::warn!("failed to refresh the cached repository: {error:#}");
|
log::warn!("failed to refresh the cached repository: {error:#}");
|
||||||
}
|
}
|
||||||
return Ok(repo);
|
return Ok(repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create the parent directory if it does not exist.
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)?;
|
||||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
}
|
||||||
|
|
||||||
|
// Remove the cached clone if it exists but is unusable.
|
||||||
|
if path.exists() {
|
||||||
|
log::warn!("removing unusable cached clone at {}", path.display());
|
||||||
|
std::fs::remove_dir_all(&path)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Repo::clone(clone_urls, &path)
|
Repo::clone(clone_urls, &path)
|
||||||
|
|||||||
@@ -1222,22 +1222,25 @@ impl Repo {
|
|||||||
base_url,
|
base_url,
|
||||||
owner,
|
owner,
|
||||||
repo_id,
|
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`.
|
/// 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<()> {
|
pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||||
self.push_refspecs(
|
self.push_refspecs(
|
||||||
base_url,
|
base_url,
|
||||||
owner,
|
owner,
|
||||||
repo_id,
|
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`.
|
/// Pushes explicit refspecs to the grasp repository at `base_url`.
|
||||||
fn push_refspecs(
|
pub fn push_refspecs(
|
||||||
&self,
|
&self,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
owner: &str,
|
owner: &str,
|
||||||
@@ -1262,6 +1265,33 @@ impl Repo {
|
|||||||
Ok(())
|
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.
|
/// Sets the `origin` URL when the repository has no remote yet.
|
||||||
pub fn ensure_origin(&self, url: &str) -> Result<()> {
|
pub fn ensure_origin(&self, url: &str) -> Result<()> {
|
||||||
if self.inner.find_remote("origin").is_ok() {
|
if self.inner.find_remote("origin").is_ok() {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
use nostr::prelude::Keys;
|
||||||
|
use signed_core::RepoAddr;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -11,6 +14,70 @@ fn blocks_parent_components() {
|
|||||||
assert_eq!(GitCache::sanitize_path_component("a/../b"), "a_.._b");
|
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]
|
#[test]
|
||||||
fn root_commit_reports_the_first_ancestor() {
|
fn root_commit_reports_the_first_ancestor() {
|
||||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||||
@@ -55,6 +122,166 @@ fn push_all_mirrors_branches_and_tags() {
|
|||||||
assert!(refs.contains("refs/tags/v1.0"));
|
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]
|
#[test]
|
||||||
fn ref_state_matches_the_git_cli_ref_listing() {
|
fn ref_state_matches_the_git_cli_ref_listing() {
|
||||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::{Error, anyhow, bail};
|
use anyhow::{Error, anyhow, bail};
|
||||||
@@ -21,11 +22,9 @@ use crate::git_store::Mirrors;
|
|||||||
use crate::inbox::Inbox;
|
use crate::inbox::Inbox;
|
||||||
use crate::push::{PushOutcome, grasp_base_url, grasp_clone_url};
|
use crate::push::{PushOutcome, grasp_base_url, grasp_clone_url};
|
||||||
use crate::repos::RepoListStore;
|
use crate::repos::RepoListStore;
|
||||||
use crate::state::{GitPushMode, GitRef, PushStepStatus, SyncState};
|
use crate::state::{GitPushMode, GitPushPolicy, GitRef, PushStepStatus, SyncState};
|
||||||
|
|
||||||
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
||||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
|
||||||
|
|
||||||
const PUMP_DEBOUNCE: Duration = Duration::from_millis(200);
|
const PUMP_DEBOUNCE: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -199,13 +198,9 @@ impl Backend {
|
|||||||
} else if content.starts_with("bunker://") {
|
} else if content.starts_with("bunker://") {
|
||||||
let (base, keys) = extract_master_key(&content)?;
|
let (base, keys) = extract_master_key(&content)?;
|
||||||
let uri = NostrConnectUri::parse(base)?;
|
let uri = NostrConnectUri::parse(base)?;
|
||||||
let mut signer = NostrConnect::new(
|
let signer = NostrConnect::builder(uri, keys)
|
||||||
uri,
|
.auth_url_handler(Arc::new(SignedAuthUrlHandler))
|
||||||
keys,
|
.build()?;
|
||||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
|
||||||
None,
|
|
||||||
)?;
|
|
||||||
signer.auth_url_handler(SignedAuthUrlHandler);
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_signer(signer, cx);
|
this.set_signer(signer, cx);
|
||||||
@@ -366,13 +361,9 @@ impl Backend {
|
|||||||
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let mut signer = NostrConnect::new(
|
let signer = NostrConnect::builder(connect_uri, keys)
|
||||||
connect_uri,
|
.auth_url_handler(Arc::new(SignedAuthUrlHandler))
|
||||||
keys,
|
.build()?;
|
||||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
|
||||||
None,
|
|
||||||
)?;
|
|
||||||
signer.auth_url_handler(SignedAuthUrlHandler);
|
|
||||||
|
|
||||||
// Verify the bunker responds before persisting the credential.
|
// Verify the bunker responds before persisting the credential.
|
||||||
let public_key = signer.get_public_key_async().await?;
|
let public_key = signer.get_public_key_async().await?;
|
||||||
@@ -588,6 +579,7 @@ impl Backend {
|
|||||||
head: Some("main".to_owned()),
|
head: Some("main".to_owned()),
|
||||||
path: Some(destination.clone()),
|
path: Some(destination.clone()),
|
||||||
mode: GitPushMode::Main,
|
mode: GitPushMode::Main,
|
||||||
|
policy: GitPushPolicy::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_step = &mut |status: PushStepStatus, cx: &mut AsyncApp| {
|
let on_step = &mut |status: PushStepStatus, cx: &mut AsyncApp| {
|
||||||
@@ -735,7 +727,17 @@ impl Backend {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Task<Result<PushOutcome, Error>> {
|
) -> Task<Result<PushOutcome, Error>> {
|
||||||
let path = Mirrors::path(&announcement.addr());
|
let path = Mirrors::path(&announcement.addr());
|
||||||
self.push_repo_from(announcement, path, None, cx)
|
self.push_repo_from(announcement, path, None, GitPushPolicy::Reconcile, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Republishes the mirror and deletes grasp refs the mirror does not have.
|
||||||
|
pub fn reset_repository(
|
||||||
|
&mut self,
|
||||||
|
announcement: Announcement,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Task<Result<PushOutcome, Error>> {
|
||||||
|
let path = Mirrors::path(&announcement.addr());
|
||||||
|
self.push_repo_from(announcement, path, None, GitPushPolicy::ResetRemote, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_checkout(
|
pub fn push_checkout(
|
||||||
@@ -745,7 +747,13 @@ impl Backend {
|
|||||||
announced_head: Option<String>,
|
announced_head: Option<String>,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Task<Result<PushOutcome, Error>> {
|
) -> Task<Result<PushOutcome, Error>> {
|
||||||
self.push_repo_from(announcement, checkout, announced_head, cx)
|
self.push_repo_from(
|
||||||
|
announcement,
|
||||||
|
checkout,
|
||||||
|
announced_head,
|
||||||
|
GitPushPolicy::Reconcile,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_repo_from(
|
fn push_repo_from(
|
||||||
@@ -753,6 +761,7 @@ impl Backend {
|
|||||||
announcement: Announcement,
|
announcement: Announcement,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
announced_head: Option<String>,
|
announced_head: Option<String>,
|
||||||
|
policy: GitPushPolicy,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Task<Result<PushOutcome, Error>> {
|
) -> Task<Result<PushOutcome, Error>> {
|
||||||
let addr = announcement.addr();
|
let addr = announcement.addr();
|
||||||
@@ -789,6 +798,11 @@ impl Backend {
|
|||||||
});
|
});
|
||||||
work.await?
|
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.
|
// Keep the announced default branch in `HEAD` when it is among the pushed refs.
|
||||||
if let Some(head) = announced_head
|
if let Some(head) = announced_head
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ pub(crate) async fn subscribe_bootstrap_only(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn sync_bootstrap_only(
|
pub async fn sync_bootstrap_only(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
filter: Filter,
|
filter: Filter,
|
||||||
opts: SyncOptions,
|
opts: SyncOptions,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ mod git_store;
|
|||||||
mod inbox;
|
mod inbox;
|
||||||
mod local_repos;
|
mod local_repos;
|
||||||
mod patch_series;
|
mod patch_series;
|
||||||
mod profile;
|
|
||||||
mod push;
|
mod push;
|
||||||
mod refresh;
|
mod refresh;
|
||||||
mod repo;
|
mod repo;
|
||||||
@@ -16,22 +15,21 @@ mod sync_status;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub use backend::{Backend, BackendEvent};
|
pub use backend::{Backend, BackendEvent};
|
||||||
pub use bootstrap::user_grasp_list_servers;
|
pub use bootstrap::{sync_bootstrap_only, user_grasp_list_servers};
|
||||||
pub use checkouts::{CheckoutStatus, CheckoutsStore};
|
pub use checkouts::{CheckoutStatus, CheckoutsStore};
|
||||||
pub use git_store::Mirrors;
|
pub use git_store::Mirrors;
|
||||||
use gpui::{App, AppContext};
|
use gpui::{App, AppContext};
|
||||||
pub use inbox::Inbox;
|
pub use inbox::Inbox;
|
||||||
pub use local_repos::{LocalReposStore, ResolvedLocalRepo};
|
pub use local_repos::{LocalReposStore, ResolvedLocalRepo};
|
||||||
pub use nostr_sdk::prelude::Timestamp;
|
pub use nostr_sdk::prelude::Timestamp;
|
||||||
pub use profile::{Profile, ProfileStore};
|
|
||||||
pub use push::{GraspServer, PushOutcome};
|
pub use push::{GraspServer, PushOutcome};
|
||||||
pub use refresh::{RefreshGate, RefreshRequest};
|
pub use refresh::{RefreshGate, RefreshRequest};
|
||||||
pub use repo::RepoStore;
|
pub use repo::RepoStore;
|
||||||
pub use repos::RepoListStore;
|
pub use repos::RepoListStore;
|
||||||
pub use signed_git::{Nip34Binding, Nip34Kind};
|
pub use signed_git::{Nip34Binding, Nip34Kind};
|
||||||
use signed_nostr::NostrBackend;
|
use signed_nostr::NostrBackend;
|
||||||
pub use state::{GitPushMode, GitRef, PushStep, PushStepStatus, SyncState};
|
pub use state::{GitPushMode, GitPushPolicy, GitRef, PushStep, PushStepStatus, SyncState};
|
||||||
pub use sync_status::{CheckoutSyncStatus, SyncStatusStore};
|
pub use sync_status::{CheckoutSyncStatus, SyncEvent, SyncStatusDirectory, SyncStatusStore};
|
||||||
|
|
||||||
pub fn init(
|
pub fn init(
|
||||||
db_path: impl AsRef<Path>,
|
db_path: impl AsRef<Path>,
|
||||||
@@ -51,7 +49,6 @@ pub fn init(
|
|||||||
Mirrors::install(repos_root);
|
Mirrors::install(repos_root);
|
||||||
|
|
||||||
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
||||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
|
||||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||||
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
use std::cell::RefCell;
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use anyhow::Error;
|
|
||||||
use flume::{Receiver, Sender};
|
|
||||||
use gpui::{
|
|
||||||
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, WeakEntity,
|
|
||||||
};
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use utils::shorten_pubkey;
|
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent};
|
|
||||||
use crate::bootstrap::sync_bootstrap_only;
|
|
||||||
|
|
||||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
|
||||||
const REQUEST_CHUNK: usize = 100;
|
|
||||||
const WARM_LIMIT: usize = 500;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Profile {
|
|
||||||
public_key: PublicKey,
|
|
||||||
metadata: Metadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Profile {
|
|
||||||
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
|
|
||||||
Self {
|
|
||||||
public_key,
|
|
||||||
metadata,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn public_key(&self) -> PublicKey {
|
|
||||||
self.public_key
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn metadata(&self) -> &Metadata {
|
|
||||||
&self.metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
// Falls back to `name`, then a shortened npub.
|
|
||||||
pub fn name(&self) -> SharedString {
|
|
||||||
if let Some(display_name) = self.metadata.display_name.as_ref()
|
|
||||||
&& !display_name.is_empty()
|
|
||||||
{
|
|
||||||
return SharedString::from(display_name.trim().to_owned());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(name) = self.metadata.name.as_ref()
|
|
||||||
&& !name.is_empty()
|
|
||||||
{
|
|
||||||
return SharedString::from(name.trim().to_owned());
|
|
||||||
}
|
|
||||||
|
|
||||||
SharedString::from(shorten_pubkey(self.public_key))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn picture(&self) -> Option<SharedString> {
|
|
||||||
self.metadata
|
|
||||||
.picture
|
|
||||||
.as_ref()
|
|
||||||
.filter(|p| !p.is_empty())
|
|
||||||
.map(|p| SharedString::from(p.clone()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ProfileStore {
|
|
||||||
profiles: HashMap<PublicKey, Profile>,
|
|
||||||
seen: RefCell<HashSet<PublicKey>>,
|
|
||||||
sender: Sender<PublicKey>,
|
|
||||||
_subscription: Subscription,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GlobalProfileStore(Entity<ProfileStore>);
|
|
||||||
|
|
||||||
impl Global for GlobalProfileStore {}
|
|
||||||
|
|
||||||
impl ProfileStore {
|
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
|
||||||
cx.global::<GlobalProfileStore>().0.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
|
||||||
cx.set_global(GlobalProfileStore(entity));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let entity = cx.entity().downgrade();
|
|
||||||
let client = backend.read(cx).client();
|
|
||||||
|
|
||||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
|
||||||
|
|
||||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
|
||||||
if let BackendEvent::ProfileUpdates(authors) = event {
|
|
||||||
this.apply_authors(authors.clone(), cx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
if let Err(e) = Self::handle_requests(this, &client, &receiver, cx).await {
|
|
||||||
log::error!("Failed to handle requests: {e}");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
cx.defer(move |cx| {
|
|
||||||
entity.update(cx, |this, cx| this.load(cx)).ok();
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
|
||||||
profiles: HashMap::new(),
|
|
||||||
seen: RefCell::new(HashSet::new()),
|
|
||||||
sender,
|
|
||||||
_subscription: subscription,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns a placeholder until fetched; queues a fetch when uncached.
|
|
||||||
pub fn get(&self, public_key: &PublicKey) -> Profile {
|
|
||||||
if let Some(profile) = self.profiles.get(public_key) {
|
|
||||||
return profile.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
let public_key = *public_key;
|
|
||||||
|
|
||||||
if self.seen.borrow_mut().insert(public_key)
|
|
||||||
&& let Err(e) = self.sender.send(public_key)
|
|
||||||
{
|
|
||||||
log::warn!("failed to queue profile fetch: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Profile::new(public_key, Metadata::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let client = backend.read(cx).client();
|
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
|
||||||
let filter = Filter::new().kind(Kind::Metadata).limit(WARM_LIMIT);
|
|
||||||
let events = client.database().query(filter).await?;
|
|
||||||
|
|
||||||
let profiles: Vec<Profile> = events
|
|
||||||
.into_iter()
|
|
||||||
.map(|event| {
|
|
||||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
|
||||||
Profile::new(event.pubkey, metadata)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok::<_, Error>(profiles)
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
let profiles = work.await?;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
for profile in profiles {
|
|
||||||
this.profiles.insert(profile.public_key(), profile);
|
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok::<_, Error>(())
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_authors(&mut self, authors: Vec<PublicKey>, cx: &mut Context<Self>) {
|
|
||||||
if authors.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let client = backend.read(cx).client();
|
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
|
||||||
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
|
||||||
let events = client.database().query(filter).await?;
|
|
||||||
// Pick the latest metadata per author off the main thread.
|
|
||||||
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
|
||||||
|
|
||||||
for event in events {
|
|
||||||
match latest.get(&event.pubkey) {
|
|
||||||
Some((ts, _)) if *ts >= event.created_at => {}
|
|
||||||
_ => {
|
|
||||||
latest.insert(
|
|
||||||
event.pubkey,
|
|
||||||
(
|
|
||||||
event.created_at,
|
|
||||||
Metadata::from_json(&event.content).unwrap_or_default(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let profiles: Vec<Profile> = latest
|
|
||||||
.into_iter()
|
|
||||||
.map(|(public_key, (_, metadata))| Profile::new(public_key, metadata))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok::<_, Error>(profiles)
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
let profiles = work.await?;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
for profile in profiles {
|
|
||||||
this.profiles.insert(profile.public_key(), profile);
|
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok::<_, Error>(())
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
|
||||||
self.apply_authors(authors, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_requests(
|
|
||||||
this: WeakEntity<ProfileStore>,
|
|
||||||
client: &Client,
|
|
||||||
receiver: &Receiver<PublicKey>,
|
|
||||||
cx: &mut AsyncApp,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match receiver.recv_async().await {
|
|
||||||
Ok(public_key) => {
|
|
||||||
batch.insert(public_key);
|
|
||||||
}
|
|
||||||
Err(_) => return Ok(()),
|
|
||||||
}
|
|
||||||
|
|
||||||
// The channel has no async timeout, race the receive against a timer.
|
|
||||||
let deadline = Instant::now() + BATCH_TIMEOUT;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let now = Instant::now();
|
|
||||||
|
|
||||||
if now >= deadline {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timer = cx.background_executor().timer(deadline - now);
|
|
||||||
futures::pin_mut!(timer);
|
|
||||||
|
|
||||||
let recv = receiver.recv_async();
|
|
||||||
futures::pin_mut!(recv);
|
|
||||||
|
|
||||||
match futures::future::select(recv, timer).await {
|
|
||||||
futures::future::Either::Left((Ok(public_key), _)) => {
|
|
||||||
batch.insert(public_key);
|
|
||||||
}
|
|
||||||
futures::future::Either::Left((Err(_), _)) => return Ok(()),
|
|
||||||
futures::future::Either::Right(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let authors: Vec<PublicKey> = batch.drain().collect();
|
|
||||||
|
|
||||||
for chunk in authors.chunks(REQUEST_CHUNK) {
|
|
||||||
let opts = SyncOptions::default();
|
|
||||||
let filter = Filter::new()
|
|
||||||
.kind(Kind::Metadata)
|
|
||||||
.authors(chunk.iter().copied());
|
|
||||||
|
|
||||||
if let Err(e) = sync_bootstrap_only(client, filter, opts).await {
|
|
||||||
log::warn!("profile fetch failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if this.update(cx, |this, cx| this.apply_seen(cx)).is_err() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1172,6 +1172,24 @@ impl RepoStore {
|
|||||||
self.run_push(push, None, cx)
|
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(
|
pub fn push_checkout(
|
||||||
&mut self,
|
&mut self,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
@@ -1226,6 +1244,7 @@ impl RepoStore {
|
|||||||
Ok(outcome) => {
|
Ok(outcome) => {
|
||||||
this.last_error = None;
|
this.last_error = None;
|
||||||
this.last_push_warning = outcome.partial_warning();
|
this.last_push_warning = outcome.partial_warning();
|
||||||
|
|
||||||
if let Some((addr, path)) = &pushed_checkout {
|
if let Some((addr, path)) = &pushed_checkout {
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
checkouts.update(cx, |store, cx| {
|
checkouts.update(cx, |store, cx| {
|
||||||
@@ -1233,8 +1252,13 @@ impl RepoStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-probe so sync badges reflect the pushed state.
|
||||||
let sync_status = SyncStatusStore::global(cx);
|
let sync_status = SyncStatusStore::global(cx);
|
||||||
sync_status.update(cx, |store, cx| store.refresh(cx));
|
let addr = pushed_checkout
|
||||||
|
.as_ref()
|
||||||
|
.map(|(addr, _)| addr.clone())
|
||||||
|
.or_else(|| this.addr.clone());
|
||||||
|
sync_status.read(cx).probe(addr);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.last_error = Some(format!("Push failed: {e}"));
|
this.last_error = Some(format!("Push failed: {e}"));
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ pub enum GitPushMode {
|
|||||||
Main,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct SyncState {
|
pub struct SyncState {
|
||||||
pub refs: Vec<GitRef>,
|
pub refs: Vec<GitRef>,
|
||||||
@@ -47,6 +58,7 @@ pub struct SyncState {
|
|||||||
/// Local repository to push from; `None` for states parsed from events.
|
/// Local repository to push from; `None` for states parsed from events.
|
||||||
pub path: Option<PathBuf>,
|
pub path: Option<PathBuf>,
|
||||||
pub mode: GitPushMode,
|
pub mode: GitPushMode,
|
||||||
|
pub policy: GitPushPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -135,6 +147,7 @@ impl SyncState {
|
|||||||
head,
|
head,
|
||||||
path: None,
|
path: None,
|
||||||
mode: GitPushMode::default(),
|
mode: GitPushMode::default(),
|
||||||
|
policy: GitPushPolicy::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +167,7 @@ impl SyncState {
|
|||||||
head: state.head,
|
head: state.head,
|
||||||
path: Some(path.to_path_buf()),
|
path: Some(path.to_path_buf()),
|
||||||
mode: GitPushMode::default(),
|
mode: GitPushMode::default(),
|
||||||
|
policy: GitPushPolicy::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,13 +176,127 @@ impl SyncState {
|
|||||||
self
|
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.
|
/// State event for these refs; the caller signs it.
|
||||||
pub fn state_event(&self, repo_id: &str) -> EventBuilder {
|
pub fn state_event(&self, repo_id: &str) -> EventBuilder {
|
||||||
Self::build(repo_id, &self.refs, self.head.as_deref())
|
Self::build(repo_id, &self.refs, self.head.as_deref())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push the state event and objects to the grasp servers,
|
/// Push the state event and objects to the grasp servers.
|
||||||
/// calling `on_step` for each step.
|
///
|
||||||
|
/// Calling `on_step` for each step.
|
||||||
pub async fn push_to_grasps<P>(
|
pub async fn push_to_grasps<P>(
|
||||||
&self,
|
&self,
|
||||||
client: &Client,
|
client: &Client,
|
||||||
@@ -191,6 +319,12 @@ impl SyncState {
|
|||||||
.map_err(|error| anyhow!("could not encode the repository owner: {error}"))?;
|
.map_err(|error| anyhow!("could not encode the repository owner: {error}"))?;
|
||||||
|
|
||||||
let mut outcome = PushOutcome::default();
|
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 {
|
for relay in &announcement.relays {
|
||||||
let step = PushStep::StageStateEvent(relay.clone());
|
let step = PushStep::StageStateEvent(relay.clone());
|
||||||
@@ -241,12 +375,24 @@ impl SyncState {
|
|||||||
let owner = owner.clone();
|
let owner = owner.clone();
|
||||||
let repo_id = announcement.id.clone();
|
let repo_id = announcement.id.clone();
|
||||||
let mode = self.mode;
|
let mode = self.mode;
|
||||||
|
let declared = declared.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let repo = Repo::open(&path)?;
|
let repo = Repo::open(&path)?;
|
||||||
|
|
||||||
match mode {
|
match mode {
|
||||||
GitPushMode::Main => repo.push_main(&base, &owner, &repo_id),
|
GitPushMode::Main => repo.push_main(&base, &owner, &repo_id),
|
||||||
GitPushMode::All => repo.push_all(&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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -370,75 +516,19 @@ impl SyncState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
/// Refspecs to force push all heads and tags to the grasp servers.
|
||||||
mod tests {
|
fn force_refspecs() -> Vec<String> {
|
||||||
use super::*;
|
vec![
|
||||||
|
"+refs/heads/*:refs/heads/*".to_owned(),
|
||||||
const COMMIT_A: &str = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
"+refs/tags/*:refs/tags/*".to_owned(),
|
||||||
const COMMIT_B: &str = "59429cfc6cb35b0a1ddace73b5a5c5ed57b8f5ca";
|
]
|
||||||
|
}
|
||||||
fn git_ref(name: &str, commit: &str) -> GitRef {
|
|
||||||
GitRef::new(name, commit).expect("valid ref")
|
/// Refspecs to delete server-only refs from the grasp servers.
|
||||||
}
|
fn deletion_refspecs(declared: &[String], server_refs: &[(String, String)]) -> Vec<String> {
|
||||||
|
server_refs
|
||||||
fn keys() -> Keys {
|
.iter()
|
||||||
Keys::new(
|
.filter(|(name, _)| !declared.contains(name))
|
||||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
.map(|(name, _)| format!(":{name}"))
|
||||||
.expect("valid secret key"),
|
.collect()
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = SyncState::parse(&event);
|
|
||||||
|
|
||||||
assert_eq!(state.head.as_deref(), Some("main"));
|
|
||||||
assert_eq!(
|
|
||||||
state.refs,
|
|
||||||
vec![
|
|
||||||
git_ref("refs/heads/main", COMMIT_A),
|
|
||||||
git_ref("refs/heads/dev", COMMIT_B),
|
|
||||||
git_ref("refs/tags/v1.0", COMMIT_A),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_round_trips_through_parse() {
|
|
||||||
let refs = vec![
|
|
||||||
git_ref("refs/heads/main", COMMIT_A),
|
|
||||||
git_ref("refs/heads/dev", COMMIT_B),
|
|
||||||
git_ref("refs/tags/v1.0", COMMIT_A),
|
|
||||||
];
|
|
||||||
|
|
||||||
let event = SyncState::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 = SyncState::parse(&event);
|
|
||||||
assert_eq!(state.refs, refs);
|
|
||||||
assert_eq!(state.head.as_deref(), Some("main"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
use std::collections::HashMap;
|
use std::cell::RefCell;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription};
|
use flume::Sender;
|
||||||
use nostr_sdk::prelude::*;
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||||
use signed_core::{Deletions, Filters, RepoAddr};
|
use signed_core::{Deletions, Filters, RepoAddr};
|
||||||
use signed_git::{Repo, RepoSyncStatus};
|
use signed_git::{Repo, RepoSyncStatus};
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent};
|
use crate::backend::{Backend, BackendEvent};
|
||||||
use crate::checkouts::CheckoutsStore;
|
use crate::checkouts::CheckoutsStore;
|
||||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
use crate::state::SyncState;
|
||||||
use crate::repos::RepoListStore;
|
|
||||||
use crate::state::{GitRef, SyncState};
|
|
||||||
|
|
||||||
const SYNC_POLL: Duration = Duration::from_secs(180);
|
const SYNC_POLL: Duration = Duration::from_secs(180);
|
||||||
const MAX_SYNC_CHECKOUTS: usize = 8;
|
const MAX_SYNC_CHECKOUTS: usize = 8;
|
||||||
@@ -28,13 +27,25 @@ pub struct CheckoutSyncStatus {
|
|||||||
pub status: RepoSyncStatus,
|
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 {
|
pub struct SyncStatusStore {
|
||||||
statuses: HashMap<RepoAddr, Vec<CheckoutSyncStatus>>,
|
active: RefCell<HashSet<RepoAddr>>,
|
||||||
refresh: RefreshGate,
|
in_flight: bool,
|
||||||
|
pass_pending: bool,
|
||||||
poll_pending: bool,
|
poll_pending: bool,
|
||||||
|
sender: Sender<()>,
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<SyncEvent> for SyncStatusStore {}
|
||||||
|
|
||||||
impl SyncStatusStore {
|
impl SyncStatusStore {
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
cx.global::<GlobalSyncStatusStore>().0.clone()
|
cx.global::<GlobalSyncStatusStore>().0.clone()
|
||||||
@@ -45,150 +56,131 @@ impl SyncStatusStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
let (sender, receiver) = flume::unbounded::<()>();
|
||||||
let mut subscriptions = Vec::new();
|
let mut subscriptions = Vec::new();
|
||||||
|
|
||||||
if !cfg!(target_arch = "wasm32") {
|
if !cfg!(target_arch = "wasm32") {
|
||||||
let repos = RepoListStore::global(cx);
|
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
|
||||||
this.refresh(cx);
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Checkout associations decide which paths get compared.
|
// Checkout associations decide which paths get compared.
|
||||||
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
|
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
|
||||||
this.refresh(cx);
|
this.run_pass(cx);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
||||||
BackendEvent::SignerChanged => {
|
BackendEvent::SignerChanged => this.clear(cx),
|
||||||
this.statuses.clear();
|
BackendEvent::RepoUpdates(_) | BackendEvent::Synced => this.run_pass(cx),
|
||||||
cx.notify();
|
|
||||||
this.refresh(cx);
|
|
||||||
}
|
|
||||||
BackendEvent::RepoUpdates(_) | BackendEvent::Synced => this.refresh(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}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let task = cx.spawn(async move |this, cx| {
|
||||||
|
loop {
|
||||||
|
receiver.recv_async().await?;
|
||||||
|
|
||||||
|
// One pass covers every signal queued since the wake.
|
||||||
|
while receiver.try_recv().is_ok() {}
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.run_pass(cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
statuses: HashMap::new(),
|
active: RefCell::new(HashSet::new()),
|
||||||
refresh: RefreshGate::default(),
|
sender,
|
||||||
|
in_flight: false,
|
||||||
|
pass_pending: false,
|
||||||
poll_pending: false,
|
poll_pending: false,
|
||||||
|
tasks: vec![task],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Latest sync status per associated checkout.
|
/// Queue a probe pass; folded into any pass already in flight.
|
||||||
pub fn statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutSyncStatus> {
|
pub fn probe(&self, addrs: impl IntoIterator<Item = RepoAddr>) {
|
||||||
self.statuses.get(addr).cloned().unwrap_or_default()
|
self.active.borrow_mut().extend(addrs);
|
||||||
|
|
||||||
|
if let Err(e) = self.sender.send(()) {
|
||||||
|
log::warn!("failed to queue sync status probe: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commits available locally but missing on Nostr.
|
// Keeps the in-flight pass so its completion releases the slot exactly once.
|
||||||
pub fn unsynced(&self, addr: &RepoAddr) -> usize {
|
fn clear(&mut self, cx: &mut Context<Self>) {
|
||||||
self.statuses
|
let had_addrs = !self.active.borrow().is_empty();
|
||||||
.get(addr)
|
self.active.borrow_mut().clear();
|
||||||
.map(|list| {
|
self.pass_pending = false;
|
||||||
list.iter()
|
|
||||||
.map(|checkout| checkout.status.ahead_total)
|
if had_addrs {
|
||||||
.sum()
|
cx.emit(SyncEvent::Updated(Vec::new()));
|
||||||
})
|
}
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
fn run_pass(&mut self, cx: &mut Context<Self>) {
|
||||||
if cfg!(target_arch = "wasm32") {
|
if cfg!(target_arch = "wasm32") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if self.refresh.request() != RefreshRequest::Schedule {
|
|
||||||
|
if self.in_flight {
|
||||||
|
self.pass_pending = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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 backend = Backend::global(cx);
|
||||||
let client = backend.read(cx).client();
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
let repos = RepoListStore::global(cx);
|
if backend.read(cx).current_user().is_none() {
|
||||||
let announcements = repos.read(cx).announcements_of(&user);
|
self.clear(cx);
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let targets: Vec<(RepoAddr, Vec<PathBuf>)> = announcements
|
if self.active.borrow().is_empty() {
|
||||||
.iter()
|
return;
|
||||||
.map(|announcement| {
|
}
|
||||||
let addr = announcement.addr();
|
|
||||||
let paths = checkouts.read(cx).associations_of(&addr);
|
self.in_flight = true;
|
||||||
(addr, paths)
|
self.schedule_poll(cx);
|
||||||
})
|
|
||||||
.collect();
|
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 work = cx.background_spawn(async move {
|
||||||
let db = client.database();
|
let db = client.database();
|
||||||
let deletion_events = db.query(Filters::deletions()).await?;
|
let deletion_events = db.query(Filters::deletions()).await?;
|
||||||
let deletions = Deletions::from_events(deletion_events);
|
let deletions = Deletions::from_events(deletion_events);
|
||||||
|
|
||||||
let mut states: HashMap<RepoAddr, Vec<GitRef>> = HashMap::new();
|
let mut updates: Vec<(RepoAddr, Vec<CheckoutSyncStatus>)> = Vec::new();
|
||||||
for (addr, _) in &targets {
|
|
||||||
|
for (addr, paths) in &targets {
|
||||||
let events = db.query(addr.state_filter()).await?;
|
let events = db.query(addr.state_filter()).await?;
|
||||||
let latest = utils::latest(
|
let latest = utils::latest(
|
||||||
events
|
events
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|event| !deletions.is_deleted(event)),
|
.filter(|event| !deletions.is_deleted(event)),
|
||||||
);
|
);
|
||||||
|
|
||||||
let Some(event) = latest else {
|
let Some(event) = latest else {
|
||||||
continue;
|
updates.push((addr.clone(), Vec::new()));
|
||||||
};
|
|
||||||
states.insert(addr.clone(), SyncState::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;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let remote_refs: Vec<(String, String)> = nostr_refs
|
let remote_refs: Vec<(String, String)> = SyncState::parse(&event)
|
||||||
|
.refs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|git_ref| (git_ref.name.clone(), git_ref.commit.to_string()))
|
.map(|git_ref| (git_ref.name.clone(), git_ref.commit.to_string()))
|
||||||
.collect();
|
.collect();
|
||||||
@@ -206,41 +198,170 @@ impl SyncStatusStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !list.is_empty() {
|
updates.push((addr.clone(), list));
|
||||||
statuses.insert(addr.clone(), list);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<_, Error>(statuses)
|
Ok::<_, Error>(updates)
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let statuses = match work.await {
|
// Reset the slot even on failure, or every later pass folds forever.
|
||||||
Ok(statuses) => statuses,
|
let updates = match work.await {
|
||||||
|
Ok(updates) => updates,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log::warn!("failed to compute nostr sync statuses: {error}");
|
log::warn!("failed to compute nostr sync statuses: {error}");
|
||||||
return this.update(cx, |this, _cx| {
|
Vec::new()
|
||||||
this.refresh.abort();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let again = this.update(cx, |this, cx| {
|
let mut again = false;
|
||||||
if this.statuses != statuses {
|
|
||||||
this.statuses = statuses;
|
this.update(cx, |this, cx| {
|
||||||
cx.notify();
|
this.in_flight = false;
|
||||||
|
if !updates.is_empty() {
|
||||||
|
cx.emit(SyncEvent::Updated(updates));
|
||||||
}
|
}
|
||||||
this.refresh.finish()
|
again = std::mem::take(&mut this.pass_pending);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if again {
|
if again {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.refresh(cx);
|
this.run_pass(cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<_, Error>(())
|
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],
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.detach();
|
}
|
||||||
|
|
||||||
|
/// Queue a probe for addrs this surface renders.
|
||||||
|
pub fn track(&mut self, addrs: impl IntoIterator<Item = RepoAddr>, cx: &mut Context<Self>) {
|
||||||
|
let missing: Vec<RepoAddr> = addrs
|
||||||
|
.into_iter()
|
||||||
|
.filter(|addr| {
|
||||||
|
!self.statuses.contains_key(addr)
|
||||||
|
&& !self.with_pending(|pending| pending.contains(addr))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if missing.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.with_pending(|pending| pending.extend(missing.iter().cloned()));
|
||||||
|
|
||||||
|
let store = SyncStatusStore::global(cx);
|
||||||
|
store.read(cx).probe(missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statuses for a repo, or empty; a miss queues a probe.
|
||||||
|
pub fn statuses_of(&self, addr: &RepoAddr, cx: &App) -> Vec<CheckoutSyncStatus> {
|
||||||
|
self.queue(addr, cx);
|
||||||
|
self.statuses.get(addr).cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commits available locally but missing on Nostr.
|
||||||
|
pub fn unsynced(&self, addr: &RepoAddr, cx: &App) -> usize {
|
||||||
|
self.queue(addr, cx);
|
||||||
|
|
||||||
|
self.statuses
|
||||||
|
.get(addr)
|
||||||
|
.map(|list| {
|
||||||
|
list.iter()
|
||||||
|
.map(|checkout| checkout.status.ahead_total)
|
||||||
|
.sum()
|
||||||
|
})
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue(&self, addr: &RepoAddr, cx: &App) {
|
||||||
|
if self.with_pending(|pending| pending.insert(addr.clone())) {
|
||||||
|
let store = SyncStatusStore::global(cx);
|
||||||
|
store.read(cx).probe([addr.clone()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(&mut self, updates: Vec<(RepoAddr, Vec<CheckoutSyncStatus>)>, cx: &mut Context<Self>) {
|
||||||
|
if updates.is_empty() {
|
||||||
|
let changed = !self.statuses.is_empty();
|
||||||
|
self.statuses.clear();
|
||||||
|
self.with_pending(|pending| pending.clear());
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
|
for (addr, list) in updates {
|
||||||
|
let relevant = self.statuses.contains_key(&addr)
|
||||||
|
|| self.with_pending(|pending| pending.remove(&addr));
|
||||||
|
|
||||||
|
if !relevant {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.statuses.get(&addr) != Some(&list) {
|
||||||
|
self.statuses.insert(addr, list);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_pending<R>(&self, then: impl FnOnce(&mut HashSet<RepoAddr>) -> R) -> R {
|
||||||
|
let mut pending = self.pending.borrow_mut();
|
||||||
|
then(&mut pending)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+206
-25
@@ -1,37 +1,62 @@
|
|||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{App, SharedString, StyleRefinement, Window};
|
use gpui::{App, Pixels, SharedString, StyleRefinement, Window, div, px};
|
||||||
use gpui_base::{Avatar as BaseAvatar, AvatarFallback, AvatarImage};
|
use gpui_base::{AvatarImage, StyledExt};
|
||||||
use gpui_component::{ActiveTheme, Sizable, Size, StyledExt};
|
use gpui_component::{ActiveTheme, Colorize, Sizable, Size};
|
||||||
|
|
||||||
use crate::pixel_avatar::{PixelAvatar, side_length};
|
/// Number of rows and columns in the pixel grid.
|
||||||
|
const GRID_SIZE: usize = 8;
|
||||||
|
/// Empty cells kept between the pattern and the avatar edge.
|
||||||
|
const MARGIN: usize = 1;
|
||||||
|
/// Probability that a cell in the left half of the pattern area is filled.
|
||||||
|
const FILL_PROBABILITY: f32 = 0.42;
|
||||||
|
/// Probability that a filled cell uses the accent shade instead of the main color.
|
||||||
|
const ACCENT_PROBABILITY: f32 = 0.25;
|
||||||
|
/// Minimum number of filled left-half cells.
|
||||||
|
const MIN_FILLED: usize = 5;
|
||||||
|
|
||||||
/// A user avatar built on the unstyled [`gpui_base::Avatar`].
|
/// An avatar for a user or other entity.
|
||||||
///
|
|
||||||
/// 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)]
|
#[derive(IntoElement)]
|
||||||
pub struct Avatar {
|
pub struct Avatar {
|
||||||
name: SharedString,
|
id: SharedString,
|
||||||
|
name: Option<SharedString>,
|
||||||
picture: Option<SharedString>,
|
picture: Option<SharedString>,
|
||||||
size: Size,
|
size: Size,
|
||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Avatar {
|
impl Avatar {
|
||||||
/// Create an avatar for `name`.
|
/// Create an avatar for `id`, a public key or any other stable string.
|
||||||
///
|
///
|
||||||
/// The name seeds the pixel fallback shown when no picture is set.
|
/// The id seeds the pixel fallback shown when no picture is set.
|
||||||
pub fn new(name: impl Into<SharedString>) -> Self {
|
pub fn new<T>(id: T) -> Self
|
||||||
|
where
|
||||||
|
T: Display,
|
||||||
|
{
|
||||||
Self {
|
Self {
|
||||||
name: name.into(),
|
id: id.to_string().into(),
|
||||||
|
name: None,
|
||||||
picture: None,
|
picture: None,
|
||||||
size: Size::Small,
|
size: Size::Small,
|
||||||
style: StyleRefinement::default(),
|
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.
|
/// The user's picture URL, if known.
|
||||||
pub fn picture(mut self, picture: Option<impl Into<SharedString>>) -> Self {
|
pub fn picture<T>(mut self, picture: Option<T>) -> Self
|
||||||
|
where
|
||||||
|
T: Into<SharedString>,
|
||||||
|
{
|
||||||
self.picture = picture.map(Into::into);
|
self.picture = picture.map(Into::into);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -52,20 +77,176 @@ impl Styled for Avatar {
|
|||||||
|
|
||||||
impl RenderOnce for Avatar {
|
impl RenderOnce for Avatar {
|
||||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let fallback = AvatarFallback::new()
|
let radius = cx.theme().radius;
|
||||||
.size_full()
|
|
||||||
.child(PixelAvatar::new(self.name.clone()).size_full());
|
|
||||||
|
|
||||||
BaseAvatar::new()
|
match self.picture {
|
||||||
.size(side_length(self.size))
|
Some(url) => div()
|
||||||
|
.size(side_length(self.size))
|
||||||
|
.flex_shrink_0()
|
||||||
|
.rounded(radius)
|
||||||
|
.overflow_hidden()
|
||||||
|
.bg(cx.theme().secondary)
|
||||||
|
.child(AvatarImage::new(url).size_full().rounded(radius))
|
||||||
|
.refine_style(&self.style)
|
||||||
|
.into_any_element(),
|
||||||
|
None => PixelAvatar {
|
||||||
|
seed: fnv1a(self.id.as_bytes()),
|
||||||
|
side: side_length(self.size),
|
||||||
|
style: self.style,
|
||||||
|
}
|
||||||
|
.into_any_element(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deterministic, offline pixel-art avatar.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
struct PixelAvatar {
|
||||||
|
seed: u64,
|
||||||
|
side: Pixels,
|
||||||
|
style: StyleRefinement,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Styled for PixelAvatar {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for PixelAvatar {
|
||||||
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let pattern = pattern(self.seed);
|
||||||
|
let mut cells = Vec::new();
|
||||||
|
|
||||||
|
let hue = self.seed as f32 / u64::MAX as f32;
|
||||||
|
let main = theme.blue.hue(hue);
|
||||||
|
|
||||||
|
let shade = if theme.is_dark() {
|
||||||
|
main.lightness((main.l * 1.6).min(0.95))
|
||||||
|
} else {
|
||||||
|
main.lightness((main.l * 0.45).max(0.18))
|
||||||
|
};
|
||||||
|
|
||||||
|
for row in 0..GRID_SIZE {
|
||||||
|
for col in 0..GRID_SIZE {
|
||||||
|
let value = pattern[row * GRID_SIZE + col];
|
||||||
|
if value != 0 {
|
||||||
|
let color = if value == 2 { shade } else { main };
|
||||||
|
cells.push(
|
||||||
|
div()
|
||||||
|
.row_start(row as i16 + 1)
|
||||||
|
.row_end(row as i16 + 2)
|
||||||
|
.col_start(col as i16 + 1)
|
||||||
|
.col_end(col as i16 + 2)
|
||||||
|
.bg(color),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div()
|
||||||
|
.grid()
|
||||||
|
.grid_cols(GRID_SIZE as u16)
|
||||||
|
.grid_rows(GRID_SIZE as u16)
|
||||||
|
.size(self.side)
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(theme.radius)
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.bg(cx.theme().secondary)
|
.bg(main.opacity(0.16))
|
||||||
.when_some(self.picture, |this, url| {
|
.children(cells)
|
||||||
this.image(AvatarImage::new(url).size_full().rounded(cx.theme().radius))
|
|
||||||
})
|
|
||||||
.fallback(fallback)
|
|
||||||
.refine_style(&self.style)
|
.refine_style(&self.style)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The rendered side length of an avatar at `size`.
|
||||||
|
fn side_length(size: Size) -> Pixels {
|
||||||
|
match size {
|
||||||
|
Size::XSmall => px(16.),
|
||||||
|
Size::Small => px(24.),
|
||||||
|
Size::Medium => px(48.),
|
||||||
|
Size::Large => px(80.),
|
||||||
|
Size::Size(size) => size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||||
|
let mut rng = PixelRng::new(seed);
|
||||||
|
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
||||||
|
let mut filled = 0usize;
|
||||||
|
|
||||||
|
// Only the inner rows and the inner left half are candidates; mirroring
|
||||||
|
// then keeps the art within the same inset, leaving the outer ring empty.
|
||||||
|
let art_rows = GRID_SIZE - 2 * MARGIN;
|
||||||
|
let art_columns = GRID_SIZE / 2 - MARGIN;
|
||||||
|
|
||||||
|
for row in MARGIN..GRID_SIZE - MARGIN {
|
||||||
|
for col in MARGIN..GRID_SIZE / 2 {
|
||||||
|
if rng.chance(FILL_PROBABILITY) {
|
||||||
|
let accent = rng.chance(ACCENT_PROBABILITY);
|
||||||
|
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
||||||
|
filled += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if filled < MIN_FILLED {
|
||||||
|
let total = art_rows * art_columns;
|
||||||
|
let start = (rng.next() % total as u64) as usize;
|
||||||
|
|
||||||
|
for offset in 0..total {
|
||||||
|
if filled >= MIN_FILLED {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ix = (start + offset) % total;
|
||||||
|
let row = MARGIN + ix / art_columns;
|
||||||
|
let col = MARGIN + ix % art_columns;
|
||||||
|
|
||||||
|
if pattern[row * GRID_SIZE + col] == 0 {
|
||||||
|
set_cell(&mut pattern, row, col, 1);
|
||||||
|
filled += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill `cell (row, col)` and its horizontal mirror.
|
||||||
|
fn set_cell(pattern: &mut [u8; GRID_SIZE * GRID_SIZE], row: usize, col: usize, value: u8) {
|
||||||
|
pattern[row * GRID_SIZE + col] = value;
|
||||||
|
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FNV-1a 64-bit hash, stable across platforms and runs.
|
||||||
|
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||||
|
let mut hash = 0xcbf2_9ce4_8422_2325u64;
|
||||||
|
for &byte in bytes {
|
||||||
|
hash ^= byte as u64;
|
||||||
|
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||||
|
}
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tiny xorshift64* PRNG for deriving the pattern from the seed.
|
||||||
|
struct PixelRng(u64);
|
||||||
|
|
||||||
|
impl PixelRng {
|
||||||
|
fn new(seed: u64) -> Self {
|
||||||
|
Self(seed.max(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next(&mut self) -> u64 {
|
||||||
|
let mut x = self.0;
|
||||||
|
x ^= x >> 12;
|
||||||
|
x ^= x << 25;
|
||||||
|
x ^= x >> 27;
|
||||||
|
self.0 = x;
|
||||||
|
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chance(&mut self, probability: f32) -> bool {
|
||||||
|
self.next() as f32 / (u64::MAX as f32) < probability
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
mod avatar;
|
mod avatar;
|
||||||
mod dropdown_button;
|
mod dropdown_button;
|
||||||
mod nav_item;
|
mod nav_item;
|
||||||
mod pixel_avatar;
|
|
||||||
mod placeholder;
|
mod placeholder;
|
||||||
mod ref_selector;
|
mod ref_selector;
|
||||||
mod segment_button;
|
mod segment_button;
|
||||||
@@ -16,7 +15,6 @@ pub use avatar::Avatar;
|
|||||||
pub use copy_row::{copy_row, menu_copy_row};
|
pub use copy_row::{copy_row, menu_copy_row};
|
||||||
pub use dropdown_button::DropdownButton;
|
pub use dropdown_button::DropdownButton;
|
||||||
pub use nav_item::NavItem;
|
pub use nav_item::NavItem;
|
||||||
pub use pixel_avatar::PixelAvatar;
|
|
||||||
pub use placeholder::placeholder;
|
pub use placeholder::placeholder;
|
||||||
pub use ref_selector::ref_selector_trigger;
|
pub use ref_selector::ref_selector_trigger;
|
||||||
pub use segment_button::{CountBadge, SegmentButton};
|
pub use segment_button::{CountBadge, SegmentButton};
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
use gpui::prelude::*;
|
|
||||||
use gpui::{App, Pixels, StyleRefinement, Window, div, px};
|
|
||||||
use gpui_base::StyledExt;
|
|
||||||
use gpui_component::{ActiveTheme, Colorize, Sizable, Size};
|
|
||||||
|
|
||||||
/// Number of rows and columns in the pixel grid.
|
|
||||||
const GRID_SIZE: usize = 8;
|
|
||||||
/// Empty cells kept between the pattern and the avatar edge, so the art
|
|
||||||
/// gathers in the center instead of filling the whole avatar.
|
|
||||||
const MARGIN: usize = 1;
|
|
||||||
/// Probability that a cell in the left half of the pattern area is filled.
|
|
||||||
const FILL_PROBABILITY: f32 = 0.42;
|
|
||||||
/// Probability that a filled cell uses the accent shade instead of the main color.
|
|
||||||
const ACCENT_PROBABILITY: f32 = 0.25;
|
|
||||||
/// Minimum number of filled left-half cells.
|
|
||||||
const MIN_FILLED: usize = 5;
|
|
||||||
|
|
||||||
/// A deterministic, offline pixel-art avatar.
|
|
||||||
#[derive(IntoElement)]
|
|
||||||
pub struct PixelAvatar {
|
|
||||||
seed: u64,
|
|
||||||
size: Size,
|
|
||||||
style: StyleRefinement,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PixelAvatar {
|
|
||||||
/// Create an avatar seeded from `seed`.
|
|
||||||
pub fn new(seed: impl AsRef<str>) -> Self {
|
|
||||||
Self {
|
|
||||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
|
||||||
size: Size::Small,
|
|
||||||
style: StyleRefinement::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Sizable for PixelAvatar {
|
|
||||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
|
||||||
self.size = size.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Styled for PixelAvatar {
|
|
||||||
fn style(&mut self) -> &mut StyleRefinement {
|
|
||||||
&mut self.style
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenderOnce for PixelAvatar {
|
|
||||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
||||||
let theme = cx.theme();
|
|
||||||
let pattern = pattern(self.seed);
|
|
||||||
let mut cells = Vec::new();
|
|
||||||
|
|
||||||
let hue = self.seed as f32 / u64::MAX as f32;
|
|
||||||
let main = theme.blue.hue(hue);
|
|
||||||
|
|
||||||
let shade = if theme.is_dark() {
|
|
||||||
main.lightness((main.l * 1.6).min(0.95))
|
|
||||||
} else {
|
|
||||||
main.lightness((main.l * 0.45).max(0.18))
|
|
||||||
};
|
|
||||||
|
|
||||||
for row in 0..GRID_SIZE {
|
|
||||||
for col in 0..GRID_SIZE {
|
|
||||||
let value = pattern[row * GRID_SIZE + col];
|
|
||||||
if value != 0 {
|
|
||||||
let color = if value == 2 { shade } else { main };
|
|
||||||
cells.push(
|
|
||||||
div()
|
|
||||||
.row_start(row as i16 + 1)
|
|
||||||
.row_end(row as i16 + 2)
|
|
||||||
.col_start(col as i16 + 1)
|
|
||||||
.col_end(col as i16 + 2)
|
|
||||||
.bg(color),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
div()
|
|
||||||
.grid()
|
|
||||||
.grid_cols(GRID_SIZE as u16)
|
|
||||||
.grid_rows(GRID_SIZE as u16)
|
|
||||||
.size(side_length(self.size))
|
|
||||||
.flex_shrink_0()
|
|
||||||
.rounded(theme.radius)
|
|
||||||
.overflow_hidden()
|
|
||||||
.bg(main.opacity(0.16))
|
|
||||||
.children(cells)
|
|
||||||
.refine_style(&self.style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The rendered side length of an avatar at `size`, shared with [`Avatar`].
|
|
||||||
pub(crate) fn side_length(size: Size) -> Pixels {
|
|
||||||
match size {
|
|
||||||
Size::XSmall => px(16.),
|
|
||||||
Size::Small => px(24.),
|
|
||||||
Size::Medium => px(48.),
|
|
||||||
Size::Large => px(80.),
|
|
||||||
Size::Size(size) => size,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
|
||||||
let mut rng = PixelRng::new(seed);
|
|
||||||
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
|
||||||
let mut filled = 0usize;
|
|
||||||
|
|
||||||
// Only the inner rows and the inner left half are candidates; mirroring
|
|
||||||
// then keeps the art within the same inset, leaving the outer ring empty.
|
|
||||||
let art_rows = GRID_SIZE - 2 * MARGIN;
|
|
||||||
let art_columns = GRID_SIZE / 2 - MARGIN;
|
|
||||||
|
|
||||||
for row in MARGIN..GRID_SIZE - MARGIN {
|
|
||||||
for col in MARGIN..GRID_SIZE / 2 {
|
|
||||||
if rng.chance(FILL_PROBABILITY) {
|
|
||||||
let accent = rng.chance(ACCENT_PROBABILITY);
|
|
||||||
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
|
||||||
filled += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if filled < MIN_FILLED {
|
|
||||||
let total = art_rows * art_columns;
|
|
||||||
let start = (rng.next() % total as u64) as usize;
|
|
||||||
|
|
||||||
for offset in 0..total {
|
|
||||||
if filled >= MIN_FILLED {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let ix = (start + offset) % total;
|
|
||||||
let row = MARGIN + ix / art_columns;
|
|
||||||
let col = MARGIN + ix % art_columns;
|
|
||||||
|
|
||||||
if pattern[row * GRID_SIZE + col] == 0 {
|
|
||||||
set_cell(&mut pattern, row, col, 1);
|
|
||||||
filled += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pattern
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fill `cell (row, col)` and its horizontal mirror.
|
|
||||||
fn set_cell(pattern: &mut [u8; GRID_SIZE * GRID_SIZE], row: usize, col: usize, value: u8) {
|
|
||||||
pattern[row * GRID_SIZE + col] = value;
|
|
||||||
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// FNV-1a 64-bit hash, stable across platforms and runs.
|
|
||||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
|
||||||
let mut hash = 0xcbf2_9ce4_8422_2325u64;
|
|
||||||
for &byte in bytes {
|
|
||||||
hash ^= byte as u64;
|
|
||||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
|
||||||
}
|
|
||||||
hash
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tiny xorshift64* PRNG for deriving the pattern from the seed.
|
|
||||||
struct PixelRng(u64);
|
|
||||||
|
|
||||||
impl PixelRng {
|
|
||||||
fn new(seed: u64) -> Self {
|
|
||||||
Self(seed.max(1))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn next(&mut self) -> u64 {
|
|
||||||
let mut x = self.0;
|
|
||||||
x ^= x >> 12;
|
|
||||||
x ^= x << 25;
|
|
||||||
x ^= x >> 27;
|
|
||||||
self.0 = x;
|
|
||||||
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn chance(&mut self, probability: f32) -> bool {
|
|
||||||
self.next() as f32 / (u64::MAX as f32) < probability
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn count_filled(pattern: &[u8; GRID_SIZE * GRID_SIZE]) -> usize {
|
|
||||||
pattern.iter().filter(|&&cell| cell != 0).count()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pattern_properties() {
|
|
||||||
for seed in 0..50 {
|
|
||||||
let pattern = pattern(seed);
|
|
||||||
assert!(
|
|
||||||
count_filled(&pattern) >= MIN_FILLED * 2,
|
|
||||||
"pattern too sparse for seed {seed}"
|
|
||||||
);
|
|
||||||
for row in 0..GRID_SIZE {
|
|
||||||
for col in 0..GRID_SIZE {
|
|
||||||
assert_eq!(
|
|
||||||
pattern[row * GRID_SIZE + col],
|
|
||||||
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)],
|
|
||||||
"asymmetric pattern for seed {seed} at ({row}, {col})"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for seed in [0, 1, 42, u64::MAX] {
|
|
||||||
assert_eq!(pattern(seed), pattern(seed));
|
|
||||||
}
|
|
||||||
assert_ne!(pattern(42), pattern(43));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ publish.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
assets = { path = "../assets" }
|
assets = { path = "../assets" }
|
||||||
dock = { workspace = true }
|
dock = { workspace = true }
|
||||||
|
metadata = { path = "../metadata" }
|
||||||
paths = { path = "../paths" }
|
paths = { path = "../paths" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
signed_core = { path = "../signed_core" }
|
signed_core = { path = "../signed_core" }
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree};
|
|||||||
use gpui_component::{
|
use gpui_component::{
|
||||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||||
};
|
};
|
||||||
|
use metadata::ProfileDirectory;
|
||||||
use signed_git::{
|
use signed_git::{
|
||||||
CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo,
|
CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo,
|
||||||
};
|
};
|
||||||
@@ -305,6 +306,8 @@ pub struct CommitDiffView {
|
|||||||
loading: bool,
|
loading: bool,
|
||||||
error: Option<SharedString>,
|
error: Option<SharedString>,
|
||||||
pane: Entity<DiffPane>,
|
pane: Entity<DiffPane>,
|
||||||
|
/// Profile for the repository owner in the tab title.
|
||||||
|
directory: Entity<ProfileDirectory>,
|
||||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +321,13 @@ impl CommitDiffView {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let pane = cx.new(DiffPane::new);
|
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.
|
// Defer until the window is ready, like the repository detail view.
|
||||||
cx.defer_in(window, |this, window, cx| {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
@@ -339,6 +349,7 @@ impl CommitDiffView {
|
|||||||
loading: true,
|
loading: true,
|
||||||
error: None,
|
error: None,
|
||||||
pane,
|
pane,
|
||||||
|
directory,
|
||||||
tasks: Vec::new(),
|
tasks: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,7 +486,7 @@ impl BasePanel for CommitDiffView {
|
|||||||
|
|
||||||
impl Panel for CommitDiffView {
|
impl Panel for CommitDiffView {
|
||||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||||
let label = SharedString::from(format!("{}/{}", self.repo_name, self.commit.id));
|
let label = SharedString::from(format!("{}/{}", self.repo_name, self.commit.id));
|
||||||
|
|
||||||
tab_title(avatar, label)
|
tab_title(avatar, label)
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ use gpui_component::button::{Button, ButtonVariants};
|
|||||||
use gpui_component::input::{Textarea, TextareaState};
|
use gpui_component::input::{Textarea, TextareaState};
|
||||||
use gpui_component::tag::Tag;
|
use gpui_component::tag::Tag;
|
||||||
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
||||||
|
use metadata::ProfileDirectory;
|
||||||
use nostr::prelude::{Event, EventId, PublicKey};
|
use nostr::prelude::{Event, EventId, PublicKey};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::RepoStore;
|
||||||
use signed_ui::Avatar;
|
use signed_ui::Avatar;
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ pub(crate) fn sidebar_section(
|
|||||||
id: EventId,
|
id: EventId,
|
||||||
roots: fn(&RepoStore) -> &[Event],
|
roots: fn(&RepoStore) -> &[Event],
|
||||||
top_gap: bool,
|
top_gap: bool,
|
||||||
|
directory: &Entity<ProfileDirectory>,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let store = store.read(cx);
|
let store = store.read(cx);
|
||||||
@@ -39,7 +41,6 @@ pub(crate) fn sidebar_section(
|
|||||||
// The caller bails out when the root is missing.
|
// The caller bails out when the root is missing.
|
||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
};
|
};
|
||||||
let profile_store = ProfileStore::global(cx);
|
|
||||||
|
|
||||||
// Participants, the root author plus everyone who commented.
|
// Participants, the root author plus everyone who commented.
|
||||||
let mut participants: Vec<PublicKey> = vec![root.pubkey];
|
let mut participants: Vec<PublicKey> = vec![root.pubkey];
|
||||||
@@ -64,14 +65,14 @@ pub(crate) fn sidebar_section(
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.child(sidebar_title("Participants", cx))
|
.child(sidebar_title("Participants", cx))
|
||||||
.children(participants.iter().map(|pubkey| {
|
.children(participants.iter().map(|pubkey| {
|
||||||
let profile = profile_store.read(cx).get(pubkey);
|
let profile = directory.read(cx).profile(pubkey, cx);
|
||||||
let name = profile.name();
|
let name = profile.name();
|
||||||
let picture = profile.picture();
|
let picture = profile.picture();
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.items_center()
|
.items_center()
|
||||||
.child(Avatar::new(name.clone()).picture(picture))
|
.child(Avatar::new(*pubkey).name(name.clone()).picture(picture))
|
||||||
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
.child(div().text_sm().truncate().text_ellipsis().child(name))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
})),
|
})),
|
||||||
@@ -109,7 +110,12 @@ pub(crate) fn sidebar_section(
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &App) -> AnyElement {
|
pub(crate) fn comments_section(
|
||||||
|
store: &Entity<RepoStore>,
|
||||||
|
root: EventId,
|
||||||
|
directory: &Entity<ProfileDirectory>,
|
||||||
|
cx: &App,
|
||||||
|
) -> AnyElement {
|
||||||
let store = store.read(cx);
|
let store = store.read(cx);
|
||||||
let comments: Vec<&Event> = store.comments_of(&root).collect();
|
let comments: Vec<&Event> = store.comments_of(&root).collect();
|
||||||
let title = SharedString::from(format!("Discussions {}", comments.len()));
|
let title = SharedString::from(format!("Discussions {}", comments.len()));
|
||||||
@@ -118,7 +124,7 @@ pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &Ap
|
|||||||
.gap_4()
|
.gap_4()
|
||||||
.child(div().text_xs().font_semibold().child(title))
|
.child(div().text_xs().font_semibold().child(title))
|
||||||
.children(comments.iter().map(|comment| {
|
.children(comments.iter().map(|comment| {
|
||||||
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
|
let profile = directory.read(cx).profile(&comment.pubkey, cx);
|
||||||
let author = profile.name();
|
let author = profile.name();
|
||||||
let picture = profile.picture();
|
let picture = profile.picture();
|
||||||
let age = relative_time(comment.created_at);
|
let age = relative_time(comment.created_at);
|
||||||
@@ -137,7 +143,11 @@ pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &Ap
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(Avatar::new(author.clone()).picture(picture))
|
.child(
|
||||||
|
Avatar::new(comment.pubkey)
|
||||||
|
.name(author.clone())
|
||||||
|
.picture(picture),
|
||||||
|
)
|
||||||
.child(author),
|
.child(author),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ use anyhow::Error;
|
|||||||
use dock::{BasePanel, DockArea, Panel, PanelEvent};
|
use dock::{BasePanel, DockArea, Panel, PanelEvent};
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState,
|
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment,
|
||||||
Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div, list, px,
|
ListState, Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div,
|
||||||
|
list, px,
|
||||||
};
|
};
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
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 nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
|
||||||
use signed_core::{InboxItem, InboxReadState, RepoAddr};
|
use signed_core::{InboxItem, InboxReadState, RepoAddr};
|
||||||
use signed_state::{
|
use signed_state::{Backend, BackendEvent, Inbox, RefreshGate, RefreshRequest, RepoListStore};
|
||||||
Backend, BackendEvent, Inbox, ProfileStore, RefreshGate, RefreshRequest, RepoListStore,
|
|
||||||
};
|
|
||||||
use signed_ui::{Avatar, CountBadge};
|
use signed_ui::{Avatar, CountBadge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
@@ -54,6 +54,8 @@ pub struct InboxView {
|
|||||||
refresh: RefreshGate,
|
refresh: RefreshGate,
|
||||||
list: ListState,
|
list: ListState,
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
/// Profiles for activity authors.
|
||||||
|
directory: Entity<ProfileDirectory>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +103,7 @@ impl InboxView {
|
|||||||
refresh: RefreshGate::default(),
|
refresh: RefreshGate::default(),
|
||||||
list,
|
list,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
|
directory: ProfileDirectory::new(cx),
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,6 +275,19 @@ impl InboxView {
|
|||||||
let rows = self.flatten_rows(§ions);
|
let rows = self.flatten_rows(§ions);
|
||||||
self.sections = Arc::new(sections);
|
self.sections = Arc::new(sections);
|
||||||
self.rows = Arc::new(rows);
|
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> {
|
fn group_sections(&self) -> Vec<InboxSection> {
|
||||||
@@ -397,8 +413,9 @@ impl InboxView {
|
|||||||
let address = section.address.clone();
|
let address = section.address.clone();
|
||||||
let first = entry_ix == 0;
|
let first = entry_ix == 0;
|
||||||
let last = entry_ix + 1 == section.entries.len();
|
let last = entry_ix + 1 == section.entries.len();
|
||||||
|
let directory = self.directory.clone();
|
||||||
|
|
||||||
thread("inbox-row", ix, item, first, last, cx)
|
thread("inbox-row", ix, item, first, last, &directory, cx)
|
||||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
this.open(root, kind, address.clone(), window, cx)
|
this.open(root, kind, address.clone(), window, cx)
|
||||||
}))
|
}))
|
||||||
@@ -461,6 +478,7 @@ fn thread(
|
|||||||
item: &InboxItem,
|
item: &InboxItem,
|
||||||
first: bool,
|
first: bool,
|
||||||
last: bool,
|
last: bool,
|
||||||
|
directory: &Entity<ProfileDirectory>,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> Stateful<Div> {
|
) -> Stateful<Div> {
|
||||||
let title = SharedString::from(item.title());
|
let title = SharedString::from(item.title());
|
||||||
@@ -472,7 +490,7 @@ fn thread(
|
|||||||
let mut timeline = v_flex().gap_2().w_full();
|
let mut timeline = v_flex().gap_2().w_full();
|
||||||
|
|
||||||
for event in item.timeline(MAX_SUB_ACTIVITIES) {
|
for event in item.timeline(MAX_SUB_ACTIVITIES) {
|
||||||
timeline = timeline.child(sub_activity(&event, me, cx));
|
timeline = timeline.child(sub_activity(&event, me, directory, cx));
|
||||||
}
|
}
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
@@ -521,9 +539,13 @@ fn thread(
|
|||||||
.child(timeline)
|
.child(timeline)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
|
fn sub_activity(
|
||||||
let profile_store = ProfileStore::global(cx).read(cx);
|
event: &Event,
|
||||||
let profile = profile_store.get(&event.pubkey);
|
me: Option<PublicKey>,
|
||||||
|
directory: &Entity<ProfileDirectory>,
|
||||||
|
cx: &App,
|
||||||
|
) -> AnyElement {
|
||||||
|
let profile = directory.read(cx).profile(&event.pubkey, cx);
|
||||||
|
|
||||||
let name = if Some(event.pubkey) == me {
|
let name = if Some(event.pubkey) == me {
|
||||||
SharedString::from("You")
|
SharedString::from("You")
|
||||||
@@ -544,7 +566,8 @@ fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
|
|||||||
.items_center()
|
.items_center()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.child(
|
.child(
|
||||||
Avatar::new(name.clone())
|
Avatar::new(event.pubkey)
|
||||||
|
.name(name.clone())
|
||||||
.picture(profile.picture())
|
.picture(profile.picture())
|
||||||
.xsmall(),
|
.xsmall(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ use gpui::{
|
|||||||
use gpui_component::input::TextareaState;
|
use gpui_component::input::TextareaState;
|
||||||
use gpui_component::scroll::ScrollableElement;
|
use gpui_component::scroll::ScrollableElement;
|
||||||
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
||||||
use nostr::prelude::EventId;
|
use metadata::ProfileDirectory;
|
||||||
|
use nostr::prelude::{EventId, PublicKey};
|
||||||
use signed_core::GitEvent;
|
use signed_core::GitEvent;
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::RepoStore;
|
||||||
use signed_ui::{Avatar, placeholder, status_badge};
|
use signed_ui::{Avatar, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
@@ -21,6 +22,8 @@ pub struct IssueDetailView {
|
|||||||
store: Entity<RepoStore>,
|
store: Entity<RepoStore>,
|
||||||
issue_id: EventId,
|
issue_id: EventId,
|
||||||
comment_input: Entity<TextareaState>,
|
comment_input: Entity<TextareaState>,
|
||||||
|
/// Profiles for the author, commenters and the repository owner.
|
||||||
|
directory: Entity<ProfileDirectory>,
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,16 +37,48 @@ impl IssueDetailView {
|
|||||||
let comment_input =
|
let comment_input =
|
||||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
|
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
|
||||||
|
|
||||||
let subscription = cx.observe(&store, |_this, _store, cx| cx.notify());
|
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||||
|
this.track_participants(cx);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
|
||||||
|
let directory = ProfileDirectory::new(cx);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
store,
|
store,
|
||||||
issue_id,
|
issue_id,
|
||||||
comment_input,
|
comment_input,
|
||||||
|
directory,
|
||||||
_subscription: subscription,
|
_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 {
|
impl BasePanel for IssueDetailView {
|
||||||
@@ -66,7 +101,7 @@ impl Panel for IssueDetailView {
|
|||||||
})
|
})
|
||||||
.unwrap_or_else(|| SharedString::from("Issue"));
|
.unwrap_or_else(|| SharedString::from("Issue"));
|
||||||
|
|
||||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||||
|
|
||||||
tab_title(avatar, short_id)
|
tab_title(avatar, short_id)
|
||||||
}
|
}
|
||||||
@@ -93,9 +128,8 @@ impl Render for IssueDetailView {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
let (title, author, picture, status, age, issue_id, content) = {
|
let (title, author, picture, status, age, issue_id, content, pubkey) = {
|
||||||
let profile_store = ProfileStore::global(cx);
|
let profile = self.directory.read(cx).profile(&issue.pubkey, cx);
|
||||||
let profile = profile_store.read(cx).get(&issue.pubkey);
|
|
||||||
let content = if issue.content.is_empty() {
|
let content = if issue.content.is_empty() {
|
||||||
SharedString::from("No description provided.")
|
SharedString::from("No description provided.")
|
||||||
} else {
|
} else {
|
||||||
@@ -110,6 +144,7 @@ impl Render for IssueDetailView {
|
|||||||
relative_time(issue.created_at),
|
relative_time(issue.created_at),
|
||||||
issue.id,
|
issue.id,
|
||||||
content,
|
content,
|
||||||
|
issue.pubkey,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -153,7 +188,11 @@ impl Render for IssueDetailView {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(Avatar::new(&author).picture(picture))
|
.child(
|
||||||
|
Avatar::new(pubkey)
|
||||||
|
.name(author.clone())
|
||||||
|
.picture(picture),
|
||||||
|
)
|
||||||
.child(author),
|
.child(author),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
@@ -169,7 +208,7 @@ impl Render for IssueDetailView {
|
|||||||
)
|
)
|
||||||
.child(div().text_sm().child(content)),
|
.child(div().text_sm().child(content)),
|
||||||
)
|
)
|
||||||
.child(comments_section(&self.store, issue_id, cx))
|
.child(comments_section(&self.store, issue_id, &self.directory, cx))
|
||||||
.child(comment_form(
|
.child(comment_form(
|
||||||
&self.store,
|
&self.store,
|
||||||
issue_id,
|
issue_id,
|
||||||
@@ -185,6 +224,7 @@ impl Render for IssueDetailView {
|
|||||||
issue_id,
|
issue_id,
|
||||||
issue_roots,
|
issue_roots,
|
||||||
false,
|
false,
|
||||||
|
&self.directory,
|
||||||
cx,
|
cx,
|
||||||
))
|
))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ use gpui_component::scroll::Scrollbar;
|
|||||||
use gpui_component::{
|
use gpui_component::{
|
||||||
ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
|
ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
|
||||||
};
|
};
|
||||||
use nostr::prelude::EventId;
|
use metadata::ProfileDirectory;
|
||||||
|
use nostr::prelude::{EventId, PublicKey};
|
||||||
use signed_core::{GitEvent, RepoStatus};
|
use signed_core::{GitEvent, RepoStatus};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::RepoStore;
|
||||||
use signed_ui::{Avatar, SegmentButton, placeholder, status_badge};
|
use signed_ui::{Avatar, SegmentButton, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
@@ -59,6 +60,8 @@ pub struct IssuesView {
|
|||||||
// e.g. switching between two empty filters.
|
// e.g. switching between two empty filters.
|
||||||
synced_filter: IssueFilter,
|
synced_filter: IssueFilter,
|
||||||
scroll_handle: VirtualListScrollHandle,
|
scroll_handle: VirtualListScrollHandle,
|
||||||
|
/// Profiles for issue authors and the repository owner.
|
||||||
|
directory: Entity<ProfileDirectory>,
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ impl IssuesView {
|
|||||||
counts: StatusCounts::default(),
|
counts: StatusCounts::default(),
|
||||||
synced_filter: IssueFilter::Open,
|
synced_filter: IssueFilter::Open,
|
||||||
scroll_handle: VirtualListScrollHandle::new(),
|
scroll_handle: VirtualListScrollHandle::new(),
|
||||||
|
directory: ProfileDirectory::new(cx),
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +110,21 @@ impl IssuesView {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let store = self.store.read(cx);
|
||||||
|
let mut keys: Vec<PublicKey> = store.issues.iter().map(|issue| issue.pubkey).collect();
|
||||||
|
|
||||||
|
if let Some(owner) = store
|
||||||
|
.announcement
|
||||||
|
.as_ref()
|
||||||
|
.map(|announcement| announcement.owner)
|
||||||
|
{
|
||||||
|
keys.push(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.directory.update(cx, |directory, cx| {
|
||||||
|
directory.track(keys, cx);
|
||||||
|
});
|
||||||
|
|
||||||
let filter_changed = self.synced_filter != filter;
|
let filter_changed = self.synced_filter != filter;
|
||||||
let visible_issues_changed = self.visible_issues != visible_issues;
|
let visible_issues_changed = self.visible_issues != visible_issues;
|
||||||
let counts_changed = self.counts != counts;
|
let counts_changed = self.counts != counts;
|
||||||
@@ -148,7 +167,7 @@ impl IssuesView {
|
|||||||
let issue = &self.store.read(cx).issues[issue_ix];
|
let issue = &self.store.read(cx).issues[issue_ix];
|
||||||
let title = issue.activity_subject();
|
let title = issue.activity_subject();
|
||||||
let id_hex = issue.id.to_hex();
|
let id_hex = issue.id.to_hex();
|
||||||
let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey);
|
let profile = self.directory.read(cx).profile(&issue.pubkey, cx);
|
||||||
let author = profile.name();
|
let author = profile.name();
|
||||||
let picture = profile.picture();
|
let picture = profile.picture();
|
||||||
let age = relative_time(issue.created_at);
|
let age = relative_time(issue.created_at);
|
||||||
@@ -189,7 +208,11 @@ impl IssuesView {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(Avatar::new(author.clone()).picture(picture))
|
.child(
|
||||||
|
Avatar::new(issue.pubkey)
|
||||||
|
.name(author.clone())
|
||||||
|
.picture(picture),
|
||||||
|
)
|
||||||
.child(div().child(author)),
|
.child(div().child(author)),
|
||||||
)
|
)
|
||||||
.child(SharedString::from("opened"))
|
.child(SharedString::from("opened"))
|
||||||
@@ -335,7 +358,7 @@ impl BasePanel for IssuesView {
|
|||||||
|
|
||||||
impl Panel for IssuesView {
|
impl Panel for IssuesView {
|
||||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||||
let label = SharedString::from(format!("{}/issues", self.repo_name));
|
let label = SharedString::from(format!("{}/issues", self.repo_name));
|
||||||
|
|
||||||
tab_title(avatar, label)
|
tab_title(avatar, label)
|
||||||
|
|||||||
@@ -12,17 +12,20 @@ pub(crate) mod sidebar;
|
|||||||
mod status_list;
|
mod status_list;
|
||||||
pub(crate) mod tree;
|
pub(crate) mod tree;
|
||||||
|
|
||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{AnyElement, App, div};
|
use gpui::{AnyElement, App, Entity, div};
|
||||||
use gpui_component::{Sizable as _, h_flex};
|
use gpui_component::{Sizable as _, h_flex};
|
||||||
pub use inbox::InboxView;
|
pub use inbox::InboxView;
|
||||||
|
use metadata::ProfileDirectory;
|
||||||
pub(crate) use placeholder_panel::PlaceholderPanel;
|
pub(crate) use placeholder_panel::PlaceholderPanel;
|
||||||
pub use repo::RepoDetailView;
|
pub use repo::RepoDetailView;
|
||||||
pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel};
|
pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel};
|
||||||
pub use repo_list::RepoListView;
|
pub use repo_list::RepoListView;
|
||||||
pub use sidebar::SidebarPanel;
|
pub use sidebar::SidebarPanel;
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::RepoStore;
|
||||||
use signed_ui::{Avatar, PixelAvatar};
|
use signed_ui::Avatar;
|
||||||
|
|
||||||
pub(crate) fn tab_title(avatar: AnyElement, label: impl IntoElement) -> impl IntoElement {
|
pub(crate) fn tab_title(avatar: AnyElement, label: impl IntoElement) -> impl IntoElement {
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -32,11 +35,15 @@ pub(crate) fn tab_title(avatar: AnyElement, label: impl IntoElement) -> impl Int
|
|||||||
.child(label)
|
.child(label)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn panel_avatar(seed: impl AsRef<str>) -> AnyElement {
|
pub(crate) fn panel_avatar(seed: impl Display) -> AnyElement {
|
||||||
PixelAvatar::new(seed).xsmall().into_any_element()
|
Avatar::new(seed).xsmall().into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn repo_tab_avatar(store: &RepoStore, cx: &App) -> AnyElement {
|
pub(crate) fn repo_tab_avatar(
|
||||||
|
store: &RepoStore,
|
||||||
|
directory: &Entity<ProfileDirectory>,
|
||||||
|
cx: &App,
|
||||||
|
) -> AnyElement {
|
||||||
let seed = store
|
let seed = store
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -53,13 +60,15 @@ pub(crate) fn repo_tab_avatar(store: &RepoStore, cx: &App) -> AnyElement {
|
|||||||
let owner = store
|
let owner = store
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|announcement| ProfileStore::global(cx).read(cx).get(&announcement.owner));
|
.map(|announcement| directory.read(cx).profile(&announcement.owner, cx));
|
||||||
|
|
||||||
match owner.and_then(|profile| profile.picture().map(|picture| (profile.name(), picture))) {
|
let (name, picture) = owner
|
||||||
Some((name, picture)) => Avatar::new(name)
|
.map(|profile| (Some(profile.name()), profile.picture()))
|
||||||
.picture(Some(picture))
|
.unwrap_or((None, None));
|
||||||
.xsmall()
|
|
||||||
.into_any_element(),
|
Avatar::new(seed)
|
||||||
None => PixelAvatar::new(seed).xsmall().into_any_element(),
|
.name(name)
|
||||||
}
|
.picture(picture)
|
||||||
|
.xsmall()
|
||||||
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ use gpui_component::{
|
|||||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
|
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
|
||||||
v_virtual_list,
|
v_virtual_list,
|
||||||
};
|
};
|
||||||
use nostr::prelude::{Event, EventId, Kind, Url};
|
use metadata::ProfileDirectory;
|
||||||
|
use nostr::prelude::{Event, EventId, Kind, PublicKey, Url};
|
||||||
use signed_core::{GitEvent, PullRequest, RepoAddr};
|
use signed_core::{GitEvent, PullRequest, RepoAddr};
|
||||||
use signed_git::{FileCommit, PatchParser};
|
use signed_git::{FileCommit, PatchParser};
|
||||||
use signed_state::{Backend, Mirrors, ProfileStore, RepoStore};
|
use signed_state::{Backend, Mirrors, RepoStore};
|
||||||
use signed_ui::{Avatar, CountBadge, placeholder, status_badge};
|
use signed_ui::{Avatar, CountBadge, placeholder, status_badge};
|
||||||
use utils::{relative_time, relative_time_secs};
|
use utils::{relative_time, relative_time_secs};
|
||||||
|
|
||||||
@@ -77,6 +78,8 @@ pub struct PullRequestDetailView {
|
|||||||
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
commit_scroll_handle: VirtualListScrollHandle,
|
commit_scroll_handle: VirtualListScrollHandle,
|
||||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||||
|
/// Profiles for the author, commenters and the repository owner.
|
||||||
|
directory: Entity<ProfileDirectory>,
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,16 +124,37 @@ impl PullRequestDetailView {
|
|||||||
commit_item_sizes: Rc::new(Vec::new()),
|
commit_item_sizes: Rc::new(Vec::new()),
|
||||||
commit_scroll_handle: VirtualListScrollHandle::new(),
|
commit_scroll_handle: VirtualListScrollHandle::new(),
|
||||||
tasks: Vec::new(),
|
tasks: Vec::new(),
|
||||||
|
directory: ProfileDirectory::new(cx),
|
||||||
_subscription: subscription,
|
_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.
|
/// 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>) {
|
fn sync(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.track_participants(cx);
|
||||||
|
|
||||||
let loaded = self.store.read(cx).loaded;
|
let loaded = self.store.read(cx).loaded;
|
||||||
|
|
||||||
let binding = {
|
let binding = {
|
||||||
@@ -407,7 +431,7 @@ impl PullRequestDetailView {
|
|||||||
return placeholder(&error, cx);
|
return placeholder(&error, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (author, picture, age, root_id) = {
|
let (author, picture, age, root_id, pubkey) = {
|
||||||
let store = self.store.read(cx);
|
let store = self.store.read(cx);
|
||||||
let Some(root) = store
|
let Some(root) = store
|
||||||
.pull_requests
|
.pull_requests
|
||||||
@@ -416,12 +440,13 @@ impl PullRequestDetailView {
|
|||||||
else {
|
else {
|
||||||
return placeholder("Pull request not found", cx);
|
return placeholder("Pull request not found", cx);
|
||||||
};
|
};
|
||||||
let profile = ProfileStore::global(cx).read(cx).get(&root.pubkey);
|
let profile = self.directory.read(cx).profile(&root.pubkey, cx);
|
||||||
(
|
(
|
||||||
profile.name(),
|
profile.name(),
|
||||||
profile.picture(),
|
profile.picture(),
|
||||||
relative_time(root.created_at),
|
relative_time(root.created_at),
|
||||||
root.id,
|
root.id,
|
||||||
|
root.pubkey,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -451,7 +476,8 @@ impl PullRequestDetailView {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(
|
.child(
|
||||||
Avatar::new(author.clone())
|
Avatar::new(pubkey)
|
||||||
|
.name(author.clone())
|
||||||
.picture(picture),
|
.picture(picture),
|
||||||
)
|
)
|
||||||
.child(author),
|
.child(author),
|
||||||
@@ -467,7 +493,7 @@ impl PullRequestDetailView {
|
|||||||
this.child(div().text_sm().child(self.description.clone()))
|
this.child(div().text_sm().child(self.description.clone()))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.child(comments_section(&self.store, root_id, cx))
|
.child(comments_section(&self.store, root_id, &self.directory, cx))
|
||||||
.child(comment_form(
|
.child(comment_form(
|
||||||
&self.store,
|
&self.store,
|
||||||
root_id,
|
root_id,
|
||||||
@@ -478,7 +504,14 @@ impl PullRequestDetailView {
|
|||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(sidebar_section(&self.store, root_id, pr_roots, true, cx))
|
.child(sidebar_section(
|
||||||
|
&self.store,
|
||||||
|
root_id,
|
||||||
|
pr_roots,
|
||||||
|
true,
|
||||||
|
&self.directory,
|
||||||
|
cx,
|
||||||
|
))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,7 +822,7 @@ impl BasePanel for PullRequestDetailView {
|
|||||||
|
|
||||||
impl Panel for PullRequestDetailView {
|
impl Panel for PullRequestDetailView {
|
||||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||||
let hex = self.pr_id.to_hex();
|
let hex = self.pr_id.to_hex();
|
||||||
let id = SharedString::from(&hex[..8]);
|
let id = SharedString::from(&hex[..8]);
|
||||||
let label = if self.repo_name.is_empty() {
|
let label = if self.repo_name.is_empty() {
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ use gpui_component::scroll::Scrollbar;
|
|||||||
use gpui_component::{
|
use gpui_component::{
|
||||||
ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||||
};
|
};
|
||||||
use nostr::prelude::{EventId, Kind};
|
use metadata::ProfileDirectory;
|
||||||
|
use nostr::prelude::{EventId, Kind, PublicKey};
|
||||||
use signed_core::{GitEvent, RepoStatus};
|
use signed_core::{GitEvent, RepoStatus};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::RepoStore;
|
||||||
use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge};
|
use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
@@ -66,6 +67,8 @@ pub struct PullRequestsView {
|
|||||||
// e.g. switching between two empty filters.
|
// e.g. switching between two empty filters.
|
||||||
synced_filter: PullRequestFilter,
|
synced_filter: PullRequestFilter,
|
||||||
scroll_handle: VirtualListScrollHandle,
|
scroll_handle: VirtualListScrollHandle,
|
||||||
|
/// Profiles for PR authors and the repository owner.
|
||||||
|
directory: Entity<ProfileDirectory>,
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,11 +100,13 @@ impl PullRequestsView {
|
|||||||
counts: StatusCounts::default(),
|
counts: StatusCounts::default(),
|
||||||
synced_filter: PullRequestFilter::Open,
|
synced_filter: PullRequestFilter::Open,
|
||||||
scroll_handle: VirtualListScrollHandle::new(),
|
scroll_handle: VirtualListScrollHandle::new(),
|
||||||
|
directory: ProfileDirectory::new(cx),
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rebuild(&mut self, cx: &mut Context<Self>) {
|
fn rebuild(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let store = self.store.read(cx);
|
||||||
let filter = self.filter;
|
let filter = self.filter;
|
||||||
|
|
||||||
let (visible_prs, counts) = {
|
let (visible_prs, counts) = {
|
||||||
@@ -117,6 +122,25 @@ impl PullRequestsView {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut keys: Vec<PublicKey> = store
|
||||||
|
.pull_requests
|
||||||
|
.iter()
|
||||||
|
.filter(|pr| pr.kind == Kind::GitPullRequest)
|
||||||
|
.map(|pr| pr.pubkey)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if let Some(owner) = store
|
||||||
|
.announcement
|
||||||
|
.as_ref()
|
||||||
|
.map(|announcement| announcement.owner)
|
||||||
|
{
|
||||||
|
keys.push(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.directory.update(cx, |directory, cx| {
|
||||||
|
directory.track(keys, cx);
|
||||||
|
});
|
||||||
|
|
||||||
let filter_changed = self.synced_filter != filter;
|
let filter_changed = self.synced_filter != filter;
|
||||||
|
|
||||||
if !filter_changed && self.visible_prs == visible_prs && self.counts == counts {
|
if !filter_changed && self.visible_prs == visible_prs && self.counts == counts {
|
||||||
@@ -165,7 +189,7 @@ impl PullRequestsView {
|
|||||||
let age = relative_time(pr.created_at);
|
let age = relative_time(pr.created_at);
|
||||||
let status = self.store.read(cx).status_of(pr);
|
let status = self.store.read(cx).status_of(pr);
|
||||||
|
|
||||||
let profile = ProfileStore::global(cx).read(cx).get(&pr.pubkey);
|
let profile = self.directory.read(cx).profile(&pr.pubkey, cx);
|
||||||
let author = profile.name();
|
let author = profile.name();
|
||||||
let picture = profile.picture();
|
let picture = profile.picture();
|
||||||
|
|
||||||
@@ -203,7 +227,11 @@ impl PullRequestsView {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(Avatar::new(author.clone()).picture(picture))
|
.child(
|
||||||
|
Avatar::new(pr.pubkey)
|
||||||
|
.name(author.clone())
|
||||||
|
.picture(picture),
|
||||||
|
)
|
||||||
.child(div().child(author)),
|
.child(div().child(author)),
|
||||||
)
|
)
|
||||||
.child(SharedString::from("opened"))
|
.child(SharedString::from("opened"))
|
||||||
@@ -335,7 +363,7 @@ impl BasePanel for PullRequestsView {
|
|||||||
|
|
||||||
impl Panel for PullRequestsView {
|
impl Panel for PullRequestsView {
|
||||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||||
let label = SharedString::from(format!("{}/pulls", self.repo_name));
|
let label = SharedString::from(format!("{}/pulls", self.repo_name));
|
||||||
|
|
||||||
tab_title(avatar, label)
|
tab_title(avatar, label)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use gpui_component::{
|
|||||||
ActiveTheme, Disableable, Icon, IconName, Sizable, VirtualListScrollHandle, h_flex, v_flex,
|
ActiveTheme, Disableable, Icon, IconName, Sizable, VirtualListScrollHandle, h_flex, v_flex,
|
||||||
v_virtual_list,
|
v_virtual_list,
|
||||||
};
|
};
|
||||||
|
use metadata::ProfileDirectory;
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
use signed_core::{Announcement, RepoAddr};
|
use signed_core::{Announcement, RepoAddr};
|
||||||
use signed_git::{GitCache, Repo};
|
use signed_git::{GitCache, Repo};
|
||||||
@@ -65,6 +66,8 @@ pub struct NewPullRequestView {
|
|||||||
scroll_handle: VirtualListScrollHandle,
|
scroll_handle: VirtualListScrollHandle,
|
||||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||||
|
/// Profile for the repository owner in the tab title.
|
||||||
|
directory: Entity<ProfileDirectory>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +225,7 @@ impl NewPullRequestView {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let directory = ProfileDirectory::new(cx);
|
||||||
let repo_name = store.read(cx).name();
|
let repo_name = store.read(cx).name();
|
||||||
let pane = cx.new(DiffPane::new);
|
let pane = cx.new(DiffPane::new);
|
||||||
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
|
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
|
||||||
@@ -270,6 +274,12 @@ impl NewPullRequestView {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) {
|
||||||
|
directory.update(cx, |directory, cx| {
|
||||||
|
directory.track([owner], cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
cx.defer_in(window, |this, window, cx| {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
let Some(addr) = this.store.read(cx).addr().cloned() else {
|
let Some(addr) = this.store.read(cx).addr().cloned() else {
|
||||||
return;
|
return;
|
||||||
@@ -312,6 +322,7 @@ impl NewPullRequestView {
|
|||||||
scroll_handle: VirtualListScrollHandle::new(),
|
scroll_handle: VirtualListScrollHandle::new(),
|
||||||
item_sizes: Rc::new(Vec::new()),
|
item_sizes: Rc::new(Vec::new()),
|
||||||
tasks: Vec::new(),
|
tasks: Vec::new(),
|
||||||
|
directory,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1313,7 +1324,7 @@ impl BasePanel for NewPullRequestView {
|
|||||||
|
|
||||||
impl Panel for NewPullRequestView {
|
impl Panel for NewPullRequestView {
|
||||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||||
let label = SharedString::from(format!("{}/new-pull-request", self.repo_name));
|
let label = SharedString::from(format!("{}/new-pull-request", self.repo_name));
|
||||||
|
|
||||||
tab_title(avatar, label)
|
tab_title(avatar, label)
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{AnyElement, App, SharedString, Window, div, px};
|
use gpui::{AnyElement, App, Entity, SharedString, Window, div, px};
|
||||||
use gpui_component::clipboard::Clipboard;
|
use gpui_component::clipboard::Clipboard;
|
||||||
use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex};
|
||||||
|
use metadata::ProfileDirectory;
|
||||||
use nostr::prelude::PublicKey;
|
use nostr::prelude::PublicKey;
|
||||||
use signed_core::Announcement;
|
use signed_core::Announcement;
|
||||||
use signed_state::ProfileStore;
|
|
||||||
use signed_ui::Avatar;
|
use signed_ui::Avatar;
|
||||||
use utils::middle_truncate;
|
use utils::middle_truncate;
|
||||||
|
|
||||||
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
|
pub(super) fn open_about_dialog(
|
||||||
|
announcement: Announcement,
|
||||||
|
directory: Entity<ProfileDirectory>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
window.open_dialog(cx, move |dialog, _window, cx| {
|
window.open_dialog(cx, move |dialog, _window, cx| {
|
||||||
let announcement = announcement.clone();
|
let announcement = announcement.clone();
|
||||||
dialog
|
dialog
|
||||||
@@ -17,11 +22,15 @@ pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window,
|
|||||||
.keyboard(true)
|
.keyboard(true)
|
||||||
.close_button(true)
|
.close_button(true)
|
||||||
.title("About")
|
.title("About")
|
||||||
.child(announcement_rows(&announcement, cx))
|
.child(announcement_rows(&announcement, &directory, cx))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
fn announcement_rows(
|
||||||
|
announcement: &Announcement,
|
||||||
|
directory: &Entity<ProfileDirectory>,
|
||||||
|
cx: &App,
|
||||||
|
) -> AnyElement {
|
||||||
let mut rows: Vec<AnyElement> = Vec::new();
|
let mut rows: Vec<AnyElement> = Vec::new();
|
||||||
|
|
||||||
rows.push(row(
|
rows.push(row(
|
||||||
@@ -107,7 +116,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
|||||||
if !announcement.maintainers.is_empty() {
|
if !announcement.maintainers.is_empty() {
|
||||||
rows.push(row(
|
rows.push(row(
|
||||||
"Maintainers",
|
"Maintainers",
|
||||||
maintainers(&announcement.maintainers, cx),
|
maintainers(&announcement.maintainers, directory, cx),
|
||||||
cx,
|
cx,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -160,13 +169,16 @@ fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
fn maintainers(
|
||||||
let profile_store = ProfileStore::global(cx);
|
maintainers: &[PublicKey],
|
||||||
|
directory: &Entity<ProfileDirectory>,
|
||||||
|
cx: &App,
|
||||||
|
) -> AnyElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
.children(maintainers.iter().map(|pubkey| {
|
.children(maintainers.iter().map(|pubkey| {
|
||||||
let profile = profile_store.read(cx).get(pubkey);
|
let profile = directory.read(cx).profile(pubkey, cx);
|
||||||
let name = profile.name();
|
let name = profile.name();
|
||||||
let picture = profile.picture();
|
let picture = profile.picture();
|
||||||
|
|
||||||
@@ -174,7 +186,7 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.items_center()
|
.items_center()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
.child(Avatar::new(name.clone()).picture(picture))
|
.child(Avatar::new(*pubkey).name(name.clone()).picture(picture))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
|
|||||||
@@ -15,22 +15,24 @@ use gpui_base::{Button as BaseButton, Disableable, Popover};
|
|||||||
use gpui_component::alert::Alert;
|
use gpui_component::alert::Alert;
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::combobox::{Combobox, ComboboxEvent};
|
use gpui_component::combobox::{Combobox, ComboboxEvent};
|
||||||
|
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||||
use gpui_component::menu::{DropdownMenu, PopupMenu};
|
use gpui_component::menu::{DropdownMenu, PopupMenu};
|
||||||
use gpui_component::spinner::Spinner;
|
use gpui_component::spinner::Spinner;
|
||||||
use gpui_component::{
|
use gpui_component::{
|
||||||
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex,
|
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, WindowExt, h_flex,
|
||||||
|
v_flex,
|
||||||
};
|
};
|
||||||
|
use metadata::ProfileDirectory;
|
||||||
use nostr::nips::nip19::Nip19Coordinate;
|
use nostr::nips::nip19::Nip19Coordinate;
|
||||||
use nostr::prelude::{RelayUrl, ToBech32, Url};
|
use nostr::prelude::{PublicKey, RelayUrl, ToBech32, Url};
|
||||||
use signed_core::{Announcement, RepoAddr, RepoStatus};
|
use signed_core::{Announcement, RepoAddr, RepoStatus};
|
||||||
use signed_git::{FileCommit, GitCache, RefSync, Repo};
|
use signed_git::{FileCommit, GitCache, RefSync, Repo};
|
||||||
use signed_state::{
|
use signed_state::{
|
||||||
Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors,
|
Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors,
|
||||||
Nip34Binding, Nip34Kind, ProfileStore, PushStepStatus, RepoListStore, RepoStore,
|
Nip34Binding, Nip34Kind, PushStepStatus, RepoListStore, RepoStore, SyncStatusDirectory,
|
||||||
SyncStatusStore,
|
|
||||||
};
|
};
|
||||||
use signed_ui::{
|
use signed_ui::{
|
||||||
Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, ref_selector_trigger,
|
Avatar, CountBadge, DropdownButton, copy_row, menu_copy_row, ref_selector_trigger,
|
||||||
};
|
};
|
||||||
use utils::middle_truncate;
|
use utils::middle_truncate;
|
||||||
|
|
||||||
@@ -72,6 +74,7 @@ pub(crate) enum RepoAction {
|
|||||||
SendPatch,
|
SendPatch,
|
||||||
About,
|
About,
|
||||||
Push,
|
Push,
|
||||||
|
ResetRemote,
|
||||||
Delete,
|
Delete,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +97,10 @@ pub struct RepoDetailView {
|
|||||||
refs: RefSwitcher,
|
refs: RefSwitcher,
|
||||||
banners: Banners,
|
banners: Banners,
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
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>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +161,6 @@ impl RepoDetailView {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
let sync_status = SyncStatusStore::global(cx);
|
|
||||||
let files = cx.new(RepoFilesView::new);
|
let files = cx.new(RepoFilesView::new);
|
||||||
let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone()));
|
let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone()));
|
||||||
let refs = RefSwitcher::new(window, cx);
|
let refs = RefSwitcher::new(window, cx);
|
||||||
@@ -191,11 +197,6 @@ 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.
|
// Defer loading the repository until the window is ready.
|
||||||
cx.defer_in(window, |this, window, cx| {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
this.load_repo(window, cx);
|
this.load_repo(window, cx);
|
||||||
@@ -216,6 +217,8 @@ impl RepoDetailView {
|
|||||||
tasks: Vec::new(),
|
tasks: Vec::new(),
|
||||||
banners: Banners::default(),
|
banners: Banners::default(),
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
|
directory: ProfileDirectory::new(cx),
|
||||||
|
sync_directory: SyncStatusDirectory::new(cx),
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -251,6 +254,8 @@ impl RepoDetailView {
|
|||||||
self._subscriptions
|
self._subscriptions
|
||||||
.push(cx.observe_in(store, window, |this, store, window, cx| {
|
.push(cx.observe_in(store, window, |this, store, window, cx| {
|
||||||
this.refresh_ready_statuses(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() {
|
if !this.repo_started && store.read(cx).announcement.is_some() {
|
||||||
this.load_repo(window, cx);
|
this.load_repo(window, cx);
|
||||||
}
|
}
|
||||||
@@ -259,6 +264,46 @@ impl RepoDetailView {
|
|||||||
self.refresh_ready_statuses(cx);
|
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>) {
|
fn refresh_ready_statuses(&mut self, cx: &mut Context<Self>) {
|
||||||
let Some(addr) = self.store.read(cx).addr().cloned() else {
|
let Some(addr) = self.store.read(cx).addr().cloned() else {
|
||||||
return;
|
return;
|
||||||
@@ -315,6 +360,8 @@ impl RepoDetailView {
|
|||||||
pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.loading = true;
|
self.loading = true;
|
||||||
self.error = None;
|
self.error = None;
|
||||||
|
self.track_profiles(cx);
|
||||||
|
self.track_sync_status(cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let (announcement, local_path) = {
|
let (announcement, local_path) = {
|
||||||
@@ -797,6 +844,63 @@ impl RepoDetailView {
|
|||||||
self.tasks.push(task);
|
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.
|
/// Delete the repository from nostr, announcement, state and activity.
|
||||||
pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let task = self
|
let task = self
|
||||||
@@ -878,9 +982,10 @@ impl RepoDetailView {
|
|||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
};
|
};
|
||||||
|
|
||||||
let nip05 = ProfileStore::global(cx)
|
let nip05 = self
|
||||||
|
.directory
|
||||||
.read(cx)
|
.read(cx)
|
||||||
.get(&source.owner)
|
.profile(&source.owner, cx)
|
||||||
.metadata()
|
.metadata()
|
||||||
.nip05
|
.nip05
|
||||||
.clone()
|
.clone()
|
||||||
@@ -896,7 +1001,7 @@ impl RepoDetailView {
|
|||||||
|
|
||||||
let name = repo_display_name(self.store.read(cx));
|
let name = repo_display_name(self.store.read(cx));
|
||||||
let description = announcement.description();
|
let description = announcement.description();
|
||||||
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
|
let avatar = Avatar::new(format!("{}:{}", announcement.owner, announcement.id));
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.on_action(
|
.on_action(
|
||||||
@@ -917,10 +1022,16 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
RepoAction::About => {
|
RepoAction::About => {
|
||||||
if let Some(announcement) = this.announcement(cx) {
|
if let Some(announcement) = this.announcement(cx) {
|
||||||
open_about_dialog(announcement.clone(), window, cx);
|
open_about_dialog(
|
||||||
|
announcement.clone(),
|
||||||
|
this.directory.clone(),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RepoAction::Push => this.push_repository(window, cx),
|
RepoAction::Push => this.push_repository(window, cx),
|
||||||
|
RepoAction::ResetRemote => this.reset_remote(window, cx),
|
||||||
RepoAction::Delete => this.delete_repository(window, cx),
|
RepoAction::Delete => this.delete_repository(window, cx),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1123,6 +1234,16 @@ impl RepoDetailView {
|
|||||||
.child(Icon::new(CustomIconName::Init))
|
.child(Icon::new(CustomIconName::Init))
|
||||||
.child("Republish")
|
.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()
|
.separator()
|
||||||
.menu_element(Box::new(RepoAction::Delete), |_, cx| {
|
.menu_element(Box::new(RepoAction::Delete), |_, cx| {
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -1253,7 +1374,7 @@ impl RepoDetailView {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|path| path.display().to_string())
|
.map(|path| path.display().to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let avatar = PixelAvatar::new(path.clone());
|
let avatar = Avatar::new(path.clone());
|
||||||
|
|
||||||
// A repository already bound to a coordinate is not offered for publishing again.
|
// A repository already bound to a coordinate is not offered for publishing again.
|
||||||
let bound = self.store.read(cx).nip34.clone();
|
let bound = self.store.read(cx).nip34.clone();
|
||||||
@@ -1261,7 +1382,7 @@ impl RepoDetailView {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|binding| binding.kind == Nip34Kind::Initialized)
|
.filter(|binding| binding.kind == Nip34Kind::Initialized)
|
||||||
{
|
{
|
||||||
Some(binding) => bound_repo_label(binding, cx),
|
Some(binding) => bound_repo_label(binding, &self.directory, cx),
|
||||||
None => Button::new("init")
|
None => Button::new("init")
|
||||||
.icon(CustomIconName::Init)
|
.icon(CustomIconName::Init)
|
||||||
.label("Initialize on Nostr")
|
.label("Initialize on Nostr")
|
||||||
@@ -1457,7 +1578,6 @@ impl RepoDetailView {
|
|||||||
let Some(announcement) = self.announcement(cx) else {
|
let Some(announcement) = self.announcement(cx) else {
|
||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
};
|
};
|
||||||
let profile_store = ProfileStore::global(cx);
|
|
||||||
|
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
let rest: Vec<_> = announcement
|
let rest: Vec<_> = announcement
|
||||||
@@ -1467,7 +1587,7 @@ impl RepoDetailView {
|
|||||||
.filter(|key| key != &announcement.owner && seen.insert(*key))
|
.filter(|key| key != &announcement.owner && seen.insert(*key))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let owner = profile_store.read(cx).get(&announcement.owner);
|
let owner = self.directory.read(cx).profile(&announcement.owner, cx);
|
||||||
let owner_name = owner.name();
|
let owner_name = owner.name();
|
||||||
let owner_picture = owner.picture();
|
let owner_picture = owner.picture();
|
||||||
|
|
||||||
@@ -1481,7 +1601,11 @@ impl RepoDetailView {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(Avatar::new(owner_name.clone()).picture(owner_picture))
|
.child(
|
||||||
|
Avatar::new(announcement.owner)
|
||||||
|
.name(owner_name.clone())
|
||||||
|
.picture(owner_picture),
|
||||||
|
)
|
||||||
.child(div().text_xs().whitespace_nowrap().child(owner_name)),
|
.child(div().text_xs().whitespace_nowrap().child(owner_name)),
|
||||||
)
|
)
|
||||||
.when(!rest.is_empty(), |this| {
|
.when(!rest.is_empty(), |this| {
|
||||||
@@ -1559,8 +1683,7 @@ impl RepoDetailView {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sync_status = SyncStatusStore::global(cx);
|
let statuses = self.sync_directory.read(cx).statuses_of(addr, cx);
|
||||||
let statuses = sync_status.read(cx).statuses_of(addr);
|
|
||||||
|
|
||||||
statuses.into_iter().find(|checkout| {
|
statuses.into_iter().find(|checkout| {
|
||||||
checkout.status.ahead_total > 0 && !self.banners.sync_dismissal(checkout)
|
checkout.status.ahead_total > 0 && !self.banners.sync_dismissal(checkout)
|
||||||
@@ -1991,6 +2114,75 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Confirmation for the remote reset, listing the server refs it deletes.
|
||||||
|
fn open_reset_remote_dialog(
|
||||||
|
store: Entity<RepoStore>,
|
||||||
|
deleted: Vec<String>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
const DESC: &str = "Grasp servers are overwritten to match the local mirror. \
|
||||||
|
Refs that do not exist locally are deleted from the servers. \
|
||||||
|
This cannot be undone.";
|
||||||
|
|
||||||
|
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||||
|
let deleted = deleted.clone();
|
||||||
|
let store = store.clone();
|
||||||
|
|
||||||
|
dialog
|
||||||
|
.w(px(480.))
|
||||||
|
.keyboard(true)
|
||||||
|
.close_button(true)
|
||||||
|
.content(move |body, _window, cx| {
|
||||||
|
let deleted = deleted.clone();
|
||||||
|
let store = store.clone();
|
||||||
|
|
||||||
|
body.child(
|
||||||
|
DialogHeader::new()
|
||||||
|
.child(DialogTitle::new().child("Reset remote to local"))
|
||||||
|
.child(DialogDescription::new().child(DESC)),
|
||||||
|
)
|
||||||
|
.child(if deleted.is_empty() {
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child("No server refs will be deleted.")
|
||||||
|
.into_any_element()
|
||||||
|
} else {
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.children(deleted.iter().map(|name| {
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.font_family(cx.theme().mono_font_family.clone())
|
||||||
|
.child(name.clone())
|
||||||
|
}))
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
DialogFooter::new()
|
||||||
|
.justify_end()
|
||||||
|
.child(Button::new("cancel-reset-remote").label("Cancel").on_click(
|
||||||
|
|_, window, cx| {
|
||||||
|
window.close_dialog(cx);
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.child(
|
||||||
|
Button::new("confirm-reset-remote")
|
||||||
|
.danger()
|
||||||
|
.label("Reset remote")
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
store.update(cx, |store, cx| {
|
||||||
|
store.reset_repository(cx).detach_and_log_err(cx);
|
||||||
|
});
|
||||||
|
window.close_dialog(cx);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString {
|
fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString {
|
||||||
let owner = announcement.owner;
|
let owner = announcement.owner;
|
||||||
let user = nip05
|
let user = nip05
|
||||||
@@ -2169,9 +2361,12 @@ pub(super) fn repo_display_name(store: &RepoStore) -> SharedString {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The owner and identifier a local repository is already bound to.
|
/// The owner and identifier a local repository is already bound to.
|
||||||
fn bound_repo_label(binding: &Nip34Binding, cx: &App) -> AnyElement {
|
fn bound_repo_label(
|
||||||
let store = ProfileStore::global(cx);
|
binding: &Nip34Binding,
|
||||||
let profile = binding.owner.map(|pk| store.read(cx).get(&pk));
|
directory: &Entity<ProfileDirectory>,
|
||||||
|
cx: &App,
|
||||||
|
) -> AnyElement {
|
||||||
|
let profile = binding.owner.map(|pk| directory.read(cx).profile(&pk, cx));
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
@@ -2180,12 +2375,17 @@ fn bound_repo_label(binding: &Nip34Binding, cx: &App) -> AnyElement {
|
|||||||
.child("Initialized by")
|
.child("Initialized by")
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.when_some(profile, |this, profile| {
|
.when_some(profile, |this, profile| {
|
||||||
|
let name = profile.name();
|
||||||
this.child(
|
this.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.text_color(cx.theme().foreground)
|
.text_color(cx.theme().foreground)
|
||||||
.child(Avatar::new(profile.name()).picture(profile.picture()))
|
.child(
|
||||||
.child(profile.name()),
|
Avatar::new(profile.public_key())
|
||||||
|
.name(name.clone())
|
||||||
|
.picture(profile.picture()),
|
||||||
|
)
|
||||||
|
.child(name),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when_some(binding.identifier.as_deref(), |this, ident| {
|
.when_some(binding.identifier.as_deref(), |this, ident| {
|
||||||
@@ -2209,7 +2409,7 @@ impl Panel for RepoDetailView {
|
|||||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let store = self.store.read(cx);
|
let store = self.store.read(cx);
|
||||||
let name = repo_display_name(store);
|
let name = repo_display_name(store);
|
||||||
let avatar = repo_tab_avatar(store, cx);
|
let avatar = repo_tab_avatar(store, &self.directory, cx);
|
||||||
|
|
||||||
tab_title(avatar, name)
|
tab_title(avatar, name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ use gpui_component::{
|
|||||||
ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex,
|
ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex,
|
||||||
v_virtual_list,
|
v_virtual_list,
|
||||||
};
|
};
|
||||||
|
use metadata::ProfileDirectory;
|
||||||
|
use nostr::prelude::PublicKey;
|
||||||
use signed_core::Announcement;
|
use signed_core::Announcement;
|
||||||
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
use signed_state::{RepoListStore, Timestamp};
|
||||||
use signed_ui::{Avatar, SegmentButton};
|
use signed_ui::{Avatar, SegmentButton};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
@@ -107,6 +109,8 @@ pub struct RepoListView {
|
|||||||
/// Indices matching [`Self::filter`] into the store's `announcements`.
|
/// Indices matching [`Self::filter`] into the store's `announcements`.
|
||||||
visible: Vec<usize>,
|
visible: Vec<usize>,
|
||||||
search: Entity<InputState>,
|
search: Entity<InputState>,
|
||||||
|
/// Profiles for the card owners.
|
||||||
|
directory: Entity<ProfileDirectory>,
|
||||||
_search_subscription: Subscription,
|
_search_subscription: Subscription,
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
@@ -118,6 +122,7 @@ impl RepoListView {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let store = RepoListStore::global(cx);
|
let store = RepoListStore::global(cx);
|
||||||
|
let directory = ProfileDirectory::new(cx);
|
||||||
|
|
||||||
let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search..."));
|
let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search..."));
|
||||||
let search_subscription = cx.subscribe(&search, |this, _search, event, cx| {
|
let search_subscription = cx.subscribe(&search, |this, _search, event, cx| {
|
||||||
@@ -126,8 +131,7 @@ impl RepoListView {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep the visible slice and row sizes in sync with the store.
|
// Rebuild the rows when the store changes.
|
||||||
// Newly announced repositories appear without waiting for a click.
|
|
||||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||||
this.rebuild_rows(cx);
|
this.rebuild_rows(cx);
|
||||||
});
|
});
|
||||||
@@ -146,6 +150,7 @@ impl RepoListView {
|
|||||||
repo_len: 0,
|
repo_len: 0,
|
||||||
visible: Vec::new(),
|
visible: Vec::new(),
|
||||||
search,
|
search,
|
||||||
|
directory,
|
||||||
_search_subscription: search_subscription,
|
_search_subscription: search_subscription,
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
}
|
}
|
||||||
@@ -158,6 +163,17 @@ impl RepoListView {
|
|||||||
|
|
||||||
self.visible = filter.visible(store, &query);
|
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.
|
// Each virtual list row holds `COLUMNS` repo cards.
|
||||||
let rows = self.visible.len().div_ceil(COLUMNS);
|
let rows = self.visible.len().div_ceil(COLUMNS);
|
||||||
|
|
||||||
@@ -176,8 +192,7 @@ impl RepoListView {
|
|||||||
last_activity: Option<Timestamp>,
|
last_activity: Option<Timestamp>,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let profile_store = ProfileStore::global(cx);
|
let owner = self.directory.read(cx).profile(&announcement.owner, cx);
|
||||||
let owner = profile_store.read(cx).get(&announcement.owner);
|
|
||||||
|
|
||||||
let name = announcement
|
let name = announcement
|
||||||
.name
|
.name
|
||||||
@@ -274,7 +289,11 @@ impl RepoListView {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.items_center()
|
.items_center()
|
||||||
.child(Avatar::new(owner.name()).picture(owner.picture()))
|
.child(
|
||||||
|
Avatar::new(announcement.owner)
|
||||||
|
.name(owner.name())
|
||||||
|
.picture(owner.picture()),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use gpui_component::input::{Input, InputState, Textarea, TextareaState};
|
|||||||
use gpui_component::scroll::ScrollableElement;
|
use gpui_component::scroll::ScrollableElement;
|
||||||
use gpui_component::spinner::Spinner;
|
use gpui_component::spinner::Spinner;
|
||||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
||||||
|
use metadata::ProfileDirectory;
|
||||||
use signed_state::RepoStore;
|
use signed_state::RepoStore;
|
||||||
|
|
||||||
use crate::views::{repo_tab_avatar, tab_title};
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
@@ -24,6 +25,8 @@ pub struct SendPatchView {
|
|||||||
submitting: bool,
|
submitting: bool,
|
||||||
/// Error of the last submit attempt, it keeps the panel open.
|
/// Error of the last submit attempt, it keeps the panel open.
|
||||||
error: Option<SharedString>,
|
error: Option<SharedString>,
|
||||||
|
/// Profile for the repository owner in the tab title.
|
||||||
|
directory: Entity<ProfileDirectory>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SendPatchView {
|
impl SendPatchView {
|
||||||
@@ -34,11 +37,17 @@ impl SendPatchView {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let repo_name = store.read(cx).name();
|
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 subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
|
||||||
|
let description =
|
||||||
let description = cx
|
cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change"));
|
||||||
.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)"));
|
|
||||||
|
|
||||||
let patch = cx.new(|cx| {
|
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")
|
TextareaState::new(window, cx).placeholder("diff --git a/file.txt b/file.txt\nindex 1234567..abcdefg 100644\n--- a/file.txt\n+++ b/file.txt")
|
||||||
});
|
});
|
||||||
@@ -53,6 +62,7 @@ impl SendPatchView {
|
|||||||
patch,
|
patch,
|
||||||
submitting: false,
|
submitting: false,
|
||||||
error: None,
|
error: None,
|
||||||
|
directory,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +218,7 @@ impl BasePanel for SendPatchView {
|
|||||||
|
|
||||||
impl Panel for SendPatchView {
|
impl Panel for SendPatchView {
|
||||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let avatar = repo_tab_avatar(self.store.read(cx), cx);
|
let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx);
|
||||||
let label = SharedString::from(format!("{}/send-patch", self.repo_name));
|
let label = SharedString::from(format!("{}/send-patch", self.repo_name));
|
||||||
|
|
||||||
tab_title(avatar, label)
|
tab_title(avatar, label)
|
||||||
|
|||||||
@@ -20,13 +20,14 @@ use gpui_component::dialog::DialogButtonProps;
|
|||||||
use gpui_component::input::InputState;
|
use gpui_component::input::InputState;
|
||||||
use gpui_component::menu::{DropdownMenu, PopupMenuItem};
|
use gpui_component::menu::{DropdownMenu, PopupMenuItem};
|
||||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, WindowExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, WindowExt, h_flex, v_flex};
|
||||||
use nostr::prelude::RelayUrl;
|
use metadata::{Profile, ProfileDirectory};
|
||||||
|
use nostr::prelude::{PublicKey, RelayUrl};
|
||||||
use signed_core::{Announcement, RepoAddr};
|
use signed_core::{Announcement, RepoAddr};
|
||||||
use signed_state::{
|
use signed_state::{
|
||||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile,
|
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, RepoListStore,
|
||||||
ProfileStore, RepoListStore, ResolvedLocalRepo, SyncStatusStore,
|
ResolvedLocalRepo, SyncStatusDirectory,
|
||||||
};
|
};
|
||||||
use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers};
|
use signed_ui::{Avatar, NavItem, title_bar_drag_handlers};
|
||||||
|
|
||||||
use super::{InboxView, PlaceholderPanel, RepoDetailView, RepoListView, open_repo_panel};
|
use super::{InboxView, PlaceholderPanel, RepoDetailView, RepoListView, open_repo_panel};
|
||||||
|
|
||||||
@@ -67,8 +68,10 @@ pub struct SidebarPanel {
|
|||||||
local_repos: Arc<Vec<ResolvedLocalRepo>>,
|
local_repos: Arc<Vec<ResolvedLocalRepo>>,
|
||||||
/// Unpushed commit counts per announced repository.
|
/// Unpushed commit counts per announced repository.
|
||||||
unpushed: HashMap<RepoAddr, usize>,
|
unpushed: HashMap<RepoAddr, usize>,
|
||||||
/// Commits not on Nostr per announced repository.
|
/// Profiles rendered by the sidebar.
|
||||||
unsynced: HashMap<RepoAddr, usize>,
|
directory: Entity<ProfileDirectory>,
|
||||||
|
/// Sync statuses for the announced repositories.
|
||||||
|
sync_directory: Entity<SyncStatusDirectory>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +81,6 @@ impl SidebarPanel {
|
|||||||
let repos = RepoListStore::global(cx);
|
let repos = RepoListStore::global(cx);
|
||||||
let local = LocalReposStore::global(cx);
|
let local = LocalReposStore::global(cx);
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
let sync_status = SyncStatusStore::global(cx);
|
|
||||||
|
|
||||||
let mut subscriptions = Vec::new();
|
let mut subscriptions = Vec::new();
|
||||||
|
|
||||||
@@ -116,12 +118,6 @@ impl SidebarPanel {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
subscriptions.push(cx.observe(&sync_status, |this, _sync, cx| {
|
|
||||||
if this.refresh_unsynced(cx) {
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
dock_area,
|
dock_area,
|
||||||
@@ -134,7 +130,8 @@ impl SidebarPanel {
|
|||||||
local_repos: Arc::new(Vec::new()),
|
local_repos: Arc::new(Vec::new()),
|
||||||
scanning: false,
|
scanning: false,
|
||||||
unpushed: HashMap::new(),
|
unpushed: HashMap::new(),
|
||||||
unsynced: HashMap::new(),
|
directory: ProfileDirectory::new(cx),
|
||||||
|
sync_directory: SyncStatusDirectory::new(cx),
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,10 +163,38 @@ impl SidebarPanel {
|
|||||||
self.local_repos = Arc::new(local_repos);
|
self.local_repos = Arc::new(local_repos);
|
||||||
self.scanning = scanning;
|
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 {
|
if announcements_changed {
|
||||||
self.request_push_watches(cx);
|
self.request_push_watches(cx);
|
||||||
self.unpushed.clear();
|
self.unpushed.clear();
|
||||||
self.unsynced.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
announcements_changed || local_changed || scanning_changed
|
announcements_changed || local_changed || scanning_changed
|
||||||
@@ -195,26 +220,6 @@ impl SidebarPanel {
|
|||||||
true
|
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>) {
|
fn request_push_watches(&self, cx: &mut Context<Self>) {
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
checkouts.update(cx, |checkouts, cx| {
|
checkouts.update(cx, |checkouts, cx| {
|
||||||
@@ -509,14 +514,16 @@ impl SidebarPanel {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> impl IntoElement {
|
) -> impl IntoElement {
|
||||||
let name = announcement.name().map(SharedString::from);
|
let name = announcement.name().map(SharedString::from);
|
||||||
// The sidebar rows are compact, so the avatar matches the 16px nav icons.
|
let owner = self.directory.read(cx).profile(&announcement.owner, cx);
|
||||||
let avatar =
|
let avatar = Avatar::new(announcement.owner)
|
||||||
PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall();
|
.name(owner.name())
|
||||||
let announcement = announcement.clone();
|
.picture(owner.picture())
|
||||||
|
.xsmall();
|
||||||
|
|
||||||
|
let announcement = announcement.clone();
|
||||||
let addr = announcement.addr();
|
let addr = announcement.addr();
|
||||||
let unpushed = self.unpushed.get(&addr).copied().unwrap_or(0);
|
let unpushed = self.unpushed.get(&addr).copied().unwrap_or(0);
|
||||||
let unsynced = self.unsynced.get(&addr).copied().unwrap_or(0);
|
let unsynced = self.sync_directory.read(cx).unsynced(&addr, cx);
|
||||||
|
|
||||||
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
|
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
|
||||||
|
|
||||||
@@ -569,7 +576,7 @@ impl SidebarPanel {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> impl IntoElement {
|
) -> impl IntoElement {
|
||||||
let name = entry.name();
|
let name = entry.name();
|
||||||
let avatar = local_avatar(entry, cx);
|
let avatar = local_avatar(entry, &self.directory, cx);
|
||||||
|
|
||||||
let id = format!("local-repo:{}", entry.path.display());
|
let id = format!("local-repo:{}", entry.path.display());
|
||||||
let entry = entry.clone();
|
let entry = entry.clone();
|
||||||
@@ -636,7 +643,11 @@ impl SidebarPanel {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(Avatar::new(name.clone()).picture(picture))
|
.child(
|
||||||
|
Avatar::new(profile.public_key())
|
||||||
|
.name(name.clone())
|
||||||
|
.picture(picture),
|
||||||
|
)
|
||||||
.child(div().text_xs().font_semibold().child(name)),
|
.child(div().text_xs().font_semibold().child(name)),
|
||||||
)
|
)
|
||||||
.dropdown_menu(move |menu, _, _| {
|
.dropdown_menu(move |menu, _, _| {
|
||||||
@@ -765,28 +776,24 @@ pub(super) fn server_host(relay: &RelayUrl) -> SharedString {
|
|||||||
.unwrap_or_else(|| SharedString::from(relay.to_string()))
|
.unwrap_or_else(|| SharedString::from(relay.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The repository's pixel avatar, with the bound owner's avatar at its bottom right.
|
/// The bound owner's avatar, falling back to the repo's pixel avatar when unowned.
|
||||||
fn local_avatar(entry: &ResolvedLocalRepo, cx: &App) -> AnyElement {
|
fn local_avatar(
|
||||||
// Matches the other sidebar rows, and the badge below sits at its corner.
|
entry: &ResolvedLocalRepo,
|
||||||
let avatar = PixelAvatar::new(entry.path.to_string_lossy()).xsmall();
|
directory: &Entity<ProfileDirectory>,
|
||||||
|
cx: &App,
|
||||||
|
) -> AnyElement {
|
||||||
let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else {
|
let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else {
|
||||||
return avatar.into_any_element();
|
return Avatar::new(entry.path.to_string_lossy())
|
||||||
|
.xsmall()
|
||||||
|
.into_any_element();
|
||||||
};
|
};
|
||||||
|
|
||||||
let store = ProfileStore::global(cx);
|
let profile = directory.read(cx).profile(&owner, cx);
|
||||||
let profile = store.read(cx).get(&owner);
|
|
||||||
|
|
||||||
div()
|
Avatar::new(owner)
|
||||||
.relative()
|
.name(profile.name())
|
||||||
.child(avatar)
|
.picture(profile.picture())
|
||||||
.child(
|
.xsmall()
|
||||||
div().absolute().bottom_neg_0p5().right_neg_0p5().child(
|
|
||||||
Avatar::new(profile.name())
|
|
||||||
.picture(profile.picture())
|
|
||||||
.size(px(14.)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,12 +834,11 @@ impl Focusable for SidebarPanel {
|
|||||||
impl Render for SidebarPanel {
|
impl Render for SidebarPanel {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let profile_store = ProfileStore::global(cx);
|
|
||||||
|
|
||||||
let profile = backend
|
let profile = backend
|
||||||
.read(cx)
|
.read(cx)
|
||||||
.current_user()
|
.current_user()
|
||||||
.map(|public_key| profile_store.read(cx).get(&public_key));
|
.map(|public_key| self.directory.read(cx).profile(&public_key, cx));
|
||||||
|
|
||||||
if profile.is_none() {
|
if profile.is_none() {
|
||||||
return self.render_sign_in(window, cx);
|
return self.render_sign_in(window, cx);
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
use std::cell::Cell;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{
|
use gpui::{App, Entity, PathPromptOptions, SharedString, Subscription, Window, div, px};
|
||||||
AnyWindowHandle, App, Entity, PathPromptOptions, SharedString, Subscription, Window, div, px,
|
|
||||||
};
|
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::input::{
|
use gpui_component::input::{Input, InputState};
|
||||||
Input, InputEvent, InputState, NumberInput, NumberInputEvent, StepAction,
|
|
||||||
};
|
|
||||||
use gpui_component::select::{Select, SelectEvent, SelectState};
|
use gpui_component::select::{Select, SelectEvent, SelectState};
|
||||||
use gpui_component::separator::Separator;
|
use gpui_component::separator::Separator;
|
||||||
use gpui_component::setting::NumberFieldOptions;
|
|
||||||
use gpui_component::switch::Switch;
|
use gpui_component::switch::Switch;
|
||||||
use gpui_component::{
|
use gpui_component::{
|
||||||
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex,
|
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, WindowExt, h_flex, v_flex,
|
||||||
v_flex,
|
|
||||||
};
|
};
|
||||||
use settings::{AppearanceMode, EventFetchingStrategy, Settings, SettingsStore};
|
use settings::{AppearanceMode, EventFetchingStrategy, Settings, SettingsStore};
|
||||||
use signed_ui::{SelectOption, setting_block, setting_row};
|
use signed_ui::{SelectOption, setting_block, setting_row};
|
||||||
@@ -31,34 +24,10 @@ fn selected_index(options: &[SelectOption], value: &str) -> Option<IndexPath> {
|
|||||||
.map(|row| IndexPath::default().row(row))
|
.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.
|
/// Created once when the dialog opens, so control state survives re-renders.
|
||||||
struct SettingsControls {
|
struct SettingsControls {
|
||||||
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
event_fetching: 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>,
|
grasp_server_input: Entity<InputState>,
|
||||||
/// The effective create-repository folder, shown in a disabled input.
|
/// The effective create-repository folder, shown in a disabled input.
|
||||||
default_folder: Entity<InputState>,
|
default_folder: Entity<InputState>,
|
||||||
@@ -107,38 +76,9 @@ impl SettingsControls {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
let (light_options, dark_options) = theme_options(cx);
|
|
||||||
let light_theme = cx.new(|cx| {
|
|
||||||
SelectState::new(
|
|
||||||
light_options.clone(),
|
|
||||||
selected_index(&light_options, &settings.theme.light_theme),
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let dark_theme = cx.new(|cx| {
|
|
||||||
SelectState::new(
|
|
||||||
dark_options.clone(),
|
|
||||||
selected_index(&dark_options, &settings.theme.dark_theme),
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
let font_size = cx.new(|cx| {
|
|
||||||
InputState::new(window, cx).default_value(settings.theme.font_size.to_string())
|
|
||||||
});
|
|
||||||
let mono_font_size = cx.new(|cx| {
|
|
||||||
InputState::new(window, cx).default_value(settings.theme.mono_font_size.to_string())
|
|
||||||
});
|
|
||||||
let radius = cx
|
|
||||||
.new(|cx| InputState::new(window, cx).default_value(settings.theme.radius.to_string()));
|
|
||||||
let radius_lg = cx.new(|cx| {
|
|
||||||
InputState::new(window, cx).default_value(settings.theme.radius_lg.to_string())
|
|
||||||
});
|
|
||||||
|
|
||||||
let grasp_server_input =
|
let grasp_server_input =
|
||||||
cx.new(|cx| InputState::new(window, cx).placeholder("wss://relay.example.com"));
|
cx.new(|cx| InputState::new(window, cx).placeholder("wss://relay.example.com"));
|
||||||
|
|
||||||
let default_folder = cx.new(|cx| {
|
let default_folder = cx.new(|cx| {
|
||||||
let folder = settings
|
let folder = settings
|
||||||
.create_repository
|
.create_repository
|
||||||
@@ -178,118 +118,9 @@ impl SettingsControls {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
subscriptions.push(cx.subscribe(&light_theme, |_, event, cx| {
|
|
||||||
if let SelectEvent::Confirm(Some(value)) = event {
|
|
||||||
let store = SettingsStore::global(cx);
|
|
||||||
store.update(cx, |store, cx| {
|
|
||||||
store.edit(
|
|
||||||
|settings| settings.theme.light_theme = value.to_string(),
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
apply_theme(cx);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
subscriptions.push(cx.subscribe(&dark_theme, |_, event, cx| {
|
|
||||||
if let SelectEvent::Confirm(Some(value)) = event {
|
|
||||||
let store = SettingsStore::global(cx);
|
|
||||||
store.update(cx, |store, cx| {
|
|
||||||
store.edit(|settings| settings.theme.dark_theme = value.to_string(), cx);
|
|
||||||
});
|
|
||||||
apply_theme(cx);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
wire_number_input(
|
|
||||||
&font_size,
|
|
||||||
&mut subscriptions,
|
|
||||||
window.window_handle(),
|
|
||||||
cx,
|
|
||||||
NumberFieldOptions {
|
|
||||||
min: 10.,
|
|
||||||
max: 32.,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
settings.theme.font_size as f64,
|
|
||||||
|value, cx| {
|
|
||||||
let store = SettingsStore::global(cx);
|
|
||||||
store.update(cx, |store, cx| {
|
|
||||||
store.edit(|settings| settings.theme.font_size = value as f32, cx);
|
|
||||||
});
|
|
||||||
apply_theme(cx);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
wire_number_input(
|
|
||||||
&mono_font_size,
|
|
||||||
&mut subscriptions,
|
|
||||||
window.window_handle(),
|
|
||||||
cx,
|
|
||||||
NumberFieldOptions {
|
|
||||||
min: 8.,
|
|
||||||
max: 32.,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
settings.theme.mono_font_size as f64,
|
|
||||||
|value, cx| {
|
|
||||||
let store = SettingsStore::global(cx);
|
|
||||||
store.update(cx, |store, cx| {
|
|
||||||
store.edit(|settings| settings.theme.mono_font_size = value as f32, cx);
|
|
||||||
});
|
|
||||||
apply_theme(cx);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
wire_number_input(
|
|
||||||
&radius,
|
|
||||||
&mut subscriptions,
|
|
||||||
window.window_handle(),
|
|
||||||
cx,
|
|
||||||
NumberFieldOptions {
|
|
||||||
min: 0.,
|
|
||||||
max: 24.,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
settings.theme.radius as f64,
|
|
||||||
|value, cx| {
|
|
||||||
let store = SettingsStore::global(cx);
|
|
||||||
store.update(cx, |store, cx| {
|
|
||||||
store.edit(|settings| settings.theme.radius = value as f32, cx);
|
|
||||||
});
|
|
||||||
apply_theme(cx);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
wire_number_input(
|
|
||||||
&radius_lg,
|
|
||||||
&mut subscriptions,
|
|
||||||
window.window_handle(),
|
|
||||||
cx,
|
|
||||||
NumberFieldOptions {
|
|
||||||
min: 0.,
|
|
||||||
max: 24.,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
settings.theme.radius_lg as f64,
|
|
||||||
|value, cx| {
|
|
||||||
let store = SettingsStore::global(cx);
|
|
||||||
store.update(cx, |store, cx| {
|
|
||||||
store.edit(|settings| settings.theme.radius_lg = value as f32, cx);
|
|
||||||
});
|
|
||||||
apply_theme(cx);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
appearance,
|
appearance,
|
||||||
event_fetching,
|
event_fetching,
|
||||||
light_theme,
|
|
||||||
dark_theme,
|
|
||||||
font_size,
|
|
||||||
mono_font_size,
|
|
||||||
radius,
|
|
||||||
radius_lg,
|
|
||||||
grasp_server_input,
|
grasp_server_input,
|
||||||
default_folder,
|
default_folder,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
@@ -320,8 +151,6 @@ fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement
|
|||||||
.w_full()
|
.w_full()
|
||||||
.child(appearance_section(controls, cx))
|
.child(appearance_section(controls, cx))
|
||||||
.child(Separator::horizontal())
|
.child(Separator::horizontal())
|
||||||
.child(theme_section(&settings, controls, cx))
|
|
||||||
.child(Separator::horizontal())
|
|
||||||
.child(tab_bar_section(&settings, cx))
|
.child(tab_bar_section(&settings, cx))
|
||||||
.child(Separator::horizontal())
|
.child(Separator::horizontal())
|
||||||
.child(grasp_servers_section(&settings, controls, cx))
|
.child(grasp_servers_section(&settings, controls, cx))
|
||||||
@@ -350,76 +179,6 @@ fn event_fetching_section(controls: &SettingsControls, cx: &App) -> impl IntoEle
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
|
||||||
v_flex()
|
|
||||||
.gap_3()
|
|
||||||
.w_full()
|
|
||||||
.child(setting_row(
|
|
||||||
cx,
|
|
||||||
"Light Theme",
|
|
||||||
"The theme to use when the appearance is light.",
|
|
||||||
Select::new(&controls.light_theme).w_full(),
|
|
||||||
))
|
|
||||||
.child(setting_row(
|
|
||||||
cx,
|
|
||||||
"Dark Theme",
|
|
||||||
"The theme to use when the appearance is dark.",
|
|
||||||
Select::new(&controls.dark_theme).w_full(),
|
|
||||||
))
|
|
||||||
.child(setting_row(
|
|
||||||
cx,
|
|
||||||
"UI Font Size",
|
|
||||||
"Font size for the UI.",
|
|
||||||
NumberInput::new(&controls.font_size).w_full(),
|
|
||||||
))
|
|
||||||
.child(setting_row(
|
|
||||||
cx,
|
|
||||||
"Editor Font Size",
|
|
||||||
"Font size for editor text.",
|
|
||||||
NumberInput::new(&controls.mono_font_size).w_full(),
|
|
||||||
))
|
|
||||||
.child(setting_row(
|
|
||||||
cx,
|
|
||||||
"Corner Radius",
|
|
||||||
"The corner radius for UI elements.",
|
|
||||||
NumberInput::new(&controls.radius).w_full(),
|
|
||||||
))
|
|
||||||
.child(setting_row(
|
|
||||||
cx,
|
|
||||||
"Large Corner Radius",
|
|
||||||
"The corner radius for large UI elements (dialogs, notifications).",
|
|
||||||
NumberInput::new(&controls.radius_lg).w_full(),
|
|
||||||
))
|
|
||||||
.child(setting_row(
|
|
||||||
cx,
|
|
||||||
"Focus Ring",
|
|
||||||
"Draw a ring around focused controls.",
|
|
||||||
Switch::new("focus-ring")
|
|
||||||
.checked(settings.theme.focus_ring)
|
|
||||||
.on_click(move |checked: &bool, _window, cx| {
|
|
||||||
let store = SettingsStore::global(cx);
|
|
||||||
store.update(cx, |store, cx| {
|
|
||||||
store.edit(|settings| settings.theme.focus_ring = *checked, cx);
|
|
||||||
});
|
|
||||||
apply_theme(cx);
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
.child(setting_row(
|
|
||||||
cx,
|
|
||||||
"Shadows",
|
|
||||||
"The shadow effect for UI elements.",
|
|
||||||
Switch::new("shadows")
|
|
||||||
.checked(settings.theme.shadow)
|
|
||||||
.on_click(move |checked: &bool, _window, cx| {
|
|
||||||
let store = SettingsStore::global(cx);
|
|
||||||
store.update(cx, |store, cx| {
|
|
||||||
store.edit(|settings| settings.theme.shadow = *checked, cx);
|
|
||||||
});
|
|
||||||
apply_theme(cx);
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tab bar chrome, such as its next/previous tab buttons.
|
/// Tab bar chrome, such as its next/previous tab buttons.
|
||||||
fn tab_bar_section(settings: &Settings, cx: &App) -> impl IntoElement {
|
fn tab_bar_section(settings: &Settings, cx: &App) -> impl IntoElement {
|
||||||
v_flex().w_full().gap_3().child(setting_row(
|
v_flex().w_full().gap_3().child(setting_row(
|
||||||
@@ -724,79 +483,6 @@ fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Windo
|
|||||||
.detach();
|
.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) {
|
fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
|
||||||
match appearance {
|
match appearance {
|
||||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||||
@@ -804,33 +490,3 @@ fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
|
|||||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_theme(cx: &mut App) {
|
|
||||||
let store = SettingsStore::global(cx);
|
|
||||||
let settings = store.read(cx).settings().theme.clone();
|
|
||||||
|
|
||||||
let registry = ThemeRegistry::global(cx);
|
|
||||||
let light_config = registry
|
|
||||||
.themes()
|
|
||||||
.get(settings.light_theme.as_str())
|
|
||||||
.cloned();
|
|
||||||
let dark_config = registry.themes().get(settings.dark_theme.as_str()).cloned();
|
|
||||||
let mode = Theme::global(cx).mode;
|
|
||||||
|
|
||||||
let theme = Theme::global_mut(cx);
|
|
||||||
theme.radius = px(settings.radius);
|
|
||||||
theme.radius_lg = px(settings.radius_lg);
|
|
||||||
theme.focus_ring = settings.focus_ring;
|
|
||||||
theme.shadow = settings.shadow;
|
|
||||||
theme.font_size = px(settings.font_size);
|
|
||||||
theme.mono_font_size = px(settings.mono_font_size);
|
|
||||||
if let Some(config) = light_config {
|
|
||||||
theme.light_theme = config;
|
|
||||||
}
|
|
||||||
if let Some(config) = dark_config {
|
|
||||||
theme.dark_theme = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-apply the active mode so the updated configs take effect.
|
|
||||||
Theme::change(mode, None, cx);
|
|
||||||
}
|
|
||||||
|
|||||||
+5
-1
@@ -14,7 +14,7 @@ product-name = "Signed"
|
|||||||
description = "Nostr Git client for browsing repositories and collaborating"
|
description = "Nostr Git client for browsing repositories and collaborating"
|
||||||
identifier = "info.reya.signed"
|
identifier = "info.reya.signed"
|
||||||
category = "DeveloperTool"
|
category = "DeveloperTool"
|
||||||
version = "0.2.0-alpha"
|
version = "0.2.0"
|
||||||
out-dir = "../dist"
|
out-dir = "../dist"
|
||||||
before-packaging-command = "cargo build --release"
|
before-packaging-command = "cargo build --release"
|
||||||
resources = ["Cargo.toml", "src"]
|
resources = ["Cargo.toml", "src"]
|
||||||
@@ -28,6 +28,7 @@ icons = [
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
assets = { path = "../crates/assets" }
|
assets = { path = "../crates/assets" }
|
||||||
|
metadata = { path = "../crates/metadata" }
|
||||||
paths = { path = "../crates/paths" }
|
paths = { path = "../crates/paths" }
|
||||||
settings = { path = "../crates/settings" }
|
settings = { path = "../crates/settings" }
|
||||||
signed_state = { path = "../crates/signed_state" }
|
signed_state = { path = "../crates/signed_state" }
|
||||||
@@ -37,6 +38,9 @@ gpui.workspace = true
|
|||||||
gpui_platform.workspace = true
|
gpui_platform.workspace = true
|
||||||
dock = { workspace = true }
|
dock = { workspace = true }
|
||||||
gpui-component.workspace = true
|
gpui-component.workspace = true
|
||||||
|
native-theme.workspace = true
|
||||||
|
native-theme-gpui.workspace = true
|
||||||
|
flume.workspace = true
|
||||||
reqwest_client.workspace = true
|
reqwest_client.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|||||||
+110
-41
@@ -5,7 +5,9 @@ use dock::TAB_BAR_HEIGHT;
|
|||||||
use gpui::*;
|
use gpui::*;
|
||||||
use gpui_component::{Theme, ThemeMode, ThemeRegistry, theme};
|
use gpui_component::{Theme, ThemeMode, ThemeRegistry, theme};
|
||||||
use gpui_platform::application;
|
use gpui_platform::application;
|
||||||
use settings::{AppearanceMode, SettingsStore};
|
use native_theme::watch::ThemeSubscription;
|
||||||
|
use native_theme_gpui::{SystemTheme, apply_system_theme};
|
||||||
|
use settings::{AppearanceMode, Settings, SettingsStore};
|
||||||
|
|
||||||
actions!(signed, [Quit]);
|
actions!(signed, [Quit]);
|
||||||
|
|
||||||
@@ -25,63 +27,71 @@ fn main() {
|
|||||||
// The persisted settings must load before the theme is applied.
|
// The persisted settings must load before the theme is applied.
|
||||||
let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx));
|
let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx));
|
||||||
SettingsStore::set_global(store.clone(), cx);
|
SettingsStore::set_global(store.clone(), cx);
|
||||||
let settings = store.read(cx).settings().clone();
|
|
||||||
|
|
||||||
let registry = ThemeRegistry::global_mut(cx);
|
let settings = store.read(cx).settings().clone();
|
||||||
for (name, content) in Assets.themes() {
|
{
|
||||||
if let Err(err) = registry.load_themes_from_str(&content) {
|
let registry = ThemeRegistry::global_mut(cx);
|
||||||
tracing::error!("Failed to load theme {name}: {err}");
|
for (name, content) in Assets.themes() {
|
||||||
|
if let Err(err) = registry.load_themes_from_str(&content) {
|
||||||
|
tracing::error!("Failed to load theme {name}: {err}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let light_theme = registry
|
|
||||||
.themes()
|
|
||||||
.get(settings.theme.light_theme.as_str())
|
|
||||||
.cloned();
|
|
||||||
let dark_theme = registry
|
|
||||||
.themes()
|
|
||||||
.get(settings.theme.dark_theme.as_str())
|
|
||||||
.cloned();
|
|
||||||
|
|
||||||
let theme = Theme::global_mut(cx);
|
match apply_native_theme(&settings, cx) {
|
||||||
theme.radius = px(settings.theme.radius);
|
Ok(()) => start_theme_watcher(cx),
|
||||||
theme.radius_lg = px(settings.theme.radius_lg);
|
Err(error) => {
|
||||||
theme.focus_ring = settings.theme.focus_ring;
|
tracing::warn!("native theme unavailable ({error})");
|
||||||
theme.shadow = settings.theme.shadow;
|
|
||||||
theme.font_size = px(settings.theme.font_size);
|
|
||||||
theme.mono_font_size = px(settings.theme.mono_font_size);
|
|
||||||
|
|
||||||
if let Some(light) = light_theme {
|
let registry = ThemeRegistry::global_mut(cx);
|
||||||
theme.light_theme = light;
|
let light_theme = registry
|
||||||
} else {
|
.themes()
|
||||||
tracing::warn!(
|
.get(settings.theme.light_theme.as_str())
|
||||||
"{} theme is missing from the registry",
|
.cloned();
|
||||||
settings.theme.light_theme
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(dark) = dark_theme {
|
let dark_theme = registry
|
||||||
theme.dark_theme = dark;
|
.themes()
|
||||||
} else {
|
.get(settings.theme.dark_theme.as_str())
|
||||||
tracing::warn!(
|
.cloned();
|
||||||
"{} theme is missing from the registry",
|
|
||||||
settings.theme.dark_theme
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
match settings.appearance {
|
let theme = Theme::global_mut(cx);
|
||||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
theme.radius = px(settings.theme.radius);
|
||||||
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
theme.radius_lg = px(settings.theme.radius_lg);
|
||||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
theme.focus_ring = settings.theme.focus_ring;
|
||||||
|
theme.shadow = settings.theme.shadow;
|
||||||
|
theme.font_size = px(settings.theme.font_size);
|
||||||
|
theme.mono_font_size = px(settings.theme.mono_font_size);
|
||||||
|
|
||||||
|
if let Some(light) = light_theme {
|
||||||
|
theme.light_theme = light;
|
||||||
|
} else {
|
||||||
|
tracing::warn!("{} theme is missing", settings.theme.light_theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(dark) = dark_theme {
|
||||||
|
theme.dark_theme = dark;
|
||||||
|
} else {
|
||||||
|
tracing::warn!("{} theme is missing", settings.theme.dark_theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
match settings.appearance {
|
||||||
|
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||||
|
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
||||||
|
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::fs::create_dir_all(paths::nostr_dir()).ok();
|
std::fs::create_dir_all(paths::nostr_dir()).ok();
|
||||||
std::fs::create_dir_all(paths::repos_dir()).ok();
|
std::fs::create_dir_all(paths::repos_dir()).ok();
|
||||||
|
|
||||||
signed_state::init(
|
signed_state::init(
|
||||||
paths::nostr_dir(),
|
paths::nostr_dir(),
|
||||||
paths::repos_dir().clone(),
|
paths::repos_dir().clone(),
|
||||||
settings.local_repos.scan_paths,
|
settings.local_repos.scan_paths,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
metadata::init(cx);
|
||||||
|
|
||||||
cx.set_app_identity("info.reya.signed", "Signed");
|
cx.set_app_identity("info.reya.signed", "Signed");
|
||||||
|
|
||||||
@@ -114,3 +124,62 @@ fn main() {
|
|||||||
cx.activate(true);
|
cx.activate(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply the OS theme to the app, scaling the font size to match the system font size.
|
||||||
|
fn apply_native_theme(settings: &Settings, cx: &mut App) -> native_theme::Result<()> {
|
||||||
|
let mut system = SystemTheme::from_system()?;
|
||||||
|
let native = system.light.defaults.font.size;
|
||||||
|
if native > 0.0 {
|
||||||
|
let scaling = settings.theme.font_size / native;
|
||||||
|
system.accessibility.text_scaling_factor *= scaling;
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_system_theme(&system, cx);
|
||||||
|
|
||||||
|
match settings.appearance {
|
||||||
|
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||||
|
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
||||||
|
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds the watcher subscription so it lives as long as the app.
|
||||||
|
struct ThemeWatcher {
|
||||||
|
_subscription: ThemeSubscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Global for ThemeWatcher {}
|
||||||
|
|
||||||
|
/// Re-apply the OS theme whenever it changes at runtime.
|
||||||
|
fn start_theme_watcher(cx: &mut App) {
|
||||||
|
let (sender, receiver) = flume::unbounded();
|
||||||
|
match native_theme::watch::on_theme_change(move |event| {
|
||||||
|
if let Err(error) = sender.send(event) {
|
||||||
|
tracing::warn!("theme change dropped ({error})");
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Ok(subscription) => {
|
||||||
|
cx.set_global(ThemeWatcher {
|
||||||
|
_subscription: subscription,
|
||||||
|
});
|
||||||
|
cx.spawn(async move |cx| {
|
||||||
|
while receiver.recv_async().await.is_ok() {
|
||||||
|
tracing::info!("OS theme changed; re-applying");
|
||||||
|
cx.update(|cx| {
|
||||||
|
let store = SettingsStore::global(cx);
|
||||||
|
let settings = store.read(cx).settings().clone();
|
||||||
|
if let Err(error) = apply_native_theme(&settings, cx) {
|
||||||
|
tracing::warn!("failed to apply updated OS theme ({error})");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("OS theme watching unavailable ({error})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
### Milestone 1: release the first out-of-box experience version
|
||||||
|
Alpha version has been released, I'm working toward the first stable release for the desktop version, which will include a fully git workflow, repository management and collaboration. The stable version will maximum compatible with other nostr git (grasp) client
|
||||||
|
|
||||||
|
### Milestone 2: release the web version and mobile apps
|
||||||
|
Release the web for browsing and repository management, full git workflow and collaboration still require the desktop app. About the mobile apps, it not only a hub for repositories but also control the agents which will be add later
|
||||||
|
|
||||||
|
### Milestone 3: social coding and web of trust
|
||||||
|
Implement social layer, each repository can become a community (NIP-29), sharing patch snippets or napplet (NIP-5D) like sharing posts. The web of trust layer will help me user focus on works and support from their circle, provide more information to help user make decision, prevent bad actor can affect the software or supply chain.
|
||||||
|
|
||||||
|
### Milestone 4: agent collaboration and workspace
|
||||||
|
Support users to plug their coding agents to resolve or review issues, patches, and pull requests. Workspace is a specific environment, allowing users to try new ideas, test a patch in a parallel branch without affecting the main tree.
|
||||||
|
|
||||||
|
### Milestone 5: private repository and team collaboration
|
||||||
|
Support multiple teammates, channel and communication. Automate turning the discussion into plans and pull requests without hopping between multiple tools.
|
||||||
Reference in New Issue
Block a user