Compare commits
6
Commits
480ee38c46
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a249174026 | ||
|
|
4f81501668 | ||
|
|
d6850d60ea | ||
|
|
4b4b8b5cf2 | ||
|
|
a2e7907193 | ||
|
|
946128433b |
+10
-5
@@ -1,11 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Breaking changes
|
||||
## v0.2.0 - 2026/10/21
|
||||
|
||||
### Added
|
||||
|
||||
- Follow the operating system's native theme on macOS, Windows and Linux (KDE, GNOME), applying the OS colors, fonts, radii and accessibility preferences.
|
||||
- Add an identity import dialog, importing an existing identity by pasting its `nsec` secret key (encrypted with a new passphrase), NIP-49 `ncryptsec` (unlocked with its passphrase), or `bunker://` NIP-46 URI
|
||||
- Add a dropdown menu on the signed-in user button in the sidebar
|
||||
- Add sign-out feature, confirmed by an alert dialog before deleting the stored session credential
|
||||
@@ -13,9 +12,11 @@
|
||||
- Allow choosing the default branch when publishing a repository
|
||||
- Detect when local commits are not on Nostr yet, showing an unsynced count badge on sidebar repository rows and a Sync now banner on the repository view
|
||||
- Add a step-by-step push checklist while publishing and syncing a repository
|
||||
- Add a `Reset remote to local` action that republishes the mirror and deletes grasp refs the mirror does not have
|
||||
|
||||
### Changed
|
||||
|
||||
- Use the built-in light/dark syntax highlighting palette instead of the Signed palette bundled in `signed.json` when the native theme is active
|
||||
- Increase the default theme corner radii from 2 to 4 and large radii from 6 to 8
|
||||
- Surface backend errors as error notifications in the workspace instead of silently dropping them
|
||||
- Break ties in repository activity lists by event id, so same-second events order deterministically
|
||||
@@ -26,19 +27,23 @@
|
||||
- Restructure the core crate into focused modules for announcements, pull requests, git events, inbox, and upstream state
|
||||
- Publish nostr events directly through the client with relay acceptance required, removing the grasp push helper and its per-server retry loop so failures surface immediately
|
||||
- Poll the repository sync drift check every 180 seconds instead of 60
|
||||
- Track sync status per view instead of globally
|
||||
- Merge the `PixelAvatar` into the `Avatar` component. Seed user pixel avatars from the public key instead of the display name, so avatars stay stable when a user renames
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix the repository view refreshing its mirror from the network while a push is in progress, so the refresh no longer races the push
|
||||
- Fix opening a repository failing with a `destination already exists` error when two loads raced to clone its mirror, clones are now serialized and later loads reuse the finished clone
|
||||
- Fix a repository never opening again after an interrupted clone left an unusable mirror directory behind, which is now removed and re-cloned
|
||||
- Fix signing in with a `bunker://` URI silently generating a new identity instead of erroring when its master key cannot be parsed
|
||||
- Fix repository sync failing with `state event in purgatory ... doesn't match push` when grasp servers hold branches or tags the local repository lacks
|
||||
- Fix sync failing with `could not fetch refs/tags/...^{}` on repositories with annotated tags, where the peel entries `git ls-remote` reports for annotated tags were mistaken for missing refs
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove the light/dark theme pickers and the font size, corner radius, focus ring and shadow settings from the settings dialog; colors and geometry now come from the native theme
|
||||
- Remove dead code: the unused `login`/`logout` family, the unwired `SyncProgress` pipeline, `merge_pull_request`, inbox mark-read/archive APIs and the wasm32-only code paths
|
||||
|
||||
### Deprecated
|
||||
|
||||
## v0.2.0-alpha - 2026/09/27
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+433
-201
File diff suppressed because it is too large
Load Diff
+8
-4
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0-alpha"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
@@ -13,9 +13,13 @@ gpui = { package = "gpui-pre", version = "0.3.8" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
|
||||
|
||||
# GPUI Kit
|
||||
gpui-component = { version = "0.7.1", features = ["tree-sitter-languages"] }
|
||||
gpui-base = "0.7.1"
|
||||
# GPUI Kit; exact pins keep the graph on the gpui-pre 0.3.8 closure that
|
||||
# native-theme-gpui 0.6.1 is verified against (0.7.3 and 0.8 pin 0.3.9).
|
||||
gpui-component = { version = "=0.7.1", features = ["tree-sitter-languages"] }
|
||||
gpui-base = "=0.7.1"
|
||||
|
||||
native-theme = { version = "0.6.1", features = ["watch"] }
|
||||
native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] }
|
||||
|
||||
dock = { path = "crates/dock" }
|
||||
settings = { path = "crates/settings" }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::Result;
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
|
||||
use crate::repo::Repo;
|
||||
@@ -8,12 +10,16 @@ use crate::repo::Repo;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitCache {
|
||||
root: PathBuf,
|
||||
clone_locks: Arc<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>>,
|
||||
}
|
||||
|
||||
impl GitCache {
|
||||
/// Creates a cache rooted at `root`.
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
Self { root }
|
||||
Self {
|
||||
root,
|
||||
clone_locks: Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the cache root directory.
|
||||
@@ -38,20 +44,40 @@ impl GitCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the clone lock for `path`, creating it on first use.
|
||||
fn clone_lock(&self, path: &Path) -> Arc<Mutex<()>> {
|
||||
let mut locks = self
|
||||
.clone_locks
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
Arc::clone(locks.entry(path.to_path_buf()).or_default())
|
||||
}
|
||||
|
||||
/// Returns the cached repository, cloning from one of `clone_urls` on first use.
|
||||
pub fn ensure_clone<U: AsRef<str>>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result<Repo> {
|
||||
let path = self.repo_path(addr);
|
||||
|
||||
if let Some(repo) = self.open(addr)? {
|
||||
// Lock the clone lock for this path to prevent concurrent clones.
|
||||
let clone_lock = self.clone_lock(&path);
|
||||
let _clone_guard = clone_lock.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
|
||||
// Refreshes the cached repository if it is already cloned, or clones it from one of `clone_urls` otherwise.
|
||||
if let Ok(Some(repo)) = self.open(addr) {
|
||||
if let Err(error) = repo.fetch() {
|
||||
log::warn!("failed to refresh the cached repository: {error:#}");
|
||||
}
|
||||
return Ok(repo);
|
||||
}
|
||||
|
||||
// Create the parent directory if it does not exist.
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Remove the cached clone if it exists but is unusable.
|
||||
if path.exists() {
|
||||
log::warn!("removing unusable cached clone at {}", path.display());
|
||||
std::fs::remove_dir_all(&path)?;
|
||||
}
|
||||
|
||||
Repo::clone(clone_urls, &path)
|
||||
|
||||
@@ -1222,22 +1222,25 @@ impl Repo {
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/main:refs/heads/main"],
|
||||
&["+refs/heads/main:refs/heads/main"],
|
||||
)
|
||||
}
|
||||
|
||||
/// Mirrors all branches and tags to the grasp repository at `base_url`.
|
||||
///
|
||||
/// Force refspecs keep the server ref map following the local repository,
|
||||
/// grasp authorizes pushes by ref map equality, not fast-forwards.
|
||||
pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
self.push_refspecs(
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
|
||||
&["+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"],
|
||||
)
|
||||
}
|
||||
|
||||
/// Pushes explicit refspecs to the grasp repository at `base_url`.
|
||||
fn push_refspecs(
|
||||
pub fn push_refspecs(
|
||||
&self,
|
||||
base_url: &str,
|
||||
owner: &str,
|
||||
@@ -1262,6 +1265,33 @@ impl Repo {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lists branches and tags advertised by the remote at `url`.
|
||||
pub fn ls_remote(&self, url: &str) -> Result<Vec<(String, String)>> {
|
||||
let url = Self::transport_url(url);
|
||||
let output = Self::run_git(self.workdir_or_dot(), &["ls-remote", &url], "git ls-remote")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git ls-remote on {url} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let refs = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (oid, name) = line.split_once('\t')?;
|
||||
let name = name.trim();
|
||||
|
||||
(name.starts_with("refs/heads/") || name.starts_with("refs/tags/"))
|
||||
.then(|| (name.to_owned(), oid.to_owned()))
|
||||
.filter(|(name, _)| !name.ends_with("^{}"))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(refs)
|
||||
}
|
||||
|
||||
/// Sets the `origin` URL when the repository has no remote yet.
|
||||
pub fn ensure_origin(&self, url: &str) -> Result<()> {
|
||||
if self.inner.find_remote("origin").is_ok() {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use nostr::prelude::Keys;
|
||||
use signed_core::RepoAddr;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -11,6 +14,70 @@ fn blocks_parent_components() {
|
||||
assert_eq!(GitCache::sanitize_path_component("a/../b"), "a_.._b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_ensure_clone_succeeds_for_every_caller() {
|
||||
let server = tempfile::tempdir().expect("tempdir");
|
||||
let server_repo = bare_server(server.path(), "npub1test", "repo");
|
||||
|
||||
let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&work_repo, "initial");
|
||||
let work_dir = work_dir.path();
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
Repo::open(work_dir)
|
||||
.expect("open")
|
||||
.push_all(&base_url, "npub1test", "repo")
|
||||
.expect("push");
|
||||
|
||||
let cache_dir = tempfile::tempdir().expect("tempdir");
|
||||
let cache = GitCache::new(cache_dir.path().to_path_buf());
|
||||
let addr = RepoAddr::new(Keys::generate().public_key(), "repo");
|
||||
let clone_url = format!("file://{}", server_repo.display());
|
||||
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..4 {
|
||||
scope.spawn(|| {
|
||||
cache
|
||||
.ensure_clone(&addr, &[&clone_url])
|
||||
.expect("ensure_clone");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
assert!(cache.open(&addr).expect("open").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_clone_recovers_from_a_leftover_directory() {
|
||||
let server = tempfile::tempdir().expect("tempdir");
|
||||
let server_repo = bare_server(server.path(), "npub1test", "repo");
|
||||
|
||||
let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&work_repo, "initial");
|
||||
let work_dir = work_dir.path();
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
Repo::open(work_dir)
|
||||
.expect("open")
|
||||
.push_all(&base_url, "npub1test", "repo")
|
||||
.expect("push");
|
||||
|
||||
let cache_dir = tempfile::tempdir().expect("tempdir");
|
||||
let cache = GitCache::new(cache_dir.path().to_path_buf());
|
||||
let addr = RepoAddr::new(Keys::generate().public_key(), "repo");
|
||||
let clone_url = format!("file://{}", server_repo.display());
|
||||
|
||||
// Simulate the remains of an interrupted clone.
|
||||
let leftover = cache.repo_path(&addr);
|
||||
std::fs::create_dir_all(&leftover).expect("mkdir");
|
||||
|
||||
cache
|
||||
.ensure_clone(&addr, &[&clone_url])
|
||||
.expect("ensure_clone");
|
||||
|
||||
assert!(cache.open(&addr).expect("open").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_commit_reports_the_first_ancestor() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
@@ -55,6 +122,166 @@ fn push_all_mirrors_branches_and_tags() {
|
||||
assert!(refs.contains("refs/tags/v1.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ls_remote_lists_branches_and_tags_only() {
|
||||
let server = tempfile::tempdir().unwrap();
|
||||
let server_repo = bare_server(server.path(), "npub1test", "my-repo");
|
||||
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
|
||||
git_run(dir, &["branch", "feature"]);
|
||||
git_run(dir, &["tag", "v1"]);
|
||||
git_run(dir, &["tag", "-a", "v2", "-m", "annotated"]);
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
let local = Repo::open(dir).expect("open");
|
||||
local
|
||||
.push_all(&base_url, "npub1test", "my-repo")
|
||||
.expect("push");
|
||||
|
||||
// Refs outside heads and tags, and the HEAD symref line, are not listed.
|
||||
let main = git_in(dir, &["rev-parse", "refs/heads/main"]).expect("main oid");
|
||||
git_run(&server_repo, &["update-ref", "refs/nostr/abc", &main]);
|
||||
|
||||
let remote = local
|
||||
.ls_remote(&format!("file://{}", server_repo.display()))
|
||||
.expect("ls-remote");
|
||||
let names: Vec<String> = remote.iter().map(|(name, _)| name.clone()).collect();
|
||||
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"refs/heads/feature".to_owned(),
|
||||
"refs/heads/main".to_owned(),
|
||||
"refs/tags/v1".to_owned(),
|
||||
"refs/tags/v2".to_owned(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
remote
|
||||
.iter()
|
||||
.find(|(name, _)| name == "refs/heads/main")
|
||||
.map(|(_, oid)| oid.as_str()),
|
||||
Some(main.as_str())
|
||||
);
|
||||
// The annotated tag lists the tag object, matching the local ref state,
|
||||
// without a peeled `^{}` duplicate.
|
||||
let v2 = git_in(dir, &["rev-parse", "refs/tags/v2"]).expect("v2 oid");
|
||||
assert_eq!(
|
||||
remote
|
||||
.iter()
|
||||
.find(|(name, _)| name == "refs/tags/v2")
|
||||
.map(|(_, oid)| oid.as_str()),
|
||||
Some(v2.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_all_force_updates_diverged_remote_branches() {
|
||||
let server = tempfile::tempdir().unwrap();
|
||||
let server_repo = bare_server(server.path(), "npub1test", "my-repo");
|
||||
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
std::fs::write(dir.path().join("b.txt"), b"two").expect("write");
|
||||
commit_all(&repo, "second");
|
||||
let dir = dir.path();
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
let local = Repo::open(dir).expect("open");
|
||||
local
|
||||
.push_all(&base_url, "npub1test", "my-repo")
|
||||
.expect("initial push");
|
||||
|
||||
// Diverge local main from the pushed state.
|
||||
git_run(dir, &["reset", "-q", "--hard", "HEAD~1"]);
|
||||
std::fs::write(dir.join("c.txt"), b"three").expect("write");
|
||||
commit_all(&repo, "diverged");
|
||||
|
||||
local
|
||||
.push_all(&base_url, "npub1test", "my-repo")
|
||||
.expect("force push");
|
||||
|
||||
assert_eq!(
|
||||
git_in(dir, &["rev-parse", "refs/heads/main"]).expect("local main"),
|
||||
git_in(&server_repo, &["rev-parse", "refs/heads/main"]).expect("server main")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_refspecs_deletes_server_refs_missing_locally() {
|
||||
let server = tempfile::tempdir().unwrap();
|
||||
let server_repo = bare_server(server.path(), "npub1test", "my-repo");
|
||||
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
|
||||
git_run(dir, &["branch", "feature"]);
|
||||
git_run(dir, &["tag", "v1"]);
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
let local = Repo::open(dir).expect("open");
|
||||
local
|
||||
.push_all(&base_url, "npub1test", "my-repo")
|
||||
.expect("initial push");
|
||||
|
||||
git_run(dir, &["branch", "-D", "feature"]);
|
||||
git_run(dir, &["tag", "-d", "v1"]);
|
||||
|
||||
local
|
||||
.push_refspecs(
|
||||
&base_url,
|
||||
"npub1test",
|
||||
"my-repo",
|
||||
&[
|
||||
"+refs/heads/*:refs/heads/*",
|
||||
"+refs/tags/*:refs/tags/*",
|
||||
":refs/heads/feature",
|
||||
":refs/tags/v1",
|
||||
],
|
||||
)
|
||||
.expect("push with deletions");
|
||||
|
||||
let refs = git_in(&server_repo, &["show-ref"]).expect("server refs");
|
||||
assert!(refs.contains("refs/heads/main"));
|
||||
assert!(!refs.contains("refs/heads/feature"));
|
||||
assert!(!refs.contains("refs/tags/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_refs_brings_server_only_branches_into_local() {
|
||||
let server = tempfile::tempdir().unwrap();
|
||||
bare_server(server.path(), "npub1test", "my-repo");
|
||||
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
git_run(dir, &["branch", "feature"]);
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
Repo::open(dir)
|
||||
.expect("open")
|
||||
.push_all(&base_url, "npub1test", "my-repo")
|
||||
.expect("push");
|
||||
let feature = git_in(dir, &["rev-parse", "refs/heads/feature"]).expect("feature oid");
|
||||
|
||||
// A repository that never had the branch fetches it by name.
|
||||
let (other_dir, _other_repo) = fixture(&[("unrelated.txt", b"x")]);
|
||||
let other = Repo::open(other_dir.path()).expect("open other");
|
||||
let server_url = format!("file://{}/npub1test/my-repo.git", server.path().display());
|
||||
other
|
||||
.fetch_refs(&[&server_url], "+refs/heads/feature:refs/heads/feature")
|
||||
.expect("fetch ref");
|
||||
|
||||
assert_eq!(
|
||||
git_in(other_dir.path(), &["rev-parse", "refs/heads/feature"]).expect("fetched feature"),
|
||||
feature
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_state_matches_the_git_cli_ref_listing() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Error, anyhow, bail};
|
||||
@@ -21,11 +22,9 @@ use crate::git_store::Mirrors;
|
||||
use crate::inbox::Inbox;
|
||||
use crate::push::{PushOutcome, grasp_base_url, grasp_clone_url};
|
||||
use crate::repos::RepoListStore;
|
||||
use crate::state::{GitPushMode, GitRef, PushStepStatus, SyncState};
|
||||
use crate::state::{GitPushMode, GitPushPolicy, GitRef, PushStepStatus, SyncState};
|
||||
|
||||
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||
|
||||
const PUMP_DEBOUNCE: Duration = Duration::from_millis(200);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -199,13 +198,9 @@ impl Backend {
|
||||
} else if content.starts_with("bunker://") {
|
||||
let (base, keys) = extract_master_key(&content)?;
|
||||
let uri = NostrConnectUri::parse(base)?;
|
||||
let mut signer = NostrConnect::new(
|
||||
uri,
|
||||
keys,
|
||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||
let signer = NostrConnect::builder(uri, keys)
|
||||
.auth_url_handler(Arc::new(SignedAuthUrlHandler))
|
||||
.build()?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(signer, cx);
|
||||
@@ -366,13 +361,9 @@ impl Backend {
|
||||
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let mut signer = NostrConnect::new(
|
||||
connect_uri,
|
||||
keys,
|
||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||
let signer = NostrConnect::builder(connect_uri, keys)
|
||||
.auth_url_handler(Arc::new(SignedAuthUrlHandler))
|
||||
.build()?;
|
||||
|
||||
// Verify the bunker responds before persisting the credential.
|
||||
let public_key = signer.get_public_key_async().await?;
|
||||
@@ -588,6 +579,7 @@ impl Backend {
|
||||
head: Some("main".to_owned()),
|
||||
path: Some(destination.clone()),
|
||||
mode: GitPushMode::Main,
|
||||
policy: GitPushPolicy::default(),
|
||||
};
|
||||
|
||||
let on_step = &mut |status: PushStepStatus, cx: &mut AsyncApp| {
|
||||
@@ -735,7 +727,17 @@ impl Backend {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PushOutcome, Error>> {
|
||||
let path = Mirrors::path(&announcement.addr());
|
||||
self.push_repo_from(announcement, path, None, cx)
|
||||
self.push_repo_from(announcement, path, None, GitPushPolicy::Reconcile, cx)
|
||||
}
|
||||
|
||||
/// Republishes the mirror and deletes grasp refs the mirror does not have.
|
||||
pub fn reset_repository(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PushOutcome, Error>> {
|
||||
let path = Mirrors::path(&announcement.addr());
|
||||
self.push_repo_from(announcement, path, None, GitPushPolicy::ResetRemote, cx)
|
||||
}
|
||||
|
||||
pub fn push_checkout(
|
||||
@@ -745,7 +747,13 @@ impl Backend {
|
||||
announced_head: Option<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PushOutcome, Error>> {
|
||||
self.push_repo_from(announcement, checkout, announced_head, cx)
|
||||
self.push_repo_from(
|
||||
announcement,
|
||||
checkout,
|
||||
announced_head,
|
||||
GitPushPolicy::Reconcile,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
fn push_repo_from(
|
||||
@@ -753,6 +761,7 @@ impl Backend {
|
||||
announcement: Announcement,
|
||||
path: PathBuf,
|
||||
announced_head: Option<String>,
|
||||
policy: GitPushPolicy,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<PushOutcome, Error>> {
|
||||
let addr = announcement.addr();
|
||||
@@ -789,6 +798,11 @@ impl Backend {
|
||||
});
|
||||
work.await?
|
||||
};
|
||||
state.policy = policy;
|
||||
|
||||
// Fetch grasp refs the local repository lacks before the state event is signed,
|
||||
// so the servers' full ref map is declared.
|
||||
state.reconcile(&announcement, cx).await?;
|
||||
|
||||
// Keep the announced default branch in `HEAD` when it is among the pushed refs.
|
||||
if let Some(head) = announced_head
|
||||
|
||||
@@ -28,7 +28,7 @@ pub use repo::RepoStore;
|
||||
pub use repos::RepoListStore;
|
||||
pub use signed_git::{Nip34Binding, Nip34Kind};
|
||||
use signed_nostr::NostrBackend;
|
||||
pub use state::{GitPushMode, GitRef, PushStep, PushStepStatus, SyncState};
|
||||
pub use state::{GitPushMode, GitPushPolicy, GitRef, PushStep, PushStepStatus, SyncState};
|
||||
pub use sync_status::{CheckoutSyncStatus, SyncEvent, SyncStatusDirectory, SyncStatusStore};
|
||||
|
||||
pub fn init(
|
||||
|
||||
@@ -1172,6 +1172,24 @@ impl RepoStore {
|
||||
self.run_push(push, None, cx)
|
||||
}
|
||||
|
||||
/// Republishes the mirror and deletes grasp refs the mirror does not have.
|
||||
pub fn reset_repository(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
|
||||
if self.pushing {
|
||||
return Task::ready(Err(anyhow::anyhow!(
|
||||
"A push to this repository is already in progress"
|
||||
)));
|
||||
}
|
||||
|
||||
let Some(announcement) = self.action_announcement(cx) else {
|
||||
return self.action_error("Repository announcement is not loaded yet", cx);
|
||||
};
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let push = backend.update(cx, |backend, cx| backend.reset_repository(announcement, cx));
|
||||
|
||||
self.run_push(push, None, cx)
|
||||
}
|
||||
|
||||
pub fn push_checkout(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
|
||||
@@ -40,6 +40,17 @@ pub enum GitPushMode {
|
||||
Main,
|
||||
}
|
||||
|
||||
/// How to handle server-only refs when pushing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum GitPushPolicy {
|
||||
/// Reconcile the local refs with the grasp servers,
|
||||
/// fetching missing refs before pushing.
|
||||
#[default]
|
||||
Reconcile,
|
||||
/// Declare the local refs only and delete server-only refs while pushing.
|
||||
ResetRemote,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SyncState {
|
||||
pub refs: Vec<GitRef>,
|
||||
@@ -47,6 +58,7 @@ pub struct SyncState {
|
||||
/// Local repository to push from; `None` for states parsed from events.
|
||||
pub path: Option<PathBuf>,
|
||||
pub mode: GitPushMode,
|
||||
pub policy: GitPushPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -135,6 +147,7 @@ impl SyncState {
|
||||
head,
|
||||
path: None,
|
||||
mode: GitPushMode::default(),
|
||||
policy: GitPushPolicy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +167,7 @@ impl SyncState {
|
||||
head: state.head,
|
||||
path: Some(path.to_path_buf()),
|
||||
mode: GitPushMode::default(),
|
||||
policy: GitPushPolicy::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -162,13 +176,127 @@ impl SyncState {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_policy(mut self, policy: GitPushPolicy) -> Self {
|
||||
self.policy = policy;
|
||||
self
|
||||
}
|
||||
|
||||
/// Reconciles the local repository with the grasp servers, fetching missing refs.
|
||||
pub async fn reconcile(
|
||||
&mut self,
|
||||
announcement: &Announcement,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<(), Error> {
|
||||
if self.policy == GitPushPolicy::ResetRemote || self.refs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(path) = self.path.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let local: Vec<(String, String)> = self
|
||||
.refs
|
||||
.iter()
|
||||
.map(|git_ref| (git_ref.name.clone(), git_ref.commit.to_string()))
|
||||
.collect();
|
||||
|
||||
let urls: Vec<String> = announcement
|
||||
.clone
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect();
|
||||
|
||||
let snapshot_path = path.clone();
|
||||
|
||||
let missing = cx
|
||||
.background_spawn(async move {
|
||||
let repo = Repo::open(&path)?;
|
||||
|
||||
// Refs the servers advertise that the local repository lacks
|
||||
let mut missing: Vec<(String, Vec<String>)> = Vec::new();
|
||||
// Diverged server tips that exist only on the servers
|
||||
let mut backups: Vec<(String, String, Vec<String>)> = Vec::new();
|
||||
|
||||
for url in &urls {
|
||||
let Ok(remote_refs) = repo.ls_remote(url) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for (name, oid) in remote_refs {
|
||||
let Some((_, local_oid)) =
|
||||
local.iter().find(|(local_name, _)| local_name == &name)
|
||||
else {
|
||||
match missing.iter_mut().find(|(known, _)| known == &name) {
|
||||
Some((_, sources)) => sources.push(url.clone()),
|
||||
None => missing.push((name, vec![url.clone()])),
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
if local_oid == &oid {
|
||||
continue;
|
||||
}
|
||||
|
||||
if repo.merge_base(local_oid, &oid).is_err() {
|
||||
match backups.iter_mut().find(|(known, _, _)| known == &name) {
|
||||
Some((_, _, sources)) => sources.push(url.clone()),
|
||||
None => {
|
||||
backups.push((name.clone(), oid.clone(), vec![url.clone()]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for (name, sources) in &missing {
|
||||
let refspec = format!("+{name}:{name}");
|
||||
if let Err(error) = repo.fetch_refs(sources, &refspec) {
|
||||
log::warn!("failed to fetch {name} from grasp servers: {error}");
|
||||
failures.push(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for (name, oid, sources) in &backups {
|
||||
let refspec = format!("+{name}:refs/nostr/backup/{oid}");
|
||||
if let Err(error) = repo.fetch_refs(sources, &refspec) {
|
||||
log::warn!("failed to back up {name} from grasp servers: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
match failures.is_empty() {
|
||||
true => Ok(missing
|
||||
.into_iter()
|
||||
.map(|(name, _)| name)
|
||||
.collect::<Vec<_>>()),
|
||||
false => bail!(
|
||||
"could not fetch {} from any grasp server",
|
||||
failures.join(", ")
|
||||
),
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
if !missing.is_empty() {
|
||||
let (mode, policy) = (self.mode, self.policy);
|
||||
*self = Self::from_repo(&snapshot_path)?;
|
||||
self.mode = mode;
|
||||
self.policy = policy;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// State event for these refs; the caller signs it.
|
||||
pub fn state_event(&self, repo_id: &str) -> EventBuilder {
|
||||
Self::build(repo_id, &self.refs, self.head.as_deref())
|
||||
}
|
||||
|
||||
/// Push the state event and objects to the grasp servers,
|
||||
/// calling `on_step` for each step.
|
||||
/// Push the state event and objects to the grasp servers.
|
||||
///
|
||||
/// Calling `on_step` for each step.
|
||||
pub async fn push_to_grasps<P>(
|
||||
&self,
|
||||
client: &Client,
|
||||
@@ -191,6 +319,12 @@ impl SyncState {
|
||||
.map_err(|error| anyhow!("could not encode the repository owner: {error}"))?;
|
||||
|
||||
let mut outcome = PushOutcome::default();
|
||||
let policy = self.policy;
|
||||
let declared: Vec<String> = self
|
||||
.refs
|
||||
.iter()
|
||||
.map(|git_ref| git_ref.name.clone())
|
||||
.collect();
|
||||
|
||||
for relay in &announcement.relays {
|
||||
let step = PushStep::StageStateEvent(relay.clone());
|
||||
@@ -241,12 +375,24 @@ impl SyncState {
|
||||
let owner = owner.clone();
|
||||
let repo_id = announcement.id.clone();
|
||||
let mode = self.mode;
|
||||
let declared = declared.clone();
|
||||
|
||||
async move {
|
||||
let repo = Repo::open(&path)?;
|
||||
|
||||
match mode {
|
||||
GitPushMode::Main => repo.push_main(&base, &owner, &repo_id),
|
||||
GitPushMode::All => repo.push_all(&base, &owner, &repo_id),
|
||||
GitPushMode::All => {
|
||||
let mut refspecs = force_refspecs();
|
||||
if policy == GitPushPolicy::ResetRemote {
|
||||
let url = format!("{base}/{owner}/{repo_id}.git");
|
||||
let server_refs = repo.ls_remote(&url)?;
|
||||
refspecs.extend(deletion_refspecs(&declared, &server_refs));
|
||||
}
|
||||
|
||||
let refspecs = refspecs.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
repo.push_refspecs(&base, &owner, &repo_id, &refspecs)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -370,75 +516,19 @@ impl SyncState {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const COMMIT_A: &str = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
const COMMIT_B: &str = "59429cfc6cb35b0a1ddace73b5a5c5ed57b8f5ca";
|
||||
|
||||
fn git_ref(name: &str, commit: &str) -> GitRef {
|
||||
GitRef::new(name, commit).expect("valid ref")
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
/// Refspecs to force push all heads and tags to the grasp servers.
|
||||
fn force_refspecs() -> Vec<String> {
|
||||
vec![
|
||||
"+refs/heads/*:refs/heads/*".to_owned(),
|
||||
"+refs/tags/*:refs/tags/*".to_owned(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Refspecs to delete server-only refs from the grasp servers.
|
||||
fn deletion_refspecs(declared: &[String], server_refs: &[(String, String)]) -> Vec<String> {
|
||||
server_refs
|
||||
.iter()
|
||||
.filter(|(name, _)| !declared.contains(name))
|
||||
.map(|(name, _)| format!(":{name}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -15,10 +15,12 @@ use gpui_base::{Button as BaseButton, Disableable, Popover};
|
||||
use gpui_component::alert::Alert;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::combobox::{Combobox, ComboboxEvent};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
use gpui_component::menu::{DropdownMenu, PopupMenu};
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{
|
||||
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex,
|
||||
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, WindowExt, h_flex,
|
||||
v_flex,
|
||||
};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::nips::nip19::Nip19Coordinate;
|
||||
@@ -72,6 +74,7 @@ pub(crate) enum RepoAction {
|
||||
SendPatch,
|
||||
About,
|
||||
Push,
|
||||
ResetRemote,
|
||||
Delete,
|
||||
}
|
||||
|
||||
@@ -841,6 +844,63 @@ impl RepoDetailView {
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
pub(super) fn reset_remote(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(announcement) = self.announcement(cx).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let store = self.store.clone();
|
||||
let mirror = Mirrors::path(&announcement.addr());
|
||||
|
||||
let urls: Vec<String> = announcement
|
||||
.clone
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect();
|
||||
|
||||
let probe = cx.background_spawn(async move {
|
||||
let mut deleted: Vec<String> = Vec::new();
|
||||
let repo = Repo::open(&mirror)?;
|
||||
|
||||
let local: HashSet<String> = repo
|
||||
.ref_state()?
|
||||
.refs
|
||||
.into_iter()
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
|
||||
for url in &urls {
|
||||
let Ok(remote_refs) = repo.ls_remote(url) else {
|
||||
continue;
|
||||
};
|
||||
for (name, _) in remote_refs {
|
||||
if !local.contains(&name) && !deleted.contains(&name) {
|
||||
deleted.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deleted.sort();
|
||||
|
||||
Ok::<_, Error>(deleted)
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let deleted = probe.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| match deleted {
|
||||
Ok(deleted) => open_reset_remote_dialog(store, deleted, window, cx),
|
||||
Err(error) => {
|
||||
this.error = Some(error.to_string().into());
|
||||
cx.notify();
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok::<(), Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Delete the repository from nostr, announcement, state and activity.
|
||||
pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let task = self
|
||||
@@ -971,6 +1031,7 @@ impl RepoDetailView {
|
||||
}
|
||||
}
|
||||
RepoAction::Push => this.push_repository(window, cx),
|
||||
RepoAction::ResetRemote => this.reset_remote(window, cx),
|
||||
RepoAction::Delete => this.delete_repository(window, cx),
|
||||
}),
|
||||
)
|
||||
@@ -1173,6 +1234,16 @@ impl RepoDetailView {
|
||||
.child(Icon::new(CustomIconName::Init))
|
||||
.child("Republish")
|
||||
})
|
||||
.menu_element(
|
||||
Box::new(RepoAction::ResetRemote),
|
||||
|_, _| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(Icon::new(IconName::RefreshCw))
|
||||
.child("Reset remote to local")
|
||||
},
|
||||
)
|
||||
.separator()
|
||||
.menu_element(Box::new(RepoAction::Delete), |_, cx| {
|
||||
h_flex()
|
||||
@@ -2043,6 +2114,75 @@ impl RepoDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirmation for the remote reset, listing the server refs it deletes.
|
||||
fn open_reset_remote_dialog(
|
||||
store: Entity<RepoStore>,
|
||||
deleted: Vec<String>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
const DESC: &str = "Grasp servers are overwritten to match the local mirror. \
|
||||
Refs that do not exist locally are deleted from the servers. \
|
||||
This cannot be undone.";
|
||||
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
let deleted = deleted.clone();
|
||||
let store = store.clone();
|
||||
|
||||
dialog
|
||||
.w(px(480.))
|
||||
.keyboard(true)
|
||||
.close_button(true)
|
||||
.content(move |body, _window, cx| {
|
||||
let deleted = deleted.clone();
|
||||
let store = store.clone();
|
||||
|
||||
body.child(
|
||||
DialogHeader::new()
|
||||
.child(DialogTitle::new().child("Reset remote to local"))
|
||||
.child(DialogDescription::new().child(DESC)),
|
||||
)
|
||||
.child(if deleted.is_empty() {
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("No server refs will be deleted.")
|
||||
.into_any_element()
|
||||
} else {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.children(deleted.iter().map(|name| {
|
||||
div()
|
||||
.text_sm()
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.child(name.clone())
|
||||
}))
|
||||
.into_any_element()
|
||||
})
|
||||
.child(
|
||||
DialogFooter::new()
|
||||
.justify_end()
|
||||
.child(Button::new("cancel-reset-remote").label("Cancel").on_click(
|
||||
|_, window, cx| {
|
||||
window.close_dialog(cx);
|
||||
},
|
||||
))
|
||||
.child(
|
||||
Button::new("confirm-reset-remote")
|
||||
.danger()
|
||||
.label("Reset remote")
|
||||
.on_click(move |_, window, cx| {
|
||||
store.update(cx, |store, cx| {
|
||||
store.reset_repository(cx).detach_and_log_err(cx);
|
||||
});
|
||||
window.close_dialog(cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString {
|
||||
let owner = announcement.owner;
|
||||
let user = nip05
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
use std::cell::Cell;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyWindowHandle, App, Entity, PathPromptOptions, SharedString, Subscription, Window, div, px,
|
||||
};
|
||||
use gpui::{App, Entity, PathPromptOptions, SharedString, Subscription, Window, div, px};
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::{
|
||||
Input, InputEvent, InputState, NumberInput, NumberInputEvent, StepAction,
|
||||
};
|
||||
use gpui_component::input::{Input, InputState};
|
||||
use gpui_component::select::{Select, SelectEvent, SelectState};
|
||||
use gpui_component::separator::Separator;
|
||||
use gpui_component::setting::NumberFieldOptions;
|
||||
use gpui_component::switch::Switch;
|
||||
use gpui_component::{
|
||||
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex,
|
||||
v_flex,
|
||||
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, WindowExt, h_flex, v_flex,
|
||||
};
|
||||
use settings::{AppearanceMode, EventFetchingStrategy, Settings, SettingsStore};
|
||||
use signed_ui::{SelectOption, setting_block, setting_row};
|
||||
@@ -31,34 +24,10 @@ fn selected_index(options: &[SelectOption], value: &str) -> Option<IndexPath> {
|
||||
.map(|row| IndexPath::default().row(row))
|
||||
}
|
||||
|
||||
/// Registered themes split into light and dark options, light first.
|
||||
fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
|
||||
let registry = ThemeRegistry::global(cx);
|
||||
let mut light = Vec::new();
|
||||
let mut dark = Vec::new();
|
||||
for config in registry.themes().values() {
|
||||
let name = config.name.clone();
|
||||
if config.mode.is_dark() {
|
||||
dark.push(SelectOption::new(name.clone(), name));
|
||||
} else {
|
||||
light.push(SelectOption::new(name.clone(), name));
|
||||
}
|
||||
}
|
||||
light.sort_by(|a, b| a.label().as_ref().cmp(b.label().as_ref()));
|
||||
dark.sort_by(|a, b| a.label().as_ref().cmp(b.label().as_ref()));
|
||||
(light, dark)
|
||||
}
|
||||
|
||||
/// Created once when the dialog opens, so control state survives re-renders.
|
||||
struct SettingsControls {
|
||||
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
||||
event_fetching: Entity<SelectState<Vec<SelectOption>>>,
|
||||
light_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||
dark_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||
font_size: Entity<InputState>,
|
||||
mono_font_size: Entity<InputState>,
|
||||
radius: Entity<InputState>,
|
||||
radius_lg: Entity<InputState>,
|
||||
grasp_server_input: Entity<InputState>,
|
||||
/// The effective create-repository folder, shown in a disabled input.
|
||||
default_folder: Entity<InputState>,
|
||||
@@ -107,38 +76,9 @@ impl SettingsControls {
|
||||
)
|
||||
});
|
||||
|
||||
let (light_options, dark_options) = theme_options(cx);
|
||||
let light_theme = cx.new(|cx| {
|
||||
SelectState::new(
|
||||
light_options.clone(),
|
||||
selected_index(&light_options, &settings.theme.light_theme),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let dark_theme = cx.new(|cx| {
|
||||
SelectState::new(
|
||||
dark_options.clone(),
|
||||
selected_index(&dark_options, &settings.theme.dark_theme),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let font_size = cx.new(|cx| {
|
||||
InputState::new(window, cx).default_value(settings.theme.font_size.to_string())
|
||||
});
|
||||
let mono_font_size = cx.new(|cx| {
|
||||
InputState::new(window, cx).default_value(settings.theme.mono_font_size.to_string())
|
||||
});
|
||||
let radius = cx
|
||||
.new(|cx| InputState::new(window, cx).default_value(settings.theme.radius.to_string()));
|
||||
let radius_lg = cx.new(|cx| {
|
||||
InputState::new(window, cx).default_value(settings.theme.radius_lg.to_string())
|
||||
});
|
||||
|
||||
let grasp_server_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("wss://relay.example.com"));
|
||||
|
||||
let default_folder = cx.new(|cx| {
|
||||
let folder = settings
|
||||
.create_repository
|
||||
@@ -178,118 +118,9 @@ impl SettingsControls {
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&light_theme, |_, event, cx| {
|
||||
if let SelectEvent::Confirm(Some(value)) = event {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| settings.theme.light_theme = value.to_string(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
apply_theme(cx);
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&dark_theme, |_, event, cx| {
|
||||
if let SelectEvent::Confirm(Some(value)) = event {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.dark_theme = value.to_string(), cx);
|
||||
});
|
||||
apply_theme(cx);
|
||||
}
|
||||
}));
|
||||
|
||||
wire_number_input(
|
||||
&font_size,
|
||||
&mut subscriptions,
|
||||
window.window_handle(),
|
||||
cx,
|
||||
NumberFieldOptions {
|
||||
min: 10.,
|
||||
max: 32.,
|
||||
..Default::default()
|
||||
},
|
||||
settings.theme.font_size as f64,
|
||||
|value, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.font_size = value as f32, cx);
|
||||
});
|
||||
apply_theme(cx);
|
||||
},
|
||||
);
|
||||
|
||||
wire_number_input(
|
||||
&mono_font_size,
|
||||
&mut subscriptions,
|
||||
window.window_handle(),
|
||||
cx,
|
||||
NumberFieldOptions {
|
||||
min: 8.,
|
||||
max: 32.,
|
||||
..Default::default()
|
||||
},
|
||||
settings.theme.mono_font_size as f64,
|
||||
|value, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.mono_font_size = value as f32, cx);
|
||||
});
|
||||
apply_theme(cx);
|
||||
},
|
||||
);
|
||||
|
||||
wire_number_input(
|
||||
&radius,
|
||||
&mut subscriptions,
|
||||
window.window_handle(),
|
||||
cx,
|
||||
NumberFieldOptions {
|
||||
min: 0.,
|
||||
max: 24.,
|
||||
..Default::default()
|
||||
},
|
||||
settings.theme.radius as f64,
|
||||
|value, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.radius = value as f32, cx);
|
||||
});
|
||||
apply_theme(cx);
|
||||
},
|
||||
);
|
||||
|
||||
wire_number_input(
|
||||
&radius_lg,
|
||||
&mut subscriptions,
|
||||
window.window_handle(),
|
||||
cx,
|
||||
NumberFieldOptions {
|
||||
min: 0.,
|
||||
max: 24.,
|
||||
..Default::default()
|
||||
},
|
||||
settings.theme.radius_lg as f64,
|
||||
|value, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.radius_lg = value as f32, cx);
|
||||
});
|
||||
apply_theme(cx);
|
||||
},
|
||||
);
|
||||
|
||||
Self {
|
||||
appearance,
|
||||
event_fetching,
|
||||
light_theme,
|
||||
dark_theme,
|
||||
font_size,
|
||||
mono_font_size,
|
||||
radius,
|
||||
radius_lg,
|
||||
grasp_server_input,
|
||||
default_folder,
|
||||
_subscriptions: subscriptions,
|
||||
@@ -320,8 +151,6 @@ fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement
|
||||
.w_full()
|
||||
.child(appearance_section(controls, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(theme_section(&settings, controls, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(tab_bar_section(&settings, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(grasp_servers_section(&settings, controls, cx))
|
||||
@@ -350,76 +179,6 @@ fn event_fetching_section(controls: &SettingsControls, cx: &App) -> impl IntoEle
|
||||
))
|
||||
}
|
||||
|
||||
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"Light Theme",
|
||||
"The theme to use when the appearance is light.",
|
||||
Select::new(&controls.light_theme).w_full(),
|
||||
))
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"Dark Theme",
|
||||
"The theme to use when the appearance is dark.",
|
||||
Select::new(&controls.dark_theme).w_full(),
|
||||
))
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"UI Font Size",
|
||||
"Font size for the UI.",
|
||||
NumberInput::new(&controls.font_size).w_full(),
|
||||
))
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"Editor Font Size",
|
||||
"Font size for editor text.",
|
||||
NumberInput::new(&controls.mono_font_size).w_full(),
|
||||
))
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"Corner Radius",
|
||||
"The corner radius for UI elements.",
|
||||
NumberInput::new(&controls.radius).w_full(),
|
||||
))
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"Large Corner Radius",
|
||||
"The corner radius for large UI elements (dialogs, notifications).",
|
||||
NumberInput::new(&controls.radius_lg).w_full(),
|
||||
))
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"Focus Ring",
|
||||
"Draw a ring around focused controls.",
|
||||
Switch::new("focus-ring")
|
||||
.checked(settings.theme.focus_ring)
|
||||
.on_click(move |checked: &bool, _window, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.focus_ring = *checked, cx);
|
||||
});
|
||||
apply_theme(cx);
|
||||
}),
|
||||
))
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"Shadows",
|
||||
"The shadow effect for UI elements.",
|
||||
Switch::new("shadows")
|
||||
.checked(settings.theme.shadow)
|
||||
.on_click(move |checked: &bool, _window, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.shadow = *checked, cx);
|
||||
});
|
||||
apply_theme(cx);
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Tab bar chrome, such as its next/previous tab buttons.
|
||||
fn tab_bar_section(settings: &Settings, cx: &App) -> impl IntoElement {
|
||||
v_flex().w_full().gap_3().child(setting_row(
|
||||
@@ -724,79 +483,6 @@ fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Windo
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Step actions clamp and persist the value; typed changes parse, clamp and persist.
|
||||
fn wire_number_input(
|
||||
state: &Entity<InputState>,
|
||||
subscriptions: &mut Vec<Subscription>,
|
||||
window_handle: AnyWindowHandle,
|
||||
cx: &mut App,
|
||||
options: NumberFieldOptions,
|
||||
initial: f64,
|
||||
on_change: impl Fn(f64, &mut App) + 'static,
|
||||
) {
|
||||
let on_change = Rc::new(on_change);
|
||||
let initial_value = Rc::new(Cell::new(initial));
|
||||
let (min, max, step) = (options.min, options.max, options.step);
|
||||
|
||||
subscriptions.push(cx.subscribe(&state.clone(), {
|
||||
let state = state.clone();
|
||||
let initial_value = initial_value.clone();
|
||||
let on_change = on_change.clone();
|
||||
move |_, event: &NumberInputEvent, cx| {
|
||||
let NumberInputEvent::Step(action) = event;
|
||||
let value = state.read(cx).value();
|
||||
if let Ok(value) = value.parse::<f64>() {
|
||||
let new_value = match action {
|
||||
StepAction::Increment => value + step,
|
||||
StepAction::Decrement => value - step,
|
||||
};
|
||||
let clamped = new_value.clamp(min, max);
|
||||
window_handle
|
||||
.update(cx, |_, window, cx| {
|
||||
state.update(cx, |input, cx| {
|
||||
input.set_value(SharedString::from(clamped.to_string()), window, cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
initial_value.set(clamped);
|
||||
on_change(clamped, cx);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&state.clone(), {
|
||||
let state = state.clone();
|
||||
let initial_value = initial_value.clone();
|
||||
let on_change = on_change.clone();
|
||||
move |_, event: &InputEvent, cx| {
|
||||
if matches!(event, InputEvent::Change) {
|
||||
let value = state.read(cx).value();
|
||||
if value == initial_value.get().to_string() {
|
||||
return;
|
||||
}
|
||||
if let Ok(parsed) = value.parse::<f64>() {
|
||||
let clamped = parsed.clamp(min, max);
|
||||
initial_value.set(clamped);
|
||||
on_change(clamped, cx);
|
||||
if clamped != parsed {
|
||||
window_handle
|
||||
.update(cx, |_, window, cx| {
|
||||
state.update(cx, |input, cx| {
|
||||
input.set_value(
|
||||
SharedString::from(clamped.to_string()),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
|
||||
match appearance {
|
||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||
@@ -804,33 +490,3 @@ fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
|
||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_theme(cx: &mut App) {
|
||||
let store = SettingsStore::global(cx);
|
||||
let settings = store.read(cx).settings().theme.clone();
|
||||
|
||||
let registry = ThemeRegistry::global(cx);
|
||||
let light_config = registry
|
||||
.themes()
|
||||
.get(settings.light_theme.as_str())
|
||||
.cloned();
|
||||
let dark_config = registry.themes().get(settings.dark_theme.as_str()).cloned();
|
||||
let mode = Theme::global(cx).mode;
|
||||
|
||||
let theme = Theme::global_mut(cx);
|
||||
theme.radius = px(settings.radius);
|
||||
theme.radius_lg = px(settings.radius_lg);
|
||||
theme.focus_ring = settings.focus_ring;
|
||||
theme.shadow = settings.shadow;
|
||||
theme.font_size = px(settings.font_size);
|
||||
theme.mono_font_size = px(settings.mono_font_size);
|
||||
if let Some(config) = light_config {
|
||||
theme.light_theme = config;
|
||||
}
|
||||
if let Some(config) = dark_config {
|
||||
theme.dark_theme = config;
|
||||
}
|
||||
|
||||
// Re-apply the active mode so the updated configs take effect.
|
||||
Theme::change(mode, None, cx);
|
||||
}
|
||||
|
||||
+4
-1
@@ -14,7 +14,7 @@ product-name = "Signed"
|
||||
description = "Nostr Git client for browsing repositories and collaborating"
|
||||
identifier = "info.reya.signed"
|
||||
category = "DeveloperTool"
|
||||
version = "0.2.0-alpha"
|
||||
version = "0.2.0"
|
||||
out-dir = "../dist"
|
||||
before-packaging-command = "cargo build --release"
|
||||
resources = ["Cargo.toml", "src"]
|
||||
@@ -38,6 +38,9 @@ gpui.workspace = true
|
||||
gpui_platform.workspace = true
|
||||
dock = { workspace = true }
|
||||
gpui-component.workspace = true
|
||||
native-theme.workspace = true
|
||||
native-theme-gpui.workspace = true
|
||||
flume.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
+109
-41
@@ -5,7 +5,9 @@ use dock::TAB_BAR_HEIGHT;
|
||||
use gpui::*;
|
||||
use gpui_component::{Theme, ThemeMode, ThemeRegistry, theme};
|
||||
use gpui_platform::application;
|
||||
use settings::{AppearanceMode, SettingsStore};
|
||||
use native_theme::watch::ThemeSubscription;
|
||||
use native_theme_gpui::{SystemTheme, apply_system_theme};
|
||||
use settings::{AppearanceMode, Settings, SettingsStore};
|
||||
|
||||
actions!(signed, [Quit]);
|
||||
|
||||
@@ -25,57 +27,64 @@ fn main() {
|
||||
// The persisted settings must load before the theme is applied.
|
||||
let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx));
|
||||
SettingsStore::set_global(store.clone(), cx);
|
||||
let settings = store.read(cx).settings().clone();
|
||||
|
||||
let registry = ThemeRegistry::global_mut(cx);
|
||||
for (name, content) in Assets.themes() {
|
||||
if let Err(err) = registry.load_themes_from_str(&content) {
|
||||
tracing::error!("Failed to load theme {name}: {err}");
|
||||
let settings = store.read(cx).settings().clone();
|
||||
{
|
||||
let registry = ThemeRegistry::global_mut(cx);
|
||||
for (name, content) in Assets.themes() {
|
||||
if let Err(err) = registry.load_themes_from_str(&content) {
|
||||
tracing::error!("Failed to load theme {name}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let light_theme = registry
|
||||
.themes()
|
||||
.get(settings.theme.light_theme.as_str())
|
||||
.cloned();
|
||||
let dark_theme = registry
|
||||
.themes()
|
||||
.get(settings.theme.dark_theme.as_str())
|
||||
.cloned();
|
||||
|
||||
let theme = Theme::global_mut(cx);
|
||||
theme.radius = px(settings.theme.radius);
|
||||
theme.radius_lg = px(settings.theme.radius_lg);
|
||||
theme.focus_ring = settings.theme.focus_ring;
|
||||
theme.shadow = settings.theme.shadow;
|
||||
theme.font_size = px(settings.theme.font_size);
|
||||
theme.mono_font_size = px(settings.theme.mono_font_size);
|
||||
match apply_native_theme(&settings, cx) {
|
||||
Ok(()) => start_theme_watcher(cx),
|
||||
Err(error) => {
|
||||
tracing::warn!("native theme unavailable ({error})");
|
||||
|
||||
if let Some(light) = light_theme {
|
||||
theme.light_theme = light;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"{} theme is missing from the registry",
|
||||
settings.theme.light_theme
|
||||
);
|
||||
}
|
||||
let registry = ThemeRegistry::global_mut(cx);
|
||||
let light_theme = registry
|
||||
.themes()
|
||||
.get(settings.theme.light_theme.as_str())
|
||||
.cloned();
|
||||
|
||||
if let Some(dark) = dark_theme {
|
||||
theme.dark_theme = dark;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"{} theme is missing from the registry",
|
||||
settings.theme.dark_theme
|
||||
);
|
||||
}
|
||||
let dark_theme = registry
|
||||
.themes()
|
||||
.get(settings.theme.dark_theme.as_str())
|
||||
.cloned();
|
||||
|
||||
match settings.appearance {
|
||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||
let theme = Theme::global_mut(cx);
|
||||
theme.radius = px(settings.theme.radius);
|
||||
theme.radius_lg = px(settings.theme.radius_lg);
|
||||
theme.focus_ring = settings.theme.focus_ring;
|
||||
theme.shadow = settings.theme.shadow;
|
||||
theme.font_size = px(settings.theme.font_size);
|
||||
theme.mono_font_size = px(settings.theme.mono_font_size);
|
||||
|
||||
if let Some(light) = light_theme {
|
||||
theme.light_theme = light;
|
||||
} else {
|
||||
tracing::warn!("{} theme is missing", settings.theme.light_theme);
|
||||
}
|
||||
|
||||
if let Some(dark) = dark_theme {
|
||||
theme.dark_theme = dark;
|
||||
} else {
|
||||
tracing::warn!("{} theme is missing", settings.theme.dark_theme);
|
||||
}
|
||||
|
||||
match settings.appearance {
|
||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(paths::nostr_dir()).ok();
|
||||
std::fs::create_dir_all(paths::repos_dir()).ok();
|
||||
|
||||
signed_state::init(
|
||||
paths::nostr_dir(),
|
||||
paths::repos_dir().clone(),
|
||||
@@ -115,3 +124,62 @@ fn main() {
|
||||
cx.activate(true);
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply the OS theme to the app, scaling the font size to match the system font size.
|
||||
fn apply_native_theme(settings: &Settings, cx: &mut App) -> native_theme::Result<()> {
|
||||
let mut system = SystemTheme::from_system()?;
|
||||
let native = system.light.defaults.font.size;
|
||||
if native > 0.0 {
|
||||
let scaling = settings.theme.font_size / native;
|
||||
system.accessibility.text_scaling_factor *= scaling;
|
||||
}
|
||||
|
||||
apply_system_theme(&system, cx);
|
||||
|
||||
match settings.appearance {
|
||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Holds the watcher subscription so it lives as long as the app.
|
||||
struct ThemeWatcher {
|
||||
_subscription: ThemeSubscription,
|
||||
}
|
||||
|
||||
impl Global for ThemeWatcher {}
|
||||
|
||||
/// Re-apply the OS theme whenever it changes at runtime.
|
||||
fn start_theme_watcher(cx: &mut App) {
|
||||
let (sender, receiver) = flume::unbounded();
|
||||
match native_theme::watch::on_theme_change(move |event| {
|
||||
if let Err(error) = sender.send(event) {
|
||||
tracing::warn!("theme change dropped ({error})");
|
||||
}
|
||||
}) {
|
||||
Ok(subscription) => {
|
||||
cx.set_global(ThemeWatcher {
|
||||
_subscription: subscription,
|
||||
});
|
||||
cx.spawn(async move |cx| {
|
||||
while receiver.recv_async().await.is_ok() {
|
||||
tracing::info!("OS theme changed; re-applying");
|
||||
cx.update(|cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
let settings = store.read(cx).settings().clone();
|
||||
if let Err(error) = apply_native_theme(&settings, cx) {
|
||||
tracing::warn!("failed to apply updated OS theme ({error})");
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!("OS theme watching unavailable ({error})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user