Compare commits

Author SHA1 Message Date
reya 534d572154 .
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
2026-09-13 17:26:07 +07:00
reya 4be75253cd update 2026-09-13 17:18:54 +07:00
reya 2741ab6ac6 clean up 2026-09-13 17:14:45 +07:00
reya a051cb165e remove dead code 2026-09-13 17:11:26 +07:00
29 changed files with 204 additions and 637 deletions
+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;
-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,
+8 -31
View File
@@ -1086,10 +1086,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 +1098,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 +1490,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()),
}
}
@@ -1715,17 +1704,11 @@ async fn push_staged_to_grasps(
) -> 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");
@@ -1806,11 +1789,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 +1949,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: ...",
),
],
+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> {
-14
View File
@@ -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`].
+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,
+9 -2
View File
@@ -1,7 +1,14 @@
use nostr::prelude::*;
/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form.
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
pub fn shorten_pubkey(public_key: PublicKey) -> String {
const HEAD_CHARS: usize = 9;
const TAIL_CHARS: usize = 4;
let npub = public_key.to_bech32().unwrap();
format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..])
format!(
"{}...{}",
&npub[..HEAD_CHARS],
&npub[npub.len() - TAIL_CHARS..]
)
}
+2 -7
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;
@@ -196,10 +194,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 +403,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;
+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;
@@ -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)
}
+17
View File
@@ -0,0 +1,17 @@
# TODO
## `BackendEvent::SyncProgress`
File: `crates/signed_state/src/backend.rs`
The variant has no subscriber. Remove the variant, the `sync_progress` field and
its accessor, and the `progress_task` in `sync_bootstrap`. Keep the terminal
`Synced` emission.
## `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`.