3 Commits
Author SHA1 Message Date
reya 05221b5af9 Replace global ProfileStore with per-view ProfileDirectory instances
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-15 14:19:18 +07:00
reya 89836a0394 Add per-panel ProfileDirectory over batched ProfileStore 2026-10-15 13:49:59 +07:00
reya 60d8eef340 update plan 2026-10-15 13:12:44 +07:00
34 changed files with 1153 additions and 1602 deletions
+5 -10
View File
@@ -1,10 +1,11 @@
# Changelog # Changelog
## v0.2.0 - 2026/10/21 ## Unreleased
### 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
@@ -12,11 +13,9 @@
- 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
@@ -27,23 +26,19 @@
- 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
+213 -461
View File
File diff suppressed because it is too large Load Diff
+4 -8
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop"]
default-members = ["desktop"] default-members = ["desktop"]
[workspace.package] [workspace.package]
version = "0.2.0" version = "0.2.0-alpha"
edition = "2024" edition = "2024"
publish = false publish = false
@@ -13,13 +13,9 @@ gpui = { package = "gpui-pre", version = "0.3.8" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] } 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; exact pins keep the graph on the gpui-pre 0.3.8 closure that # GPUI Kit
# native-theme-gpui 0.6.1 is verified against (0.7.3 and 0.8 pin 0.3.9). gpui-component = { version = "0.7.1", features = ["tree-sitter-languages"] }
gpui-component = { version = "=0.7.1", features = ["tree-sitter-languages"] } gpui-base = "0.7.1"
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" }
-16
View File
@@ -1,16 +0,0 @@
[package]
name = "metadata"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
signed_state = { path = "../signed_state" }
utils = { path = "../utils" }
nostr-sdk.workspace = true
flume.workspace = true
futures.workspace = true
anyhow.workspace = true
gpui.workspace = true
log.workspace = true
-9
View File
@@ -1,9 +0,0 @@
mod profile;
use gpui::{App, AppContext};
pub use profile::{Profile, ProfileDirectory, ProfileEvent, ProfileStore};
/// Initialize the global profile store.
pub fn init(cx: &mut App) {
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
}
+5 -31
View File
@@ -1,8 +1,6 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use anyhow::Result; use anyhow::{Context, Result};
use signed_core::{Announcement, RepoAddr}; use signed_core::{Announcement, RepoAddr};
use crate::repo::Repo; use crate::repo::Repo;
@@ -10,16 +8,12 @@ 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 { Self { root }
root,
clone_locks: Arc::default(),
}
} }
/// Returns the cache root directory. /// Returns the cache root directory.
@@ -44,40 +38,20 @@ impl GitCache {
} }
} }
/// Returns the clone lock for `path`, creating it on first use.
fn clone_lock(&self, path: &Path) -> Arc<Mutex<()>> {
let mut locks = self
.clone_locks
.lock()
.unwrap_or_else(PoisonError::into_inner);
Arc::clone(locks.entry(path.to_path_buf()).or_default())
}
/// Returns the cached repository, cloning from one of `clone_urls` on first use. /// 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);
// Lock the clone lock for this path to prevent concurrent clones. if let Some(repo) = self.open(addr)? {
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)
+3 -33
View File
@@ -1222,25 +1222,22 @@ 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`.
pub fn push_refspecs( fn push_refspecs(
&self, &self,
base_url: &str, base_url: &str,
owner: &str, owner: &str,
@@ -1265,33 +1262,6 @@ 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() {
-227
View File
@@ -1,9 +1,6 @@
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]
@@ -14,70 +11,6 @@ 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")]);
@@ -122,166 +55,6 @@ 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")]);
+19 -33
View File
@@ -1,7 +1,6 @@
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};
@@ -22,9 +21,11 @@ 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, GitPushPolicy, GitRef, PushStepStatus, SyncState}; use crate::state::{GitPushMode, 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)]
@@ -198,9 +199,13 @@ 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 signer = NostrConnect::builder(uri, keys) let mut signer = NostrConnect::new(
.auth_url_handler(Arc::new(SignedAuthUrlHandler)) uri,
.build()?; keys,
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);
@@ -361,9 +366,13 @@ 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 signer = NostrConnect::builder(connect_uri, keys) let mut signer = NostrConnect::new(
.auth_url_handler(Arc::new(SignedAuthUrlHandler)) connect_uri,
.build()?; keys,
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?;
@@ -579,7 +588,6 @@ 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| {
@@ -727,17 +735,7 @@ 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, GitPushPolicy::Reconcile, cx) self.push_repo_from(announcement, path, None, 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(
@@ -747,13 +745,7 @@ 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( self.push_repo_from(announcement, checkout, announced_head, cx)
announcement,
checkout,
announced_head,
GitPushPolicy::Reconcile,
cx,
)
} }
fn push_repo_from( fn push_repo_from(
@@ -761,7 +753,6 @@ 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();
@@ -798,11 +789,6 @@ 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
+1 -1
View File
@@ -46,7 +46,7 @@ pub(crate) async fn subscribe_bootstrap_only(
Ok(()) Ok(())
} }
pub async fn sync_bootstrap_only( pub(crate) async fn sync_bootstrap_only(
client: &Client, client: &Client,
filter: Filter, filter: Filter,
opts: SyncOptions, opts: SyncOptions,
+6 -3
View File
@@ -5,6 +5,7 @@ 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;
@@ -15,21 +16,22 @@ 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::{sync_bootstrap_only, user_grasp_list_servers}; pub use bootstrap::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, ProfileDirectory, ProfileEvent, 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, GitPushPolicy, GitRef, PushStep, PushStepStatus, SyncState}; pub use state::{GitPushMode, GitRef, PushStep, PushStepStatus, SyncState};
pub use sync_status::{CheckoutSyncStatus, SyncEvent, SyncStatusDirectory, SyncStatusStore}; pub use sync_status::{CheckoutSyncStatus, SyncStatusStore};
pub fn init( pub fn init(
db_path: impl AsRef<Path>, db_path: impl AsRef<Path>,
@@ -49,6 +51,7 @@ 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);
@@ -7,9 +7,11 @@ use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task, App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use signed_state::{Backend, BackendEvent, sync_bootstrap_only};
use utils::shorten_pubkey; use utils::shorten_pubkey;
use crate::backend::{Backend, BackendEvent};
use crate::bootstrap::sync_bootstrap_only;
const BATCH_TIMEOUT: Duration = Duration::from_millis(500); const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
const REQUEST_CHUNK: usize = 100; const REQUEST_CHUNK: usize = 100;
@@ -102,7 +104,7 @@ impl Global for GlobalProfileStore {}
impl EventEmitter<ProfileEvent> for ProfileStore {} impl EventEmitter<ProfileEvent> for ProfileStore {}
impl ProfileStore { impl ProfileStore {
pub(crate) fn global(cx: &App) -> Entity<Self> { pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalProfileStore>().0.clone() cx.global::<GlobalProfileStore>().0.clone()
} }
@@ -166,9 +168,7 @@ impl ProfileStore {
.kind(Kind::Metadata) .kind(Kind::Metadata)
.authors(chunk.iter().copied()); .authors(chunk.iter().copied());
if let Err(e) = sync_bootstrap_only(&client, filter, opts).await { sync_bootstrap_only(&client, filter, opts).await.ok();
log::warn!("profile fetch failed: {e}");
}
} }
// Emit even when the sync failed, the database may hold newer data. // Emit even when the sync failed, the database may hold newer data.
@@ -320,6 +320,7 @@ impl ProfileDirectory {
Ok::<_, anyhow::Error>(Profile::latest_profiles(events)) Ok::<_, anyhow::Error>(Profile::latest_profiles(events))
}); });
self.tasks.retain(|task| !task.is_ready());
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let loaded = work.await; let loaded = work.await;
@@ -364,10 +365,7 @@ impl ProfileDirectory {
} }
} }
fn with_pending<R, T>(&self, then: T) -> R fn with_pending<R>(&self, then: impl FnOnce(&mut HashSet<PublicKey>) -> R) -> R {
where
T: FnOnce(&mut HashSet<PublicKey>) -> R,
{
let mut pending = self.pending.borrow_mut(); let mut pending = self.pending.borrow_mut();
then(&mut pending) then(&mut pending)
} }
+1 -25
View File
@@ -1172,24 +1172,6 @@ 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,
@@ -1244,7 +1226,6 @@ 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| {
@@ -1252,13 +1233,8 @@ impl RepoStore {
}); });
} }
// Re-probe so sync badges reflect the pushed state.
let sync_status = SyncStatusStore::global(cx); let sync_status = SyncStatusStore::global(cx);
let addr = pushed_checkout sync_status.update(cx, |store, cx| store.refresh(cx));
.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}"));
+73 -163
View File
@@ -40,17 +40,6 @@ 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>,
@@ -58,7 +47,6 @@ 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)]
@@ -147,7 +135,6 @@ impl SyncState {
head, head,
path: None, path: None,
mode: GitPushMode::default(), mode: GitPushMode::default(),
policy: GitPushPolicy::default(),
} }
} }
@@ -167,7 +154,6 @@ 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(),
}) })
} }
@@ -176,127 +162,13 @@ 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,
@@ -319,12 +191,6 @@ 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());
@@ -375,24 +241,12 @@ 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 => { GitPushMode::All => repo.push_all(&base, &owner, &repo_id),
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)
}
} }
} }
}); });
@@ -516,19 +370,75 @@ impl SyncState {
} }
} }
/// Refspecs to force push all heads and tags to the grasp servers. #[cfg(test)]
fn force_refspecs() -> Vec<String> { mod tests {
vec![ use super::*;
"+refs/heads/*:refs/heads/*".to_owned(),
"+refs/tags/*:refs/tags/*".to_owned(),
]
}
/// Refspecs to delete server-only refs from the grasp servers. const COMMIT_A: &str = "aa231c4c6a5777dc89b42207b499891a344add5c";
fn deletion_refspecs(declared: &[String], server_refs: &[(String, String)]) -> Vec<String> { const COMMIT_B: &str = "59429cfc6cb35b0a1ddace73b5a5c5ed57b8f5ca";
server_refs
.iter() fn git_ref(name: &str, commit: &str) -> GitRef {
.filter(|(name, _)| !declared.contains(name)) GitRef::new(name, commit).expect("valid ref")
.map(|(name, _)| format!(":{name}")) }
.collect()
fn keys() -> Keys {
Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
.expect("valid secret key"),
)
}
fn state_event(tags: &[&[&str]]) -> Event {
let tags: Vec<Tag> = tags
.iter()
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
.collect();
EventBuilder::new(Kind::RepoState, "")
.tags(tags)
.finalize(&keys())
.expect("signed event")
}
#[test]
fn parses_heads_and_tags() {
let event = state_event(&[
&["HEAD", "ref: refs/heads/main"],
&["refs/heads/main", COMMIT_A],
&["refs/heads/dev", COMMIT_B],
&["refs/tags/v1.0", COMMIT_A],
]);
let state = 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"));
}
} }
+118 -239
View File
@@ -1,17 +1,18 @@
use std::cell::RefCell; use std::collections::HashMap;
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 flume::Sender; use gpui::{App, AppContext, Context, Entity, Global, Subscription};
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; use nostr_sdk::prelude::*;
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::state::SyncState; use crate::refresh::{RefreshGate, RefreshRequest};
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;
@@ -27,25 +28,13 @@ 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 {
active: RefCell<HashSet<RepoAddr>>, statuses: HashMap<RepoAddr, Vec<CheckoutSyncStatus>>,
in_flight: bool, refresh: RefreshGate,
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()
@@ -56,131 +45,150 @@ 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 backend = Backend::global(cx); let repos = RepoListStore::global(cx);
let checkouts = CheckoutsStore::global(cx); let checkouts = CheckoutsStore::global(cx);
let backend = Backend::global(cx);
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
this.refresh(cx);
}));
// Checkout associations decide which paths get compared. // Checkout associations decide which paths get compared.
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
this.run_pass(cx); this.refresh(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 => this.clear(cx), BackendEvent::SignerChanged => {
BackendEvent::RepoUpdates(_) | BackendEvent::Synced => this.run_pass(cx), this.statuses.clear();
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 {
active: RefCell::new(HashSet::new()), statuses: HashMap::new(),
sender, refresh: RefreshGate::default(),
in_flight: false,
pass_pending: false,
poll_pending: false, poll_pending: false,
tasks: vec![task],
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
/// Queue a probe pass; folded into any pass already in flight. /// Latest sync status per associated checkout.
pub fn probe(&self, addrs: impl IntoIterator<Item = RepoAddr>) { pub fn statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutSyncStatus> {
self.active.borrow_mut().extend(addrs); self.statuses.get(addr).cloned().unwrap_or_default()
if let Err(e) = self.sender.send(()) {
log::warn!("failed to queue sync status probe: {e}");
}
} }
// Keeps the in-flight pass so its completion releases the slot exactly once. /// Commits available locally but missing on Nostr.
fn clear(&mut self, cx: &mut Context<Self>) { pub fn unsynced(&self, addr: &RepoAddr) -> usize {
let had_addrs = !self.active.borrow().is_empty(); self.statuses
self.active.borrow_mut().clear(); .get(addr)
self.pass_pending = false; .map(|list| {
list.iter()
if had_addrs { .map(|checkout| checkout.status.ahead_total)
cx.emit(SyncEvent::Updated(Vec::new())); .sum()
} })
.unwrap_or(0)
} }
fn run_pass(&mut self, cx: &mut Context<Self>) { pub fn refresh(&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();
if backend.read(cx).current_user().is_none() { let repos = RepoListStore::global(cx);
self.clear(cx); let announcements = repos.read(cx).announcements_of(&user);
return; let checkouts = CheckoutsStore::global(cx);
}
if self.active.borrow().is_empty() { let targets: Vec<(RepoAddr, Vec<PathBuf>)> = announcements
return; .iter()
} .map(|announcement| {
let addr = announcement.addr();
self.in_flight = true; let paths = checkouts.read(cx).associations_of(&addr);
self.schedule_poll(cx); (addr, paths)
})
let targets: Vec<(RepoAddr, Vec<PathBuf>)> = { .collect();
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 updates: Vec<(RepoAddr, Vec<CheckoutSyncStatus>)> = Vec::new(); let mut states: HashMap<RepoAddr, Vec<GitRef>> = HashMap::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 {
updates.push((addr.clone(), Vec::new())); continue;
};
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)> = SyncState::parse(&event) let remote_refs: Vec<(String, String)> = nostr_refs
.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();
@@ -198,170 +206,41 @@ impl SyncStatusStore {
} }
} }
updates.push((addr.clone(), list)); if !list.is_empty() {
statuses.insert(addr.clone(), list);
}
} }
Ok::<_, Error>(updates) Ok::<_, Error>(statuses)
}); });
self.tasks.push(cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
// Reset the slot even on failure, or every later pass folds forever. let statuses = match work.await {
let updates = match work.await { Ok(statuses) => statuses,
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}");
Vec::new() return this.update(cx, |this, _cx| {
this.refresh.abort();
});
} }
}; };
let mut again = false; let again = this.update(cx, |this, cx| {
if this.statuses != statuses {
this.update(cx, |this, cx| { this.statuses = statuses;
this.in_flight = false; cx.notify();
if !updates.is_empty() {
cx.emit(SyncEvent::Updated(updates));
} }
again = std::mem::take(&mut this.pass_pending); this.refresh.finish()
})?; })?;
if again { if again {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.run_pass(cx); this.refresh(cx);
})?; })?;
} }
Ok::<(), anyhow::Error>(()) Ok::<_, 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)
} }
} }
-1
View File
@@ -7,7 +7,6 @@ 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,11 +17,10 @@ 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,
}; };
use signed_state::RepoStore; use signed_state::{ProfileDirectory, RepoStore};
use signed_ui::{placeholder, tree_row}; use signed_ui::{placeholder, tree_row};
use utils::relative_time_secs; use utils::relative_time_secs;
+1 -2
View File
@@ -5,9 +5,8 @@ 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::RepoStore; use signed_state::{ProfileDirectory, RepoStore};
use signed_ui::Avatar; use signed_ui::Avatar;
use utils::relative_time; use utils::relative_time;
+3 -2
View File
@@ -11,10 +11,11 @@ use gpui::{
}; };
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::{Backend, BackendEvent, Inbox, RefreshGate, RefreshRequest, RepoListStore}; use signed_state::{
Backend, BackendEvent, Inbox, ProfileDirectory, RefreshGate, RefreshRequest, RepoListStore,
};
use signed_ui::{Avatar, CountBadge}; use signed_ui::{Avatar, CountBadge};
use utils::relative_time; use utils::relative_time;
+1 -2
View File
@@ -7,10 +7,9 @@ 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 metadata::ProfileDirectory;
use nostr::prelude::{EventId, PublicKey}; use nostr::prelude::{EventId, PublicKey};
use signed_core::GitEvent; use signed_core::GitEvent;
use signed_state::RepoStore; use signed_state::{ProfileDirectory, RepoStore};
use signed_ui::{Avatar, placeholder, status_badge}; use signed_ui::{Avatar, placeholder, status_badge};
use utils::relative_time; use utils::relative_time;
+1 -2
View File
@@ -15,10 +15,9 @@ 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 metadata::ProfileDirectory;
use nostr::prelude::{EventId, PublicKey}; use nostr::prelude::{EventId, PublicKey};
use signed_core::{GitEvent, RepoStatus}; use signed_core::{GitEvent, RepoStatus};
use signed_state::RepoStore; use signed_state::{ProfileDirectory, 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;
+1 -2
View File
@@ -18,13 +18,12 @@ use gpui::prelude::*;
use gpui::{AnyElement, App, Entity, 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::RepoStore; use signed_state::{ProfileDirectory, 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 {
@@ -20,11 +20,10 @@ 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 metadata::ProfileDirectory;
use nostr::prelude::{Event, EventId, Kind, PublicKey, Url}; 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, RepoStore}; use signed_state::{Backend, Mirrors, ProfileDirectory, 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};
@@ -13,10 +13,9 @@ 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 metadata::ProfileDirectory;
use nostr::prelude::{EventId, Kind, PublicKey}; use nostr::prelude::{EventId, Kind, PublicKey};
use signed_core::{GitEvent, RepoStatus}; use signed_core::{GitEvent, RepoStatus};
use signed_state::RepoStore; use signed_state::{ProfileDirectory, 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;
@@ -20,11 +20,10 @@ 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};
use signed_state::{Backend, CheckoutsStore, Mirrors, RepoListStore, RepoStore}; use signed_state::{Backend, CheckoutsStore, Mirrors, ProfileDirectory, RepoListStore, RepoStore};
use signed_ui::{CountBadge, placeholder, ref_selector_trigger}; use signed_ui::{CountBadge, placeholder, ref_selector_trigger};
use utils::middle_truncate; use utils::middle_truncate;
+1 -1
View File
@@ -2,9 +2,9 @@ use gpui::prelude::*;
use gpui::{AnyElement, App, Entity, 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::ProfileDirectory;
use signed_ui::Avatar; use signed_ui::Avatar;
use utils::middle_truncate; use utils::middle_truncate;
+11 -160
View File
@@ -15,21 +15,19 @@ 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, WindowExt, h_flex, ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex,
v_flex,
}; };
use metadata::ProfileDirectory;
use nostr::nips::nip19::Nip19Coordinate; use nostr::nips::nip19::Nip19Coordinate;
use nostr::prelude::{PublicKey, 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, PushStepStatus, RepoListStore, RepoStore, SyncStatusDirectory, Nip34Binding, Nip34Kind, ProfileDirectory, PushStepStatus, RepoListStore, RepoStore,
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,
@@ -74,7 +72,6 @@ pub(crate) enum RepoAction {
SendPatch, SendPatch,
About, About,
Push, Push,
ResetRemote,
Delete, Delete,
} }
@@ -99,8 +96,6 @@ pub struct RepoDetailView {
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
/// Profiles rendered by the header, maintainers and tab title. /// Profiles rendered by the header, maintainers and tab title.
directory: Entity<ProfileDirectory>, directory: Entity<ProfileDirectory>,
/// Sync status feeding the sync-to-Nostr banner.
sync_directory: Entity<SyncStatusDirectory>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -161,6 +156,7 @@ 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);
@@ -197,6 +193,11 @@ impl RepoDetailView {
} }
})); }));
// The sync-to-Nostr banner reads the global sync store directly.
subscriptions.push(cx.observe(&sync_status, |_this, _sync, cx| {
cx.notify();
}));
// Defer loading the repository until the window is ready. // 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);
@@ -218,7 +219,6 @@ impl RepoDetailView {
banners: Banners::default(), banners: Banners::default(),
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
directory: ProfileDirectory::new(cx), directory: ProfileDirectory::new(cx),
sync_directory: SyncStatusDirectory::new(cx),
_subscriptions: subscriptions, _subscriptions: subscriptions,
}; };
@@ -255,7 +255,6 @@ impl RepoDetailView {
.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_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);
} }
@@ -293,17 +292,6 @@ impl RepoDetailView {
}); });
} }
/// 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;
@@ -361,7 +349,6 @@ impl RepoDetailView {
self.loading = true; self.loading = true;
self.error = None; self.error = None;
self.track_profiles(cx); self.track_profiles(cx);
self.track_sync_status(cx);
cx.notify(); cx.notify();
let (announcement, local_path) = { let (announcement, local_path) = {
@@ -844,63 +831,6 @@ 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
@@ -1031,7 +961,6 @@ impl RepoDetailView {
} }
} }
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),
}), }),
) )
@@ -1234,16 +1163,6 @@ 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()
@@ -1683,7 +1602,8 @@ impl RepoDetailView {
return None; return None;
} }
let statuses = self.sync_directory.read(cx).statuses_of(addr, cx); let sync_status = SyncStatusStore::global(cx);
let statuses = sync_status.read(cx).statuses_of(addr);
statuses.into_iter().find(|checkout| { 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)
@@ -2114,75 +2034,6 @@ impl RepoDetailView {
} }
} }
/// Confirmation for the remote reset, listing the server refs it deletes.
fn open_reset_remote_dialog(
store: Entity<RepoStore>,
deleted: Vec<String>,
window: &mut Window,
cx: &mut App,
) {
const DESC: &str = "Grasp servers are overwritten to match the local mirror. \
Refs that do not exist locally are deleted from the servers. \
This cannot be undone.";
window.open_dialog(cx, move |dialog, _window, _cx| {
let deleted = deleted.clone();
let store = store.clone();
dialog
.w(px(480.))
.keyboard(true)
.close_button(true)
.content(move |body, _window, cx| {
let deleted = deleted.clone();
let store = store.clone();
body.child(
DialogHeader::new()
.child(DialogTitle::new().child("Reset remote to local"))
.child(DialogDescription::new().child(DESC)),
)
.child(if deleted.is_empty() {
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("No server refs will be deleted.")
.into_any_element()
} else {
v_flex()
.gap_1()
.children(deleted.iter().map(|name| {
div()
.text_sm()
.font_family(cx.theme().mono_font_family.clone())
.child(name.clone())
}))
.into_any_element()
})
.child(
DialogFooter::new()
.justify_end()
.child(Button::new("cancel-reset-remote").label("Cancel").on_click(
|_, window, cx| {
window.close_dialog(cx);
},
))
.child(
Button::new("confirm-reset-remote")
.danger()
.label("Reset remote")
.on_click(move |_, window, cx| {
store.update(cx, |store, cx| {
store.reset_repository(cx).detach_and_log_err(cx);
});
window.close_dialog(cx);
}),
),
)
})
});
}
fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString { fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString {
let owner = announcement.owner; let owner = announcement.owner;
let user = nip05 let user = nip05
+1 -2
View File
@@ -14,10 +14,9 @@ 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 nostr::prelude::PublicKey;
use signed_core::Announcement; use signed_core::Announcement;
use signed_state::{RepoListStore, Timestamp}; use signed_state::{ProfileDirectory, RepoListStore, Timestamp};
use signed_ui::{Avatar, SegmentButton}; use signed_ui::{Avatar, SegmentButton};
use utils::relative_time; use utils::relative_time;
+1 -2
View File
@@ -9,8 +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::{ProfileDirectory, RepoStore};
use signed_state::RepoStore;
use crate::views::{repo_tab_avatar, tab_title}; use crate::views::{repo_tab_avatar, tab_title};
+53 -31
View File
@@ -20,12 +20,11 @@ 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 metadata::{Profile, ProfileDirectory};
use nostr::prelude::{PublicKey, RelayUrl}; 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, RepoListStore, Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile,
ResolvedLocalRepo, SyncStatusDirectory, ProfileDirectory, RepoListStore, ResolvedLocalRepo, SyncStatusStore,
}; };
use signed_ui::{Avatar, NavItem, title_bar_drag_handlers}; use signed_ui::{Avatar, NavItem, title_bar_drag_handlers};
@@ -68,10 +67,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.
unsynced: HashMap<RepoAddr, usize>,
/// Profiles rendered by the sidebar. /// Profiles rendered by the sidebar.
directory: Entity<ProfileDirectory>, directory: Entity<ProfileDirectory>,
/// Sync statuses for the announced repositories.
sync_directory: Entity<SyncStatusDirectory>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -81,6 +80,7 @@ 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();
@@ -118,6 +118,12 @@ 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,
@@ -130,8 +136,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), directory: ProfileDirectory::new(cx),
sync_directory: SyncStatusDirectory::new(cx),
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -182,19 +188,10 @@ impl SidebarPanel {
directory.track(profiles, 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
@@ -220,6 +217,26 @@ 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| {
@@ -514,16 +531,13 @@ 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);
let owner = self.directory.read(cx).profile(&announcement.owner, cx); // The sidebar rows are compact, so the avatar matches the 16px nav icons.
let avatar = Avatar::new(announcement.owner) let avatar = Avatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall();
.name(owner.name())
.picture(owner.picture())
.xsmall();
let announcement = announcement.clone(); 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.sync_directory.read(cx).unsynced(&addr, cx); let unsynced = self.unsynced.get(&addr).copied().unwrap_or(0);
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar); let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
@@ -776,24 +790,32 @@ 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 bound owner's avatar, falling back to the repo's pixel avatar when unowned. /// The repository's pixel avatar, with the bound owner's avatar at its bottom right.
fn local_avatar( fn local_avatar(
entry: &ResolvedLocalRepo, entry: &ResolvedLocalRepo,
directory: &Entity<ProfileDirectory>, directory: &Entity<ProfileDirectory>,
cx: &App, cx: &App,
) -> AnyElement { ) -> AnyElement {
// Matches the other sidebar rows, and the badge below sits at its corner.
let avatar = Avatar::new(entry.path.to_string_lossy()).xsmall();
let 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::new(entry.path.to_string_lossy()) return avatar.into_any_element();
.xsmall()
.into_any_element();
}; };
let profile = directory.read(cx).profile(&owner, cx); let profile = directory.read(cx).profile(&owner, cx);
Avatar::new(owner) div()
.name(profile.name()) .relative()
.picture(profile.picture()) .child(avatar)
.xsmall() .child(
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()
} }
@@ -1,15 +1,22 @@
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::{App, Entity, PathPromptOptions, SharedString, Subscription, Window, div, px}; use gpui::{
AnyWindowHandle, App, Entity, PathPromptOptions, SharedString, Subscription, Window, div, px,
};
use gpui_component::button::{Button, ButtonVariants}; use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::{Input, InputState}; use gpui_component::input::{
Input, InputEvent, InputState, NumberInput, NumberInputEvent, StepAction,
};
use gpui_component::select::{Select, SelectEvent, SelectState}; use gpui_component::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, WindowExt, h_flex, v_flex, ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_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};
@@ -24,10 +31,34 @@ 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>,
@@ -76,9 +107,38 @@ impl SettingsControls {
) )
}); });
let (light_options, dark_options) = theme_options(cx);
let light_theme = cx.new(|cx| {
SelectState::new(
light_options.clone(),
selected_index(&light_options, &settings.theme.light_theme),
window,
cx,
)
});
let dark_theme = cx.new(|cx| {
SelectState::new(
dark_options.clone(),
selected_index(&dark_options, &settings.theme.dark_theme),
window,
cx,
)
});
let font_size = cx.new(|cx| {
InputState::new(window, cx).default_value(settings.theme.font_size.to_string())
});
let mono_font_size = cx.new(|cx| {
InputState::new(window, cx).default_value(settings.theme.mono_font_size.to_string())
});
let radius = cx
.new(|cx| InputState::new(window, cx).default_value(settings.theme.radius.to_string()));
let radius_lg = cx.new(|cx| {
InputState::new(window, cx).default_value(settings.theme.radius_lg.to_string())
});
let grasp_server_input = 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
@@ -118,9 +178,118 @@ impl SettingsControls {
} }
})); }));
subscriptions.push(cx.subscribe(&light_theme, |_, event, cx| {
if let SelectEvent::Confirm(Some(value)) = event {
let store = SettingsStore::global(cx);
store.update(cx, |store, cx| {
store.edit(
|settings| settings.theme.light_theme = value.to_string(),
cx,
);
});
apply_theme(cx);
}
}));
subscriptions.push(cx.subscribe(&dark_theme, |_, event, cx| {
if let SelectEvent::Confirm(Some(value)) = event {
let store = SettingsStore::global(cx);
store.update(cx, |store, cx| {
store.edit(|settings| settings.theme.dark_theme = value.to_string(), cx);
});
apply_theme(cx);
}
}));
wire_number_input(
&font_size,
&mut subscriptions,
window.window_handle(),
cx,
NumberFieldOptions {
min: 10.,
max: 32.,
..Default::default()
},
settings.theme.font_size as f64,
|value, cx| {
let store = SettingsStore::global(cx);
store.update(cx, |store, cx| {
store.edit(|settings| settings.theme.font_size = value as f32, cx);
});
apply_theme(cx);
},
);
wire_number_input(
&mono_font_size,
&mut subscriptions,
window.window_handle(),
cx,
NumberFieldOptions {
min: 8.,
max: 32.,
..Default::default()
},
settings.theme.mono_font_size as f64,
|value, cx| {
let store = SettingsStore::global(cx);
store.update(cx, |store, cx| {
store.edit(|settings| settings.theme.mono_font_size = value as f32, cx);
});
apply_theme(cx);
},
);
wire_number_input(
&radius,
&mut subscriptions,
window.window_handle(),
cx,
NumberFieldOptions {
min: 0.,
max: 24.,
..Default::default()
},
settings.theme.radius as f64,
|value, cx| {
let store = SettingsStore::global(cx);
store.update(cx, |store, cx| {
store.edit(|settings| settings.theme.radius = value as f32, cx);
});
apply_theme(cx);
},
);
wire_number_input(
&radius_lg,
&mut subscriptions,
window.window_handle(),
cx,
NumberFieldOptions {
min: 0.,
max: 24.,
..Default::default()
},
settings.theme.radius_lg as f64,
|value, cx| {
let store = SettingsStore::global(cx);
store.update(cx, |store, cx| {
store.edit(|settings| settings.theme.radius_lg = value as f32, cx);
});
apply_theme(cx);
},
);
Self { 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,
@@ -151,6 +320,8 @@ 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))
@@ -179,6 +350,76 @@ fn event_fetching_section(controls: &SettingsControls, cx: &App) -> impl IntoEle
)) ))
} }
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
v_flex()
.gap_3()
.w_full()
.child(setting_row(
cx,
"Light Theme",
"The theme to use when the appearance is light.",
Select::new(&controls.light_theme).w_full(),
))
.child(setting_row(
cx,
"Dark Theme",
"The theme to use when the appearance is dark.",
Select::new(&controls.dark_theme).w_full(),
))
.child(setting_row(
cx,
"UI Font Size",
"Font size for the UI.",
NumberInput::new(&controls.font_size).w_full(),
))
.child(setting_row(
cx,
"Editor Font Size",
"Font size for editor text.",
NumberInput::new(&controls.mono_font_size).w_full(),
))
.child(setting_row(
cx,
"Corner Radius",
"The corner radius for UI elements.",
NumberInput::new(&controls.radius).w_full(),
))
.child(setting_row(
cx,
"Large Corner Radius",
"The corner radius for large UI elements (dialogs, notifications).",
NumberInput::new(&controls.radius_lg).w_full(),
))
.child(setting_row(
cx,
"Focus Ring",
"Draw a ring around focused controls.",
Switch::new("focus-ring")
.checked(settings.theme.focus_ring)
.on_click(move |checked: &bool, _window, cx| {
let store = SettingsStore::global(cx);
store.update(cx, |store, cx| {
store.edit(|settings| settings.theme.focus_ring = *checked, cx);
});
apply_theme(cx);
}),
))
.child(setting_row(
cx,
"Shadows",
"The shadow effect for UI elements.",
Switch::new("shadows")
.checked(settings.theme.shadow)
.on_click(move |checked: &bool, _window, cx| {
let store = SettingsStore::global(cx);
store.update(cx, |store, cx| {
store.edit(|settings| settings.theme.shadow = *checked, cx);
});
apply_theme(cx);
}),
))
}
/// Tab bar chrome, such as its next/previous tab buttons. /// 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(
@@ -483,6 +724,79 @@ 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),
@@ -490,3 +804,33 @@ 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);
}
+1 -5
View File
@@ -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" version = "0.2.0-alpha"
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,7 +28,6 @@ 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" }
@@ -38,9 +37,6 @@ 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
+41 -110
View File
@@ -5,9 +5,7 @@ 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 native_theme::watch::ThemeSubscription; use settings::{AppearanceMode, SettingsStore};
use native_theme_gpui::{SystemTheme, apply_system_theme};
use settings::{AppearanceMode, Settings, SettingsStore};
actions!(signed, [Quit]); actions!(signed, [Quit]);
@@ -27,71 +25,63 @@ 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 settings = store.read(cx).settings().clone();
{
let registry = ThemeRegistry::global_mut(cx); let registry = ThemeRegistry::global_mut(cx);
for (name, content) in Assets.themes() { for (name, content) in Assets.themes() {
if let Err(err) = registry.load_themes_from_str(&content) { if let Err(err) = registry.load_themes_from_str(&content) {
tracing::error!("Failed to load theme {name}: {err}"); 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();
match apply_native_theme(&settings, cx) { let theme = Theme::global_mut(cx);
Ok(()) => start_theme_watcher(cx), theme.radius = px(settings.theme.radius);
Err(error) => { theme.radius_lg = px(settings.theme.radius_lg);
tracing::warn!("native theme unavailable ({error})"); 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);
let registry = ThemeRegistry::global_mut(cx); if let Some(light) = light_theme {
let light_theme = registry theme.light_theme = light;
.themes() } else {
.get(settings.theme.light_theme.as_str()) tracing::warn!(
.cloned(); "{} theme is missing from the registry",
settings.theme.light_theme
);
}
let dark_theme = registry if let Some(dark) = dark_theme {
.themes() theme.dark_theme = dark;
.get(settings.theme.dark_theme.as_str()) } else {
.cloned(); tracing::warn!(
"{} theme is missing from the registry",
settings.theme.dark_theme
);
}
let theme = Theme::global_mut(cx); match settings.appearance {
theme.radius = px(settings.theme.radius); AppearanceMode::System => Theme::sync_system_appearance(None, cx),
theme.radius_lg = px(settings.theme.radius_lg); AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
theme.focus_ring = settings.theme.focus_ring; AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
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");
@@ -124,62 +114,3 @@ 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})")
}
}
}
+230
View File
@@ -0,0 +1,230 @@
# Per-view profile directories
Status: proposal
Reference: coop `a365a8f` "bind user metadata to per-view directories"
## Background
`ProfileStore` (`crates/signed_state/src/profile.rs`) is a single global entity:
- Warm-loads the 500 most recent metadata events at startup (`WARM_LIMIT`).
- `get(pk)` returns a cached clone or a placeholder and queues a network fetch
(flume channel, 500 ms batching, chunks of 100, `sync_bootstrap_only`).
- Subscribes to `BackendEvent::ProfileUpdates` to re-query the local database.
- Every view reads through `ProfileStore::global(cx)`.
Problems:
- Invalidation is global: every `cx.notify()` on the store invalidates every
panel that read it, so any profile update repaints all profile-consuming
surfaces (including `panel.cached` ones), not just the panel whose data
changed.
- Startup work and memory scale with the local database, not with what is on
screen, and the global map never shrinks.
- Every screen shares one data set, so nothing is cancellable or reclaimable.
## Goals
- Profile data is owned per panel/screen. A directory dies with its panel,
cancelling outstanding work and dropping its cache.
- An update re-renders only the panel whose data changed: reads during render
are tracked by the window, and `panel.cached` repaints a panel as soon as
its directory notifies.
- Local database first: query the local cache, fetch from relays only for keys
the directory cannot resolve.
- One shared network pipeline (batching, dedupe) so N panels still produce one
relay sync per key.
Non-goals: changing `Profile` accessors, changing `Backend`, persistence.
## Design
Two pieces replace the fat global:
1. `ProfileStore` (global, thin): network fetch service + event broadcaster.
Holds no profiles and does no warm load.
- `fetch(keys)`: dedupe via an in-flight set, batch via the existing flume
channel + 500 ms timeout, chunks of 100 through `sync_bootstrap_only`.
- Emits `ProfileEvent::Updated(Vec<PublicKey>)` (a) after each synced batch
and (b) when `BackendEvent::ProfileUpdates` arrives from the pump.
2. `ProfileDirectory` (per panel/screen entity): the metadata one surface
renders.
- `track(keys, cx)`: queries the local database for missing keys, inserts
what it finds, queues the rest for a network fetch.
- `profile(key, cx)`: cached value or placeholder; a miss marks the key
pending and queues a fetch (same call shape as today's `get`).
- Subscribes to `ProfileEvent::Updated`, refreshes only keys it tracks or
has pending, notifies itself on change.
- Stores its own tasks and subscription; both drop with the panel.
```
panel view ──track(keys)──▶ ProfileDirectory ──DB query──▶ local database
▲ │ keys not found
│ cx.notify() ▼
└────refresh(authors)── ProfileEvent::Updated ──── ProfileStore (global)
▲ │ batch +
│ │ sync_bootstrap_only
BackendEvent::ProfileUpdates ▼
relay pump ─────────────▶ relays
```
Panels own `Entity<ProfileDirectory>` and read it directly in render; no
`cx.observe` is needed. gpui-pre records which entities a window reads while
drawing (`App::record_entities_accessed`) and, on `cx.notify`, invalidates
exactly the views that read them (`App::notify` → `Window::invalidate_view`).
This is the mechanism coop relies on — its `Directory` only calls
`cx.notify()` — and it composes with `panel.cached(...)`, which repaints a
panel when the panel view itself is invalidated. Scoping data per directory is
what scopes invalidation: notifying the old global store would invalidate
every panel that ever read it, while notifying one directory invalidates only
the panels reading it.
## API sketch
```rust
// signed_state/src/profile.rs
#[derive(Debug, Clone)]
pub enum ProfileEvent {
/// Metadata for these authors may have changed in the local database.
Updated(Vec<PublicKey>),
}
impl EventEmitter<ProfileEvent> for ProfileStore {}
/// Thin global: batches metadata requests from directories and syncs them
/// into the local database. Holds no profile data.
pub struct ProfileStore {
in_flight: RefCell<HashSet<PublicKey>>,
sender: Sender<PublicKey>,
_subscription: Subscription,
}
impl ProfileStore {
pub fn global(cx: &App) -> Entity<Self>;
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App);
/// Queue a batched metadata request; no-op for keys already in flight.
pub fn fetch(&self, keys: impl IntoIterator<Item = PublicKey>);
}
/// Metadata cache for one panel or screen.
pub struct ProfileDirectory {
profiles: HashMap<PublicKey, Profile>,
pending: RefCell<HashSet<PublicKey>>,
tasks: SmallVec<[Task<()>; 2]>,
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl ProfileDirectory {
/// Create a directory, following the profile store's events.
pub fn new(cx: &mut App) -> Entity<Self>;
/// Resolve keys from the local database, queue the rest for fetch.
pub fn track(&mut self, keys: impl IntoIterator<Item = PublicKey>, cx: &mut Context<Self>);
/// The profile for a key, or a placeholder that queues a fetch.
pub fn profile(&self, public_key: &PublicKey, cx: &App) -> Profile;
/// Re-query tracked/pending keys named by a `ProfileEvent`.
fn refresh(&mut self, authors: &[PublicKey], cx: &mut Context<Self>);
}
```
Panel wiring:
```rust
pub struct IssuesView {
// ...
directory: Entity<ProfileDirectory>,
}
// constructor — no observe wiring; render-time reads of the directory are
// tracked automatically and `cx.notify()` on it invalidates the panel.
let directory = ProfileDirectory::new(cx);
```
Behavior details carried over from coop's `Directory`:
- The `pending` set guards the window between `track`/`profile` and the
database task finishing, so events arriving mid-query are still accepted.
- Inserts never overwrite data that already arrived from an event
(insert-if-absent on bulk load).
- `refresh` removes a pending key only when the database produced a profile,
so keys that exist on no relay stay pending and never loop into refetches;
`in_flight` in the store is released on every `Updated` emission.
## Where directories live
| Surface | File | Keys tracked | When |
| --- | --- | --- | --- |
| `SidebarPanel` | `views/sidebar/mod.rs` | current user, local repo owners | on `SignerChanged`, on local repos update |
| `RepoListView` | `views/repo_list.rs` | announcement owners | when cards rebuild |
| `RepoDetailView` | `views/repo/mod.rs`, `repo/about.rs` | owner, maintainers, binding owners | on announcement/sync; about dialog captures the handle |
| `IssuesView` | `views/issues/mod.rs` | issue authors, repo owner (tab title) | in `rebuild` |
| `IssueDetailView` | `views/issues/detail.rs` | author, discussion participants | on store events; passed to `discussion::` helpers |
| `PullRequestsView` | `views/pull_requests/mod.rs` | PR authors, repo owner | in `rebuild` |
| `PullRequestDetailView` | `views/pull_requests/detail.rs` | author, participants | on store events |
| `NewPullRequestView`, `SendPatchView`, `CommitDiffView` | respective files | repo owner (tab title only) | on construction |
| `InboxView` | `views/inbox.rs` | activity authors | in rows rebuild |
Helpers that stop reading the global and take `&Entity<ProfileDirectory>`
instead — every caller has `&mut self` or captures the handle:
- `repo_tab_avatar(store, cx)` in `views/mod.rs` (all `Panel::title` impls)
- `discussion::sidebar_section`, `discussion::comments_section`
- `about::maintainers` via `open_about_dialog` (dialog closure captures the
entity handle, same as coop's device-registry notification)
- `bound_repo_label` in `views/repo/mod.rs`
- `sub_activity` in `views/inbox.rs`, `local_avatar` in `views/sidebar/mod.rs`
## Migration steps
1. `signed_state`: introduce `ProfileEvent` + `EventEmitter`, extract the
"latest metadata per author" reduction from `apply_authors` into a pure
`fn latest_profiles(events: Vec<Event>) -> Vec<Profile>`, and strip
`ProfileStore` down to the fetch service (drop `profiles`, `seen`, `load`,
`apply_seen`, `apply_authors`, `get`, `WARM_LIMIT`). The batch loop emits
`ProfileEvent::Updated` instead of querying; the `BackendEvent::ProfileUpdates`
subscription re-emits and releases in-flight keys.
2. `signed_state`: add `ProfileDirectory` in the same `profile.rs` and export
it from `lib.rs`. `init` keeps creating the global store.
3. `workspace`: add a `directory` field to each surface in the table, replace
`ProfileStore::global(cx).read(cx).get(...)` with
`self.directory.read(cx).profile(...)`, thread the handle through the free
helpers, and call `track` from each surface's data-load/rebuild path.
4. Delete `ProfileStore::get` last; the compiler walks the remaining call
sites. The workspace crate will not compile between steps 1 and 3, so this
lands as one change.
## Edge cases
- **Relay event during a database query**: covered by the pending set.
- **Keys with no profile on relays**: stay pending/in-flight, no retry storm
(strictly no worse than today's permanent `seen` dedupe).
- **Panel closed**: directory drops → subscription, tasks, and cache drop; no
cross-panel notifies.
- **Same person on many panels**: each directory keeps its own `Profile`
clone. Metadata JSON is small and queries are local index lookups; if this
ever shows up, a directory can attach to `RepoStore` later.
- **Own profile edits**: publishing flows through `Backend` → pump →
`ProfileUpdates` → store event → directories refresh, so no extra channel is
needed.
- **Dock tab titles**: `Panel::title` receives `&mut self`, so panels pass
their own directory to `repo_tab_avatar`; no global needed for chrome.
## Testing
- Unit: `latest_profiles` keeps the newest event per author (pure, no GPUI).
- Unit: `ProfileDirectory` pending-set dedupe and insert-if-absent merge via
`TestAppContext` (the dock crate already uses GPUI test contexts), seeding
the directory through a `#[cfg(test)]` helper rather than the network.
- Manual: cold start with an empty database, open explore / repo / issues /
inbox, confirm placeholders resolve without interacting with each panel, and
that `panel.cached` tabs still repaint on profile arrival.
## Alternatives considered
- **Attach the directory to `RepoStore`** instead of panels: repo surfaces
sharing one store would share one directory, but `RepoStore` instances are
created per open and sidebar/inbox/explore still need their own; revisit
only if duplicated queries ever matter.
- **Keep the global store as-is**: read tracking already propagates updates,
but the warm load, unbounded cache, and all-panels invalidation remain; does
not meet the goals.