Compare commits
8
Commits
cd61a09320
...
redesign
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2124b00590 | ||
|
|
a249174026 | ||
|
|
4f81501668 | ||
|
|
d6850d60ea | ||
|
|
4b4b8b5cf2 | ||
|
|
a2e7907193 | ||
|
|
946128433b | ||
|
|
30ff255372 |
@@ -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 {}`.
|
||||||
|
|||||||
+10
-5
@@ -1,11 +1,10 @@
|
|||||||
# 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
|
||||||
@@ -13,9 +12,11 @@
|
|||||||
- 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 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
|
||||||
@@ -26,19 +27,23 @@
|
|||||||
- 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
|
- 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
|
- 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
|
- 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 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 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" }
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +65,7 @@ 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();
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -94,8 +129,7 @@ impl Render for IssueDetailView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (title, author, picture, status, age, issue_id, content, pubkey) = {
|
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 {
|
||||||
@@ -154,7 +188,11 @@ impl Render for IssueDetailView {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(Avatar::new(pubkey).name(author.clone()).picture(picture))
|
.child(
|
||||||
|
Avatar::new(pubkey)
|
||||||
|
.name(author.clone())
|
||||||
|
.picture(picture),
|
||||||
|
)
|
||||||
.child(author),
|
.child(author),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
@@ -170,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,
|
||||||
@@ -186,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);
|
||||||
@@ -339,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)
|
||||||
|
|||||||
@@ -15,15 +15,16 @@ pub(crate) mod tree;
|
|||||||
use std::fmt::Display;
|
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;
|
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 {
|
||||||
@@ -38,7 +39,11 @@ pub(crate) fn panel_avatar(seed: impl Display) -> AnyElement {
|
|||||||
Avatar::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()
|
||||||
@@ -55,7 +60,7 @@ 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));
|
||||||
|
|
||||||
let (name, picture) = owner
|
let (name, picture) = owner
|
||||||
.map(|profile| (Some(profile.name()), profile.picture()))
|
.map(|profile| (Some(profile.name()), profile.picture()))
|
||||||
|
|||||||
@@ -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 = {
|
||||||
@@ -416,7 +440,7 @@ 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(),
|
||||||
@@ -469,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,
|
||||||
@@ -480,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,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();
|
||||||
|
|
||||||
@@ -339,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();
|
||||||
|
|
||||||
|
|||||||
@@ -15,19 +15,21 @@ 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, copy_row, menu_copy_row, ref_selector_trigger,
|
Avatar, CountBadge, DropdownButton, copy_row, menu_copy_row, ref_selector_trigger,
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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,9 +1601,11 @@ impl RepoDetailView {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(Avatar::new(announcement.owner)
|
.child(
|
||||||
.name(owner_name.clone())
|
Avatar::new(announcement.owner)
|
||||||
.picture(owner_picture))
|
.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| {
|
||||||
@@ -1561,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)
|
||||||
@@ -1993,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
|
||||||
@@ -2171,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()
|
||||||
@@ -2216,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
|
||||||
|
|||||||
@@ -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,11 +20,12 @@ 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, title_bar_drag_handlers};
|
use signed_ui::{Avatar, NavItem, title_bar_drag_handlers};
|
||||||
|
|
||||||
@@ -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,13 +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 = Avatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall();
|
let avatar = Avatar::new(announcement.owner)
|
||||||
let announcement = announcement.clone();
|
.name(owner.name())
|
||||||
|
.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);
|
||||||
|
|
||||||
@@ -568,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();
|
||||||
@@ -768,29 +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 = Avatar::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(owner)
|
|
||||||
.name(profile.name())
|
|
||||||
.picture(profile.picture())
|
|
||||||
.size(px(14.)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,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})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user