chore: remove unnecessary optimization (#20)
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run

Reviewed-on: #20
This commit was merged in pull request #20.
This commit is contained in:
2026-09-13 14:48:50 +00:00
parent f6b8a5e133
commit a74c166391
39 changed files with 389 additions and 724 deletions
Generated
+1
View File
@@ -8006,6 +8006,7 @@ dependencies = [
"bitcoin_hashes 1.2.0",
"flume 0.11.1",
"futures",
"gix",
"gpui",
"log",
"nostr",
+1 -6
View File
@@ -39,12 +39,7 @@ impl Assets {
.filter_map(|path| {
let data = Self::get(path.as_ref())?;
let name = path.strip_prefix("themes/").unwrap_or(path.as_ref());
let content = match data.data {
std::borrow::Cow::Borrowed(bytes) => {
std::str::from_utf8(bytes).ok()?.to_owned()
}
std::borrow::Cow::Owned(bytes) => String::from_utf8(bytes).ok()?,
};
let content = std::str::from_utf8(data.data.as_ref()).ok()?.to_owned();
Some((name.to_owned(), content))
})
.collect()
+4 -18
View File
@@ -15,7 +15,7 @@ use gpui_base::dock::{
};
use gpui_base::resize_handle;
use gpui_component::scroll::ScrollbarMode;
use gpui_component::{ActiveTheme as _, Side, StyledExt as _};
use gpui_component::{ActiveTheme as _, Side};
use crate::invalid_panel::InvalidPanel;
use crate::tab_panel::SignedTabGroupSkin;
@@ -155,27 +155,13 @@ impl DockAreaRenderer for SignedDockSkin {
cx: &mut App,
) -> AnyElement {
let placement = dock.placement();
let open = dock.is_open();
// A closed left or right dock takes no space.
// A closed bottom dock keeps a strip so its tab bar stays clickable.
if !open && !placement.is_bottom() {
return div().into_any_element();
}
div()
.flex()
.flex_none()
.size_full()
.relative()
.overflow_hidden()
.map(|this| match placement {
DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(dock.size()),
DockPlacement::Bottom => this.w_full().h(dock.size()),
// Base never builds a dock for the centre.
DockPlacement::Center => this,
})
// The closed bottom dock's strip is the tab bar itself, a full tab bar tall.
.when(!open && placement.is_bottom(), |this| {
// A closed bottom dock keeps a strip, and that strip is the tab bar.
.when(!dock.is_open() && placement.is_bottom(), |this| {
this.h(TAB_BAR_HEIGHT)
})
.child(content)
+1 -1
View File
@@ -198,7 +198,7 @@ impl SignedTabGroupSkin {
DockPlacement::Bottom => area
.layout(DockPlacement::Bottom)
.and_then(|tree| left_top_group(tree.root())),
DockPlacement::Center => None,
DockPlacement::Center => return None,
};
if designated != Some(group.node()) {
return None;
+5 -1
View File
@@ -90,9 +90,13 @@ pub fn nostr_dir() -> &'static PathBuf {
}
/// Returns the path to the local git clone cache, the grasp mirrors.
///
/// The mirrors are disposable and re-cloned from their grasp server on
/// demand, so the cache lives in the OS temp directory for the system to
/// reclaim.
pub fn repos_dir() -> &'static PathBuf {
static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new();
REPOS_DIR.get_or_init(|| data_dir().join("repos"))
REPOS_DIR.get_or_init(|| std::env::temp_dir().join(APP_NAME_LOWERCASE).join("repos"))
}
pub fn settings_file() -> &'static PathBuf {
-241
View File
@@ -5,244 +5,3 @@ use nostr::prelude::*;
/// A markdown note attached to an issue, patch or PR by its author or a maintainer,
/// not part of the NIP-34 draft, read support for interop.
pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624);
/// Whether a kind-1985 label event is a valid annotation of `root`.
///
/// The event references the root with a lowercase `e` tag,
/// its author must be the root author or a maintainer.
fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> bool {
if event.kind != Kind::Label {
return false;
}
if event.pubkey != root.pubkey && !maintainers.contains(&event.pubkey) {
return false;
}
let root_id = root.id.to_hex();
event
.tags
.iter()
.any(|tag| tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id))
}
/// Whether a kind-1985 label event declares the `#t` namespace,
/// it must also carry at least one `["l", "<value>", "#t"]` label.
fn has_hashtag_labels(event: &Event) -> bool {
event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"])
&& event.tags.iter().any(|tag| {
let slice = tag.as_slice();
slice.len() >= 3 && slice[0] == "l" && slice[2] == "#t" && !slice[1].is_empty()
})
}
/// Effective hashtag labels of `root`,
/// the `t` tags on the event itself, self-reported by its author,
/// authorized NIP-32 kind-1985 events in the `#t` namespace add more.
///
/// Labels are additive, so all valid label events contribute,
/// there is no latest-wins semantics.
pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -> Vec<String> {
let mut labels: Vec<String> = root
.tags
.hashtags()
.map(|hashtag| hashtag.to_string())
.collect();
for event in label_events {
if !label_targets_root(event, root, maintainers) || !has_hashtag_labels(event) {
continue;
}
for tag in event.tags.iter() {
let slice = tag.as_slice();
if slice.len() >= 3 && slice[0] == "l" && slice[2] == "#t" && !slice[1].is_empty() {
let label = &slice[1];
if !labels.contains(label) {
labels.push(label.clone());
}
}
}
}
labels
}
/// Subject or title override of `root` from authorized kind-1985 label events,
/// only label events in the `#subject` namespace count.
///
/// Returns `None` when no valid override exists.
pub fn subject_override(
root: &Event,
label_events: &[Event],
maintainers: &[PublicKey],
) -> Option<String> {
label_events
.iter()
.filter(|event| label_targets_root(event, root, maintainers))
.filter(|event| {
event
.tags
.iter()
.any(|tag| tag.as_slice() == ["L", "#subject"])
&& event.tags.iter().any(|tag| {
let slice = tag.as_slice();
slice.len() >= 3
&& slice[0] == "l"
&& slice[2] == "#subject"
&& !slice[1].is_empty()
})
})
.max_by(|a, b| {
a.created_at
.cmp(&b.created_at)
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
})
.and_then(|event| {
event.tags.iter().find_map(|tag| {
let slice = tag.as_slice();
(slice.len() >= 3
&& slice[0] == "l"
&& slice[2] == "#subject"
&& !slice[1].is_empty())
.then(|| slice[1].clone())
})
})
}
/// Effective hashtag labels and subject override of `root` in one pass,
/// mirrors ngit's `get_labels_and_subject`.
pub fn labels_and_subject(
root: &Event,
label_events: &[Event],
maintainers: &[PublicKey],
) -> (Vec<String>, Option<String>) {
(
labels(root, label_events, maintainers),
subject_override(root, label_events, maintainers),
)
}
/// Effective cover note of `root`.
///
/// Returns `None` when no valid cover note exists.
pub fn cover_note<'a>(
root: &Event,
cover_notes: &'a [Event],
maintainers: &[PublicKey],
) -> Option<&'a Event> {
let root_id = root.id.to_hex();
cover_notes
.iter()
.filter(|event| {
event.kind == COVER_NOTE_KIND
&& (event.pubkey == root.pubkey || maintainers.contains(&event.pubkey))
&& event.tags.iter().any(|tag| {
tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id)
})
})
.max_by(|a, b| {
a.created_at
.cmp(&b.created_at)
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn keys_from_hex(hex: &str) -> Keys {
Keys::new(SecretKey::from_hex(hex).expect("valid secret key"))
}
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(author)
.expect("signed event")
}
fn root_event() -> Event {
signed(
&keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001"),
Kind::GitIssue,
vec![Tag::hashtag("bug")],
100,
)
}
fn e_tag(event: &Event) -> Tag {
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
}
#[test]
fn labels_take_inline_hashtags_and_external_label_events() {
let root = root_event();
let maintainer =
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
let labels_event = signed(
&maintainer,
Kind::Label,
vec![
e_tag(&root),
Tag::parse(["L", "#t"]).expect("valid L tag"),
Tag::parse(["l", "help-wanted", "#t"]).expect("valid l tag"),
],
200,
);
let labels = labels(&root, &[labels_event], &[maintainer.public_key()]);
assert_eq!(labels, vec!["bug", "help-wanted"]);
}
#[test]
fn subject_override_latest_authorized_event_wins() {
let root = root_event();
let maintainer =
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
let older = signed(
&maintainer,
Kind::Label,
vec![
e_tag(&root),
Tag::parse(["L", "#subject"]).expect("valid L tag"),
Tag::parse(["l", "Old title", "#subject"]).expect("valid l tag"),
],
200,
);
let newer = signed(
&maintainer,
Kind::Label,
vec![
e_tag(&root),
Tag::parse(["L", "#subject"]).expect("valid L tag"),
Tag::parse(["l", "New title", "#subject"]).expect("valid l tag"),
],
300,
);
assert_eq!(
subject_override(&root, &[newer, older], &[maintainer.public_key()]),
Some("New title".to_owned())
);
}
#[test]
fn cover_note_latest_authorized_event_wins() {
let root = root_event();
let maintainer =
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
let stranger =
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000003");
let older = signed(&maintainer, COVER_NOTE_KIND, vec![e_tag(&root)], 200);
let newer = signed(&maintainer, COVER_NOTE_KIND, vec![e_tag(&root)], 300);
let unauthorized = signed(&stranger, COVER_NOTE_KIND, vec![e_tag(&root)], 400);
let newer_id = newer.id;
let events = [older, unauthorized, newer];
let maintainers = [maintainer.public_key()];
let note = cover_note(&root, &events, &maintainers);
assert_eq!(note.map(|event| event.id), Some(newer_id));
}
}
-87
View File
@@ -1,87 +0,0 @@
use nostr::prelude::*;
use crate::RepoAddr;
/// Target of a `nostr://` clone URL, as defined by NIP-34.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CloneTarget {
/// `nostr://<naddr1...>` encodes a direct repository address.
Addr(RepoAddr),
/// `nostr://<npub|nip05>/[relay-hint/]<identifier>`
UserRepo {
/// `npub1...` or a NIP-05 identifier.
user: String,
relay_hint: Option<RelayUrl>,
/// `d` tag identifier of the repository.
identifier: String,
},
}
/// Parse a `nostr://` clone URL. Returns `None` for other URL schemes.
pub fn parse_clone_url(url: &str) -> Option<CloneTarget> {
let rest = url.strip_prefix("nostr://")?;
let mut parts = rest.split('/');
let first = parts.next()?;
let second = parts.next()?;
let third = parts.next();
if first.starts_with("naddr1") {
let coordinate = Nip19Coordinate::from_bech32(first).ok()?;
return Some(CloneTarget::Addr(coordinate.coordinate));
}
let (relay_hint, identifier) = match third {
Some(id) => (
RelayUrl::parse(&percent_decode(second)).ok(),
percent_decode(id),
),
None => (None, percent_decode(second)),
};
Some(CloneTarget::UserRepo {
user: first.to_owned(),
relay_hint,
identifier,
})
}
fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = &input[i + 1..i + 3];
if let Ok(v) = u8::from_str_radix(hex, 16) {
out.push(v);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_percent_encoded_parts() {
let target = parse_clone_url(
"nostr://danconwaydev.com/ws%3A%2F%2Flocalhost%3A7334/my-local-only-repo",
)
.unwrap();
assert_eq!(
target,
CloneTarget::UserRepo {
user: "danconwaydev.com".to_owned(),
relay_hint: RelayUrl::parse("ws://localhost:7334").ok(),
identifier: "my-local-only-repo".to_owned(),
}
);
}
}
-12
View File
@@ -91,18 +91,6 @@ pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
.events(roots)
}
/// Cover notes and NIP-32 label events referencing any of the given root events.
/// These are kinds 1624 and 1985, matched via the `#e` tag.
///
/// Because they carry no repository `a` tag, they are fetched by root like comments.
///
/// Batched, like [`statuses_for`].
pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
Filter::new()
.kinds([crate::COVER_NOTE_KIND, Kind::Label])
.events(roots)
}
/// A user's grasp list, kind `10317`.
pub fn grasp_list(public_key: PublicKey) -> Filter {
Filter::new()
+5 -10
View File
@@ -19,8 +19,6 @@ pub struct InboxItem {
pub root: EventId,
/// The root event itself, when it is known locally.
pub root_event: Option<Event>,
/// Kind of the root event, when it is known locally.
pub root_kind: Option<Kind>,
/// Repository the root belongs to, from the root's `a` tag.
pub address: Option<RepoAddr>,
/// Notification events directed at the user, newest first.
@@ -45,13 +43,11 @@ impl InboxItem {
}
pub fn kind(&self) -> Option<Kind> {
self.root_kind.or_else(|| {
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(|event| event.kind)
})
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(|event| event.kind)
}
/// Timestamp of the newest event in the thread.
@@ -202,7 +198,6 @@ where
let mut item = InboxItem {
root,
root_kind: root_event.as_ref().map(|event| event.kind),
address: root_event
.as_ref()
.and_then(|event| event.tags.coordinates().next()),
+1 -3
View File
@@ -1,6 +1,5 @@
pub mod addr;
pub mod annotations;
pub mod clone_url;
pub mod deletions;
pub mod filters;
pub mod inbox;
@@ -9,8 +8,7 @@ pub mod state;
pub mod status;
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
pub use clone_url::{CloneTarget, parse_clone_url};
pub use annotations::COVER_NOTE_KIND;
pub use deletions::Deletions;
pub use filters::{
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
+2 -9
View File
@@ -41,12 +41,10 @@ pub struct Upstream {
/// Upstream repository coordinate when the `u` tag names a NIP-34 repository.
/// `None` for the git-URL form.
pub addr: Option<RepoAddr>,
/// Relay hint for the upstream, if the `u` tag carries one.
pub relay_hint: Option<RelayUrl>,
}
impl Upstream {
fn parse(raw: &str, relay_hint: Option<&str>) -> Self {
fn parse(raw: &str) -> Self {
let coordinate = raw.split('|').next().unwrap_or(raw);
let addr = coordinate
.parse::<Coordinate>()
@@ -55,7 +53,6 @@ impl Upstream {
Self {
raw: raw.to_owned(),
addr,
relay_hint: relay_hint.and_then(|hint| RelayUrl::parse(hint).ok()),
}
}
@@ -317,7 +314,7 @@ impl Announcement {
let values = tag.as_slice();
let raw = values.get(1).map(String::as_str).unwrap_or_default();
if !raw.is_empty() {
upstream = Some(Upstream::parse(raw, values.get(2).map(String::as_str)));
upstream = Some(Upstream::parse(raw));
}
}
}
@@ -511,10 +508,6 @@ mod tests {
upstream.raw,
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git"
);
assert_eq!(
upstream.relay_hint,
Some(RelayUrl::parse("wss://relay.example.com").expect("valid relay"))
);
assert_eq!(
upstream.display().to_string(),
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream"
-11
View File
@@ -72,17 +72,6 @@ fn file_commit_with_description(
})
}
/// Find the most recent commit that changed `rel`, a path relative to the worktree.
///
/// `Ok(None)` when no commit touched the file, e.g. an untracked file.
pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileCommit>> {
let rel = rel.to_path_buf();
Ok(last_commits(repo, std::slice::from_ref(&rel))?
.into_iter()
.next()
.map(|(_, commit)| commit))
}
/// Newest commit touching each of `rels`, like `git log -1 -- <rel>` per path.
/// `rels` are paths relative to the worktree.
///
+2 -2
View File
@@ -16,8 +16,8 @@ pub use diff::{
worktree_commit_range_diff,
};
pub use history::{
CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, last_commit,
worktree_all_commits, worktree_commit, worktree_commit_range_commits, worktree_last_commits,
CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, worktree_all_commits,
worktree_commit, worktree_commit_range_commits, worktree_last_commits,
};
pub use patch::{
apply_patch, format_patch_between, patch_commits, patch_diffs, split_patch_series,
+3 -9
View File
@@ -1,6 +1,6 @@
use std::path::Path;
use anyhow::{Context, Result, bail};
use anyhow::{Context, Result};
use crate::history::open_with_cache;
use crate::worktree::{force_checkout, worktree_dirty};
@@ -171,12 +171,7 @@ pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<Str
let mut index = repo.index_from_tree(&tree)?;
index.write(gix::index::write::Options::default())?;
let commit = commit.to_string();
if commit.len() != 40 {
bail!("unexpected initial commit id: {commit}");
}
Ok(commit)
Ok(commit.to_string())
}
/// The earliest unique commit of the repository at `repo_path`.
@@ -201,8 +196,7 @@ pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
{
let info = info?;
if info.parent_ids().next().is_none() {
let id = info.id().to_string();
return Ok((id.len() == 40).then_some(id));
return Ok(Some(info.id().to_string()));
}
}
+13 -7
View File
@@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::Command;
use super::*;
@@ -578,7 +578,7 @@ fn git_run(dir: &Path, args: &[&str]) {
}
#[test]
fn last_commit_returns_most_recent_change() {
fn worktree_last_commits_returns_most_recent_change() {
let (dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
@@ -589,9 +589,12 @@ fn last_commit_returns_most_recent_change() {
std::fs::write(dir.path().join("b.txt"), b"other").expect("write");
commit_all(&repo, "add b");
let commit = last_commit(&repo, Path::new("a.txt"))
let commit = worktree_last_commits(dir.path(), &[PathBuf::from("a.txt")])
.expect("lookup")
.expect("found");
.into_iter()
.next()
.expect("found")
.1;
assert_eq!(commit.summary, "change a");
assert_eq!(commit.author, "Test Author");
assert!(!commit.id.is_empty());
@@ -621,7 +624,7 @@ fn all_commits_lists_every_commit() {
}
#[test]
fn last_commit_reports_merge_commits() {
fn worktree_last_commits_reports_merge_commits() {
let (dir, repo) = fixture(&[("a.txt", b"base")]);
commit_all(&repo, "initial");
@@ -645,9 +648,12 @@ fn last_commit_reports_merge_commits() {
// `--no-ff` forces a merge commit, it is the latest commit changing a.txt.
run(&["merge", "--no-ff", "--no-edit", "feature"]);
let commit = last_commit(&repo, Path::new("a.txt"))
let commit = worktree_last_commits(dir.path(), &[PathBuf::from("a.txt")])
.expect("lookup")
.expect("found");
.into_iter()
.next()
.expect("found")
.1;
assert_eq!(
commit.id,
repo.head_id().expect("head").shorten_or_id().to_string()
+3 -13
View File
@@ -64,20 +64,10 @@ pub fn worktree_commits_ahead(workdir: &Path, base: &str, branch: &str) -> u32 {
walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32
}
/// Resolve `rev` to a commit id, accepting full refs,
/// symbolic refs and the bare branch names callers pass, like git's DWIM.
/// Resolve `rev` to a commit id, accepting full refs or the bare branch names
/// callers pass. `gix`'s revision parser already applies git's ref DWIM.
fn resolve_commit<'a>(repo: &'a gix::Repository, rev: &str) -> Option<gix::Id<'a>> {
if let Ok(id) = repo.rev_parse_single(rev.as_bytes()) {
return Some(id);
}
// Branch names arrive bare, like git resolving `main`.
if rev.contains('/') {
return None;
}
repo.rev_parse_single(format!("refs/heads/{rev}").as_bytes())
.ok()
repo.rev_parse_single(rev.as_bytes()).ok()
}
/// Relative paths of all entries in the worktree, files and directories.
+16 -39
View File
@@ -5,32 +5,9 @@ use std::pin::Pin;
use std::sync::{Arc, RwLock};
use nostr_connect::client::AuthUrlHandler;
use nostr_sdk::error::Error as SignerError;
use nostr_sdk::prelude::*;
#[derive(Debug)]
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
impl fmt::Display for UniversalSignerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl Error for UniversalSignerError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&*self.0)
}
}
impl UniversalSignerError {
pub fn new<E>(err: E) -> Self
where
E: Error + Send + Sync + 'static,
{
UniversalSignerError(Box::new(err))
}
}
/// A type-erased signer whose inner signer can be swapped in-place.
#[derive(Clone, Debug)]
pub struct UniversalSigner {
@@ -65,21 +42,21 @@ impl UniversalSigner {
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>>;
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>>;
fn nip44_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
}
#[derive(Debug)]
@@ -94,22 +71,22 @@ where
{
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>> {
Box::pin(async move {
AsyncGetPublicKey::get_public_key_async(&self.0)
.await
.map_err(UniversalSignerError::new)
.map_err(SignerError::other)
})
}
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>> {
Box::pin(async move {
AsyncSignEvent::sign_event_async(&self.0, unsigned)
.await
.map_err(UniversalSignerError::new)
.map_err(SignerError::other)
})
}
@@ -117,11 +94,11 @@ where
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
Box::pin(async move {
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
.await
.map_err(UniversalSignerError::new)
.map_err(SignerError::other)
})
}
@@ -129,17 +106,17 @@ where
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
Box::pin(async move {
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
.await
.map_err(UniversalSignerError::new)
.map_err(SignerError::other)
})
}
}
impl AsyncGetPublicKey for UniversalSigner {
type Error = UniversalSignerError;
type Error = SignerError;
fn get_public_key_async(
&self,
@@ -150,7 +127,7 @@ impl AsyncGetPublicKey for UniversalSigner {
}
impl AsyncSignEvent for UniversalSigner {
type Error = UniversalSignerError;
type Error = SignerError;
fn sign_event_async(
&self,
@@ -162,7 +139,7 @@ impl AsyncSignEvent for UniversalSigner {
}
impl AsyncNip44 for UniversalSigner {
type Error = UniversalSignerError;
type Error = SignerError;
fn nip44_encrypt_async<'a>(
&'a self,
+1
View File
@@ -17,6 +17,7 @@ nostr-connect.workspace = true
bitcoin_hashes = "1"
gix.workspace = true
gpui.workspace = true
flume.workspace = true
futures.workspace = true
+19 -36
View File
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
use anyhow::{Error, anyhow, bail};
use bitcoin_hashes::sha1::Hash as Sha1Hash;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
use gpui::{App, AppContext, BackgroundExecutor, Context, Entity, EventEmitter, Global, Task};
use nostr::event::IntoEventBuilder;
use nostr_connect::prelude::*;
use nostr_sdk::client::SyncSummary;
@@ -13,7 +13,7 @@ use nostr_sdk::prelude::*;
use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_name};
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
use crate::git_store::GitStore;
use crate::git_store::repo_mirror_path;
use crate::inbox::Inbox;
use crate::repos::RepoListStore;
@@ -531,6 +531,7 @@ impl Backend {
let repo_id = repo_id.clone();
let servers = servers.clone();
let refs = refs.clone();
let executor = cx.background_executor().clone();
async move {
push_staged_to_grasps(
&client,
@@ -541,6 +542,7 @@ impl Backend {
&destination,
&owner,
&servers,
&executor,
signed_git::push_main,
)
.await
@@ -686,6 +688,7 @@ impl Backend {
let servers = servers.clone();
let refs = refs.clone();
let head = head.clone();
let executor = cx.background_executor().clone();
async move {
push_staged_to_grasps(
&client,
@@ -696,6 +699,7 @@ impl Backend {
&path,
&owner,
&servers,
&executor,
signed_git::push_all,
)
.await
@@ -752,8 +756,7 @@ impl Backend {
announcement: Announcement,
cx: &mut Context<Self>,
) -> Task<Result<PushOutcome, Error>> {
let cache = GitStore::global(cx).cache().clone();
let path = cache.repo_path(&announcement.addr());
let path = repo_mirror_path(&announcement.addr());
self.push_repo_from(announcement, path, None, cx)
}
@@ -854,6 +857,7 @@ impl Backend {
let relays = relays.clone();
let refs = refs.clone();
let head = head.clone();
let executor = cx.background_executor().clone();
async move {
push_staged_to_grasps(
&client,
@@ -864,6 +868,7 @@ impl Backend {
&path,
&owner,
&relays,
&executor,
signed_git::push_all,
)
.await
@@ -1086,10 +1091,6 @@ impl Backend {
self.signer.clone()
}
pub fn pushing_repos(&self) -> Entity<HashSet<RepoAddr>> {
self.pushing_repos.clone()
}
pub fn inbox(&self) -> Entity<Inbox> {
self.inbox.clone()
}
@@ -1102,10 +1103,6 @@ impl Backend {
self.passphrase_required
}
pub fn emit_error(&mut self, message: impl Into<String>, cx: &mut Context<Self>) {
cx.emit(BackendEvent::error(message));
}
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
let client = self.client.clone();
let me = self.current_user;
@@ -1498,24 +1495,21 @@ const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1);
#[derive(Debug, Clone)]
pub struct GraspServerResult {
pub relay: RelayUrl,
pub git_url: String,
/// `None` when the server accepted the data, the reason otherwise.
pub reason: Option<String>,
}
impl GraspServerResult {
fn ok(relay: RelayUrl, git_url: String) -> Self {
fn ok(relay: RelayUrl) -> Self {
Self {
relay,
git_url,
reason: None,
}
}
fn failed(relay: RelayUrl, git_url: String, reason: impl Into<String>) -> Self {
fn failed(relay: RelayUrl, reason: impl Into<String>) -> Self {
Self {
relay,
git_url,
reason: Some(reason.into()),
}
}
@@ -1711,21 +1705,16 @@ async fn push_staged_to_grasps(
path: &Path,
owner: &str,
servers: &[RelayUrl],
executor: &BackgroundExecutor,
push: fn(&Path, &str, &str, &str) -> Result<(), Error>,
) -> PushOutcome {
let mut outcome = PushOutcome::default();
if refs.is_empty() {
return outcome;
}
for relay in servers {
let Some(base) = grasp_base_url(relay) else {
outcome.servers.push(GraspServerResult::failed(
relay.clone(),
relay.to_string(),
"no domain",
));
outcome
.servers
.push(GraspServerResult::failed(relay.clone(), "no domain"));
continue;
};
let git_url = format!("{base}/{owner}/{repo_id}.git");
@@ -1739,7 +1728,7 @@ async fn push_staged_to_grasps(
'server: for attempt in 1..=GRASP_PUSH_ATTEMPTS {
if attempt > 1 {
// Give the server's ingest a moment before re-staging.
std::thread::sleep(GRASP_RETRY_DELAY);
executor.timer(GRASP_RETRY_DELAY).await;
}
let (event, created_at) =
@@ -1806,11 +1795,9 @@ async fn push_staged_to_grasps(
log::warn!("grasp push failed: {relay}: {reason}");
outcome
.servers
.push(GraspServerResult::failed(relay.clone(), git_url, reason));
.push(GraspServerResult::failed(relay.clone(), reason));
}
None => outcome
.servers
.push(GraspServerResult::ok(relay.clone(), git_url)),
None => outcome.servers.push(GraspServerResult::ok(relay.clone())),
}
}
@@ -1968,13 +1955,9 @@ mod tests {
fn push_outcome_reports_partial_failures() {
let outcome = PushOutcome {
servers: vec![
GraspServerResult::ok(
RelayUrl::parse("wss://gitnostr.com").expect("url"),
"https://gitnostr.com/npub1owner/repo.git".to_owned(),
),
GraspServerResult::ok(RelayUrl::parse("wss://gitnostr.com").expect("url")),
GraspServerResult::failed(
RelayUrl::parse("wss://relay.ngit.dev").expect("url"),
"https://relay.ngit.dev/npub1owner/repo.git".to_owned(),
"remote: ERR authorisation failed: No state events in purgatory\nfatal: ...",
),
],
+13 -6
View File
@@ -9,7 +9,7 @@ use settings::{CheckoutRecord, SettingsStore};
use signed_core::{Announcement, RepoAddr};
use crate::backend::{Backend, BackendEvent};
use crate::git_store::GitStore;
use crate::git_store::repo_mirror_root;
use crate::refresh::{RefreshGate, RefreshRequest};
use crate::repos::{LocalReposStore, RepoListStore};
@@ -93,6 +93,8 @@ pub struct CheckoutsStore {
/// A recompute defaults the base the same way.
requested_head: HashMap<RepoAddr, Option<String>>,
refresh: RefreshGate,
/// True while the timer between a scheduled refresh and its run is pending.
debounce_pending: bool,
local_pending: bool,
/// When the last full pass (with a remote refresh) completed.
///
@@ -163,6 +165,7 @@ impl CheckoutsStore {
push_statuses: HashMap::new(),
requested_head: HashMap::new(),
refresh: RefreshGate::default(),
debounce_pending: false,
local_pending: false,
last_full_sync: None,
_subscriptions: subscriptions,
@@ -279,12 +282,15 @@ impl CheckoutsStore {
/// Re-resolve the associations and the requested statuses.
///
/// Requests arriving while a pass runs fold into a follow-up.
/// Requests arriving while a pass runs fold into a follow-up, requests
/// arriving while the debounce timer is pending are dropped.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refresh.request() != RefreshRequest::Schedule {
if self.debounce_pending || self.refresh.request() != RefreshRequest::Schedule {
return;
}
self.debounce_pending = true;
cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx))
@@ -300,6 +306,7 @@ impl CheckoutsStore {
/// remote reconciliation cadence ([`Self::local_tick`]); they also restart
/// the fast local pass.
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.debounce_pending = false;
self.refresh.begin();
let records = {
@@ -321,7 +328,7 @@ impl CheckoutsStore {
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
let scanned = LocalReposStore::global(cx).read(cx).repos.clone();
let cache_root = GitStore::global(cx).cache().root().canonicalize().ok();
let cache_root = repo_mirror_root().canonicalize().ok();
let requested: Vec<(RepoAddr, Option<String>)> = self
.status_requested
@@ -453,7 +460,7 @@ impl CheckoutsStore {
}
// A full pass or a fresh request covers this tick, skip it.
if self.refresh.running() || self.refresh.debouncing() {
if self.refresh.running() || self.debounce_pending {
self.schedule_local_pass(cx);
return;
}
@@ -515,7 +522,7 @@ impl CheckoutsStore {
this.update(cx, |this, cx| {
// A full pass or a fresh request will apply fresher data
// (the tracking refs move only when a full pass fetches).
if this.refresh.running() || this.refresh.debouncing() {
if this.refresh.running() || this.debounce_pending {
return;
}
+29 -20
View File
@@ -1,32 +1,41 @@
use std::path::PathBuf;
use std::sync::OnceLock;
use gpui::{App, Global};
use anyhow::Result;
use gix::Repository;
use signed_core::RepoAddr;
use signed_git::GitCache;
struct GlobalGitStore(GitCache);
static GIT_CACHE: OnceLock<GitCache> = OnceLock::new();
impl Global for GlobalGitStore {}
fn git_cache() -> &'static GitCache {
GIT_CACHE
.get()
.expect("git cache is initialized by signed_state::init")
}
/// Global access to the on-disk git clone cache, the grasp mirrors.
#[derive(Debug, Clone)]
pub struct GitStore(GitCache);
/// The root directory of the repository mirrors.
pub(crate) fn repo_mirror_root() -> PathBuf {
git_cache().root().to_path_buf()
}
impl GitStore {
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
let store = Self::new(root);
cx.set_global(GlobalGitStore(store.0.clone()));
store
}
/// The on-disk path of the mirror of `addr`.
pub fn repo_mirror_path(addr: &RepoAddr) -> PathBuf {
git_cache().repo_path(addr)
}
pub fn global(cx: &App) -> Self {
Self(cx.global::<GlobalGitStore>().0.clone())
}
/// Open the mirror of `addr`, if it has been cloned.
pub fn open_repo_mirror(addr: &RepoAddr) -> Result<Option<Repository>> {
git_cache().open(addr)
}
fn new(root: impl Into<PathBuf>) -> Self {
Self(GitCache::new(root.into()))
}
/// Open the mirror of `addr`, cloning it first when it does not exist yet.
pub fn ensure_repo_mirror<U: AsRef<str>>(addr: &RepoAddr, clone_urls: &[U]) -> Result<Repository> {
git_cache().ensure_clone(addr, clone_urls)
}
pub fn cache(&self) -> &GitCache {
&self.0
pub(crate) fn set_git_cache(root: impl Into<PathBuf>) {
if GIT_CACHE.set(GitCache::new(root.into())).is_err() {
log::warn!("git cache root is already set, keeping the first one");
}
}
+16 -3
View File
@@ -11,7 +11,8 @@ use std::path::{Path, PathBuf};
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
pub use git_store::GitStore;
use git_store::set_git_cache;
pub use git_store::{ensure_repo_mirror, open_repo_mirror, repo_mirror_path};
use gpui::{App, AppContext};
pub use inbox::{Inbox, query_inbox};
pub use nostr_sdk::prelude::Timestamp;
@@ -31,6 +32,7 @@ pub fn init(
// rustls uses the `aws_lc_rs` provider by default.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
// Initialize the nostr client and signer
let (client, signer) = cx.foreground_executor().block_on(async move {
let path = db_path.as_ref().to_path_buf();
new_backend(path)
@@ -38,11 +40,22 @@ pub fn init(
.expect("failed to initialize nostr backend")
});
// Set Git cache for the repos root
set_git_cache(repos_root);
// Set global stores for the backend
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
// Set global stores for the profile
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
// Set global stores for the repo list and local repos
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
GitStore::set_global(repos_root, cx);
// Set global stores for the local repos
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
// Set global stores for the checkouts
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
}
@@ -50,10 +63,10 @@ pub fn init(
#[cfg(target_arch = "wasm32")]
pub fn init(cx: &mut App) {
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
set_git_cache(PathBuf::new());
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);
GitStore::set_global(PathBuf::new(), cx);
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
}
+1 -1
View File
@@ -50,7 +50,7 @@ impl Profile {
return SharedString::from(name.trim().to_owned());
}
SharedString::from(shorten_pubkey(self.public_key, 4))
SharedString::from(shorten_pubkey(self.public_key))
}
pub fn picture(&self) -> Option<SharedString> {
+49 -13
View File
@@ -3,14 +3,13 @@
pub struct RefreshGate {
running: bool,
dirty: bool,
debouncing: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshRequest {
/// No run or timer covers the request, start the debounce timer.
/// No run covers the request, start one now.
Schedule,
/// A run or pending timer already covers the request.
/// A run is in flight and covers the request, fold it into a follow-up.
Fold,
}
@@ -19,28 +18,20 @@ impl RefreshGate {
self.running
}
pub fn debouncing(&self) -> bool {
self.debouncing
}
/// A new refresh request arrived.
///
/// Folded into a follow-up run while one is in flight, dropped while the
/// debounce timer is pending, otherwise starts the timer.
/// Folded into a follow-up run while one is in flight, otherwise the
/// caller starts the run itself.
pub fn request(&mut self) -> RefreshRequest {
if self.running {
self.dirty = true;
RefreshRequest::Fold
} else if self.debouncing {
RefreshRequest::Fold
} else {
self.debouncing = true;
RefreshRequest::Schedule
}
}
pub fn begin(&mut self) {
self.debouncing = false;
self.running = true;
}
@@ -55,3 +46,48 @@ impl RefreshGate {
self.running = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_request_while_running_folds_into_a_follow_up() {
let mut gate = RefreshGate::default();
gate.begin();
assert_eq!(gate.request(), RefreshRequest::Fold);
assert!(gate.finish());
}
#[test]
fn a_request_without_a_run_schedules() {
let mut gate = RefreshGate::default();
assert_eq!(gate.request(), RefreshRequest::Schedule);
assert!(!gate.running());
}
#[test]
fn a_request_after_a_run_schedules_again() {
let mut gate = RefreshGate::default();
gate.begin();
assert_eq!(gate.request(), RefreshRequest::Fold);
assert!(gate.finish());
assert_eq!(gate.request(), RefreshRequest::Schedule);
}
#[test]
fn abort_keeps_the_pending_request() {
let mut gate = RefreshGate::default();
gate.begin();
assert_eq!(gate.request(), RefreshRequest::Fold);
gate.abort();
assert!(!gate.running());
gate.begin();
assert!(gate.finish());
}
}
+2 -18
View File
@@ -17,7 +17,7 @@ use crate::backend::{
user_grasp_list_servers,
};
use crate::checkouts::CheckoutsStore;
use crate::git_store::GitStore;
use crate::git_store::ensure_repo_mirror;
use crate::refresh::{RefreshGate, RefreshRequest};
use crate::repos::RepoListStore;
@@ -56,10 +56,6 @@ pub struct RepoStore {
/// Computed with [`Self::status_by_root`] on every refresh.
open_issue_count: usize,
open_pr_count: usize,
/// Incremented on every applied refresh.
///
/// Views key their derived-data caches to it instead of recomputing on every render.
version: u64,
pub last_error: Option<String>,
/// Non-fatal warning of the last action, if any.
///
@@ -125,7 +121,6 @@ impl RepoStore {
status_by_root: HashMap::new(),
open_issue_count: 0,
open_pr_count: 0,
version: 0,
last_error: None,
last_warning: None,
last_push_warning: None,
@@ -153,7 +148,6 @@ impl RepoStore {
status_by_root: HashMap::new(),
open_issue_count: 0,
open_pr_count: 0,
version: 0,
last_error: None,
last_warning: None,
last_push_warning: None,
@@ -401,9 +395,6 @@ impl RepoStore {
}
}
// Cover notes, 1624, and label events, 1985, carry no `a` tag.
// Query them per root like comments and statuses.
// The events are only stored for interop and nothing displays them.
sort_newest_first(&mut issues);
sort_newest_first(&mut patches);
sort_newest_first(&mut pull_requests);
@@ -522,7 +513,6 @@ impl RepoStore {
this.open_issue_count = open_issue_count;
this.open_pr_count = open_pr_count;
this.loaded = true;
this.version = this.version.wrapping_add(1);
// Comments and statuses without an `a` tag.
// None are addressed to the repository.
@@ -580,10 +570,6 @@ impl RepoStore {
status_of(&self.status_by_root, root)
}
pub fn version(&self) -> u64 {
self.version
}
/// Number of open issues.
///
/// Issues whose resolved status is [`RepoStatus::Open`].
@@ -1210,8 +1196,6 @@ impl RepoStore {
return;
}
let cache = GitStore::global(cx).cache().clone();
let clone_urls: Vec<Url> = self
.announcement
.as_ref()
@@ -1237,7 +1221,7 @@ impl RepoStore {
let root = root.clone();
let apply = cx.background_spawn(async move {
let repo = cache.ensure_clone(&addr, &clone_urls)?;
let repo = ensure_repo_mirror(&addr, &clone_urls)?;
let workdir = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
+7 -24
View File
@@ -8,22 +8,18 @@ use gpui_component::menu::PopupMenu;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex};
/// A split dropdown button built on `gpui_base::Popover`.
/// An action element with a separate caret trigger that opens a [`PopupMenu`].
/// The action and the caret are caller-supplied elements, so the look stays in the app.
/// This component only owns the popover wiring.
/// An action element next to a caret that opens a [`PopupMenu`].
#[derive(IntoElement)]
pub struct DropdownButton {
id: ElementId,
style: StyleRefinement,
anchor: Anchor,
action: Option<AnyElement>,
caret: Option<CaretBuilder>,
menu: Option<MenuBuilder>,
}
type MenuBuilder =
Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>;
type CaretBuilder = Box<dyn FnOnce(bool, &Window, &App) -> AnyElement>;
impl DropdownButton {
pub fn new(id: impl Into<ElementId>) -> Self {
@@ -32,7 +28,6 @@ impl DropdownButton {
style: StyleRefinement::default(),
anchor: Anchor::TopRight,
action: None,
caret: None,
menu: None,
}
}
@@ -54,14 +49,6 @@ impl DropdownButton {
self.menu = Some(Box::new(builder));
self
}
/// Which corner of the caret the menu anchors to.
/// Defaults to [`Anchor::TopRight`], lining the menu's right edge up with the caret's.
#[allow(dead_code)] // API knob, current call sites use the default anchor.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self
}
}
impl Styled for DropdownButton {
@@ -91,28 +78,24 @@ impl RenderOnce for DropdownButton {
let menu_state =
window.use_keyed_state(popover_id.clone(), cx, |_, _| DropdownMenuState::default());
let caret = self.caret.unwrap_or_else(|| {
let id = popover_id.clone();
Box::new(move |is_open, _, cx| {
let caret = default_caret(id.clone(), cx);
let selected = caret.is_selected();
caret.selected(selected || is_open).into_any_element()
})
});
h_flex()
.id(self.id)
.refine_style(&self.style)
.gap_0p5()
.when_some(self.action, |this, action| this.child(action))
.when_some(self.menu, |this, builder| {
let caret_id = popover_id.clone();
this.child(
Popover::new(popover_id)
.anchor(anchor)
// The menu dismisses itself on outside click or Escape.
// The subscription below closes the popover along with it.
.overlay_closable(false)
.trigger_with(caret)
.trigger_with(move |is_open, _, cx| {
let caret = default_caret(caret_id.clone(), cx);
let selected = caret.is_selected();
caret.selected(selected || is_open).into_any_element()
})
.content(
move |_, window, cx| match menu_state.read(cx).menu.clone() {
Some(menu) => menu,
+45 -3
View File
@@ -1,7 +1,49 @@
use nostr::prelude::*;
/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form.
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
let npub = public_key.to_bech32().unwrap();
format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..])
pub fn shorten_pubkey(public_key: PublicKey) -> String {
let encoded = public_key
.to_bech32()
.unwrap_or_else(|_| public_key.to_hex());
truncate_middle(&encoded)
}
fn truncate_middle(value: &str) -> String {
const HEAD_CHARS: usize = 9;
const TAIL_CHARS: usize = 4;
let length = value.chars().count();
if length <= HEAD_CHARS + TAIL_CHARS + 3 {
return value.to_owned();
}
let head: String = value.chars().take(HEAD_CHARS).collect();
let tail: String = value.chars().skip(length - TAIL_CHARS).collect();
format!("{head}...{tail}")
}
#[cfg(test)]
mod tests {
use super::*;
const PUBLIC_KEY_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272";
#[test]
fn shortens_a_valid_pubkey() {
let public_key = PublicKey::from_hex(PUBLIC_KEY_HEX).expect("valid pubkey");
let npub = public_key.to_bech32().expect("valid pubkey encodes");
assert_eq!(
shorten_pubkey(public_key),
format!("{}...{}", &npub[..9], &npub[npub.len() - 4..])
);
}
#[test]
fn leaves_short_values_intact() {
assert_eq!(truncate_middle("npub1short"), "npub1short");
assert_eq!(truncate_middle("thirteenchars"), "thirteenchars");
}
}
+2 -8
View File
@@ -1,6 +1,5 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Error;
use dock::{BasePanel, DockArea, Panel, PanelEvent};
@@ -21,7 +20,6 @@ use utils::relative_time;
use super::{RepoItem, open_repo_item};
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
const LIST_OVERDRAW: Pixels = px(400.);
const MAX_SUB_ACTIVITIES: usize = 5;
@@ -179,7 +177,6 @@ impl InboxView {
}
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
debug_assert!(!self.refresh.debouncing());
if self.refresh.running() {
self.refresh.request();
return;
@@ -196,10 +193,7 @@ impl InboxView {
return;
}
self.tasks.push(cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx))
}));
self.run_refresh(cx);
}
fn run_refresh(&mut self, cx: &mut Context<Self>) {
@@ -408,7 +402,7 @@ impl InboxView {
};
let root = item.root;
let kind = item.root_kind;
let kind = item.root_event.as_ref().map(|event| event.kind);
let address = section.address.clone();
let first = entry_ix == 0;
let last = entry_ix + 1 == section.entries.len();
+12 -25
View File
@@ -24,6 +24,7 @@ use utils::relative_time;
pub(super) mod detail;
use self::detail::IssueDetailView;
use super::status_list::{StatusCounts, filter_by_status};
const ISSUE_ROW_HEIGHT: f32 = 73.;
@@ -52,7 +53,7 @@ pub struct IssuesView {
filter: IssueFilter,
item_sizes: Rc<Vec<Size<Pixels>>>,
visible_issues: Vec<usize>,
counts: (usize, usize, usize),
counts: StatusCounts,
// A filter change notifies even when the visible rows are unchanged,
// e.g. switching between two empty filters.
synced_filter: IssueFilter,
@@ -85,7 +86,7 @@ impl IssuesView {
filter: IssueFilter::Open,
item_sizes: Rc::new(Vec::new()),
visible_issues: Vec::new(),
counts: (0, 0, 0),
counts: StatusCounts::default(),
synced_filter: IssueFilter::Open,
scroll_handle: VirtualListScrollHandle::new(),
_subscription: subscription,
@@ -97,25 +98,11 @@ impl IssuesView {
let (visible_issues, counts) = {
let store = self.store.read(cx);
let mut counts = (0usize, 0usize, 0usize);
let visible_issues: Vec<usize> = store
.issues
.iter()
.enumerate()
.filter_map(|(ix, issue)| {
let status = store.status_of(issue);
counts.0 += 1;
match status {
RepoStatus::Open => counts.1 += 1,
RepoStatus::Closed => counts.2 += 1,
RepoStatus::Draft | RepoStatus::Applied => {}
}
filter.matches(status).then_some(ix)
})
.collect();
(visible_issues, counts)
filter_by_status(
&store.issues,
|issue| store.status_of(issue),
|status| filter.matches(status),
)
};
let filter_changed = self.synced_filter != filter;
@@ -218,7 +205,7 @@ impl IssuesView {
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let (total, open, closed) = self.counts;
let counts = self.counts;
h_flex()
.px_4()
@@ -234,7 +221,7 @@ impl IssuesView {
.child(
SegmentButton::new("all", "All")
.icon(Icon::new(CustomIconName::GitIssueDone))
.count(total)
.count(counts.total)
.selected(self.filter == IssueFilter::All)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = IssueFilter::All;
@@ -244,7 +231,7 @@ impl IssuesView {
.child(
SegmentButton::new("open", "Open")
.icon(Icon::new(CustomIconName::GitIssueOpen))
.count(open)
.count(counts.open)
.selected(self.filter == IssueFilter::Open)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = IssueFilter::Open;
@@ -254,7 +241,7 @@ impl IssuesView {
.child(
SegmentButton::new("closed", "Closed")
.icon(Icon::new(CustomIconName::GitIssueClosed))
.count(closed)
.count(counts.closed)
.selected(self.filter == IssueFilter::Closed)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = IssueFilter::Closed;
+1
View File
@@ -8,6 +8,7 @@ mod repo;
mod repo_list;
mod send_patch;
pub(crate) mod sidebar;
mod status_list;
pub(crate) mod tree;
pub use inbox::InboxView;
@@ -26,7 +26,7 @@ use signed_core::{
merge_base_of, pull_request_patch,
};
use signed_git::{FileCommit, patch_commits, patch_diffs};
use signed_state::{Backend, GitStore, ProfileStore, RepoStore};
use signed_state::{Backend, ProfileStore, RepoStore, ensure_repo_mirror};
use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
use utils::{relative_time, relative_time_secs};
@@ -217,8 +217,6 @@ impl PullRequestDetailView {
self.current_commit = binding.tip.clone().map(SharedString::from);
cx.notify();
let cache = GitStore::global(cx).cache().clone();
self.load_generation = self.load_generation.wrapping_add(1);
let generation = self.load_generation;
@@ -257,7 +255,6 @@ impl PullRequestDetailView {
let git = if use_nostr {
None
} else {
let cache = cache.clone();
let addr = addr.clone();
let clone_urls = clone_urls.clone();
let base = base.clone();
@@ -265,7 +262,7 @@ impl PullRequestDetailView {
Some(
cx.background_spawn(async move {
let repo = cache.ensure_clone(&addr, &clone_urls)?;
let repo = ensure_repo_mirror(&addr, &clone_urls)?;
let workdir = repo
.workdir()
+16 -33
View File
@@ -25,6 +25,7 @@ pub(super) mod new;
use self::detail::PullRequestDetailView;
use self::new::open_new_pull_panel;
use super::send_patch::open_send_patch_panel;
use super::status_list::{StatusCounts, filter_by_status};
use crate::views::repo::RepoAction;
const ROW_HEIGHT: f32 = 73.;
@@ -59,8 +60,7 @@ pub struct PullRequestsView {
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Indices into the store's `pull_requests` matching [`Self::filter`].
visible_prs: Vec<usize>,
/// Header counts `(total, open, closed, draft, merged)`.
counts: (usize, usize, usize, usize, usize),
counts: StatusCounts,
// A filter change notifies even when the visible rows are unchanged,
// e.g. switching between two empty filters.
synced_filter: PullRequestFilter,
@@ -93,7 +93,7 @@ impl PullRequestsView {
filter: PullRequestFilter::Open,
item_sizes: Rc::new(Vec::new()),
visible_prs: Vec::new(),
counts: (0, 0, 0, 0, 0),
counts: StatusCounts::default(),
synced_filter: PullRequestFilter::Open,
scroll_handle: VirtualListScrollHandle::new(),
_subscription: subscription,
@@ -105,32 +105,15 @@ impl PullRequestsView {
let (visible_prs, counts) = {
let store = self.store.read(cx);
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
let visible_prs: Vec<usize> = store
let roots = store
.pull_requests
.iter()
.enumerate()
.filter_map(|(ix, pr)| {
if pr.kind != Kind::GitPullRequest {
return None;
}
let status = store.status_of(pr);
counts.0 += 1;
match status {
RepoStatus::Open => counts.1 += 1,
RepoStatus::Closed => counts.2 += 1,
RepoStatus::Draft => counts.3 += 1,
RepoStatus::Applied => counts.4 += 1,
}
filter.matches(status).then_some(ix)
})
.collect();
(visible_prs, counts)
.filter(|pr| pr.kind == Kind::GitPullRequest);
filter_by_status(
roots,
|pr| store.status_of(pr),
|status| filter.matches(status),
)
};
let filter_changed = self.synced_filter != filter;
@@ -236,7 +219,7 @@ impl PullRequestsView {
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let (total, open, closed, draft, merged) = self.counts;
let counts = self.counts;
h_flex()
.px_4()
@@ -252,7 +235,7 @@ impl PullRequestsView {
.child(
SegmentButton::new("all", "All")
.icon(Icon::new(CustomIconName::GitPullRequest))
.count(total)
.count(counts.total)
.selected(self.filter == PullRequestFilter::All)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::All;
@@ -262,7 +245,7 @@ impl PullRequestsView {
.child(
SegmentButton::new("open", "Open")
.icon(Icon::new(CustomIconName::GitPullRequest))
.count(open)
.count(counts.open)
.selected(self.filter == PullRequestFilter::Open)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::Open;
@@ -272,7 +255,7 @@ impl PullRequestsView {
.child(
SegmentButton::new("closed", "Closed")
.icon(Icon::new(CustomIconName::GitPullRequestClosed))
.count(closed)
.count(counts.closed)
.selected(self.filter == PullRequestFilter::Closed)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::Closed;
@@ -282,7 +265,7 @@ impl PullRequestsView {
.child(
SegmentButton::new("draft", "Draft")
.icon(Icon::new(CustomIconName::GitPullRequestDraft))
.count(draft)
.count(counts.draft)
.selected(self.filter == PullRequestFilter::Draft)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::Draft;
@@ -292,7 +275,7 @@ impl PullRequestsView {
.child(
SegmentButton::new("merged", "Merged")
.icon(Icon::new(CustomIconName::GitPullRequestMerged))
.count(merged)
.count(counts.applied)
.selected(self.filter == PullRequestFilter::Merged)
.on_click(cx.listener(|this, _event, _window, cx| {
this.filter = PullRequestFilter::Merged;
+14 -19
View File
@@ -26,7 +26,9 @@ use signed_git::{
delete_refs_with_prefix, fetch_repo_refs, fork_namespace, merge_base, refs_with_prefix,
worktree_commit_range_commits, worktree_commit_range_diff,
};
use signed_state::{Backend, CheckoutsStore, GitStore, RepoListStore, RepoStore};
use signed_state::{
Backend, CheckoutsStore, RepoListStore, RepoStore, ensure_repo_mirror, repo_mirror_path,
};
use signed_ui::{CountBadge, placeholder, ref_selector_trigger};
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row};
@@ -73,7 +75,7 @@ struct ForkCompare {
announcement: Announcement,
/// Import namespace of the form `<owner-hex>/<sanitized-id>`.
namespace: String,
/// Path of the target repository's GitCache mirror.
/// Path of the target repository's mirror.
mirror_path: PathBuf,
}
@@ -533,8 +535,7 @@ impl NewPullRequestView {
let Some((base, _euc)) = self.base_repo(cx) else {
return;
};
let cache = GitStore::global(cx).cache().clone();
let mirror_path = cache.repo_path(&base);
let mirror_path = repo_mirror_path(&base);
let namespace = fork_namespace(&announcement);
let clone_urls = announcement.clone.clone();
@@ -561,24 +562,20 @@ impl NewPullRequestView {
let task: gpui::Task<Result<(), anyhow::Error>> =
cx.spawn_in(window, async move |this, cx| {
// The fork and base must share history for a merge-base to exist.
// The target's mirror is the object store both sides land in.
// `ensure_clone` fetches `origin` when the mirror already exists.
let result = cx
.background_spawn({
let cache = cache.clone();
let base = base.clone();
let base_clone_urls = base_clone_urls.clone();
let namespace = namespace.clone();
let clone_urls = clone_urls.clone();
let mirror_path = mirror_path.clone();
async move {
cache.ensure_clone(&base, &base_clone_urls)?;
ensure_repo_mirror(&base, &base_clone_urls)?;
// Prune stale imports of any fork.
// Then import this fork's heads under its namespace.
// Prune stale imports of any fork. Then import this fork's heads under its namespace.
delete_refs_with_prefix(&mirror_path, "refs/fork")?;
// Fetch the fork's refs and import them under the fork's namespace.
fetch_repo_refs(
&mirror_path,
&clone_urls,
@@ -658,8 +655,6 @@ impl NewPullRequestView {
let (base_branches, compare_branches) = match result {
Ok(branches) => branches,
Err(error) => {
// Keep the previous source, if any.
// The error shows inline next to the compare bar.
self.error = Some(format!("Could not compare against the fork: {error}").into());
cx.notify();
return;
@@ -673,8 +668,7 @@ impl NewPullRequestView {
}
if base_branches.is_empty() {
self.error =
Some("Could not list the target repository's branches; try again later".into());
self.error = Some("Could not list the target repository's branches.".into());
cx.notify();
return;
}
@@ -687,11 +681,8 @@ impl NewPullRequestView {
.map(SharedString::from)
.collect();
// Base defaults to the announced HEAD branch when the mirror has it.
// Otherwise `main`, then the first branch.
// The fork's `main` is the compare default, else the first branch.
// A refresh keeps the previous selection when the branch still exists.
let announced = self.store.read(cx).head.clone();
let contains =
|name: &str, list: &[SharedString]| list.iter().any(|branch| branch.as_ref() == name);
@@ -790,16 +781,19 @@ impl NewPullRequestView {
"{base_name} and {compare_name} share no common ancestor"
)
})?;
let commits = worktree_commit_range_commits(
Path::new(&repo_path),
&merge_base,
&compare,
)?;
let diff = worktree_commit_range_diff(
Path::new(&repo_path),
&merge_base,
&compare,
)?;
Ok::<_, anyhow::Error>((merge_base, commits, diff))
}
})
@@ -923,6 +917,7 @@ impl NewPullRequestView {
let Some(repo_path) = self.work_path() else {
return;
};
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
+5 -9
View File
@@ -26,8 +26,8 @@ use nostr::prelude::{RelayUrl, ToBech32, Url};
use signed_core::{Announcement, RepoAddr, RepoStatus};
use signed_git::FileCommit;
use signed_state::{
Backend, CheckoutStatus, CheckoutsStore, GitStore, LocalReposStore, ProfileStore,
RepoListStore, RepoStore, pr_proposes_checkout,
Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, ProfileStore, RepoListStore,
RepoStore, ensure_repo_mirror, open_repo_mirror, pr_proposes_checkout,
};
use signed_ui::{
CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row, menu_copy_row, middle_truncate,
@@ -343,15 +343,13 @@ impl RepoDetailView {
self.repo_started = true;
let cache = GitStore::global(cx).cache().clone();
let addr = announcement.addr();
let clone_urls: Vec<Url> = announcement.clone.clone();
let disk = {
let cache = cache.clone();
let addr = addr.clone();
cx.background_spawn(async move {
match cache.open(&addr)? {
match open_repo_mirror(&addr)? {
Some(repo) => Ok(Some(load_repo_data(&repo)?)),
None => Ok(None),
}
@@ -365,11 +363,10 @@ impl RepoDetailView {
let data = match disk {
Ok(Some(data)) => Ok(data),
Ok(None) => {
let cache = cache.clone();
let addr = addr.clone();
let clone_urls = clone_urls.clone();
cx.background_spawn(async move {
let repo = cache.ensure_clone(&addr, &clone_urls)?;
let repo = ensure_repo_mirror(&addr, &clone_urls)?;
load_repo_data(&repo)
})
.await
@@ -393,11 +390,10 @@ impl RepoDetailView {
}
let refresh = {
let cache = cache.clone();
let addr = addr.clone();
cx.background_spawn(async move {
let Some(repo) = cache.open(&addr)? else {
let Some(repo) = open_repo_mirror(&addr)? else {
return Ok::<_, Error>(None);
};
@@ -8,6 +8,8 @@ use nostr::prelude::*;
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
use signed_state::Backend;
use super::{normalize_server, server_host};
/// State of the grasp-server section of a publish dialog, so async results can be rendered.
#[derive(Default)]
pub struct GraspServersState {
@@ -155,7 +157,7 @@ fn render_server_row(
.text_color(cx.theme().muted_foreground)
.text_sm()
.rounded(cx.theme().radius)
.child(display_server(relay)),
.child(server_host(relay)),
)
.child(
Button::new(format!("remove-relay:{ix}"))
@@ -174,14 +176,6 @@ fn render_server_row(
)
}
/// Shows only the host, since grasp servers are entered without a scheme.
fn display_server(relay: &RelayUrl) -> SharedString {
relay
.domain()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(relay.to_string()))
}
/// Accepts a bare host as well as a full URL.
fn add_relay(
state: &Entity<GraspServersState>,
@@ -194,14 +188,8 @@ fn add_relay(
return;
}
let normalized = if value.contains("://") {
value.clone()
} else {
format!("wss://{value}")
};
match RelayUrl::parse(&normalized) {
Ok(relay) => {
match normalize_server(&value) {
Some((_, relay)) => {
state.update(cx, |state, _| {
state.error = None;
if !state.grasp_servers.contains(&relay) {
@@ -210,7 +198,7 @@ fn add_relay(
});
input.update(cx, |input, cx| input.set_value("", window, cx));
}
Err(_) => {
None => {
state.update(cx, |state, _| {
state.error = Some(format!("Invalid grasp server URL: {value}").into());
});
+21
View File
@@ -17,6 +17,7 @@ use gpui_base::Button as BaseButton;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::InputState;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use nostr::prelude::RelayUrl;
use signed_core::{Announcement, RepoAddr, identifier_from_name};
use signed_state::{
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
@@ -542,6 +543,26 @@ impl SidebarPanel {
}
}
/// Normalize a user-typed grasp server, adding a `wss://` scheme when none is given.
///
/// Returns the text to store and the parsed relay URL, or `None` when it is not a valid relay URL.
pub(super) fn normalize_server(input: &str) -> Option<(String, RelayUrl)> {
let text = if input.contains("://") {
input.to_owned()
} else {
format!("wss://{input}")
};
RelayUrl::parse(&text).ok().map(|relay| (text, relay))
}
/// The host of a relay URL, which is what the server lists show; the scheme is implied.
pub(super) fn server_host(relay: &RelayUrl) -> SharedString {
relay
.domain()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(relay.to_string()))
}
fn pick_banner() -> SharedString {
let num = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -18,10 +18,11 @@ use gpui_component::{
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex,
v_flex,
};
use nostr::prelude::RelayUrl;
use settings::{AppearanceMode, Settings, SettingsStore};
use signed_ui::{SelectOption, setting_block, setting_row};
use super::{normalize_server, server_host};
/// Looks up the option index used to seed a [`SelectState`].
fn selected_index(options: &[SelectOption], value: &str) -> Option<IndexPath> {
options
@@ -454,13 +455,11 @@ fn grasp_server_editor(
}
/// Shows only the host, since grasp servers are entered without a scheme.
/// Matches how the publish dialogs display servers.
fn display_server(server: &str) -> SharedString {
RelayUrl::parse(server)
.ok()
.and_then(|relay| relay.domain().map(|domain| domain.to_owned()))
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(server.to_owned()))
match normalize_server(server) {
Some((_, relay)) => server_host(&relay),
None => SharedString::from(server.to_owned()),
}
}
fn repositories_section(
@@ -568,14 +567,9 @@ fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
if value.is_empty() {
return;
}
let normalized = if value.contains("://") {
value
} else {
format!("wss://{value}")
};
if RelayUrl::parse(&normalized).is_err() {
let Some((normalized, _)) = normalize_server(&value) else {
return;
}
};
let store = SettingsStore::global(cx);
store.update(cx, |store, cx| {
+45
View File
@@ -0,0 +1,45 @@
use nostr::prelude::Event;
use signed_core::RepoStatus;
/// Root events counted by their resolved status.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct StatusCounts {
pub(crate) total: usize,
pub(crate) open: usize,
pub(crate) closed: usize,
pub(crate) draft: usize,
pub(crate) applied: usize,
}
impl StatusCounts {
fn record(&mut self, status: RepoStatus) {
self.total += 1;
match status {
RepoStatus::Open => self.open += 1,
RepoStatus::Closed => self.closed += 1,
RepoStatus::Draft => self.draft += 1,
RepoStatus::Applied => self.applied += 1,
}
}
}
/// Indices of `roots` whose status `keep` accepts, counting every root's status.
pub(crate) fn filter_by_status<'a>(
roots: impl IntoIterator<Item = &'a Event>,
status_of: impl Fn(&Event) -> RepoStatus,
keep: impl Fn(RepoStatus) -> bool,
) -> (Vec<usize>, StatusCounts) {
let mut counts = StatusCounts::default();
let visible = roots
.into_iter()
.enumerate()
.filter_map(|(index, root)| {
let status = status_of(root);
counts.record(status);
keep(status).then_some(index)
})
.collect();
(visible, counts)
}
+18
View File
@@ -0,0 +1,18 @@
# TODO
## `BackendEvent::SyncProgress`
File: `crates/signed_state/src/backend.rs`
Kept intentionally. The progress pipeline (the `SyncProgress` variant, the `sync_progress`
field and its accessor, and the progress task in `sync_bootstrap`) is retained for a planned
sync progress indicator. No subscriber exists yet. Do not remove it without revisiting that
plan.
## `login` / `logout` family
File: `crates/signed_state/src/backend.rs`
No UI path calls these. `import_dialog::open` is an empty stub. Decide whether to
delete the family (`login`, `login_with_new_identity`, `login_with_nsec`,
`login_with_bunker`, `logout`) or wire the stub to `Backend::login`.