14 Commits
Author SHA1 Message Date
reya 31256a4870 chore: clean up core crate
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-10-07 16:46:29 +07:00
reya 5586f67f0e feat: allow choosing the default branch when publishing a repo
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-10-07 11:52:53 +07:00
reya 5887a14c50 chore: refactor git module (#27)
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: #27
2026-10-07 02:40:12 +00:00
reya a706fadafa chore: upgrade gpui-component to 0.7 and gix to 0.88
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-10-07 05:19:15 +07:00
reya 8900d1f3ee feat: add a quit action
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-10-06 17:19:21 +07:00
reya 237b6a6996 feat: add user menu and sign-out support
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-10-06 15:50:30 +07:00
reya 62f355d70e chore: only refresh checkouts when related settings change
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-10-06 14:46:00 +07:00
reya f2a0ed002d chore: load user data via gossip and defer grasp relay connection
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-06 13:48:35 +07:00
reya cd72b25626 chore: remove trivial single-use helper functions
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-06 10:20:28 +07:00
reya a4df8e5731 chore: update changelog
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-04 12:46:09 +07:00
reya d4ba71a362 feat: add identity import dialog
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-04 10:55:32 +07:00
reya 6ec4b7e813 chore: adjust default theme
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-04 08:27:27 +07:00
reya c76e909f7f chore: refactor backend around domain types (#26)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #26
2026-10-03 10:35:48 +00:00
reya 6c4cc9f2ca chore: update deps
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-09-27 15:48:51 +07:00
88 changed files with 7541 additions and 8348 deletions
+27 -2
View File
@@ -6,6 +6,33 @@
### Added
- Add an identity import dialog, importing an existing identity by pasting its `nsec` secret key (encrypted with a new passphrase), NIP-49 `ncryptsec` (unlocked with its passphrase), or `bunker://` NIP-46 URI
- Add a dropdown menu on the signed-in user button in the sidebar
- Add sign-out feature, confirmed by an alert dialog before deleting the stored session credential
- Add a quit action, bound to `secondary-q` (cmd on macOS, ctrl on others)
### Changed
- Increase the default theme corner radii from 2 to 4 and large radii from 6 to 8
- Surface backend errors as error notifications in the workspace instead of silently dropping them
- Break ties in repository activity lists by event id, so same-second events order deterministically
- Restructure the backend around domain types: git operations behind a `Repo` type, the grasp push pipeline behind `GraspPush`, nostr connectivity behind `NostrBackend`, and shared helpers consolidated into `utils`
- Fetch the logged-in user's grasp list, code follows, followed repositories, contacts, profile metadata, mute list, and blossom servers via gossip at login instead of the bootstrap relays
- Connect to grasp relays and load the inbox only after the user's grasp list event arrives
- Refresh the checkouts store only when the checkouts settings change
### Fixed
### Removed
- Remove dead code: the unused `login`/`logout` family, the unwired `SyncProgress` pipeline, `merge_pull_request`, inbox mark-read/archive APIs and the wasm32-only code paths
### Deprecated
## v0.2.0-alpha - 2026/09/27
### Added
- Show an avatar in each panel's tab, using the repository owner's profile picture when set and a pixel avatar otherwise
- Add a Tab Bar setting to hide the previous/next tab buttons, hidden by default
- Add an Event Fetching Strategy setting, fetching a repository's activity from its announced relays only (Curated) or from every maintainer's relays as well (Uncensored, the default)
@@ -29,8 +56,6 @@
- Remove cover note support, the kind-1624 GitWorkshop and `ngit` extension outside the NIP-34
### Deprecated
## v0.1.0-alpha - 2026/09/14
Initial alpha release.
Generated
+527 -567
View File
File diff suppressed because it is too large Load Diff
+6 -11
View File
@@ -9,26 +9,25 @@ edition = "2024"
publish = false
[workspace.dependencies]
gpui = { package = "gpui-pre", version = "0.3.6" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.6", features = ["font-kit", "x11", "wayland"] }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.6" }
gpui = { package = "gpui-pre", version = "0.3.8" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
# GPUI Kit
gpui-component = { version = "0.6.6", features = ["tree-sitter-languages"] }
gpui-base = "0.6.6"
gpui-component = { version = "0.7.1", features = ["tree-sitter-languages"] }
gpui-base = "0.7.1"
dock = { path = "crates/dock" }
settings = { path = "crates/settings" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] }
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
gix = { version = "0.87.1", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation", "status"] }
gix = { version = "0.88", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation", "status"] }
smol = "2"
futures = "0.3"
@@ -45,10 +44,6 @@ tracing = "0.1"
errno = { version = "0.3.14", default-features = false }
instant = "0.1"
[patch.crates-io]
# Use stacker's psm version which may have better WASM support
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
[profile.release]
strip = true
opt-level = "z"
+8 -18
View File
@@ -13,8 +13,8 @@ use gpui_base::dock::{
DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState,
PanelView, TabGroupRenderer,
};
use gpui_base::resize_handle;
use gpui_component::{ActiveTheme as _, Side};
use gpui_base::{HandleEdge, resize_handle};
use gpui_component::ActiveTheme as _;
use crate::invalid_panel::InvalidPanel;
use crate::panel_handle;
@@ -40,11 +40,6 @@ impl SkinShared {
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
&self.resizing_dock
}
/// Redraw the area after a setting changed. The skin is not an entity, so nothing else would.
pub(crate) fn notify(&self, cx: &mut App) {
_ = self.area.update(cx, |_, cx| cx.notify());
}
}
/// The Signed appearance for a [`DockArea`].
@@ -74,16 +69,6 @@ impl SignedDockSkin {
pub(crate) fn shared(&self) -> &Rc<SkinShared> {
&self.shared
}
/// Whether tab bars offer the affordance that collapses a neighbouring dock.
pub fn is_toggle_button_visible(&self) -> bool {
self.shared.is_toggle_button_visible()
}
pub fn set_toggle_button_visible(&self, visible: bool, cx: &mut App) {
self.shared.toggle_button_visible.set(visible);
self.shared.notify(cx);
}
}
/// Payload a dock's resize handle drags.
@@ -179,7 +164,12 @@ impl SignedDockSkin {
let shared = self.shared().clone();
resize_handle("resize-handle", placement.axis())
.when(placement.is_left(), |this| this.placement(Side::Left))
.when(placement.is_left(), |this| {
this.inside(HandleEdge::Trailing)
})
.when(placement.is_right() || placement.is_bottom(), |this| {
this.inside(HandleEdge::Leading)
})
.on_drag(ResizePanel, move |info, _, _, cx| {
cx.stop_propagation();
shared.resizing_dock().set(Some(placement));
-13
View File
@@ -25,19 +25,6 @@ pub fn add_center_panel(
area.add_panel_view(panel, DockPlacement::Center, None, window, cx);
}
/// Add an already-wrapped panel handle to the bottom dock of `area`.
///
/// Used for sub-views that hang under the center, such as the inbox's Unread
/// and Archived lists.
pub fn add_bottom_panel(
area: &mut DockArea,
panel: Arc<dyn PanelView>,
window: &mut Window,
cx: &mut Context<DockArea>,
) {
area.add_panel_view(panel, DockPlacement::Bottom, None, window, cx);
}
/// The fixed height of the tab bar, which doubles as the window title bar.
pub const TAB_BAR_HEIGHT: Pixels = px(44.);
+3 -6
View File
@@ -71,11 +71,6 @@ impl Render for DragPanelPreview {
}
}
/// The zoom affordance for the group's displayed panel, if it offers one.
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
panel_zoom_control(group.active_panel()?, cx)
}
/// The zoom affordance `panel` offers, if any.
///
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
@@ -542,7 +537,9 @@ impl SignedTabGroupSkin {
impl TabGroupRenderer for SignedTabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
let control = zoom_control(group, cx);
let control = group
.active_panel()
.and_then(|panel| panel_zoom_control(panel, cx));
// An emptied group draws nothing, so no bare tab bar is left behind.
if group.panels().is_empty() {
return div().id("tab-panel");
+3 -3
View File
@@ -23,7 +23,7 @@ static CURRENT_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// On Windows, this is `%APPDATA%\Signed`.
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
pub fn home_dir() -> PathBuf {
fn home_dir() -> PathBuf {
dirs::home_dir().expect("failed to determine home directory")
}
@@ -41,7 +41,7 @@ pub fn documents_dir() -> PathBuf {
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
}
pub fn config_dir() -> &'static PathBuf {
fn config_dir() -> &'static PathBuf {
CONFIG_DIR.get_or_init(|| {
if cfg!(target_os = "windows") {
dirs::config_dir()
@@ -60,7 +60,7 @@ pub fn config_dir() -> &'static PathBuf {
})
}
pub fn data_dir() -> &'static PathBuf {
fn data_dir() -> &'static PathBuf {
CURRENT_DATA_DIR.get_or_init(|| {
if cfg!(target_os = "macos") {
home_dir()
+8 -9
View File
@@ -28,7 +28,6 @@ pub enum EventFetchingStrategy {
Uncensored,
}
/// Fields mirror the gpui-component `Theme` surface customized at startup.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ThemeSettings {
@@ -46,6 +45,7 @@ pub struct ThemeSettings {
pub radius_lg: f32,
/// Whether focused controls draw a ring outside their border.
pub focus_ring: bool,
/// Whether to apply a shadow to elements.
pub shadow: bool,
}
@@ -56,8 +56,8 @@ impl Default for ThemeSettings {
dark_theme: "Signed Dark".into(),
font_size: 16.0,
mono_font_size: 13.0,
radius: 2.0,
radius_lg: 6.0,
radius: 4.0,
radius_lg: 8.0,
focus_ring: false,
shadow: false,
}
@@ -102,18 +102,17 @@ impl Default for GraspServersSettings {
#[serde(default)]
pub struct LocalReposSettings {
/// The directories scanned for local git repositories,
/// defaults to the user's Desktop and Documents folders.
/// Defaults:
///
/// - `~/Desktop`
/// - `~/Documents`
pub scan_paths: Vec<PathBuf>,
}
fn default_scan_paths() -> Vec<PathBuf> {
vec![paths::desktop_dir(), paths::documents_dir()]
}
impl Default for LocalReposSettings {
fn default() -> Self {
Self {
scan_paths: default_scan_paths(),
scan_paths: vec![paths::desktop_dir(), paths::documents_dir()],
}
}
}
+1
View File
@@ -7,3 +7,4 @@ publish.workspace = true
[dependencies]
nostr.workspace = true
serde.workspace = true
utils = { path = "../utils" }
+92 -17
View File
@@ -1,23 +1,98 @@
use std::fmt;
use std::str::FromStr;
use nostr::prelude::*;
use serde::{Deserialize, Serialize};
/// Address of a NIP-34 repository announcement, `30617:<owner-pubkey>:<repo-id>`.
///
/// The Rust Nostr SDK's [`Coordinate`] parses, formats and hashes this,
/// the alias reuses the SDK type while keeping repository-specific vocabulary.
pub type RepoAddr = Coordinate;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RepoAddr(Coordinate);
pub fn repo_addr(owner: PublicKey, id: impl Into<String>) -> RepoAddr {
Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id)
impl RepoAddr {
pub fn new(owner: PublicKey, identifier: impl Into<String>) -> Self {
Self(Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(identifier))
}
pub fn identifier_from_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '/' {
c
} else {
'-'
}
})
.collect()
}
pub fn kind(&self) -> Kind {
self.0.kind
}
pub fn public_key(&self) -> PublicKey {
self.0.public_key
}
pub fn identifier(&self) -> &str {
&self.0.identifier
}
pub fn coordinate(&self) -> &Coordinate {
&self.0
}
pub fn announcement_filter(&self) -> Filter {
Filter::new()
.kind(Kind::GitRepoAnnouncement)
.author(self.public_key())
.identifier(self.identifier())
}
pub fn state_filter(&self) -> Filter {
Filter::new()
.kind(Kind::RepoState)
.author(self.public_key())
.identifier(self.identifier())
}
pub fn activity_filter(&self) -> Filter {
Filter::new()
.kinds(crate::filters::ACTIVITY_KINDS)
.coordinate(&self.0)
}
pub fn deletion_filters(&self) -> Vec<Filter> {
vec![
Filter::new()
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
.author(self.public_key()),
Filter::new().kind(Kind::EventDeletion).coordinate(&self.0),
]
}
}
pub fn identifier_from_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '/' {
c
} else {
'-'
}
})
.collect()
impl fmt::Display for RepoAddr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl FromStr for RepoAddr {
type Err = <Coordinate as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Coordinate::from_str(s).map(RepoAddr)
}
}
impl From<Coordinate> for RepoAddr {
fn from(coordinate: Coordinate) -> Self {
RepoAddr(coordinate)
}
}
impl From<RepoAddr> for Coordinate {
fn from(addr: RepoAddr) -> Self {
addr.0
}
}
+492
View File
@@ -0,0 +1,492 @@
use std::collections::HashSet;
use nostr::prelude::*;
use crate::RepoAddr;
use crate::upstream::Upstream;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Announcement {
pub event_id: EventId,
pub id: String,
pub owner: PublicKey,
pub created_at: Timestamp,
pub name: Option<String>,
pub description: Option<String>,
pub web: Vec<Url>,
pub clone: Vec<Url>,
pub relays: Vec<RelayUrl>,
pub euc: Option<String>,
pub maintainers: Vec<PublicKey>,
pub upstream: Option<Upstream>,
pub hashtags: Vec<String>,
}
impl Announcement {
/// Lists forks of the base, the user's own first.
pub fn forks_in<'a>(
announcements: &'a [Announcement],
base: &RepoAddr,
base_euc: Option<&str>,
user: Option<PublicKey>,
) -> Vec<&'a Announcement> {
let (mut own, mut others) = (Vec::new(), Vec::new());
for announcement in announcements {
if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) {
continue;
}
if Some(announcement.owner) == user {
own.push(announcement);
} else {
others.push(announcement);
}
}
own.into_iter().chain(others).collect()
}
/// Parses a kind 30617 event, rejecting other kinds and missing `d` tags.
pub fn from_event(event: &Event) -> Option<Self> {
if event.kind != Kind::GitRepoAnnouncement {
return None;
}
let id = event.tags.identifier()?;
let mut hashtags: Vec<String> = Vec::new();
hashtags.extend(event.tags.hashtags().map(|t| t.to_string()));
let mut name: Option<String> = None;
let mut description: Option<String> = None;
let mut web: Vec<Url> = Vec::new();
let mut clone: Vec<Url> = Vec::new();
let mut relays: Vec<RelayUrl> = Vec::new();
let mut euc: Option<String> = None;
let mut maintainers: Vec<PublicKey> = Vec::new();
let mut upstream: Option<Upstream> = None;
for tag in event.tags.iter() {
match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Name(value)) => name = Some(value),
Ok(Nip34Tag::Description(value)) => description = Some(value),
Ok(Nip34Tag::Web(urls)) => web.extend(urls),
Ok(Nip34Tag::Clone(urls)) => clone.extend(urls),
Ok(Nip34Tag::Relays(urls)) => relays.extend(urls),
Ok(Nip34Tag::EarliestUniqueCommitId(commit)) => euc = Some(commit.to_string()),
Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys),
_ => {}
}
if upstream.is_none() && tag.kind() == "u" {
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));
}
}
}
Some(Self {
event_id: event.id,
owner: event.pubkey,
created_at: event.created_at,
id,
name,
description,
web,
clone,
relays,
euc,
maintainers,
upstream,
hashtags,
})
}
/// Returns the owner and identifier as a repository address.
pub fn addr(&self) -> RepoAddr {
RepoAddr::new(self.owner, self.id.clone())
}
/// Returns the name, defaulting to "Untitled".
pub fn name(&self) -> String {
self.name.clone().unwrap_or("Untitled".into())
}
/// Matches a `u` tag pointing at the base or a shared earliest unique commit.
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
if self.addr() == *base {
return false;
}
if self.upstream.as_ref().and_then(|u| u.addr.as_ref()) == Some(base) {
return true;
}
base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc))
}
/// Returns the description, defaulting to "No description".
pub fn description(&self) -> String {
self.description
.clone()
.unwrap_or("No description".to_string())
}
/// Adds the owner to the maintainers unless a `u` tag marks this a subordinate fork.
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
let mut maintainers = self.maintainers.clone();
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
maintainers.push(self.owner);
}
maintainers
}
/// Returns deduplicated `git clone` commands for the advertised URLs.
pub fn clone_urls(&self) -> Vec<String> {
let mut seen = HashSet::new();
self.clone
.iter()
.map(|url| format!("git clone {url}"))
.filter(|command| seen.insert(command.clone()))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
const MAINTAINER_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272";
/// Returns the test signer keys.
fn keys() -> Keys {
Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
.expect("valid secret key"),
)
}
/// Builds a signed repo announcement event from raw tags.
fn announcement_event(tags: &[&[&str]]) -> Event {
let tags: Vec<Tag> = tags
.iter()
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(tags)
.finalize(&keys())
.expect("signed event")
}
/// Parses an event carrying every supported tag.
#[test]
fn parses_full_announcement() {
let event = announcement_event(&[
&["d", "my-repo"],
&["name", "My Repo"],
&["description", "A test repository"],
&["web", "https://example.com/repo"],
&["clone", "https://example.com/repo.git"],
&["relays", "wss://relay.example.com"],
&["r", "aa231c4c6a5777dc89b42207b499891a344add5c", "euc"],
&["maintainers", MAINTAINER_HEX],
&["t", "rust"],
&["t", "nostr"],
]);
let announcement = Announcement::from_event(&event).expect("parses");
assert_eq!(announcement.owner, keys().public_key());
assert_eq!(announcement.id, "my-repo");
assert_eq!(announcement.name.as_deref(), Some("My Repo"));
assert_eq!(
announcement.description.as_deref(),
Some("A test repository")
);
assert_eq!(
announcement.web,
vec![Url::parse("https://example.com/repo").unwrap()]
);
assert_eq!(
announcement.clone,
vec![Url::parse("https://example.com/repo.git").unwrap()]
);
assert_eq!(
announcement.relays,
vec![RelayUrl::parse("wss://relay.example.com").unwrap()]
);
assert_eq!(
announcement.euc.as_deref(),
Some("aa231c4c6a5777dc89b42207b499891a344add5c")
);
assert_eq!(
announcement.maintainers,
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
);
assert_eq!(announcement.hashtags, vec!["rust", "nostr"]);
}
/// Skips malformed clone, relay, and maintainer values.
#[test]
fn drops_malformed_values() {
let event = announcement_event(&[
&["d", "my-repo"],
&["clone", "not a url"],
&["relays", "wss://good.example.com"],
&["maintainers", "not-a-pubkey"],
]);
let announcement = Announcement::from_event(&event).expect("parses");
assert!(announcement.clone.is_empty());
assert_eq!(
announcement.relays,
vec![RelayUrl::parse("wss://good.example.com").unwrap()]
);
assert!(announcement.maintainers.is_empty());
}
/// Parses the `u` tag into an upstream coordinate.
#[test]
fn parses_upstream_tag() {
let event = announcement_event(&[
&["d", "my-fork"],
&[
"u",
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git",
"wss://relay.example.com",
],
]);
let announcement = Announcement::from_event(&event).expect("parses");
let upstream = announcement.upstream.expect("parses the u tag");
assert_eq!(
upstream.addr,
Some(RepoAddr::new(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream"
))
);
assert_eq!(
upstream.raw,
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git"
);
assert_eq!(
upstream.display().to_string(),
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream"
);
}
/// Matches a fork through its `u` tag coordinate.
#[test]
fn is_fork_of_matches_the_u_tag_coordinate() {
let base = RepoAddr::new(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream",
);
let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]);
let fork = Announcement::from_event(&event).expect("parses");
assert!(fork.is_fork_of(&base, None));
}
/// Matches forks sharing the base's earliest unique commit.
#[test]
fn is_fork_of_matches_a_shared_euc() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let base_event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
let base = Announcement::from_event(&base_event).expect("parses");
let base_addr = base.addr();
let fork_event = announcement_event(&[&["d", "mirror"], &["r", euc, "euc"]]);
let fork = Announcement::from_event(&fork_event).expect("parses");
assert!(fork.is_fork_of(&base_addr, base.euc.as_deref()));
let other_event = announcement_event(&[
&["d", "other"],
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
]);
let other = Announcement::from_event(&other_event).expect("parses");
assert!(!other.is_fork_of(&base_addr, base.euc.as_deref()));
assert!(!fork.is_fork_of(&base_addr, None));
}
/// Matches permanent forks whose `u` tag points at the base despite a diverged EUC.
#[test]
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
let base = RepoAddr::new(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream",
);
let base_euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let event = announcement_event(&[
&["d", "my-fork"],
&["u", &base.to_string()],
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
]);
let fork = Announcement::from_event(&event).expect("parses");
assert!(fork.is_fork_of(&base, Some(base_euc)));
}
/// Includes the owner among maintainers for primary repositories.
#[test]
fn effective_maintainers_include_owner_for_primary_repos() {
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
let announcement = Announcement::from_event(&event).expect("parses");
let maintainers = announcement.effective_maintainers();
assert_eq!(maintainers.len(), 2);
assert!(maintainers.contains(&announcement.owner));
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
}
/// Excludes the owner from maintainers for subordinate forks.
#[test]
fn effective_maintainers_exclude_owner_for_subordinate_forks() {
let event = announcement_event(&[
&["d", "my-fork"],
&["u", "30617:abc:upstream|https://example.com/upstream.git"],
&["maintainers", MAINTAINER_HEX],
]);
let announcement = Announcement::from_event(&event).expect("parses");
let maintainers = announcement.effective_maintainers();
assert!(!maintainers.contains(&announcement.owner));
assert_eq!(
maintainers,
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
);
}
const OWNER_KEYS: [&str; 3] = [
"0000000000000000000000000000000000000000000000000000000000000001",
"0000000000000000000000000000000000000000000000000000000000000002",
"0000000000000000000000000000000000000000000000000000000000000003",
];
/// Builds an announcement event signed by the given owner key.
fn owned_announcement_event(owner: &str, tags: &[&[&str]]) -> Event {
let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key"));
let tags: Vec<Tag> = tags
.iter()
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(tags)
.finalize(&keys)
.expect("signed event")
}
/// Builds one announcement owned by the owner key at the given index.
fn owned_announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec<Announcement> {
vec![
Announcement::from_event(&owned_announcement_event(OWNER_KEYS[owner_ix], tags))
.expect("parses"),
]
}
/// Places the user's own forks before everyone else's.
#[test]
fn fork_candidates_orders_own_forks_first() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let clone = "https://grasp.example/npub1x/my-fork.git";
let base_addr = RepoAddr::new(
PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"),
"upstream",
);
let all = vec![
owned_announcements(
2,
&[
&["d", "other-project"],
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
],
)
.pop()
.unwrap(),
owned_announcements(
1,
&[&["d", "my-fork"], &["r", euc, "euc"], &["clone", clone]],
)
.pop()
.unwrap(),
owned_announcements(
2,
&[
&["d", "their-fork"],
&["u", &base_addr.to_string()],
&["clone", clone],
],
)
.pop()
.unwrap(),
];
let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey");
let forks = Announcement::forks_in(&all, &base_addr, Some(euc), Some(user));
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids, vec!["my-fork", "their-fork"]);
}
/// Excludes the base itself, unrelated repos, and repos without clone URLs.
#[test]
fn fork_candidates_excludes_base_unrelated_and_unfetchable() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey");
let base_addr = RepoAddr::new(base_owner, "upstream");
let mut all = vec![
owned_announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]])
.pop()
.unwrap(),
owned_announcements(1, &[&["d", "no-clone-fork"], &["r", euc, "euc"]])
.pop()
.unwrap(),
owned_announcements(
2,
&[
&["d", "other"],
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
],
)
.pop()
.unwrap(),
owned_announcements(
2,
&[
&["d", "mirror"],
&["r", euc, "euc"],
&["clone", "https://grasp.example/x/mirror.git"],
],
)
.pop()
.unwrap(),
];
let forks = Announcement::forks_in(&all, &base_addr, Some(euc), Some(base_owner));
assert_eq!(forks.len(), 1);
assert_eq!(forks[0].id, "mirror");
all.push(
owned_announcements(
2,
&[
&["d", "u-fork"],
&["u", &base_addr.to_string()],
&["clone", "https://grasp.example/x/u-fork.git"],
],
)
.pop()
.unwrap(),
);
let forks = Announcement::forks_in(&all, &base_addr, None, Some(base_owner));
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids, vec!["u-fork"]);
}
}
+8 -19
View File
@@ -2,26 +2,18 @@ use std::collections::HashSet;
use nostr::prelude::*;
/// NIP-09 deletion requests and NIP-62 vanish requests,
/// built from the kind-5 and kind-62 events in the local database.
///
/// Deleted events are hidden before they reach the UI.
///
/// Pass any event through [`Deletions::is_deleted`] before showing it.
pub struct Deletions {
/// `(deleted event id, expected author)` from `e` tags of kind-5 events.
ids: HashSet<(EventId, PublicKey)>,
/// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events.
///
/// All versions of the addressable event up to `cutoff` are deleted.
coords: Vec<(Coordinate, PublicKey, Timestamp)>,
/// `(author, cutoff)` from kind-62 vanish requests.
vanished: Vec<(PublicKey, Timestamp)>,
}
impl Deletions {
/// Build the deletion index from raw kind-5 and kind-62 events.
pub fn from_events(events: impl IntoIterator<Item = Event>) -> Self {
/// Builds a [`Deletions`] instance from a collection of events.
pub fn from_events<E>(events: E) -> Self
where
E: IntoIterator<Item = Event>,
{
let mut ids = HashSet::new();
let mut coords = Vec::new();
let mut vanished = Vec::new();
@@ -36,8 +28,8 @@ impl Deletions {
.map(|c| (c, event.pubkey, event.created_at)),
);
} else if event.kind == Kind::RequestToVanish {
// Client-side we can't verify which relay the request targeted.
// Any vanish request is then honored for the author's events.
// We can't verify which relay the request targeted, so any
// vanish request is honored for the author's events.
vanished.push((event.pubkey, event.created_at));
}
}
@@ -49,10 +41,7 @@ impl Deletions {
}
}
/// Whether the event is covered by a valid deletion or vanish request.
/// A request is valid when its author matches the deleted event's author, per NIP-09.
///
/// Addressable events are deleted up to the request's `created_at`.
/// Returns whether the given event is deleted.
pub fn is_deleted(&self, event: &Event) -> bool {
if self
.vanished
+117 -175
View File
@@ -2,9 +2,6 @@ use std::time::Duration;
use nostr::prelude::*;
use crate::RepoAddr;
/// Kinds that make up the activity of a repository.
pub const ACTIVITY_KINDS: [Kind; 9] = [
Kind::Comment,
Kind::GitPatch,
@@ -17,8 +14,7 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [
Kind::GitStatusDraft,
];
/// Kinds that notify a user when they tag them via their `p` tag.
pub const NOTIFICATION_KINDS: [Kind; 8] = [
const NOTIFICATION_KINDS: [Kind; 8] = [
Kind::GitIssue,
Kind::GitPullRequest,
Kind::GitPatch,
@@ -29,7 +25,6 @@ pub const NOTIFICATION_KINDS: [Kind; 8] = [
Kind::GitStatusDraft,
];
/// Git root kinds that make a comment count as git activity.
const GIT_ROOT_KINDS: [Kind; 4] = [
Kind::GitIssue,
Kind::GitPatch,
@@ -37,7 +32,6 @@ const GIT_ROOT_KINDS: [Kind; 4] = [
Kind::GitRepoAnnouncement,
];
/// Kinds that carry repository data: announcements, states, activity and deletions.
pub fn is_repo_kind(kind: Kind) -> bool {
kind == Kind::GitRepoAnnouncement
|| kind == Kind::RepoState
@@ -46,184 +40,140 @@ pub fn is_repo_kind(kind: Kind) -> bool {
|| ACTIVITY_KINDS.contains(&kind)
}
/// Value of the first tag named `name` on `event`.
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
fn tag_kind(event: &Event, name: &str) -> Option<Kind> {
event
.tags
.iter()
.find(|tag| tag.kind() == name)
.and_then(|tag| tag.content())
.and_then(|value| value.parse::<Kind>().ok())
}
/// Kind named by the first tag `name` on `event`.
fn tag_kind(event: &Event, name: &str) -> Option<Kind> {
tag_value(event, name)?.parse::<Kind>().ok()
}
pub struct Filters;
/// Latest announcement event for a repository.
pub fn announcement(addr: &RepoAddr) -> Filter {
Filter::new()
.kind(Kind::GitRepoAnnouncement)
.author(addr.public_key)
.identifier(addr.identifier.clone())
}
impl Filters {
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
/// Latest state event for a repository, carrying refs and HEAD.
pub fn state(addr: &RepoAddr) -> Filter {
Filter::new()
.kind(Kind::RepoState)
.author(addr.public_key)
.identifier(addr.identifier.clone())
}
/// All NIP-34 activity addressed to a repository via its `#a` tag.
/// Covers issues, PRs, patches, statuses and kind-1111 comments.
/// The `a` tag is optional on status events per NIP-34.
/// Statuses published without it are not matched here.
pub fn activity(addr: &RepoAddr) -> Filter {
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
}
/// Status events, kinds `1630..=1633`, referencing any of the given root events.
/// They are matched via the `#e` tag. One filter covers all roots.
///
/// A negentropy sync reconciles them in a single session, not one per root.
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
Filter::new()
.kinds([
Kind::GitStatusOpen,
Kind::GitStatusApplied,
Kind::GitStatusClosed,
Kind::GitStatusDraft,
])
.events(roots)
}
/// A user's grasp list, kind `10317`.
pub fn grasp_list(public_key: PublicKey) -> Filter {
Filter::new()
.kind(Kind::GitUserGraspList)
.author(public_key)
}
/// NIP-22 comments, kind `1111`, referencing any of the given root events.
/// The roots are issues, patches and PRs.
///
/// Returns two filters, since combining `#E` and `#e` would AND the conditions.
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
if roots.is_empty() {
return Vec::new();
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
Filter::new()
.kinds([
Kind::GitStatusOpen,
Kind::GitStatusApplied,
Kind::GitStatusClosed,
Kind::GitStatusDraft,
])
.events(roots)
}
vec![
pub fn grasp_list(public_key: PublicKey) -> Filter {
Filter::new()
.kind(Kind::GitUserGraspList)
.author(public_key)
}
/// NIP-51: code (people who produce NIP-34 events) follow list
pub fn git_authors(public_key: PublicKey) -> Filter {
Filter::new().kind(Kind::Custom(10017)).author(public_key)
}
/// NIP-51: NIP-34 followed repositories
pub fn git_repos(public_key: PublicKey) -> Filter {
Filter::new().kind(Kind::Custom(10018)).author(public_key)
}
/// Replaceable events, so the latest of each kind is all we need.
pub fn user_metadata(public_key: PublicKey) -> Vec<Filter> {
vec![
Self::grasp_list(public_key).limit(1),
Self::git_authors(public_key).limit(1),
Self::git_repos(public_key).limit(1),
Filter::new()
.kind(Kind::ContactList)
.author(public_key)
.limit(1),
Filter::new()
.kind(Kind::Metadata)
.author(public_key)
.limit(1),
Filter::new()
.kind(Kind::MuteList)
.author(public_key)
.limit(1),
Filter::new()
.kind(Kind::BlossomServerList)
.author(public_key)
.limit(1),
]
}
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
if roots.is_empty() {
return Vec::new();
}
vec![
Filter::new()
.kind(Kind::Comment)
.custom_tags(SingleLetterTag::UPPERCASE_E, roots.clone()),
Filter::new()
.kind(Kind::Comment)
.custom_tags(SingleLetterTag::LOWERCASE_E, roots),
]
}
fn notification_comments(me: PublicKey) -> Filter {
Filter::new()
.kind(Kind::Comment)
.custom_tags(SingleLetterTag::UPPERCASE_E, roots.clone()),
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
}
pub fn notifications(me: PublicKey) -> Vec<Filter> {
vec![
Self::notification_comments(me),
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
]
}
pub fn authored_activity(me: PublicKey) -> Filter {
Filter::new().kinds(ACTIVITY_KINDS).author(me)
}
pub fn all_announcements() -> Filter {
Filter::new().kind(Kind::GitRepoAnnouncement)
}
pub fn all_states() -> Filter {
Filter::new().kind(Kind::RepoState)
}
fn deletions_since() -> Timestamp {
let now = Timestamp::now().as_secs();
Timestamp::from_secs(now - now % 86_400) - Self::DELETIONS_LOOKBACK
}
pub fn deletions() -> Filter {
Filter::new()
.kind(Kind::Comment)
.custom_tags(SingleLetterTag::LOWERCASE_E, roots),
]
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
.since(Self::deletions_since())
}
}
/// NIP-22 comments on our issues, patches and pull requests.
/// They are matched via the uppercase `P` and `K` tags, not authorship.
pub fn notification_comments(me: PublicKey) -> Filter {
Filter::new()
.kind(Kind::Comment)
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
}
/// Activity directed at us: comments on our roots, and git events tagging us
/// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag.
pub fn notifications(me: PublicKey) -> Vec<Filter> {
vec![
notification_comments(me),
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
]
}
/// Git activity authored by `me`, for "Continue where you left off".
///
/// A comment on an unrelated kind is matched too, so results must be filtered
/// through [`is_git_activity`] before display.
pub fn authored_activity(me: PublicKey) -> Filter {
Filter::new().kinds(ACTIVITY_KINDS).author(me)
}
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
fn is_git_comment(event: &Event) -> bool {
pub(crate) fn is_git_comment(event: &Event) -> bool {
event.kind == Kind::Comment
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
}
/// Whether a status event references a git root, checked via its `k` tag.
fn is_git_status(event: &Event) -> bool {
pub(crate) fn is_git_status(event: &Event) -> bool {
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
}
/// Whether `event` is git activity worth showing in the activity list.
pub fn is_git_activity(event: &Event) -> bool {
match event.kind {
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
Kind::Comment => is_git_comment(event),
Kind::GitStatusOpen
| Kind::GitStatusApplied
| Kind::GitStatusClosed
| Kind::GitStatusDraft => is_git_status(event),
_ => false,
}
}
/// All repository announcements, for global discovery.
pub fn all_announcements() -> Filter {
Filter::new().kind(Kind::GitRepoAnnouncement)
}
/// All repository state events, carrying each repository's refs and last push time.
pub fn all_states() -> Filter {
Filter::new().kind(Kind::RepoState)
}
/// How far back deletion requests are fetched and stored.
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
/// `now` minus [`DELETIONS_LOOKBACK`].
/// Quantized to whole days so identical filters hash the same.
///
/// This lets the backend's sync dedup match identical filters.
fn deletions_since() -> Timestamp {
let now = Timestamp::now().as_secs();
Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK
}
/// All deletion-related events within [`DELETIONS_LOOKBACK`].
/// These are NIP-09 kind `5` and NIP-62 kind `62`.
///
/// Deletion requests must be known before any other event is shown.
pub fn deletions() -> Filter {
Filter::new()
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
.since(deletions_since())
}
/// Deletion events relevant to a single repository.
///
/// Requests authored by the repository owner.
///
/// Requests addressed to the repository coordinate via its `#a` tag.
pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
vec![
Filter::new()
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
.author(addr.public_key),
Filter::new().kind(Kind::EventDeletion).coordinate(addr),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::GitEvent;
fn keys(seed: u8) -> Keys {
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
@@ -252,14 +202,10 @@ mod tests {
);
let on_note = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::TextNote)]);
assert!(is_git_activity(&on_git));
assert!(is_git_activity(&on_repo));
assert!(!is_git_activity(&on_note));
assert!(!is_git_activity(&signed(
&keys(1),
Kind::Comment,
Vec::new()
)));
assert!(on_git.is_git_activity());
assert!(on_repo.is_git_activity());
assert!(!on_note.is_git_activity());
assert!(!signed(&keys(1), Kind::Comment, Vec::new()).is_git_activity());
}
#[test]
@@ -275,12 +221,8 @@ mod tests {
vec![kind_tag("k", Kind::Metadata)],
);
assert!(is_git_activity(&status));
assert!(!is_git_activity(&unrelated));
assert!(!is_git_activity(&signed(
&keys(1),
Kind::GitStatusClosed,
Vec::new()
)));
assert!(status.is_git_activity());
assert!(!unrelated.is_git_activity());
assert!(!signed(&keys(1), Kind::GitStatusClosed, Vec::new()).is_git_activity());
}
}
+145
View File
@@ -0,0 +1,145 @@
use nostr::prelude::*;
pub trait GitEvent {
/// Returns the NIP-34 subject or the first non-empty content line.
fn activity_subject(&self) -> String;
/// Returns the current commit id.
fn current_commit(&self) -> Option<String>;
/// Returns the merge base commit id.
fn merge_base(&self) -> Option<String>;
/// Returns the advertised clone URLs.
fn clone_urls(&self) -> Option<Vec<Url>>;
/// Returns the branch name.
fn branch_name(&self) -> Option<String>;
/// Returns whether the event is git activity.
fn is_git_activity(&self) -> bool;
/// Returns whether an `e` or `E` tag points at the root event.
fn references_root(&self, root: &EventId) -> bool;
}
impl GitEvent for Event {
/// Reads the subject tag, then the first content line, then "Untitled".
fn activity_subject(&self) -> String {
let subject = self
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Subject(subject)) => Some(subject),
_ => None,
});
subject
.or_else(|| {
self.content
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(|value| value.to_string())
})
.unwrap_or("Untitled".to_string())
}
/// Reads the `commit` tag.
fn current_commit(&self) -> Option<String> {
self.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
_ => None,
})
}
/// Reads the `merge-base` tag.
fn merge_base(&self) -> Option<String> {
self.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()),
_ => None,
})
}
/// Reads the `clone` tag.
fn clone_urls(&self) -> Option<Vec<Url>> {
self.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Clone(urls)) => Some(urls),
_ => None,
})
}
/// Reads the `branch-name` tag.
fn branch_name(&self) -> Option<String> {
self.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::BranchName(name)) => Some(name),
_ => None,
})
}
/// Classifies git issue, patch, pull request, comment, and status kinds.
fn is_git_activity(&self) -> bool {
match self.kind {
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
Kind::Comment => crate::filters::is_git_comment(self),
Kind::GitStatusOpen
| Kind::GitStatusApplied
| Kind::GitStatusClosed
| Kind::GitStatusDraft => crate::filters::is_git_status(self),
_ => false,
}
}
/// Compares `e` and `E` tag contents against the root id.
fn references_root(&self, root: &EventId) -> bool {
let root = root.to_hex();
self.tags
.iter()
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
}
}
impl<T: GitEvent + ?Sized> GitEvent for &T {
/// Delegates to the referenced event.
fn activity_subject(&self) -> String {
(*self).activity_subject()
}
/// Delegates to the referenced event.
fn current_commit(&self) -> Option<String> {
(*self).current_commit()
}
/// Delegates to the referenced event.
fn merge_base(&self) -> Option<String> {
(*self).merge_base()
}
/// Delegates to the referenced event.
fn clone_urls(&self) -> Option<Vec<Url>> {
(*self).clone_urls()
}
/// Delegates to the referenced event.
fn branch_name(&self) -> Option<String> {
(*self).branch_name()
}
/// Delegates to the referenced event.
fn is_git_activity(&self) -> bool {
(*self).is_git_activity()
}
/// Delegates to the referenced event.
fn references_root(&self, root: &EventId) -> bool {
(*self).references_root(root)
}
}
-672
View File
@@ -1,672 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use nostr::prelude::*;
use serde::{Deserialize, Serialize};
use crate::{RepoAddr, activity_subject};
/// Window before `now` that an advanced cutoff retreats to.
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
/// Window before `now` that a mark-all cutoff retreats to.
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
/// A thread of notification and own-activity events sharing one root.
#[derive(Debug, Clone)]
pub struct InboxItem {
/// The root issue, patch or pull request the events belong to.
pub root: EventId,
/// The root event itself, when it is known locally.
pub root_event: Option<Event>,
/// Repository the root belongs to, from the root's `a` tag.
pub address: Option<RepoAddr>,
/// Notification events directed at the user, newest first.
pub events: Vec<Event>,
/// The user's own events in the thread, newest first.
pub own_events: Vec<Event>,
/// Unread event ids, oldest first.
pub unread_ids: Vec<EventId>,
/// Whether every notification event in the thread is archived.
pub archived: bool,
}
impl InboxItem {
/// Title of the thread, read from its root issue/patch/PR when known.
pub fn title(&self) -> String {
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(activity_subject)
.unwrap_or_else(|| "Untitled".to_string())
}
pub fn kind(&self) -> Option<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.
pub fn latest_activity(&self) -> Timestamp {
self.root_event
.as_ref()
.into_iter()
.chain(self.own_events.first())
.chain(self.events.first())
.map(|event| event.created_at)
.max()
.unwrap_or_default()
}
/// Up to `limit` events of the thread, oldest first.
pub fn timeline(&self, limit: usize) -> Vec<Event> {
let mut seen: HashSet<EventId> = HashSet::new();
let mut events: Vec<Event> = Vec::new();
if let Some(root) = &self.root_event {
seen.insert(root.id);
events.push(root.clone());
}
let mut rest: Vec<Event> = self
.own_events
.iter()
.chain(self.events.iter())
.filter(|event| seen.insert(event.id))
.cloned()
.collect();
rest.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
});
rest.truncate(limit.saturating_sub(events.len()));
events.extend(rest);
events.sort_by_key(|event| event.created_at);
events
}
/// Whether the thread has an unread event still visible in the inbox.
pub fn is_unread(&self) -> bool {
!self.archived && !self.unread_ids.is_empty()
}
pub fn apply_state(&mut self, state: &InboxReadState) {
self.unread_ids = self
.events
.iter()
.rev()
.filter(|event| !state.is_read(event))
.map(|event| event.id)
.collect();
// A thread without notification events is never archived.
self.archived =
!self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event));
}
}
/// Root issue, patch or pull request of a notification event.
///
/// Returns `None` when the event is not git-related, or when its root is a
/// coordinate rather than an event.
///
/// - issue (1621) / PR (1618): itself
/// - patch (1617): its `e` parent patch, else itself
/// - NIP-22 comment (1111): uppercase `E` root pointer
/// - PR update (1619): uppercase `E`
/// - statuses (1630-1633): NIP-10 root `e`
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
where
L: Fn(EventId) -> Option<Event>,
{
match event.kind {
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
Kind::GitPatch => Some(match first_e_id(event) {
Some(parent) => resolve_thread_root(parent, lookup),
None => event.id,
}),
Kind::Comment => match nip22::extract_root(event) {
Some(CommentTarget::Event { id, .. }) => Some(resolve_thread_root(id, lookup)),
_ => None,
},
Kind::GitPullRequestUpdate => {
first_uppercase_e_id(event).map(|root| resolve_thread_root(root, lookup))
}
Kind::GitStatusOpen
| Kind::GitStatusApplied
| Kind::GitStatusClosed
| Kind::GitStatusDraft => {
nip10_root_id(event).map(|root| resolve_thread_root(root, lookup))
}
_ => None,
}
}
/// Group notification events and the user's own events into one item per thread.
pub fn group<E, O, L>(
events: E,
own: O,
me: PublicKey,
state: &InboxReadState,
lookup: &L,
) -> Vec<InboxItem>
where
E: IntoIterator<Item = Event>,
O: IntoIterator<Item = Event>,
L: Fn(EventId) -> Option<Event>,
{
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
for event in events {
if event.pubkey == me {
continue;
}
let Some(root) = notification_root(&event, lookup) else {
continue;
};
groups.entry(root).or_default().push(event);
}
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
for event in own {
let root = notification_root(&event, lookup).unwrap_or(event.id);
own_groups.entry(root).or_default().push(event);
}
let mut roots: Vec<EventId> = groups.keys().chain(own_groups.keys()).copied().collect();
roots.sort();
roots.dedup();
let mut items: Vec<InboxItem> = roots
.into_iter()
.map(|root| {
let mut events = groups.remove(&root).unwrap_or_default();
let mut own_events = own_groups.remove(&root).unwrap_or_default();
sort_newest_first(&mut events);
sort_newest_first(&mut own_events);
let root_event = lookup(root);
let mut item = InboxItem {
root,
address: root_event
.as_ref()
.and_then(|event| event.tags.coordinates().next()),
root_event,
events,
own_events,
unread_ids: Vec::new(),
archived: false,
};
item.apply_state(state);
item
})
.collect();
items.sort_by(|a, b| {
b.latest_activity()
.cmp(&a.latest_activity())
.then_with(|| b.root.to_hex().cmp(&a.root.to_hex()))
});
items
}
/// Sort thread events newest first, ties broken by id.
fn sort_newest_first(events: &mut [Event]) {
events.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
});
}
/// Read and archive state of the inbox, a high-water-mark model.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InboxReadState {
#[serde(default)]
pub read_before: Timestamp,
#[serde(default)]
pub read_ids: HashSet<EventId>,
#[serde(default)]
pub archived_before: Timestamp,
#[serde(default)]
pub archived_ids: HashSet<EventId>,
}
impl InboxReadState {
/// Whether `event` is at or before the read cutoff, or marked read.
pub fn is_read(&self, event: &Event) -> bool {
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
}
/// Whether `event` is at or before the archived cutoff, or marked archived.
pub fn is_archived(&self, event: &Event) -> bool {
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
}
/// Mark one event read. Events at or before the cutoff are already read.
pub fn mark_read(&mut self, event: &Event) {
if event.created_at > self.read_before {
self.read_ids.insert(event.id);
}
}
/// Mark one event archived. Events at or before the cutoff are already archived.
pub fn mark_archived(&mut self, event: &Event) {
if event.created_at > self.archived_before {
self.archived_ids.insert(event.id);
}
}
/// Mark every non-self event read, anchoring the cutoff ten days back.
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
let cutoff = now - MARK_ALL_WINDOW;
self.read_before = cutoff;
self.read_ids = all
.iter()
.filter(|event| event.pubkey != me && event.created_at > cutoff)
.map(|event| event.id)
.collect();
}
/// Advance the read cutoff to the newest point that keeps unread events
/// unread, then prune the id set.
pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event));
self.read_before = cutoff;
prune_ids(&mut self.read_ids, all, cutoff);
}
/// Advance the archived cutoff, mirroring [`Self::advance_read`].
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| {
self.is_archived(event)
});
self.archived_before = cutoff;
prune_ids(&mut self.archived_ids, all, cutoff);
}
}
/// Newest cutoff that keeps unread events unread, never earlier than `current`.
fn advance_cutoff<M>(
all: &[Event],
me: PublicKey,
now: Timestamp,
current: Timestamp,
is_marked: M,
) -> Timestamp
where
M: Fn(&Event) -> bool,
{
let fallback = now - ADVANCE_WINDOW;
let oldest = all
.iter()
.filter(|event| event.pubkey != me && !is_marked(event))
.map(|event| event.created_at)
.min();
let candidate = match oldest {
Some(at) if at < fallback => at - 1,
_ => fallback,
};
candidate.max(current)
}
/// Drop ids whose event is unknown or now covered by the cutoff.
fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
let created_at: HashMap<EventId, Timestamp> = all
.iter()
.map(|event| (event.id, event.created_at))
.collect();
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
}
/// Follow NIP-10/NIP-22 parent pointers until a root item is reached.
fn resolve_thread_root(id: EventId, lookup: &impl Fn(EventId) -> Option<Event>) -> EventId {
let mut seen = HashSet::new();
let mut root = id;
loop {
if !seen.insert(root) {
return id;
}
let Some(event) = lookup(root) else {
return root;
};
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
return root;
}
match parent_id(&event) {
Some(parent) => root = parent,
None => return root,
}
}
}
/// Parent of a thread event, mirroring gitworkshop's `getParentId`.
fn parent_id(event: &Event) -> Option<EventId> {
for marker in ["reply", "root"] {
if let Some(id) = event
.tags
.iter()
.find_map(|tag| e_tag_with_marker(tag, marker))
{
return Some(id);
}
}
if let Some(id) = event.tags.iter().find_map(|tag| {
if tag.kind() != "e" {
return None;
}
let slice = tag.as_slice();
let is_mention = slice.len() == 4 && slice[3] == "mention";
if is_mention {
return None;
}
tag.content()
.and_then(|content| EventId::from_hex(content).ok())
}) {
return Some(id);
}
first_uppercase_e_id(event)
}
/// NIP-10 root of an event: the `e` tag marked `root`, else the first `e` tag.
fn nip10_root_id(event: &Event) -> Option<EventId> {
event
.tags
.iter()
.find_map(|tag| e_tag_with_marker(tag, "root"))
.or_else(|| first_e_id(event))
}
/// First `e` tag id, in document order.
fn first_e_id(event: &Event) -> Option<EventId> {
first_tag_id(event, "e")
}
/// First uppercase `E` tag id, in document order.
fn first_uppercase_e_id(event: &Event) -> Option<EventId> {
first_tag_id(event, "E")
}
fn first_tag_id(event: &Event, name: &str) -> Option<EventId> {
event.tags.iter().find_map(|tag| {
if tag.kind() != name {
return None;
}
tag.content()
.and_then(|content| EventId::from_hex(content).ok())
})
}
/// Event id from a four-element `e` tag carrying `marker`.
fn e_tag_with_marker(tag: &Tag, marker: &str) -> Option<EventId> {
let slice = tag.as_slice();
if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker {
return None;
}
tag.content()
.and_then(|content| EventId::from_hex(content).ok())
}
#[cfg(test)]
mod tests {
use super::*;
fn keys(seed: u8) -> Keys {
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
hex.push_str(&format!("{seed:02x}"));
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_secs(created_at))
.finalize(author)
.expect("signed event")
}
fn e_tag(event: &Event) -> Tag {
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
}
fn marked_e_tag(event: &Event, marker: &str) -> Tag {
Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker])
.expect("valid e tag")
}
fn uppercase_e_tag(event: &Event) -> Tag {
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
}
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
move |id| events.iter().find(|event| event.id == id).cloned()
}
fn issue(author: &Keys, at: u64) -> Event {
signed(author, Kind::GitIssue, Vec::new(), at)
}
fn titled_issue(author: &Keys, title: &str, at: u64) -> Event {
signed(
author,
Kind::GitIssue,
vec![Tag::parse(["subject", title]).expect("valid subject tag")],
at,
)
}
#[test]
fn comment_resolves_to_its_uppercase_root() {
let issue = issue(&keys(1), 100);
let comment = signed(
&keys(2),
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("valid K tag"),
],
200,
);
let events = [issue.clone(), comment.clone()];
assert_eq!(
notification_root(&comment, &lookup(&events)),
Some(issue.id)
);
}
#[test]
fn child_patch_resolves_to_the_root_patch() {
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
let events = [root_patch.clone(), child_patch.clone()];
assert_eq!(
notification_root(&child_patch, &lookup(&events)),
Some(root_patch.id)
);
}
#[test]
fn status_resolves_via_the_root_marker() {
let issue = issue(&keys(1), 100);
let status = signed(
&keys(2),
Kind::GitStatusClosed,
vec![marked_e_tag(&issue, "root")],
200,
);
let events = [issue.clone(), status.clone()];
assert_eq!(notification_root(&status, &lookup(&events)), Some(issue.id));
}
#[test]
fn nested_comment_chain_follows_to_the_root() {
let issue = issue(&keys(1), 100);
let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300);
let events = [issue.clone(), reply, nested.clone()];
assert_eq!(notification_root(&nested, &lookup(&events)), Some(issue.id));
}
#[test]
fn group_merges_own_events_into_the_matching_thread() {
let me = keys(1);
let issue = titled_issue(&me, "Add retry logic", 100);
let mine = signed(
&me,
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("K tag"),
],
150,
);
let reply = signed(
&keys(2),
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("K tag"),
],
200,
);
let context = [issue.clone(), mine.clone(), reply.clone()];
let items = group(
[reply.clone()],
[issue.clone(), mine.clone()],
me.public_key(),
&InboxReadState::default(),
&lookup(&context),
);
assert_eq!(items.len(), 1);
assert_eq!(items[0].root, issue.id);
assert_eq!(
items[0].root_event.as_ref().map(|event| event.id),
Some(issue.id)
);
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
assert_eq!(items[0].title(), "Add retry logic");
assert_eq!(items[0].events, vec![reply.clone()]);
// The own events are kept apart from the notifications, newest first.
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
assert_eq!(
items[0]
.timeline(5)
.iter()
.map(|event| event.id)
.collect::<Vec<_>>(),
vec![issue.id, mine.id, reply.id]
);
}
#[test]
fn mark_all_read_marks_known_recent_events() {
let me = keys(1);
let now = Timestamp::from_secs(1_000_000_000);
let recent = issue(&keys(2), now.as_secs() - 1000);
let old = issue(&keys(2), now.as_secs() - 5 * 24 * 60 * 60);
let ancient = issue(&keys(2), now.as_secs() - 20 * 24 * 60 * 60);
let mine = issue(&keys(1), now.as_secs() - 100);
let mut state = InboxReadState::default();
state.mark_all_read(
&[recent.clone(), old.clone(), ancient.clone(), mine.clone()],
me.public_key(),
now,
);
assert_eq!(state.read_before, now - MARK_ALL_WINDOW);
assert_eq!(state.read_ids, HashSet::from([recent.id, old.id]));
assert!(state.is_read(&recent));
assert!(state.is_read(&ancient));
assert!(!state.is_read(&mine));
}
#[test]
fn advance_read_never_moves_the_cutoff_backwards() {
let me = keys(1);
let unread = issue(&keys(2), 1_000);
let all = [unread];
let now = Timestamp::from_secs(1_000_000_000);
let mut state = InboxReadState {
read_before: Timestamp::from_secs(999_999_999),
..Default::default()
};
state.advance_read(&all, me.public_key(), now);
assert_eq!(state.read_before, Timestamp::from_secs(999_999_999));
}
#[test]
fn advance_read_moves_before_the_oldest_unread_and_prunes_ids() {
let me = keys(1);
let now = Timestamp::from_secs(1_000_000_000);
let five_days = 5 * 24 * 60 * 60;
let old_unread = issue(&keys(2), now.as_secs() - five_days);
// Read ids that fall before and after the new cutoff.
let stale = signed(
&keys(2),
Kind::GitIssue,
Vec::new(),
now.as_secs() - five_days - 1000,
);
let fresh = signed(
&keys(2),
Kind::GitIssue,
Vec::new(),
now.as_secs() - 100_000,
);
let mut state = InboxReadState {
read_ids: HashSet::from([stale.id, fresh.id]),
..Default::default()
};
state.advance_read(
&[old_unread.clone(), stale.clone(), fresh.clone()],
me.public_key(),
now,
);
assert_eq!(state.read_before, old_unread.created_at - 1);
assert_eq!(state.read_ids, HashSet::from([fresh.id]));
}
#[test]
fn mark_archived_skips_events_at_or_before_the_cutoff() {
let now = Timestamp::from_secs(1_000_000_000);
let event = issue(&keys(2), now.as_secs() - 1000);
let mut state = InboxReadState {
archived_before: now,
..Default::default()
};
state.mark_archived(&event);
assert!(state.archived_ids.is_empty());
let mut state = InboxReadState::default();
state.mark_archived(&event);
assert_eq!(state.archived_ids, HashSet::from([event.id]));
}
}
+172
View File
@@ -0,0 +1,172 @@
use std::collections::HashMap;
use nostr::prelude::*;
use utils::sort_newest_first;
use super::{InboxItem, InboxReadState, ThreadResolver};
use crate::RepoAddr;
/// Groups events into inbox threads, merging the user's own events.
pub fn group<E, O, L>(
events: E,
own: O,
me: PublicKey,
state: &InboxReadState,
lookup: &L,
) -> Vec<InboxItem>
where
E: IntoIterator<Item = Event>,
O: IntoIterator<Item = Event>,
L: Fn(EventId) -> Option<Event>,
{
let resolver = ThreadResolver::new(lookup);
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
for event in events {
if event.pubkey == me {
continue;
}
let Some(root) = resolver.notification_root(&event) else {
continue;
};
groups.entry(root).or_default().push(event);
}
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
for event in own {
let root = resolver.notification_root(&event).unwrap_or(event.id);
own_groups.entry(root).or_default().push(event);
}
let mut roots: Vec<EventId> = groups.keys().chain(own_groups.keys()).copied().collect();
roots.sort();
roots.dedup();
let mut items: Vec<InboxItem> = roots
.into_iter()
.map(|root| {
let mut events = groups.remove(&root).unwrap_or_default();
let mut own_events = own_groups.remove(&root).unwrap_or_default();
sort_newest_first(&mut events);
sort_newest_first(&mut own_events);
let root_event = lookup(root);
let mut item = InboxItem {
root,
address: root_event
.as_ref()
.and_then(|event| event.tags.coordinates().next())
.map(RepoAddr::from),
root_event,
events,
own_events,
unread_ids: Vec::new(),
archived: false,
};
item.apply_state(state);
item
})
.collect();
items.sort_by(|a, b| {
b.latest_activity()
.cmp(&a.latest_activity())
.then_with(|| b.root.to_hex().cmp(&a.root.to_hex()))
});
items
}
#[cfg(test)]
mod tests {
use super::*;
/// Derives deterministic signer keys from a seed byte.
fn keys(seed: u8) -> Keys {
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
hex.push_str(&format!("{seed:02x}"));
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
}
/// Builds a signed event with a fixed timestamp.
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from_secs(created_at))
.finalize(author)
.expect("signed event")
}
/// Builds an uppercase `E` tag for the event.
fn uppercase_e_tag(event: &Event) -> Tag {
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
}
/// Builds an event lookup closure over a slice.
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
move |id| events.iter().find(|event| event.id == id).cloned()
}
/// Builds a git issue event with a subject tag.
fn titled_issue(author: &Keys, title: &str, at: u64) -> Event {
signed(
author,
Kind::GitIssue,
vec![Tag::parse(["subject", title]).expect("valid subject tag")],
at,
)
}
/// Merges the user's own events into the matching thread.
#[test]
fn group_merges_own_events_into_the_matching_thread() {
let me = keys(1);
let issue = titled_issue(&me, "Add retry logic", 100);
let mine = signed(
&me,
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("K tag"),
],
150,
);
let reply = signed(
&keys(2),
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("K tag"),
],
200,
);
let context = [issue.clone(), mine.clone(), reply.clone()];
let items = group(
[reply.clone()],
[issue.clone(), mine.clone()],
me.public_key(),
&InboxReadState::default(),
&lookup(&context),
);
assert_eq!(items.len(), 1);
assert_eq!(items[0].root, issue.id);
assert_eq!(
items[0].root_event.as_ref().map(|event| event.id),
Some(issue.id)
);
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
assert_eq!(items[0].title(), "Add retry logic");
assert_eq!(items[0].events, vec![reply.clone()]);
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
assert_eq!(
items[0]
.timeline(5)
.iter()
.map(|event| event.id)
.collect::<Vec<_>>(),
vec![issue.id, mine.id, reply.id]
);
}
}
+99
View File
@@ -0,0 +1,99 @@
use std::collections::HashSet;
use nostr::prelude::*;
use super::InboxReadState;
use crate::{GitEvent, RepoAddr};
#[derive(Debug, Clone)]
pub struct InboxItem {
pub root: EventId,
pub root_event: Option<Event>,
pub address: Option<RepoAddr>,
pub events: Vec<Event>,
pub own_events: Vec<Event>,
pub unread_ids: Vec<EventId>,
pub archived: bool,
}
impl InboxItem {
/// Returns the thread title from the root, own events, or events.
pub fn title(&self) -> String {
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(|event| event.activity_subject())
.unwrap_or_else(|| "Untitled".to_string())
}
/// Returns the kind of the thread's primary event.
pub fn kind(&self) -> Option<Kind> {
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(|event| event.kind)
}
/// Returns the newest timestamp among the root, own, and other events.
pub fn latest_activity(&self) -> Timestamp {
self.root_event
.as_ref()
.into_iter()
.chain(self.own_events.first())
.chain(self.events.first())
.map(|event| event.created_at)
.max()
.unwrap_or_default()
}
/// Builds a chronological timeline capped at `limit` events, root first.
pub fn timeline(&self, limit: usize) -> Vec<Event> {
let mut seen: HashSet<EventId> = HashSet::new();
let mut events: Vec<Event> = Vec::new();
if let Some(root) = &self.root_event {
seen.insert(root.id);
events.push(root.clone());
}
let mut rest: Vec<Event> = self
.own_events
.iter()
.chain(self.events.iter())
.filter(|event| seen.insert(event.id))
.cloned()
.collect();
rest.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
});
rest.truncate(limit.saturating_sub(events.len()));
events.extend(rest);
events.sort_by_key(|event| event.created_at);
events
}
/// Returns whether the thread has unread events and is not archived.
pub fn is_unread(&self) -> bool {
!self.archived && !self.unread_ids.is_empty()
}
/// Recomputes unread ids and the archived flag from the read state.
pub fn apply_state(&mut self, state: &InboxReadState) {
self.unread_ids = self
.events
.iter()
.rev()
.filter(|event| !state.is_read(event))
.map(|event| event.id)
.collect();
self.archived =
!self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event));
}
}
+9
View File
@@ -0,0 +1,9 @@
mod group;
mod item;
mod read_state;
mod resolver;
pub use group::group;
pub use item::InboxItem;
pub use read_state::InboxReadState;
pub use resolver::ThreadResolver;
+109
View File
@@ -0,0 +1,109 @@
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use nostr::prelude::*;
use serde::{Deserialize, Serialize};
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InboxReadState {
#[serde(default)]
pub read_before: Timestamp,
#[serde(default)]
pub read_ids: HashSet<EventId>,
#[serde(default)]
pub archived_before: Timestamp,
#[serde(default)]
pub archived_ids: HashSet<EventId>,
}
impl InboxReadState {
/// Returns whether the event falls under the read high-water mark or id set.
pub fn is_read(&self, event: &Event) -> bool {
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
}
/// Returns whether the event falls under the archived high-water mark or id set.
pub fn is_archived(&self, event: &Event) -> bool {
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
}
/// Records the event as read when it is past the current cutoff.
pub fn mark_read(&mut self, event: &Event) {
if event.created_at > self.read_before {
self.read_ids.insert(event.id);
}
}
/// Records the event as archived when it is past the current cutoff.
pub fn mark_archived(&mut self, event: &Event) {
if event.created_at > self.archived_before {
self.archived_ids.insert(event.id);
}
}
/// Marks all events from other authors within the window as read.
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
let cutoff = now - MARK_ALL_WINDOW;
self.read_before = cutoff;
self.read_ids = all
.iter()
.filter(|event| event.pubkey != me && event.created_at > cutoff)
.map(|event| event.id)
.collect();
}
/// Advances the read cutoff as far as possible, then prunes the id set.
pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event));
self.read_before = cutoff;
prune_ids(&mut self.read_ids, all, cutoff);
}
/// Advances the archived cutoff as far as possible, then prunes the id set.
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| {
self.is_archived(event)
});
self.archived_before = cutoff;
prune_ids(&mut self.archived_ids, all, cutoff);
}
}
/// Computes the newest cutoff that leaves unmarked events uncovered.
fn advance_cutoff<M>(
all: &[Event],
me: PublicKey,
now: Timestamp,
current: Timestamp,
is_marked: M,
) -> Timestamp
where
M: Fn(&Event) -> bool,
{
let fallback = now - ADVANCE_WINDOW;
let oldest = all
.iter()
.filter(|event| event.pubkey != me && !is_marked(event))
.map(|event| event.created_at)
.min();
let candidate = match oldest {
Some(at) if at < fallback => at - 1,
_ => fallback,
};
candidate.max(current)
}
/// Drops ids whose events predate the cutoff.
fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
let created_at: HashMap<EventId, Timestamp> = all
.iter()
.map(|event| (event.id, event.created_at))
.collect();
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
}
+249
View File
@@ -0,0 +1,249 @@
use std::collections::HashSet;
use nostr::prelude::*;
pub struct ThreadResolver<'a, L: ?Sized> {
lookup: &'a L,
}
impl<'a, L> ThreadResolver<'a, L>
where
L: Fn(EventId) -> Option<Event> + ?Sized,
{
/// Creates a resolver over the given event lookup.
pub fn new(lookup: &'a L) -> Self {
Self { lookup }
}
/// Maps a git event kind to the thread root it notifies about.
pub fn notification_root(&self, event: &Event) -> Option<EventId> {
match event.kind {
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
Kind::GitPatch => Some(match self.first_e_id(event) {
Some(parent) => self.resolve_thread_root(parent),
None => event.id,
}),
Kind::Comment => match nip22::extract_root(event) {
Some(CommentTarget::Event { id, .. }) => Some(self.resolve_thread_root(id)),
_ => None,
},
Kind::GitPullRequestUpdate => self
.first_uppercase_e_id(event)
.map(|root| self.resolve_thread_root(root)),
Kind::GitStatusOpen
| Kind::GitStatusApplied
| Kind::GitStatusClosed
| Kind::GitStatusDraft => self
.nip10_root_id(event)
.map(|root| self.resolve_thread_root(root)),
_ => None,
}
}
/// Follows parent pointers until a git issue or pull request root is reached.
pub fn resolve_thread_root(&self, id: EventId) -> EventId {
let mut seen = HashSet::new();
let mut root = id;
loop {
if !seen.insert(root) {
return id;
}
let Some(event) = (self.lookup)(root) else {
return root;
};
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
return root;
}
match self.parent_id(&event) {
Some(parent) => root = parent,
None => return root,
}
}
}
/// Mirrors gitworkshop's `getParentId` parent resolution.
fn parent_id(&self, event: &Event) -> Option<EventId> {
for marker in ["reply", "root"] {
if let Some(id) = event
.tags
.iter()
.find_map(|tag| self.e_tag_with_marker(tag, marker))
{
return Some(id);
}
}
if let Some(id) = event.tags.iter().find_map(|tag| {
if tag.kind() != "e" {
return None;
}
let slice = tag.as_slice();
let is_mention = slice.len() == 4 && slice[3] == "mention";
if is_mention {
return None;
}
tag.content()
.and_then(|content| EventId::from_hex(content).ok())
}) {
return Some(id);
}
self.first_uppercase_e_id(event)
}
/// Reads the NIP-10 `root` marker, falling back to the first `e` tag.
fn nip10_root_id(&self, event: &Event) -> Option<EventId> {
event
.tags
.iter()
.find_map(|tag| self.e_tag_with_marker(tag, "root"))
.or_else(|| self.first_e_id(event))
}
/// Reads the first lowercase `e` tag id.
fn first_e_id(&self, event: &Event) -> Option<EventId> {
self.first_tag_id(event, "e")
}
/// Reads the first uppercase `E` tag id.
fn first_uppercase_e_id(&self, event: &Event) -> Option<EventId> {
self.first_tag_id(event, "E")
}
/// Reads the first event id from tags of the given kind.
fn first_tag_id(&self, event: &Event, name: &str) -> Option<EventId> {
event.tags.iter().find_map(|tag| {
if tag.kind() != name {
return None;
}
tag.content()
.and_then(|content| EventId::from_hex(content).ok())
})
}
/// Parses a four-value `e` tag carrying the given marker.
fn e_tag_with_marker(&self, tag: &Tag, marker: &str) -> Option<EventId> {
let slice = tag.as_slice();
if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker {
return None;
}
tag.content()
.and_then(|content| EventId::from_hex(content).ok())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Derives deterministic signer keys from a seed byte.
fn keys(seed: u8) -> Keys {
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
hex.push_str(&format!("{seed:02x}"));
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
}
/// Builds a signed event with a fixed timestamp.
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from_secs(created_at))
.finalize(author)
.expect("signed event")
}
/// Builds a plain lowercase `e` tag for the event.
fn e_tag(event: &Event) -> Tag {
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
}
/// Builds a four-value `e` tag with the given marker.
fn marked_e_tag(event: &Event, marker: &str) -> Tag {
Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker])
.expect("valid e tag")
}
/// Builds an uppercase `E` tag for the event.
fn uppercase_e_tag(event: &Event) -> Tag {
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
}
/// Builds an event lookup closure over a slice.
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
move |id| events.iter().find(|event| event.id == id).cloned()
}
/// Builds a git issue event.
fn issue(author: &Keys, at: u64) -> Event {
signed(author, Kind::GitIssue, Vec::new(), at)
}
/// Resolves a comment to its uppercase `E` root.
#[test]
fn comment_resolves_to_its_uppercase_root() {
let issue = issue(&keys(1), 100);
let comment = signed(
&keys(2),
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("valid K tag"),
],
200,
);
let events = [issue.clone(), comment.clone()];
assert_eq!(
ThreadResolver::new(&lookup(&events)).notification_root(&comment),
Some(issue.id)
);
}
/// Resolves a child patch to its root patch.
#[test]
fn child_patch_resolves_to_the_root_patch() {
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
let events = [root_patch.clone(), child_patch.clone()];
assert_eq!(
ThreadResolver::new(&lookup(&events)).notification_root(&child_patch),
Some(root_patch.id)
);
}
/// Resolves a status through its `root` marker tag.
#[test]
fn status_resolves_via_the_root_marker() {
let issue = issue(&keys(1), 100);
let status = signed(
&keys(2),
Kind::GitStatusClosed,
vec![marked_e_tag(&issue, "root")],
200,
);
let events = [issue.clone(), status.clone()];
assert_eq!(
ThreadResolver::new(&lookup(&events)).notification_root(&status),
Some(issue.id)
);
}
/// Follows a nested comment chain to the issue root.
#[test]
fn nested_comment_chain_follows_to_the_root() {
let issue = issue(&keys(1), 100);
let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300);
let events = [issue.clone(), reply, nested.clone()];
assert_eq!(
ThreadResolver::new(&lookup(&events)).notification_root(&nested),
Some(issue.id)
);
}
}
+13 -12
View File
@@ -1,20 +1,21 @@
pub mod addr;
pub mod announcement;
pub mod deletions;
pub mod filters;
pub mod git_event;
pub mod inbox;
pub mod model;
pub mod pull_request;
pub mod state;
pub mod status;
pub mod upstream;
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
pub use addr::RepoAddr;
pub use announcement::Announcement;
pub use deletions::Deletions;
pub use filters::{
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
};
pub use inbox::{InboxItem, InboxReadState, group, notification_root};
pub use model::{
Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of,
fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches,
};
pub use state::{build_state, parse_state};
pub use status::{RepoStatus, references_root, resolve_status};
pub use filters::Filters;
pub use git_event::GitEvent;
pub use inbox::{InboxItem, InboxReadState, ThreadResolver};
pub use pull_request::PullRequest;
pub use state::RepoState;
pub use status::RepoStatus;
pub use upstream::Upstream;
-883
View File
@@ -1,883 +0,0 @@
use std::collections::HashSet;
use nostr::prelude::*;
use crate::{RepoAddr, repo_addr};
/// Parsed NIP-34 repository announcement, plain data ready for the UI.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Announcement {
/// ID of the announcement event itself.
pub event_id: EventId,
/// Repository ID, the `d` tag.
pub id: String,
/// Author of the announcement event.
pub owner: PublicKey,
/// When the announcement was published, used for latest-wins resolution.
pub created_at: Timestamp,
pub name: Option<String>,
pub description: Option<String>,
/// Webpage URLs for browsing.
pub web: Vec<Url>,
/// URLs for `git clone`.
pub clone: Vec<Url>,
/// Relays the repository monitors for patches and issues.
pub relays: Vec<RelayUrl>,
/// Earliest unique commit ID, the `r` tag with `euc` marker.
pub euc: Option<String>,
/// Other recognized maintainers.
pub maintainers: Vec<PublicKey>,
/// Marks the repository as a subordinate fork of the upstream, per NIP-34.
pub upstream: Option<Upstream>,
/// Hashtags labelling the repository, the `t` tags.
pub hashtags: Vec<String>,
}
/// The `u` tag of a fork announcement, per NIP-34.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Upstream {
/// Raw first value of the `u` tag, a coordinate or git URL.
pub raw: String,
/// Upstream repository coordinate when the `u` tag names a NIP-34 repository.
/// `None` for the git-URL form.
pub addr: Option<RepoAddr>,
}
impl Upstream {
fn parse(raw: &str) -> Self {
let coordinate = raw.split('|').next().unwrap_or(raw);
let addr = coordinate
.parse::<Coordinate>()
.ok()
.filter(|c| c.kind == Kind::GitRepoAnnouncement);
Self {
raw: raw.to_owned(),
addr,
}
}
pub fn display(&self) -> String {
match &self.addr {
Some(addr) => addr.to_string(),
None => self.raw.clone(),
}
}
}
/// Subject of a NIP-34 issue or pull request event.
/// Taken from the `subject` tag, else the first non-empty line of the content.
pub fn activity_subject(event: &Event) -> String {
let subject = event
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Subject(subject)) => Some(subject),
_ => None,
});
subject
.or_else(|| {
event
.content
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(|value| value.to_string())
})
.unwrap_or("Untitled".to_string())
}
/// The patch set of a pull request.
///
/// Returns an empty list when no patch event can be linked to the PR.
pub fn pull_request_patches<'a>(
pr: &Event,
patches: impl IntoIterator<Item = &'a Event>,
) -> Vec<&'a Event> {
let patches: Vec<&'a Event> = patches.into_iter().collect();
// The PR references its root patch via an `e` tag.
// Follow the NIP-10 reply chain forward from there.
// Each patch replies to the previous one, and among several replies the newest wins.
if let Some(root_id) = pr.tags.event_ids().next()
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
{
return forward_series(root, &patches);
}
// The PR has no `e` tag.
// The last patch of the set carries the tip commit in its `commit` or `r` tag.
// Walk the reply chain backward to the root.
let Some(tip) = current_commit_of(pr) else {
return Vec::new();
};
let Some(last) = patches
.iter()
.filter(|patch| patch_produces_commit(patch, &tip))
.max_by_key(|patch| patch.created_at)
.copied()
else {
return Vec::new();
};
let mut series = vec![last];
loop {
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
break;
};
let Some(prev) = patches
.iter()
.find(|patch| patch.id == prev_id && !series.contains(patch))
.copied()
else {
break;
};
series.push(prev);
}
series.reverse();
series
}
pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a Event>) -> String {
let patches: Vec<&'a Event> = patches.into_iter().collect();
let series = pull_request_patches(pr, patches.iter().copied());
if series.is_empty() {
return pr.content.clone();
}
series
.iter()
.map(|patch| patch.content.as_str())
.collect::<Vec<_>>()
.join("\n")
}
/// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first.
fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
let mut series = vec![root];
loop {
let next = patches
.iter()
.filter(|patch| !series.contains(patch))
.filter(|patch| {
patch
.tags
.event_ids()
.any(|id| id == series.last().unwrap().id)
})
.max_by_key(|patch| patch.created_at);
let Some(next) = next else {
break;
};
series.push(next);
}
series
}
/// The `c` tag of an event, the tip of the proposed branch, as hex.
pub fn current_commit_of(event: &Event) -> Option<String> {
event
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
_ => None,
})
}
/// The `merge-base` tag of an event, the base commit a pull request diffs against.
pub fn merge_base_of(event: &Event) -> Option<String> {
event
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()),
_ => None,
})
}
/// The `clone` tag of an event, URLs the tip commit can be fetched from.
pub fn clone_urls_of(event: &Event) -> Option<Vec<Url>> {
event
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Clone(urls)) => Some(urls),
_ => None,
})
}
/// The `branch-name` tag of an event, the proposed branch's name.
pub fn branch_name_of(event: &Event) -> Option<String> {
event
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::BranchName(name)) => Some(name),
_ => None,
})
}
/// The newest `GitPullRequestUpdate` revising `root`, from the root's own author.
///
/// A pull request's tip is only mutable by its author, per NIP-34; updates
/// from anyone else are ignored even if they are newer.
pub fn latest_update<'a>(
events: impl Iterator<Item = &'a Event>,
root: &Event,
) -> Option<&'a Event> {
let root_hex = root.id.to_hex();
events
.filter(|e| e.kind == Kind::GitPullRequestUpdate)
.filter(|e| e.pubkey == root.pubkey)
.filter(|e| {
e.tags
.iter()
.any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str()))
})
.max_by_key(|e| e.created_at)
}
/// The announced forks of `base` a new pull request compare can be built from.
///
/// The user's own forks are listed first.
pub fn fork_candidates<'a>(
announcements: &'a [Announcement],
base: &RepoAddr,
base_euc: Option<&str>,
user: Option<PublicKey>,
) -> Vec<&'a Announcement> {
let (mut own, mut others) = (Vec::new(), Vec::new());
for announcement in announcements {
if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) {
continue;
}
if Some(announcement.owner) == user {
own.push(announcement);
} else {
others.push(announcement);
}
}
own.into_iter().chain(others).collect()
}
/// Whether `patch` produces `commit`, found via its `commit` or `r` tag.
///
/// It lets clients find existing patches for a specific commit.
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
patch
.tags
.iter()
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
_ => false,
})
}
impl Announcement {
/// Parse a kind `30617` event.
///
/// Returns `None` when the kind is wrong or the `d` tag is missing.
pub fn from_event(event: &Event) -> Option<Self> {
if event.kind != Kind::GitRepoAnnouncement {
return None;
}
let id = event.tags.identifier()?;
let mut hashtags: Vec<String> = Vec::new();
hashtags.extend(event.tags.hashtags().map(|t| t.to_string()));
let mut name: Option<String> = None;
let mut description: Option<String> = None;
let mut web: Vec<Url> = Vec::new();
let mut clone: Vec<Url> = Vec::new();
let mut relays: Vec<RelayUrl> = Vec::new();
let mut euc: Option<String> = None;
let mut maintainers: Vec<PublicKey> = Vec::new();
let mut upstream: Option<Upstream> = None;
for tag in event.tags.iter() {
match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Name(value)) => name = Some(value),
Ok(Nip34Tag::Description(value)) => description = Some(value),
Ok(Nip34Tag::Web(urls)) => web.extend(urls),
Ok(Nip34Tag::Clone(urls)) => clone.extend(urls),
Ok(Nip34Tag::Relays(urls)) => relays.extend(urls),
Ok(Nip34Tag::EarliestUniqueCommitId(commit)) => euc = Some(commit.to_string()),
Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys),
_ => {}
}
// The SDK's `Nip34Tag` does not model the `u` tag, so parse it manually.
// Only the first `u` tag is used.
if upstream.is_none() && tag.kind() == "u" {
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));
}
}
}
Some(Self {
event_id: event.id,
owner: event.pubkey,
created_at: event.created_at,
id,
name,
description,
web,
clone,
relays,
euc,
maintainers,
upstream,
hashtags,
})
}
pub fn addr(&self) -> RepoAddr {
repo_addr(self.owner, self.id.clone())
}
/// The name of the repository, or a default if none is provided.
pub fn name(&self) -> String {
self.name.clone().unwrap_or("Untitled".into())
}
/// Whether this announcement is a fork of the repository at `base`.
/// Its `u` tag points at `base`, which also covers permanent forks whose EUC diverged.
///
/// Or it shares `base`'s earliest unique commit and is not the base itself.
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
if self.addr() == *base {
return false;
}
if self.upstream.as_ref().and_then(|u| u.addr.as_ref()) == Some(base) {
return true;
}
base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc))
}
/// The description of the repository, or a default if none is provided.
pub fn description(&self) -> String {
self.description
.clone()
.unwrap_or("No description".to_string())
}
/// The effective maintainers of this repository,
/// the announced `maintainers` plus the announcement author.
///
/// A `u` tag that marks the repository as a subordinate fork excludes them, per NIP-34.
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
let mut maintainers = self.maintainers.clone();
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
maintainers.push(self.owner);
}
maintainers
}
/// The `git clone` URLs for this repository, deduplicated.
pub fn clone_urls(&self) -> Vec<String> {
let mut seen = HashSet::new();
self.clone
.iter()
.map(|url| format!("git clone {url}"))
.filter(|command| seen.insert(command.clone()))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
const MAINTAINER_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272";
fn keys() -> Keys {
Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
.expect("valid secret key"),
)
}
fn announcement_event(tags: &[&[&str]]) -> Event {
let tags: Vec<Tag> = tags
.iter()
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(tags)
.finalize(&keys())
.expect("signed event")
}
#[test]
fn parses_full_announcement() {
let event = announcement_event(&[
&["d", "my-repo"],
&["name", "My Repo"],
&["description", "A test repository"],
&["web", "https://example.com/repo"],
&["clone", "https://example.com/repo.git"],
&["relays", "wss://relay.example.com"],
&["r", "aa231c4c6a5777dc89b42207b499891a344add5c", "euc"],
&["maintainers", MAINTAINER_HEX],
&["t", "rust"],
&["t", "nostr"],
]);
let announcement = Announcement::from_event(&event).expect("parses");
assert_eq!(announcement.owner, keys().public_key());
assert_eq!(announcement.id, "my-repo");
assert_eq!(announcement.name.as_deref(), Some("My Repo"));
assert_eq!(
announcement.description.as_deref(),
Some("A test repository")
);
assert_eq!(
announcement.web,
vec![Url::parse("https://example.com/repo").unwrap()]
);
assert_eq!(
announcement.clone,
vec![Url::parse("https://example.com/repo.git").unwrap()]
);
assert_eq!(
announcement.relays,
vec![RelayUrl::parse("wss://relay.example.com").unwrap()]
);
assert_eq!(
announcement.euc.as_deref(),
Some("aa231c4c6a5777dc89b42207b499891a344add5c")
);
assert_eq!(
announcement.maintainers,
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
);
assert_eq!(announcement.hashtags, vec!["rust", "nostr"]);
}
#[test]
fn drops_malformed_values() {
let event = announcement_event(&[
&["d", "my-repo"],
&["clone", "not a url"],
&["relays", "wss://good.example.com"],
&["maintainers", "not-a-pubkey"],
]);
let announcement = Announcement::from_event(&event).expect("parses");
// An invalid URL keeps the whole clone tag from being parsed.
assert!(announcement.clone.is_empty());
assert_eq!(
announcement.relays,
vec![RelayUrl::parse("wss://good.example.com").unwrap()]
);
assert!(announcement.maintainers.is_empty());
}
#[test]
fn parses_upstream_tag() {
let event = announcement_event(&[
&["d", "my-fork"],
&[
"u",
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git",
"wss://relay.example.com",
],
]);
let announcement = Announcement::from_event(&event).expect("parses");
let upstream = announcement.upstream.expect("parses the u tag");
// The coordinate part resolves to a repository address.
// The raw value keeps the `|git-url` suffix.
assert_eq!(
upstream.addr,
Some(crate::repo_addr(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream"
))
);
assert_eq!(
upstream.raw,
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git"
);
assert_eq!(
upstream.display().to_string(),
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream"
);
}
#[test]
fn is_fork_of_matches_the_u_tag_coordinate() {
// The base repository, announced by the `u` tag's owner.
let base = crate::repo_addr(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream",
);
let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]);
let fork = Announcement::from_event(&event).expect("parses");
// A `u` tag pointing at the base address marks a fork.
// This holds even when neither side announces an EUC.
assert!(fork.is_fork_of(&base, None));
}
#[test]
fn is_fork_of_matches_a_shared_euc() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
// The base repo has no `u` tag. It announces the family EUC.
let base_event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
let base = Announcement::from_event(&base_event).expect("parses");
let base_addr = base.addr();
// A fork with no `u` tag, a pure mirror or cross-hosted clone, shares the EUC.
// Clients of the family can then find it.
let fork_event = announcement_event(&[&["d", "mirror"], &["r", euc, "euc"]]);
let fork = Announcement::from_event(&fork_event).expect("parses");
assert!(fork.is_fork_of(&base_addr, base.euc.as_deref()));
// An unrelated repository with a different EUC is not a fork.
let other_event = announcement_event(&[
&["d", "other"],
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
]);
let other = Announcement::from_event(&other_event).expect("parses");
assert!(!other.is_fork_of(&base_addr, base.euc.as_deref()));
// Without a base EUC there is nothing to compare against.
assert!(!fork.is_fork_of(&base_addr, None));
}
#[test]
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
// A permanent fork re-announces its EUC, the first commit after the fork.
// Only the `u` tag still relates it to the base.
let base = crate::repo_addr(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream",
);
let base_euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let event = announcement_event(&[
&["d", "my-fork"],
&["u", &base.to_string()],
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
]);
let fork = Announcement::from_event(&event).expect("parses");
assert!(fork.is_fork_of(&base, Some(base_euc)));
}
#[test]
fn effective_maintainers_include_owner_for_primary_repos() {
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
let announcement = Announcement::from_event(&event).expect("parses");
let maintainers = announcement.effective_maintainers();
// The owner asserts themselves as a maintainer of the primary project, per NIP-34.
// Announced co-maintainers are included too.
assert_eq!(maintainers.len(), 2);
assert!(maintainers.contains(&announcement.owner));
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
}
#[test]
fn effective_maintainers_exclude_owner_for_subordinate_forks() {
let event = announcement_event(&[
&["d", "my-fork"],
&["u", "30617:abc:upstream|https://example.com/upstream.git"],
&["maintainers", MAINTAINER_HEX],
]);
let announcement = Announcement::from_event(&event).expect("parses");
let maintainers = announcement.effective_maintainers();
// A `u` tag marks the repository as a subordinate fork.
// The author is then not a maintainer of the primary project, per NIP-34.
assert!(!maintainers.contains(&announcement.owner));
assert_eq!(
maintainers,
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
);
}
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
EventBuilder::new(Kind::GitPullRequest, content)
.tags(tags)
.finalize(&keys())
.expect("signed event")
}
fn patch_event(content: &str, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(Kind::GitPatch, content)
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys())
.expect("signed event")
}
#[test]
fn pull_request_patch_joins_the_whole_patch_set() {
// A PR references the root patch, per NIP-34.
// Later patches of the set reply to the previous one via NIP-10 `e` tags.
let root = patch_event("patch-one", vec![], 100);
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
let pr = pr_event("description", vec![Tag::event(root.id)]);
assert_eq!(
pull_request_patch(&pr, [&root, &second]),
"patch-one\npatch-two"
);
assert_eq!(
pull_request_patches(&pr, [&root, &second]),
vec![&root, &second]
);
}
#[test]
fn pull_request_patches_walks_the_reply_chain_in_order() {
let root = patch_event("patch-one", vec![], 100);
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
let pr = pr_event("description", vec![Tag::event(root.id)]);
let series = pull_request_patches(&pr, [&third, &root, &second]);
assert_eq!(
series
.iter()
.map(|p| p.content.as_str())
.collect::<Vec<_>>(),
vec!["patch-one", "patch-two", "patch-three"]
);
}
#[test]
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
// PRs without an `e` tag fall back to the patch producing the tip commit.
// Walk the reply chain backward to the root.
let root = patch_event("patch-one", vec![], 100);
let tip = "1111111111111111111111111111111111111111";
let last = patch_event(
"patch-two",
vec![
Tag::event(root.id),
Tag::parse(["r", tip]).expect("valid tag"),
],
200,
);
let pr = pr_event(
"description",
vec![Tag::parse(["c", tip]).expect("valid tag")],
);
let series = pull_request_patches(&pr, [&root, &last]);
assert_eq!(
series
.iter()
.map(|p| p.content.as_str())
.collect::<Vec<_>>(),
vec!["patch-one", "patch-two"]
);
}
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
fn signed_at(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys())
.expect("signed event")
}
fn pr_root() -> Event {
signed_at(
Kind::GitPullRequest,
vec![
Tag::parse(["c", COMMIT_HEX]).expect("valid tag"),
Tag::parse(["branch-name", "feature/x"]).expect("valid tag"),
],
100,
)
}
#[test]
fn latest_update_picks_newest_revision_of_the_root() {
let root = pr_root();
let root_hex = root.id.to_hex();
let revision = |created_at: u64| {
signed_at(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", &root_hex]).expect("valid tag")],
created_at,
)
};
// An update revising a different PR must be ignored even though it is newer.
let unrelated = signed_at(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
999,
);
let events = [unrelated, revision(200), root.clone(), revision(300)];
let latest = latest_update(events.iter(), &root).expect("an update");
assert_eq!(latest.created_at.as_secs(), 300);
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
}
#[test]
fn latest_update_ignores_other_authors() {
let root = pr_root();
let root_hex = root.id.to_hex();
let other = Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
.expect("valid secret key"),
);
let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "")
.tags([Tag::parse(["E", &root_hex]).expect("valid tag")])
.custom_created_at(Timestamp::from(999))
.finalize(&other)
.expect("signed event");
// The tip of a PR is only mutable by its author.
// A newer update from anyone else must not win.
assert!(latest_update([&stranger, &root].into_iter(), &root).is_none());
}
const OWNER_KEYS: [&str; 3] = [
"0000000000000000000000000000000000000000000000000000000000000001",
"0000000000000000000000000000000000000000000000000000000000000002",
"0000000000000000000000000000000000000000000000000000000000000003",
];
fn owned_announcement_event(owner: &str, tags: &[&[&str]]) -> Event {
let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key"));
let tags: Vec<Tag> = tags
.iter()
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(tags)
.finalize(&keys)
.expect("signed event")
}
fn owned_announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec<Announcement> {
vec![
Announcement::from_event(&owned_announcement_event(OWNER_KEYS[owner_ix], tags))
.expect("parses"),
]
}
#[test]
fn fork_candidates_orders_own_forks_first() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let clone = "https://grasp.example/npub1x/my-fork.git";
let base_addr = crate::repo_addr(
PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"),
"upstream",
);
// Newest first, as RepoListStore keeps them.
// Unrelated repo, the user's fork with the shared EUC, another fork with a `u` tag.
let all = vec![
owned_announcements(
2,
&[
&["d", "other-project"],
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
],
)
.pop()
.unwrap(),
owned_announcements(
1,
&[&["d", "my-fork"], &["r", euc, "euc"], &["clone", clone]],
)
.pop()
.unwrap(),
owned_announcements(
2,
&[
&["d", "their-fork"],
&["u", &base_addr.to_string()],
&["clone", clone],
],
)
.pop()
.unwrap(),
];
let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey");
let forks = fork_candidates(&all, &base_addr, Some(euc), Some(user));
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids, vec!["my-fork", "their-fork"]);
}
#[test]
fn fork_candidates_excludes_base_unrelated_and_unfetchable() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey");
let base_addr = crate::repo_addr(base_owner, "upstream");
let mut all = vec![
owned_announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]])
.pop()
.unwrap(),
owned_announcements(1, &[&["d", "no-clone-fork"], &["r", euc, "euc"]])
.pop()
.unwrap(),
owned_announcements(
2,
&[
&["d", "other"],
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
],
)
.pop()
.unwrap(),
owned_announcements(
2,
&[
&["d", "mirror"],
&["r", euc, "euc"],
&["clone", "https://grasp.example/x/mirror.git"],
],
)
.pop()
.unwrap(),
];
let forks = fork_candidates(&all, &base_addr, Some(euc), Some(base_owner));
assert_eq!(forks.len(), 1);
assert_eq!(forks[0].id, "mirror");
// Without a base EUC only `u`-tag forks match.
all.push(
owned_announcements(
2,
&[
&["d", "u-fork"],
&["u", &base_addr.to_string()],
&["clone", "https://grasp.example/x/u-fork.git"],
],
)
.pop()
.unwrap(),
);
let forks = fork_candidates(&all, &base_addr, None, Some(base_owner));
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids, vec!["u-fork"]);
}
}
+279
View File
@@ -0,0 +1,279 @@
use nostr::prelude::*;
use crate::git_event::GitEvent;
pub struct PullRequest<'a>(pub &'a Event);
impl<'a> PullRequest<'a> {
/// Wraps a pull request root event.
pub fn new(event: &'a Event) -> Self {
Self(event)
}
/// Returns the ordered patch series, found via the root reference or the tip commit.
pub fn patches(&self, patches: impl IntoIterator<Item = &'a Event>) -> Vec<&'a Event> {
let pr = self.0;
let patches: Vec<&'a Event> = patches.into_iter().collect();
if let Some(root_id) = pr.tags.event_ids().next()
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
{
return Self::forward_series(root, &patches);
}
let Some(tip) = pr.current_commit() else {
return Vec::new();
};
let Some(last) = patches
.iter()
.filter(|patch| Self::patch_produces_commit(patch, &tip))
.max_by_key(|patch| patch.created_at)
.copied()
else {
return Vec::new();
};
let mut series = vec![last];
loop {
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
break;
};
let Some(prev) = patches
.iter()
.find(|patch| patch.id == prev_id && !series.contains(patch))
.copied()
else {
break;
};
series.push(prev);
}
series.reverse();
series
}
/// Joins the patch series with newlines, falling back to the event content.
pub fn patch(&self, patches: impl IntoIterator<Item = &'a Event>) -> String {
let pr = self.0;
let patches: Vec<&'a Event> = patches.into_iter().collect();
let series = self.patches(patches.iter().copied());
if series.is_empty() {
return pr.content.clone();
}
series
.iter()
.map(|patch| patch.content.as_str())
.collect::<Vec<_>>()
.join("\n")
}
/// Returns the root author's newest update, since only the author may move the tip.
pub fn latest_update(
events: impl Iterator<Item = &'a Event>,
root: &Event,
) -> Option<&'a Event> {
let root_hex = root.id.to_hex();
events
.filter(|e| e.kind == Kind::GitPullRequestUpdate)
.filter(|e| e.pubkey == root.pubkey)
.filter(|e| {
e.tags
.iter()
.any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str()))
})
.max_by_key(|e| e.created_at)
}
/// Walks reply links outward from the root patch, taking the newest child first.
fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
let mut series = vec![root];
loop {
let next = patches
.iter()
.filter(|patch| !series.contains(patch))
.filter(|patch| {
patch
.tags
.event_ids()
.any(|id| id == series.last().unwrap().id)
})
.max_by_key(|patch| patch.created_at);
let Some(next) = next else {
break;
};
series.push(next);
}
series
}
/// Checks the commit against the patch's `c` and `r` tags.
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
patch
.tags
.iter()
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
_ => false,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Returns the test signer keys.
fn keys() -> Keys {
Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
.expect("valid secret key"),
)
}
/// Builds a signed pull request event.
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
EventBuilder::new(Kind::GitPullRequest, content)
.tags(tags)
.finalize(&keys())
.expect("signed event")
}
/// Builds a signed patch event with a fixed timestamp.
fn patch_event(content: &str, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(Kind::GitPatch, content)
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys())
.expect("signed event")
}
/// Joins the full patch series into one diff.
#[test]
fn pull_request_patch_joins_the_whole_patch_set() {
let root = patch_event("patch-one", vec![], 100);
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
let pr = pr_event("description", vec![Tag::event(root.id)]);
assert_eq!(
PullRequest::new(&pr).patch([&root, &second]),
"patch-one\npatch-two"
);
assert_eq!(
PullRequest::new(&pr).patches([&root, &second]),
vec![&root, &second]
);
}
/// Orders patches by walking the reply chain.
#[test]
fn pull_request_patches_walks_the_reply_chain_in_order() {
let root = patch_event("patch-one", vec![], 100);
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
let pr = pr_event("description", vec![Tag::event(root.id)]);
let series = PullRequest::new(&pr).patches([&third, &root, &second]);
assert_eq!(
series
.iter()
.map(|p| p.content.as_str())
.collect::<Vec<_>>(),
vec!["patch-one", "patch-two", "patch-three"]
);
}
/// Finds the patch series through the declared tip commit.
#[test]
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
let root = patch_event("patch-one", vec![], 100);
let tip = "1111111111111111111111111111111111111111";
let last = patch_event(
"patch-two",
vec![
Tag::event(root.id),
Tag::parse(["r", tip]).expect("valid tag"),
],
200,
);
let pr = pr_event(
"description",
vec![Tag::parse(["c", tip]).expect("valid tag")],
);
let series = PullRequest::new(&pr).patches([&root, &last]);
assert_eq!(
series
.iter()
.map(|p| p.content.as_str())
.collect::<Vec<_>>(),
vec!["patch-one", "patch-two"]
);
}
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
/// Builds a signed event with a fixed timestamp.
fn signed_at(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys())
.expect("signed event")
}
/// Builds the pull request root used by the update tests.
fn pr_root() -> Event {
signed_at(
Kind::GitPullRequest,
vec![
Tag::parse(["c", COMMIT_HEX]).expect("valid tag"),
Tag::parse(["branch-name", "feature/x"]).expect("valid tag"),
],
100,
)
}
/// Picks the newest update targeting the root.
#[test]
fn latest_update_picks_newest_revision_of_the_root() {
let root = pr_root();
let root_hex = root.id.to_hex();
let revision = |created_at: u64| {
signed_at(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", &root_hex]).expect("valid tag")],
created_at,
)
};
let unrelated = signed_at(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
999,
);
let events = [unrelated, revision(200), root.clone(), revision(300)];
let latest = PullRequest::latest_update(events.iter(), &root).expect("an update");
assert_eq!(latest.created_at.as_secs(), 300);
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
}
/// Ignores updates from authors other than the root owner.
#[test]
fn latest_update_ignores_other_authors() {
let root = pr_root();
let root_hex = root.id.to_hex();
let other = Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
.expect("valid secret key"),
);
let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "")
.tags([Tag::parse(["E", &root_hex]).expect("valid tag")])
.custom_created_at(Timestamp::from(999))
.finalize(&other)
.expect("signed event");
assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none());
}
}
+44 -41
View File
@@ -1,44 +1,47 @@
use nostr::prelude::*;
/// Build a kind `30618` repository state event from refs and HEAD,
/// it is published as `ref: refs/heads/<branch>`.
///
/// The `d` tag matches the repository id.
pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
for (name, commit) in refs {
tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag"));
}
if let Some(head) = head {
tags.push(
Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"),
);
}
EventBuilder::new(Kind::RepoState, "").tags(tags)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoState {
pub refs: Vec<(String, String)>,
pub head: Option<String>,
}
/// Parse a kind `30618` repository state event into refs and HEAD.
///
/// `refs` are `(refname, commit-id)` pairs.
/// `head` is the branch pointed to by the `HEAD` tag, if any.
pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
let mut refs = Vec::new();
let mut head = None;
impl RepoState {
pub fn build(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
for tag in event.tags.iter() {
match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Head(branch)) => head = Some(branch),
Ok(Nip34Tag::RefHead { branch, commit }) => {
refs.push((format!("refs/heads/{branch}"), commit.to_string()));
}
Ok(Nip34Tag::RefTag { name, commit }) => {
refs.push((format!("refs/tags/{name}"), commit.to_string()));
}
_ => {}
for (name, commit) in refs {
tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag"));
}
if let Some(head) = head {
tags.push(
Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"),
);
}
EventBuilder::new(Kind::RepoState, "").tags(tags)
}
(refs, head)
pub fn parse(event: &Event) -> Self {
let mut refs = Vec::new();
let mut head = None;
for tag in event.tags.iter() {
match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Head(branch)) => head = Some(branch),
Ok(Nip34Tag::RefHead { branch, commit }) => {
refs.push((format!("refs/heads/{branch}"), commit.to_string()));
}
Ok(Nip34Tag::RefTag { name, commit }) => {
refs.push((format!("refs/tags/{name}"), commit.to_string()));
}
_ => {}
}
}
Self { refs, head }
}
}
#[cfg(test)]
@@ -76,11 +79,11 @@ mod tests {
&["refs/tags/v1.0", COMMIT_A],
]);
let (refs, head) = parse_state(&event);
let state = RepoState::parse(&event);
assert_eq!(head.as_deref(), Some("main"));
assert_eq!(state.head.as_deref(), Some("main"));
assert_eq!(
refs,
state.refs,
vec![
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
@@ -90,22 +93,22 @@ mod tests {
}
#[test]
fn build_state_round_trips_through_parse() {
fn build_round_trips_through_parse() {
let refs = [
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()),
];
let event = build_state("my-repo", &refs, Some("main"))
let event = RepoState::build("my-repo", &refs, Some("main"))
.finalize(&keys())
.expect("signed event");
assert_eq!(event.kind, Kind::RepoState);
assert_eq!(event.tags.identifier().as_deref(), Some("my-repo"));
let (parsed_refs, head) = parse_state(&event);
assert_eq!(parsed_refs, refs);
assert_eq!(head.as_deref(), Some("main"));
let state = RepoState::parse(&event);
assert_eq!(state.refs, refs);
assert_eq!(state.head.as_deref(), Some("main"));
}
}
+21 -38
View File
@@ -1,6 +1,5 @@
use nostr::prelude::*;
/// Status of a root patch, pull request or issue, kinds `1630..=1633`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RepoStatus {
Open,
@@ -28,42 +27,29 @@ impl RepoStatus {
Self::Draft => Kind::GitStatusDraft,
}
}
}
/// NIP-10 and NIP-34 use the lowercase `e` tag.
///
/// NIP-22 comments, kind `1111`, use the uppercase `E` tag for the thread root.
pub fn references_root(event: &Event, root: &EventId) -> bool {
let root = root.to_hex();
event
.tags
.iter()
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
}
/// Resolve the status of a root event per NIP-34.
///
/// Defaults to [`RepoStatus::Open`].
pub fn resolve_status<'a, I>(
status_events: I,
root_author: &PublicKey,
maintainers: &[PublicKey],
) -> RepoStatus
where
I: IntoIterator<Item = &'a Event>,
{
status_events
.into_iter()
.filter(|e| RepoStatus::from_kind(e.kind).is_some())
.filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey))
.max_by_key(|e| e.created_at)
.and_then(|e| RepoStatus::from_kind(e.kind))
.unwrap_or(RepoStatus::Open)
pub fn resolve<'a, I>(
status_events: I,
root_author: &PublicKey,
maintainers: &[PublicKey],
) -> Self
where
I: IntoIterator<Item = &'a Event>,
{
status_events
.into_iter()
.filter(|e| RepoStatus::from_kind(e.kind).is_some())
.filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey))
.max_by_key(|e| e.created_at)
.and_then(|e| RepoStatus::from_kind(e.kind))
.unwrap_or(RepoStatus::Open)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::GitEvent;
const ROOT_ID_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111";
const OTHER_ID_HEX: &str = "2222222222222222222222222222222222222222222222222222222222222222";
@@ -94,11 +80,8 @@ mod tests {
))
.expect("signed event");
assert!(references_root(&event, &root));
assert!(!references_root(
&event,
&EventId::from_hex(OTHER_ID_HEX).expect("valid id")
));
assert!(event.references_root(&root));
assert!(!event.references_root(&EventId::from_hex(OTHER_ID_HEX).expect("valid id")));
}
#[test]
@@ -115,7 +98,7 @@ mod tests {
];
assert_eq!(
resolve_status(
RepoStatus::resolve(
statuses.iter(),
&owner.public_key(),
&[maintainer.public_key()]
@@ -140,7 +123,7 @@ mod tests {
];
assert_eq!(
resolve_status(
RepoStatus::resolve(
statuses.iter(),
&owner.public_key(),
&[maintainer.public_key()]
+33
View File
@@ -0,0 +1,33 @@
use nostr::prelude::*;
use crate::RepoAddr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Upstream {
pub raw: String,
pub addr: Option<RepoAddr>,
}
impl Upstream {
/// Parses a raw `u` tag value, extracting the coordinate when present.
pub(crate) fn parse(raw: &str) -> Self {
let coordinate = raw.split('|').next().unwrap_or(raw);
let addr = coordinate
.parse::<Coordinate>()
.ok()
.filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement)
.map(RepoAddr::from);
Self {
raw: raw.to_owned(),
addr,
}
}
/// Shows the coordinate when parsed, otherwise the raw value.
pub fn display(&self) -> String {
match &self.addr {
Some(addr) => addr.to_string(),
None => self.raw.clone(),
}
}
}
+4 -3
View File
@@ -10,10 +10,11 @@ signed_core = { path = "../signed_core" }
nostr.workspace = true
serde.workspace = true
serde_json.workspace = true
gix = { workspace = true, features = ["revision", "blob-diff"] }
gix-worktree = "0.56"
gix-worktree-state = "0.34"
log.workspace = true
anyhow.workspace = true
gix = { workspace = true, features = ["revision", "blob-diff"] }
gix-worktree = "0.57"
gix-worktree-state = "0.35"
diffy = "0.5"
ignore = "0.4"
+43 -45
View File
@@ -3,49 +3,49 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use signed_core::{Announcement, RepoAddr};
use crate::remote::{clone_repo, fetch_all};
use crate::repo::Repo;
/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id.
#[derive(Debug, Clone)]
pub struct GitCache {
root: PathBuf,
}
impl GitCache {
/// Creates a cache rooted at `root`.
pub fn new(root: PathBuf) -> Self {
Self { root }
}
/// Returns the cache root directory.
pub fn root(&self) -> &Path {
&self.root
}
/// Returns the cache path for a repository address.
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
self.root
.join(addr.public_key.to_hex())
.join(sanitize_path_component(&addr.identifier))
.join(addr.public_key().to_hex())
.join(Self::sanitize_path_component(addr.identifier()))
}
pub fn open(&self, addr: &RepoAddr) -> Result<Option<gix::Repository>> {
/// Opens the cached repository, or `None` when it is not cloned yet.
pub fn open(&self, addr: &RepoAddr) -> Result<Option<Repo>> {
let path = self.repo_path(addr);
match gix::open(&path) {
Ok(repo) => Ok(Some(repo)),
Err(gix::open::Error::NotARepository { .. }) => Ok(None),
Err(gix::open::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Ok(repo) => Ok(Some(Repo::new(repo))),
Err(e) if e.is_not_found() => Ok(None),
Err(e) => Err(e.into()),
}
}
/// Open the existing clone, fetching it first.
pub fn ensure_clone<U: AsRef<str>>(
&self,
addr: &RepoAddr,
clone_urls: &[U],
) -> Result<gix::Repository> {
/// Returns the cached repository, cloning from one of `clone_urls` on first use.
pub fn ensure_clone<U: AsRef<str>>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result<Repo> {
let path = self.repo_path(addr);
if let Some(repo) = self.open(addr)? {
fetch_all(&repo).ok();
if let Err(error) = repo.fetch() {
log::warn!("failed to refresh the cached repository: {error:#}");
}
return Ok(repo);
}
@@ -54,37 +54,35 @@ impl GitCache {
.with_context(|| format!("failed to create {}", parent.display()))?;
}
clone_repo(clone_urls, &path)?;
self.open(addr)?
.ok_or_else(|| anyhow::anyhow!("clone finished but the repository cannot be opened"))
}
}
/// Map an untrusted repository id or display name to a safe single path component.
pub fn sanitize_path_component(id: &str) -> String {
let sanitized: String = id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
c
} else {
'_'
}
})
.collect();
if sanitized == "." || sanitized == ".." {
return "_".to_owned();
Repo::clone(clone_urls, &path)
}
sanitized
}
/// Maps untrusted relay content onto a safe single path component.
pub fn sanitize_path_component(id: &str) -> String {
let sanitized: String = id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
c
} else {
'_'
}
})
.collect();
/// The refs namespace of a fork's import in the target mirror.
pub fn fork_namespace(announcement: &Announcement) -> String {
format!(
"{}/{}",
announcement.owner.to_hex(),
sanitize_path_component(&announcement.id)
)
if sanitized == "." || sanitized == ".." {
return "_".to_owned();
}
sanitized
}
/// Returns the `owner/identifier` namespace used for fork checkouts.
pub fn fork_namespace(announcement: &Announcement) -> String {
format!(
"{}/{}",
announcement.owner.to_hex(),
Self::sanitize_path_component(&announcement.id)
)
}
}
+153 -211
View File
@@ -1,11 +1,7 @@
use std::path::Path;
use anyhow::Result;
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffLineKind {
/// An unchanged context line, present on both sides.
Context,
Addition,
Deletion,
@@ -14,26 +10,51 @@ pub enum DiffLineKind {
#[derive(Debug, Clone)]
pub struct DiffLine {
pub kind: DiffLineKind,
/// 1-based line number in the old version, if the line exists there.
pub old: Option<u32>,
/// 1-based line number in the new version, if the line exists there.
pub new: Option<u32>,
/// Line content without the trailing newline.
pub text: String,
}
/// A hunk of a file diff, like `@@ -a,b +c,d @@`.
impl DiffLine {
/// Creates a diff line with its kind, optional line numbers, and text.
pub fn new(kind: DiffLineKind, old: Option<u32>, new: Option<u32>, text: String) -> Self {
Self {
kind,
old,
new,
text,
}
}
}
#[derive(Debug, Clone)]
pub struct DiffHunk {
/// 1-based start line in the old version.
pub old_start: u32,
pub old_lines: u32,
/// 1-based start line in the new version.
pub new_start: u32,
pub new_lines: u32,
pub lines: Vec<DiffLine>,
}
impl DiffHunk {
/// Creates a hunk spanning the given old and new ranges.
pub fn new(
old_start: u32,
old_lines: u32,
new_start: u32,
new_lines: u32,
lines: Vec<DiffLine>,
) -> Self {
Self {
old_start,
old_lines,
new_start,
new_lines,
lines,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffStatus {
Added,
@@ -45,251 +66,172 @@ pub enum DiffStatus {
#[derive(Debug, Clone)]
pub struct FileDiff {
/// Path of the file relative to the repo root.
///
/// For renames and copies, this is the destination path.
pub path: String,
/// Previous path, for renames and copies.
pub old_path: Option<String>,
pub status: DiffStatus,
/// Number of added lines, 0 for binary files.
pub insertions: usize,
/// Number of removed lines, 0 for binary files.
pub deletions: usize,
/// True if either version is binary, then `hunks` is empty.
pub binary: bool,
pub hunks: Vec<DiffHunk>,
}
impl FileDiff {
/// Creates a file diff; `old_path` is the rename or copy source and `binary` files have no hunks.
pub fn new(
path: String,
old_path: Option<String>,
status: DiffStatus,
insertions: usize,
deletions: usize,
binary: bool,
hunks: Vec<DiffHunk>,
) -> Self {
Self {
path,
old_path,
status,
insertions,
deletions,
binary,
hunks,
}
}
}
#[derive(Debug, Clone)]
pub struct CommitDiff {
pub files: Vec<FileDiff>,
}
/// The changes of the commit `id`, short or full, in the repository at `workdir`.
///
/// Compared against its first parent, the empty tree for the root commit.
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
commit_diff(&gix::open(workdir)?, id)
impl CommitDiff {
/// Creates a commit diff from its files.
pub fn new(files: Vec<FileDiff>) -> Self {
Self { files }
}
}
fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
let commit_id = repo.rev_parse_single(id.as_bytes())?;
let commit = commit_id.object()?.into_commit();
let new_tree = commit.tree()?;
let old_tree = match commit.parent_ids().next() {
Some(parent) => Some(parent.object()?.into_commit().tree()?),
None => None,
};
tree_diff(repo, old_tree.as_ref(), &new_tree)
pub(crate) struct HunkBuilder {
old_start: u32,
old_lines: u32,
new_start: u32,
new_lines: u32,
old: u32,
new: u32,
lines: Vec<DiffLine>,
insertions: usize,
deletions: usize,
}
/// The changes between two commits, `base`..`tip`, like `git diff base tip`.
///
/// Directories and submodules are skipped, files are sorted by path.
pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result<CommitDiff> {
let repo = gix::open(workdir)?;
let base_tree = repo
.rev_parse_single(base.as_bytes())?
.object()?
.into_commit()
.tree()?;
let tip_tree = repo
.rev_parse_single(tip.as_bytes())?
.object()?
.into_commit()
.tree()?;
tree_diff(&repo, Some(&base_tree), &tip_tree)
}
fn tree_diff(
repo: &gix::Repository,
old_tree: Option<&gix::Tree<'_>>,
new_tree: &gix::Tree<'_>,
) -> Result<CommitDiff> {
use gix::diff::blob::platform::prepare_diff::Operation;
use gix::object::tree::diff::Change;
use gix::objs::tree::EntryKind;
let changes = repo.diff_tree_to_tree(old_tree, Some(new_tree), None)?;
let mut cache = repo.diff_resource_cache_for_tree_diff()?;
let mut files = Vec::new();
for change in changes {
let attached = Change::from_change_ref(change.to_ref(), repo, repo);
// Skip directory trees and submodule gitlinks, only files are listed.
let (path, old_path, status) = match attached {
Change::Addition {
location,
entry_mode,
..
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
(location.to_owned(), None, DiffStatus::Added)
}
Change::Deletion {
location,
entry_mode,
..
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
(location.to_owned(), None, DiffStatus::Deleted)
}
Change::Modification {
location,
previous_entry_mode,
entry_mode,
..
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
&& !matches!(
previous_entry_mode.kind(),
EntryKind::Tree | EntryKind::Commit
) =>
{
(location.to_owned(), None, DiffStatus::Modified)
}
Change::Rewrite {
location,
source_location,
source_entry_mode,
entry_mode,
copy,
..
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
&& !matches!(
source_entry_mode.kind(),
EntryKind::Tree | EntryKind::Commit
) =>
{
let status = if copy {
DiffStatus::Copied
} else {
DiffStatus::Renamed
};
(
location.to_owned(),
Some(source_location.to_owned()),
status,
)
}
_ => continue,
};
// Always diff with the built-in algorithm.
// External diff drivers would shell out, out of scope for a read-only viewer.
let platform = attached.diff(&mut cache)?;
platform
.resource_cache
.options
.skip_internal_diff_if_external_is_configured = true;
let outcome = platform.resource_cache.prepare_diff()?;
let (binary, hunks, insertions, deletions) = match outcome.operation {
Operation::InternalDiff { algorithm } => {
let input = outcome.interned_input();
let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input);
let mut hunks = Vec::new();
let mut insertions = 0usize;
let mut deletions = 0usize;
let collector = HunkCollector {
hunks: &mut hunks,
insertions: &mut insertions,
deletions: &mut deletions,
};
gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default())
.consume()?;
(false, hunks, insertions, deletions)
}
Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0),
Operation::ExternalCommand { .. } => unreachable!("external diff drivers are disabled"),
};
files.push(FileDiff {
path: String::from_utf8_lossy(&path).into_owned(),
old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()),
status,
insertions,
deletions,
binary,
hunks,
});
impl HunkBuilder {
/// Starts a hunk spanning the given old and new ranges.
pub(crate) fn new(old_start: u32, old_lines: u32, new_start: u32, new_lines: u32) -> Self {
Self {
old_start,
old_lines,
new_start,
new_lines,
old: old_start,
new: new_start,
lines: Vec::new(),
insertions: 0,
deletions: 0,
}
}
files.sort_by(|a, b| a.path.cmp(&b.path));
/// Appends a line, numbering it and counting insertions and deletions.
pub(crate) fn push(&mut self, kind: DiffLineKind, text: impl Into<String>) {
let (old_no, new_no) = match kind {
DiffLineKind::Context => {
let numbers = (Some(self.old), Some(self.new));
self.old += 1;
self.new += 1;
numbers
}
DiffLineKind::Addition => {
self.insertions += 1;
let number = Some(self.new);
self.new += 1;
(None, number)
}
DiffLineKind::Deletion => {
self.deletions += 1;
let number = Some(self.old);
self.old += 1;
(number, None)
}
};
Ok(CommitDiff { files })
self.lines
.push(DiffLine::new(kind, old_no, new_no, text.into()));
}
/// Finishes the hunk and returns it with its insertion and deletion counts.
pub(crate) fn finish(self) -> (DiffHunk, usize, usize) {
let hunk = DiffHunk::new(
self.old_start,
self.old_lines,
self.new_start,
self.new_lines,
self.lines,
);
(hunk, self.insertions, self.deletions)
}
}
/// Collects the hunks of one blob diff while tracking per-line numbers.
struct HunkCollector<'a> {
pub(crate) struct HunkCollector<'a> {
hunks: &'a mut Vec<DiffHunk>,
insertions: &'a mut usize,
deletions: &'a mut usize,
}
impl<'a> HunkCollector<'a> {
/// Collects hunks into `hunks` while counting `insertions` and `deletions`.
pub(crate) fn new(
hunks: &'a mut Vec<DiffHunk>,
insertions: &'a mut usize,
deletions: &'a mut usize,
) -> Self {
Self {
hunks,
insertions,
deletions,
}
}
}
impl ConsumeHunk for HunkCollector<'_> {
type Out = ();
/// Consumes one hunk, tracking line numbers and insertion/deletion counts.
fn consume_hunk(
&mut self,
header: HunkHeader,
lines: &[(GixLineKind, &[u8])],
) -> std::io::Result<()> {
let mut old_ln = header.before_hunk_start;
let mut new_ln = header.after_hunk_start;
let mut out = Vec::with_capacity(lines.len());
let mut builder = HunkBuilder::new(
header.before_hunk_start,
header.before_hunk_len,
header.after_hunk_start,
header.after_hunk_len,
);
for (kind, content) in lines {
let text = String::from_utf8_lossy(content).into_owned();
let line = match kind {
GixLineKind::Context => {
let line = DiffLine {
kind: DiffLineKind::Context,
old: Some(old_ln),
new: Some(new_ln),
text,
};
old_ln += 1;
new_ln += 1;
line
}
GixLineKind::Remove => {
*self.deletions += 1;
let line = DiffLine {
kind: DiffLineKind::Deletion,
old: Some(old_ln),
new: None,
text,
};
old_ln += 1;
line
}
GixLineKind::Add => {
*self.insertions += 1;
let line = DiffLine {
kind: DiffLineKind::Addition,
old: None,
new: Some(new_ln),
text,
};
new_ln += 1;
line
}
let kind = match kind {
GixLineKind::Context => DiffLineKind::Context,
GixLineKind::Remove => DiffLineKind::Deletion,
GixLineKind::Add => DiffLineKind::Addition,
};
out.push(line);
builder.push(kind, String::from_utf8_lossy(content).into_owned());
}
self.hunks.push(DiffHunk {
old_start: header.before_hunk_start,
old_lines: header.before_hunk_len,
new_start: header.after_hunk_start,
new_lines: header.after_hunk_len,
lines: out,
});
let (hunk, insertions, deletions) = builder.finish();
*self.insertions += insertions;
*self.deletions += deletions;
self.hunks.push(hunk);
Ok(())
}
/// Marks the end of the unified diff stream.
fn finish(self) {}
}
+59 -230
View File
@@ -1,248 +1,77 @@
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::Result;
/// In-memory object cache for history walks, see [`open_with_cache`].
///
/// Without one, a walk re-decodes the same commit objects from the object database.
/// Sized generously: a walk can cover a large portion of the repository's history.
const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024;
/// Metadata of a commit, as shown in the repository browser's file header.
#[derive(Debug, Clone)]
pub struct FileCommit {
/// Shortened commit id, 7+ hex chars, disambiguated if needed.
pub id: String,
/// First line of the commit message.
pub summary: String,
/// Rest of the commit message after the title.
///
/// `None` for single-line commit messages.
pub description: Option<String>,
pub author: String,
/// Author time, seconds since the Unix epoch.
pub time: i64,
}
/// Open the repository at `workdir` with an in-memory object cache.
///
/// Only history walks use it, they re-decode the same commit objects repeatedly.
/// Single-object reads open the repository plain.
pub(crate) fn open_with_cache(workdir: &Path) -> Result<gix::Repository> {
let mut repo = gix::open(workdir)?;
repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES);
Ok(repo)
}
/// A [`FileCommit`] with author, message title, body and shortened id.
///
/// The diff panel fetches the full commit on demand.
fn file_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
file_commit_with_description(commit, true)
}
/// A [`FileCommit`] without the message body, for history lists that never display it.
///
/// Skipping the body saves an allocation per listed commit.
fn file_commit_summary(commit: &gix::Commit<'_>) -> Result<FileCommit> {
file_commit_with_description(commit, false)
}
fn file_commit_with_description(
commit: &gix::Commit<'_>,
include_description: bool,
) -> Result<FileCommit> {
let author = commit.author()?;
let message = commit.message()?;
Ok(FileCommit {
id: commit.id().shorten_or_id().to_string(),
summary: String::from_utf8_lossy(message.title).trim().to_string(),
description: if include_description {
message
.body
.map(|body| String::from_utf8_lossy(body).trim().to_string())
.filter(|body| !body.is_empty())
} else {
None
},
author: String::from_utf8_lossy(author.name).trim().to_string(),
time: author.time()?.seconds,
})
}
/// Newest commit touching each of `rels`, like `git log -1 -- <rel>` per path.
/// `rels` are paths relative to the worktree.
///
/// Paths without any commit, like untracked files, are absent from the result.
pub fn worktree_last_commits(
workdir: &Path,
rels: &[PathBuf],
) -> Result<Vec<(PathBuf, FileCommit)>> {
last_commits(&open_with_cache(workdir)?, rels)
}
/// Stops as soon as every pending path has its commit.
fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
use gix::traverse::commit::simple::CommitTimeOrder;
let Some(head) = repo.head_id().ok() else {
return Ok(Vec::new());
};
let mut pending: Vec<PathBuf> = Vec::with_capacity(rels.len());
let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len());
for rel in rels {
if seen.insert(rel.as_path()) {
pending.push(rel.clone());
}
}
let walk = repo
.rev_walk([head])
.sorting(gix::revision::walk::Sorting::ByCommitTime(
CommitTimeOrder::NewestFirst,
));
let mut found = Vec::new();
for info in walk.all()? {
if pending.is_empty() {
break;
}
let info = info?;
let commit = info.object()?;
let tree = commit.tree()?;
let parent_tree = match info.parent_ids().next() {
Some(parent) => Some(parent.object()?.into_commit().tree()?),
None => None,
};
// Compare each unresolved path against this commit and its first parent.
let mut ix = 0;
while ix < pending.len() {
let rel = &pending[ix];
let blob = tree.lookup_entry_by_path(rel)?;
let parent_blob = match &parent_tree {
Some(tree) => tree.lookup_entry_by_path(rel)?,
None => None,
};
if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach())
{
found.push((rel.clone(), file_commit(&commit)?));
pending.swap_remove(ix);
} else {
ix += 1;
}
}
}
Ok(found)
}
/// Cap on [`CommitList::commits`]. The virtual list renders a window at a time,
/// the tab badge shows the real count.
///
/// A huge history is never fully materialized in memory.
pub const MAX_LISTED_COMMITS: usize = 20_000;
/// Commits reachable from `HEAD`, newest first, possibly capped.
pub struct CommitList {
/// Number of commits reachable from HEAD.
pub total: usize,
/// Newest commits, capped at [`MAX_LISTED_COMMITS`].
pub commits: Vec<FileCommit>,
}
/// All commits reachable from `HEAD`, newest first, with author and summary.
///
/// Returns an empty list for a repository without any commits yet.
pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
use gix::traverse::commit::simple::CommitTimeOrder;
impl CommitList {
/// Creates a list holding `total` commits of which `commits` are materialized.
pub fn new(total: usize, commits: Vec<FileCommit>) -> Self {
Self { total, commits }
}
}
let Some(head) = repo.head_id().ok() else {
return Ok(CommitList {
total: 0,
commits: Vec::new(),
});
};
#[derive(Debug, Clone)]
pub struct FileCommit {
pub id: String,
pub summary: String,
pub description: Option<String>,
pub author: String,
pub time: i64,
}
let walk = repo
.rev_walk([head])
.sorting(gix::revision::walk::Sorting::ByCommitTime(
CommitTimeOrder::NewestFirst,
));
let mut commits = Vec::new();
let mut total = 0;
for info in walk.all()? {
let info = info?;
total += 1;
if commits.len() < MAX_LISTED_COMMITS {
commits.push(file_commit_summary(&info.object()?)?);
impl FileCommit {
/// Creates a commit entry from its id, message parts, author, and timestamp.
pub fn new(
id: String,
summary: String,
description: Option<String>,
author: String,
time: i64,
) -> Self {
Self {
id,
summary,
description,
author,
time,
}
}
Ok(CommitList { total, commits })
}
/// Converts a gix commit including its message body.
pub(crate) fn from_commit(commit: &gix::Commit<'_>) -> anyhow::Result<Self> {
Self::from_commit_with_description(commit, true)
}
/// For non-bare clones the clone root is the worktree.
pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> {
all_commits(&open_with_cache(workdir)?)
}
/// Converts a gix commit, skipping the message body since lists never show it.
pub(crate) fn from_commit_summary(commit: &gix::Commit<'_>) -> anyhow::Result<Self> {
Self::from_commit_with_description(commit, false)
}
/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`.
pub fn worktree_commit_range_commits(
workdir: &Path,
base: &str,
tip: &str,
) -> Result<Vec<FileCommit>> {
use gix::traverse::commit::simple::CommitTimeOrder;
/// Converts a gix commit, optionally skipping the message body.
fn from_commit_with_description(
commit: &gix::Commit<'_>,
include_description: bool,
) -> anyhow::Result<Self> {
use crate::GixResultExt as _;
let repo = open_with_cache(workdir)?;
let base_id = repo.rev_parse_single(base.as_bytes())?;
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
let walk = repo
.rev_walk([tip_id])
.sorting(gix::revision::walk::Sorting::ByCommitTime(
CommitTimeOrder::NewestFirst,
let author = commit.author().into_anyhow()?;
let message = commit.message().into_anyhow()?;
Ok(FileCommit::new(
commit.id().shorten_or_id().to_string(),
String::from_utf8_lossy(message.title).trim().to_string(),
if include_description {
message
.body
.map(|body| String::from_utf8_lossy(body).trim().to_string())
.filter(|body| !body.is_empty())
} else {
None
},
String::from_utf8_lossy(author.name).trim().to_string(),
author.time().into_anyhow()?.seconds,
))
.with_hidden([base_id]);
let mut commits = Vec::new();
for info in walk.all()? {
let info = info?;
commits.push(file_commit_summary(&info.object()?)?);
}
Ok(commits)
}
/// The commit HEAD points to, like `git log -1`.
///
/// `Ok(None)` for a repository without commits yet, an unborn HEAD.
pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
let Some(head) = repo.head_id().ok() else {
return Ok(None);
};
let commit = head.object()?.into_commit();
Ok(Some(file_commit(&commit)?))
}
/// Full metadata of the commit `id`, short or full, in the repository at `workdir`.
/// Like [`head_commit`] for an arbitrary commit.
///
/// `Ok(None)` when the id cannot be resolved.
pub fn worktree_commit(workdir: &Path, id: &str) -> Result<Option<FileCommit>> {
let repo = gix::open(workdir)?;
match repo.rev_parse_single(id.as_bytes()) {
Ok(commit_id) => {
let commit = commit_id.object()?.into_commit();
Ok(Some(file_commit(&commit)?))
}
Err(_) => Ok(None),
}
}
+22 -32
View File
@@ -3,7 +3,6 @@ mod diff;
mod history;
mod nip34;
mod patch;
mod remote;
mod repo;
mod scan;
mod worktree;
@@ -11,41 +10,32 @@ mod worktree;
#[cfg(test)]
mod tests;
pub use cache::{GitCache, fork_namespace, sanitize_path_component};
pub use diff::{
CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff, worktree_commit_diff,
worktree_commit_range_diff,
};
pub use history::{
CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, worktree_all_commits,
worktree_commit, worktree_commit_range_commits, worktree_last_commits,
};
pub use nip34::{
GraspSignals, Nip34Binding, Nip34Kind, detect_nip34, is_grasp_url, set_nostr_repo,
};
pub use patch::{
apply_patch, format_patch_between, patch_commits, patch_diffs, split_patch_series,
};
pub use remote::{
clone_repo, ensure_origin, fetch_all, fetch_repo_refs, origin_url, push_all, push_commit_ref,
push_main, remote_has_refs, set_origin,
};
pub use repo::{
RepoRefState, commits_since, current_branch, delete_refs_with_prefix, fast_forward_branches,
head_commit_id, init_repository, merge_base, refs_with_prefix, repo_branches, repo_ref_state,
repo_tags, root_commit, worktree_branches, worktree_current_branch, worktree_ref_exists,
worktree_ref_state,
};
pub use cache::GitCache;
pub use diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
pub use history::{CommitList, FileCommit, MAX_LISTED_COMMITS};
pub use nip34::{GraspSignals, Nip34Binding, Nip34Kind};
pub use patch::PatchParser;
pub use repo::{Repo, RepoRefState};
pub use scan::{LocalRepo, find_git_repos};
pub use worktree::{
WorktreeSnapshot, find_readme, worktree_checkout_branch, worktree_checkout_tag,
worktree_commits_ahead, worktree_dirty, worktree_entries, worktree_read, worktree_snapshot,
};
pub use worktree::WorktreeSnapshot;
pub(crate) trait GixResultExt<T> {
/// Converts a gix exception result into an `anyhow` result.
fn into_anyhow(self) -> anyhow::Result<T>;
}
impl<T, E> GixResultExt<T> for Result<T, gix::Exn<E>>
where
E: std::error::Error + Send + Sync + 'static,
{
fn into_anyhow(self) -> anyhow::Result<T> {
self.map_err(|exn| anyhow::Error::from(exn.into_error()))
}
}
/// The terminal prompt is disabled so a credential request fails instead of hanging.
#[cfg(test)]
fn git_in(dir: &std::path::Path, args: &[&str]) -> anyhow::Result<String> {
let output = remote::git_output(dir, args, "git")?;
let output = Repo::run_git(dir, args, "git")?;
if !output.status.success() {
anyhow::bail!(
+22 -413
View File
@@ -1,17 +1,15 @@
use std::path::Path;
use anyhow::Result;
use gix::bstr::ByteSlice;
use nostr::prelude::*;
/// The kind of NIP-34 relationship a local repository has on disk.
#[derive(serde::Deserialize)]
pub(crate) struct Nip34Json {
pub(crate) identifier: Option<String>,
pub(crate) owner: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Nip34Kind {
/// Bound to a NIP-34 coordinate, by `nak`'s `nip34.json` or `ngit`'s `nostr.repo`.
Initialized,
/// Cloned from a `nostr://` remote but never initialized locally.
Cloned,
/// Nostr tooling touched the repository but no binding is recoverable.
ToolingOnly,
}
@@ -22,7 +20,6 @@ pub struct GraspSignals {
pub nostr_repo_config: bool,
pub nostr_remote: bool,
pub grasp_remote: bool,
/// `nip34_grasp_remote` is the `nak`-specific `nip34/grasp/<host>` remote name.
pub nip34_grasp_remote: bool,
pub nip34_state_refs: bool,
pub nostr_cache: bool,
@@ -31,424 +28,36 @@ pub struct GraspSignals {
}
impl GraspSignals {
/// Returns whether any Nostr tooling marker was found.
pub fn any(&self) -> bool {
*self != Self::default()
}
}
/// What a local repository's on-disk state says about its NIP-34 binding.
#[derive(Debug, Clone, PartialEq)]
pub struct Nip34Binding {
pub kind: Nip34Kind,
pub signals: GraspSignals,
/// Coordinate owner and identifier, from `nip34.json` or `nostr.repo`.
pub owner: Option<PublicKey>,
pub identifier: Option<String>,
pub grasp_urls: Vec<String>,
}
#[derive(serde::Deserialize)]
struct Nip34Json {
identifier: Option<String>,
owner: Option<String>,
}
pub fn detect_nip34(repo_path: &Path) -> Option<Nip34Binding> {
let repo = gix::open(repo_path).ok()?;
let common_dir = repo.common_dir().to_path_buf();
let workdir = repo.workdir().map(Path::to_path_buf);
let mut signals = GraspSignals::default();
let mut owner: Option<PublicKey> = None;
let mut identifier: Option<String> = None;
let mut grasp_urls: Vec<String> = Vec::new();
if let Some(workdir) = &workdir {
if let Ok(bytes) = std::fs::read(workdir.join("nip34.json"))
&& let Ok(config) = serde_json::from_slice::<Nip34Json>(&bytes)
{
signals.nip34_json = true;
identifier = config.identifier.and_then(non_empty);
owner = config
.owner
.as_deref()
.and_then(|value| PublicKey::parse(value).ok());
}
if workdir.join("maintainers.yaml").is_file() {
signals.maintainers_yaml = true;
impl Nip34Binding {
/// Creates a binding from its kind, detected signals, and recovered coordinates.
pub fn new(
kind: Nip34Kind,
signals: GraspSignals,
owner: Option<PublicKey>,
identifier: Option<String>,
grasp_urls: Vec<String>,
) -> Self {
Self {
kind,
signals,
owner,
identifier,
grasp_urls,
}
}
if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude"))
&& exclude.contains("nip34.json")
{
signals.nip34_excluded = true;
}
// `ngit` keeps its repository event cache in the Git common directory.
if common_dir.join("nostr-cache.lmdb").is_file() {
signals.nostr_cache = true;
}
// `ngit` reads and writes `nostr.repo` at repository-local scope only.
if let Ok(config) = gix::config::File::from_path_no_includes(
common_dir.join("config"),
gix::config::Source::Local,
) {
if let Some(value) = config.string("nostr.repo")
&& let Some((key, id)) = coordinate_from_naddr(&value.to_str_lossy())
{
signals.nostr_repo_config = true;
owner = Some(key);
identifier = Some(id);
}
for key in ["nostr.repo-relay-only", "nostr.nostate", "nostr.private"] {
if config.string(key).is_some() {
signals.nostr_aux_config = true;
}
}
if let Some(sections) = config.sections_by_name("remote") {
for section in sections {
let Some(name) = section.header().subsection_name() else {
continue;
};
let nak_grasp_remote = name.to_str_lossy().starts_with("nip34/grasp/");
for url in section.values("url") {
let url = url.to_str_lossy();
if url.starts_with("nostr://") {
signals.nostr_remote = true;
// Strong markers win; only fill an empty binding.
if owner.is_none()
&& identifier.is_none()
&& let Some((key, id)) = parse_nostr_url(&url)
{
owner = Some(key);
identifier = Some(id);
}
}
if is_grasp_url(&url) {
signals.grasp_remote = true;
signals.nip34_grasp_remote |= nak_grasp_remote;
grasp_urls.push(url.to_string());
if owner.is_none()
&& identifier.is_none()
&& let Some((key, id)) = grasp_parts(&url)
{
owner = Some(key);
identifier = Some(id);
}
}
}
}
}
}
// `nak` materializes a kind-30618 state as `refs/heads/nip34/state/*`.
if let Ok(platform) = repo.references()
&& let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/")
&& refs.next().is_some()
{
signals.nip34_state_refs = true;
}
if !signals.any() {
return None;
}
let kind = if signals.nip34_json
|| signals.nostr_repo_config
|| signals.nip34_grasp_remote
|| signals.nip34_state_refs
{
Nip34Kind::Initialized
} else if signals.nostr_remote {
Nip34Kind::Cloned
} else {
Nip34Kind::ToolingOnly
};
Some(Nip34Binding {
kind,
signals,
owner,
identifier,
grasp_urls,
})
}
/// Record a repository's NIP-34 coordinate in its local `nostr.repo` config.
pub fn set_nostr_repo(repo_path: &Path, naddr: &str) -> Result<()> {
let repo = gix::open(repo_path)?;
crate::remote::edit_local_config(&repo, |config| {
config.set_raw_value("nostr.repo", naddr)?;
Ok(())
})
}
/// Mirrors `nak`'s `IsGraspURL`: two path segments, a path of at least 65 bytes,
/// and a first segment that decodes as an `npub`.
pub fn is_grasp_url(url: &str) -> bool {
let Ok(parsed) = Url::parse(url) else {
return false;
};
if !matches!(parsed.scheme(), "http" | "https" | "grasp") {
return false;
}
let path = parsed.path();
if path.matches('/').count() != 2 || path.len() < 65 {
return false;
}
grasp_parts(url).is_some()
}
fn grasp_parts(url: &str) -> Option<(PublicKey, String)> {
let parsed = Url::parse(url).ok()?;
let mut segments = parsed.path_segments()?.filter(|part| !part.is_empty());
let owner = PublicKey::parse(segments.next()?).ok()?;
let identifier = non_empty(segments.next()?.trim_end_matches(".git"))?;
Some((owner, identifier))
}
fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> {
let coordinate = Nip19Coordinate::from_bech32(value).ok()?;
if coordinate.kind != Kind::GitRepoAnnouncement {
return None;
}
let identifier = non_empty(coordinate.identifier.clone())?;
Some((coordinate.public_key, identifier))
}
/// Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`,
/// `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding.
fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> {
let rest = url.strip_prefix("nostr://")?;
if rest.starts_with("naddr1") {
return coordinate_from_naddr(rest);
}
let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after);
let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect();
if parts
.first()
.is_some_and(|first| matches!(*first, "ssh" | "https" | "http"))
{
parts.remove(0);
}
// `[owner, (relay), identifier]`.
if parts.len() < 2 {
return None;
}
let owner = PublicKey::parse(parts[0]).ok()?;
let identifier = non_empty(parts.last()?.trim_end_matches(".git"))?;
Some((owner, identifier))
}
fn non_empty(value: impl Into<String>) -> Option<String> {
let value = value.into();
(!value.is_empty()).then_some(value)
}
#[cfg(test)]
mod tests {
use std::process::Command;
use super::*;
fn init_repo() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
std::fs::create_dir_all(&path).expect("mkdir");
git(&path, &["init", "-q"]);
(dir, path)
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "Test Author")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test Author")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.env("GIT_EDITOR", "true")
.args(args)
.status()
.expect("spawn git");
assert!(status.success(), "git {args:?} failed");
}
fn key() -> PublicKey {
Keys::generate().public_key()
}
fn naddr(kind: Kind, owner: PublicKey, identifier: &str) -> String {
let coordinate = Coordinate::new(kind, owner).identifier(identifier);
Nip19Coordinate::new(coordinate, Vec::<RelayUrl>::new())
.to_bech32()
.expect("naddr")
}
#[test]
fn plain_repository_has_no_binding() {
let (_dir, path) = init_repo();
assert!(detect_nip34(&path).is_none());
}
#[test]
fn nip34_json_marks_a_repository_initialized() {
let (_dir, path) = init_repo();
let owner = key();
let npub = owner.to_bech32().expect("npub");
std::fs::write(
path.join("nip34.json"),
format!(r#"{{"identifier":"my-repo","owner":"{npub}"}}"#),
)
.expect("write");
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::Initialized);
assert!(binding.signals.nip34_json);
assert_eq!(binding.owner, Some(owner));
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
}
#[test]
fn malformed_nip34_json_is_ignored() {
let (_dir, path) = init_repo();
std::fs::write(path.join("nip34.json"), b"not json").expect("write");
assert!(detect_nip34(&path).is_none());
}
#[test]
fn nak_exclude_and_state_refs_are_detected() {
let (_dir, path) = init_repo();
std::fs::create_dir_all(path.join(".git/info")).expect("mkdir");
std::fs::write(path.join(".git/info/exclude"), "nip34.json\n").expect("write");
git(&path, &["commit", "-q", "--allow-empty", "-m", "initial"]);
git(
&path,
&["update-ref", "refs/heads/nip34/state/HEAD", "HEAD"],
);
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::Initialized);
assert!(binding.signals.nip34_excluded);
assert!(binding.signals.nip34_state_refs);
}
#[test]
fn nostr_repo_config_marks_a_repository_initialized() {
let (_dir, path) = init_repo();
let owner = key();
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
git(&path, &["config", "nostr.repo", &naddr]);
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::Initialized);
assert!(binding.signals.nostr_repo_config);
assert_eq!(binding.owner, Some(owner));
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
}
#[test]
fn the_written_nostr_repo_marker_is_detected() {
let (_dir, path) = init_repo();
let owner = key();
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
set_nostr_repo(&path, &naddr).expect("write marker");
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::Initialized);
assert!(binding.signals.nostr_repo_config);
assert_eq!(binding.owner, Some(owner));
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
}
#[test]
fn nostr_remote_is_a_nip34_clone() {
let (_dir, path) = init_repo();
let owner = key();
let npub = owner.to_bech32().expect("npub");
let url = format!("nostr://{npub}/relay.ngit.dev/my-repo");
git(&path, &["remote", "add", "origin", &url]);
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::Cloned);
assert!(binding.signals.nostr_remote);
assert_eq!(binding.owner, Some(owner));
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
}
#[test]
fn nak_grasp_remote_marks_a_repository_initialized() {
let (_dir, path) = init_repo();
let owner = key();
let npub = owner.to_bech32().expect("npub");
let url = format!("https://gitnostr.com/{npub}/my-repo.git");
git(
&path,
&["config", "remote.nip34/grasp/gitnostr.com.url", &url],
);
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::Initialized);
assert!(binding.signals.nip34_grasp_remote);
assert!(binding.signals.grasp_remote);
assert_eq!(binding.grasp_urls, vec![url]);
assert_eq!(binding.owner, Some(owner));
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
}
#[test]
fn nostr_cache_alone_is_tooling_only() {
let (_dir, path) = init_repo();
std::fs::write(path.join(".git/nostr-cache.lmdb"), b"cache").expect("write");
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::ToolingOnly);
assert!(binding.signals.nostr_cache);
}
#[test]
fn grasp_urls_are_recognised_by_shape() {
let owner = key();
let npub = owner.to_bech32().expect("npub");
assert!(is_grasp_url(&format!(
"https://gitnostr.com/{npub}/my-repo.git"
)));
assert!(is_grasp_url(&format!(
"grasp://gitnostr.com/{npub}/my-repo.git"
)));
assert!(!is_grasp_url("https://gitnostr.com/my-repo.git"));
assert!(!is_grasp_url(
"https://gitnostr.com/not-a-pubkey/my-repo.git"
));
assert!(!is_grasp_url(&format!(
"ssh://gitnostr.com/{npub}/my-repo.git"
)));
}
}
+218 -302
View File
@@ -1,322 +1,238 @@
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail};
use anyhow::Result;
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
use diffy::{Hunk, Line};
use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
use crate::diff::{CommitDiff, DiffHunk, DiffLineKind, DiffStatus, FileDiff, HunkBuilder};
use crate::history::FileCommit;
/// Apply a `git format-patch` patch or series with `git am`.
///
/// Uses the git CLI because it handles the mbox format natively.
///
/// TODO: replace with a pure-Rust implementation later without changing callers.
pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
let mut child = Command::new("git")
.arg("am")
.current_dir(repo_path)
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn `git am`")?;
pub struct PatchParser;
child
.stdin
.as_mut()
.expect("stdin piped")
.write_all(patch.as_bytes())?;
let output = child.wait_with_output()?;
if !output.status.success() {
bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr));
impl PatchParser {
/// Splits an mbox series into the raw text of each message.
pub fn split_patch_series(patch: &str) -> Vec<&str> {
Self::envelopes(patch)
.into_iter()
.map(|message| message.text)
.collect()
}
Ok(())
}
/// Parses a git diff into file diffs, returning an empty diff without one.
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
return Ok(CommitDiff::new(Vec::new()));
}
/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`.
/// Fails when the range has no commits.
///
/// The mbox is returned untrimmed. Trailing newlines are part of the format.
pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result<String> {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["format-patch", "--stdout", &format!("{base}..{tip}")])
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped())
.output()
.context("failed to spawn `git format-patch`")?;
let mut files = Vec::new();
if !output.status.success() {
bail!(
"git format-patch failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
files.push(Self::file_diff(file?));
}
Ok(CommitDiff::new(files))
}
/// Lists the commits represented by the mbox messages that carry an id.
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
Self::envelopes(patch)
.into_iter()
.filter(|message| !message.id.is_empty())
.map(|message| {
FileCommit::new(
message.id.to_string(),
message
.header("Subject")
.map(Self::strip_patch_prefix)
.unwrap_or_default(),
None,
message
.header("From")
.map(Self::name_from_address)
.unwrap_or_default(),
message
.header("Date")
.and_then(|value| gix::date::parse(value.trim(), None).ok())
.map(|time| time.seconds)
.unwrap_or(0),
)
})
.collect()
}
/// Splits `patch` on `From <40-hex> <date>` envelopes, falling back to one message.
fn envelopes(patch: &str) -> Vec<Envelope<'_>> {
let mut messages: Vec<Envelope<'_>> = Vec::new();
let mut current: Option<(usize, &str, Vec<&str>)> = None;
let mut headers_closed = false;
let mut offset = 0usize;
for line in patch.lines() {
let line_start = offset;
offset += line.len() + 1;
let is_envelope = line
.strip_prefix("From ")
.and_then(|rest| rest.split_whitespace().next())
.is_some_and(|id| id.len() == 40);
if is_envelope {
if let Some((start, id, headers)) = current.take() {
messages.push(Envelope::new(&patch[start..], id, headers));
}
let id = line
.strip_prefix("From ")
.and_then(|rest| rest.split_whitespace().next())
.unwrap_or("");
current = Some((line_start, id, Vec::new()));
headers_closed = false;
continue;
}
if let Some((_, _, headers)) = &mut current {
if headers_closed {
continue;
}
if line.is_empty() {
headers_closed = true;
} else {
headers.push(line);
}
}
}
if let Some((start, id, headers)) = current.take() {
messages.push(Envelope::new(&patch[start..], id, headers));
}
if messages.is_empty() {
messages.push(Envelope::new(patch, "", Vec::new()));
}
messages
}
/// Extracts the display name from a `From` header value.
fn name_from_address(from: &str) -> String {
match from.trim().find('<') {
Some(ix) => from[..ix].trim().to_string(),
None => from.trim().to_string(),
}
}
/// Strips mailing list prefixes like `[PATCH]` or `[RFC PATCH 1/2]`.
fn strip_patch_prefix(subject: &str) -> String {
let trimmed = subject.trim();
let Some(rest) = trimmed.strip_prefix('[') else {
return trimmed.to_string();
};
let Some(end) = rest.find(']') else {
return trimmed.to_string();
};
if rest[..end].to_ascii_lowercase().contains("patch") {
rest[end + 1..].trim().to_string()
} else {
trimmed.to_string()
}
}
/// Converts one parsed file patch, keeping rename and copy paths unstripped.
fn file_diff(file: FilePatch<'_, str>) -> FileDiff {
let stripped;
let operation = match file.operation() {
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
operation => {
stripped = operation.strip_prefix(1);
&stripped
}
};
let (path, old_path, status) = match operation {
FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added),
FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted),
FileOperation::Modify { modified, .. } => {
(modified.as_ref(), None, DiffStatus::Modified)
}
FileOperation::Rename { from, to } => {
(to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed)
}
FileOperation::Copy { from, to } => {
(to.as_ref(), Some(from.as_ref()), DiffStatus::Copied)
}
};
let mut insertions = 0usize;
let mut deletions = 0usize;
let mut hunks = Vec::new();
let patch = file.patch();
if let Some(text) = patch.as_text() {
for hunk in text.hunks() {
let (hunk, hunk_insertions, hunk_deletions) = Self::hunk_diff(hunk);
insertions += hunk_insertions;
deletions += hunk_deletions;
hunks.push(hunk);
}
}
FileDiff::new(
path.to_owned(),
old_path.map(str::to_owned),
status,
insertions,
deletions,
patch.is_binary(),
hunks,
)
}
/// Converts a diffy hunk into a `DiffHunk` with its insertion and deletion counts.
fn hunk_diff(hunk: &Hunk<'_, str>) -> (DiffHunk, usize, usize) {
let old_range = hunk.old_range();
let new_range = hunk.new_range();
let mut builder = HunkBuilder::new(
old_range.start() as u32,
old_range.len() as u32,
new_range.start() as u32,
new_range.len() as u32,
);
}
let patch = String::from_utf8_lossy(&output.stdout).into_owned();
for line in hunk.lines() {
let (kind, text) = match line {
Line::Context(text) => (DiffLineKind::Context, *text),
Line::Delete(text) => (DiffLineKind::Deletion, *text),
Line::Insert(text) => (DiffLineKind::Addition, *text),
};
if patch.trim().is_empty() {
bail!("no commits between {base} and {tip}");
}
Ok(patch)
}
/// Split a `git format-patch` series into its individual patches, mbox messages.
///
/// A single patch yields one element.
/// A malformed input yields one element covering it.
pub fn split_patch_series(patch: &str) -> Vec<&str> {
let mut starts = vec![0usize];
let mut search_from = 1;
while let Some(rel) = patch[search_from..].find("\nFrom ") {
let ix = search_from + rel + 1;
let hex = patch[ix + 5..]
.split(|c: char| !c.is_ascii_hexdigit())
.next()
.unwrap_or("");
if hex.len() == 40 {
starts.push(ix);
}
search_from = ix + 1;
}
starts
.iter()
.enumerate()
.map(|(i, &start)| {
let end = starts.get(i + 1).copied().unwrap_or(patch.len());
&patch[start..end]
})
.collect()
}
/// Parse `git format-patch` output, a single patch or a series.
///
/// Backed by [`diffy::patch_set`], which implements git's extended diff format:
/// `diff --git` headers, rename and copy detection, binary detection, and
/// C-style quoted or octal-escaped paths.
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
return Ok(CommitDiff { files: Vec::new() });
}
let mut files = Vec::new();
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
files.push(file_diff(file?)?);
}
Ok(CommitDiff { files })
}
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
// The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path
// component is dropped, the same way `git apply -p1` does.
// Rename and copy paths come from their own headers, unprefixed.
let stripped;
let operation = match file.operation() {
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
operation => {
stripped = operation.strip_prefix(1);
&stripped
}
};
let (path, old_path, status) = match operation {
FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added),
FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted),
FileOperation::Modify { modified, .. } => (modified.as_ref(), None, DiffStatus::Modified),
FileOperation::Rename { from, to } => {
(to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed)
}
FileOperation::Copy { from, to } => (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied),
};
let mut insertions = 0usize;
let mut deletions = 0usize;
let mut hunks = Vec::new();
let patch = file.patch();
if let Some(text) = patch.as_text() {
for hunk in text.hunks() {
let hunk = hunk_diff(hunk);
insertions += hunk
.lines
.iter()
.filter(|line| line.kind == DiffLineKind::Addition)
.count();
deletions += hunk
.lines
.iter()
.filter(|line| line.kind == DiffLineKind::Deletion)
.count();
hunks.push(hunk);
}
}
Ok(FileDiff {
path: path.to_owned(),
old_path: old_path.map(str::to_owned),
status,
insertions,
deletions,
binary: patch.is_binary(),
hunks,
})
}
/// The [`DiffHunk`] of one parsed hunk, including the line number of every line.
///
/// `diffy` reports only the hunk header ranges. The per-line numbers are
/// counted from them the way the header encodes them: context lines advance
/// both sides, deletions only the old, insertions only the new.
fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk {
let old_range = hunk.old_range();
let new_range = hunk.new_range();
let mut old = old_range.start() as u32;
let mut new = new_range.start() as u32;
let mut lines = Vec::with_capacity(hunk.lines().len());
for line in hunk.lines() {
let (kind, text) = match line {
Line::Context(text) => (DiffLineKind::Context, *text),
Line::Delete(text) => (DiffLineKind::Deletion, *text),
Line::Insert(text) => (DiffLineKind::Addition, *text),
};
let (old_no, new_no) = match kind {
DiffLineKind::Context => {
let numbers = (Some(old), Some(new));
old += 1;
new += 1;
numbers
}
DiffLineKind::Addition => {
let number = Some(new);
new += 1;
(None, number)
}
DiffLineKind::Deletion => {
let number = Some(old);
old += 1;
(number, None)
}
};
lines.push(DiffLine {
kind,
old: old_no,
new: new_no,
text: line_text(text),
});
}
DiffHunk {
old_start: old_range.start() as u32,
old_lines: old_range.len() as u32,
new_start: new_range.start() as u32,
new_lines: new_range.len() as u32,
lines,
}
}
/// The content of a parsed line without its line ending.
///
/// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off.
fn line_text(text: &str) -> String {
let text = text.strip_suffix('\n').unwrap_or(text);
text.strip_suffix('\r').unwrap_or(text).to_owned()
}
/// Commits of a `git format-patch` output, a single patch or a series.
///
/// Entries appear in patch order, oldest first as `git format-patch` produces them.
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
let lines: Vec<&str> = patch.lines().collect();
let mut commits = Vec::new();
let mut i = 0;
while i < lines.len() {
// A patch starts with its `From <id> <date>` envelope line.
let Some(rest) = lines[i].strip_prefix("From ") else {
i += 1;
continue;
};
let Some(id) = rest.split_whitespace().next() else {
i += 1;
continue;
};
if id.len() != 40 {
i += 1;
continue;
let text = text.strip_suffix('\n').unwrap_or(text);
let text = text.strip_suffix('\r').unwrap_or(text);
builder.push(kind, text.to_owned());
}
let mut author = String::new();
let mut summary = String::new();
let mut time = 0i64;
// Envelope headers run up to the blank line before the commit message.
i += 1;
while i < lines.len() && !lines[i].is_empty() {
let header = lines[i];
if let Some(value) = header.strip_prefix("From: ") {
author = name_from_address(value);
} else if let Some(value) = header.strip_prefix("Subject: ") {
summary = strip_patch_prefix(value);
} else if let Some(value) = header.strip_prefix("Date: ") {
time = gix::date::parse(value.trim(), None)
.map(|t| t.seconds)
.unwrap_or(0);
}
i += 1;
}
commits.push(FileCommit {
id: id.to_string(),
summary,
description: None,
author,
time,
});
}
commits
}
fn name_from_address(from: &str) -> String {
match from.trim().find('<') {
Some(ix) => from[..ix].trim().to_string(),
None => from.trim().to_string(),
builder.finish()
}
}
/// Strip the patch prefix from a `Subject:` header.
///
/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`.
fn strip_patch_prefix(subject: &str) -> String {
let trimmed = subject.trim();
let Some(rest) = trimmed.strip_prefix('[') else {
return trimmed.to_string();
};
let Some(end) = rest.find(']') else {
return trimmed.to_string();
};
if rest[..end].to_ascii_lowercase().contains("patch") {
rest[end + 1..].trim().to_string()
} else {
trimmed.to_string()
struct Envelope<'a> {
text: &'a str,
id: &'a str,
headers: Vec<&'a str>,
}
impl<'a> Envelope<'a> {
/// Creates an envelope from its text, commit id, and header lines.
fn new(text: &'a str, id: &'a str, headers: Vec<&'a str>) -> Self {
Self { text, id, headers }
}
/// Returns the first header line matching `name: `.
fn header(&self, name: &str) -> Option<&str> {
let prefix = format!("{name}: ");
self.headers
.iter()
.find(|line| line.starts_with(&prefix))
.map(|line| &line[prefix.len()..])
}
}
-351
View File
@@ -1,351 +0,0 @@
use std::collections::HashMap;
use std::path::Path;
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail};
use gix::interrupt::IS_INTERRUPTED;
use gix::progress::Discard;
/// Clone into `path` from the first working URL in `clone_urls`.
///
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache.
pub fn clone_repo<U: AsRef<str>>(clone_urls: &[U], path: &Path) -> Result<()> {
if path.exists() {
bail!("destination {} already exists", path.display());
}
try_each_url(clone_urls, "clone", |url| {
let repo = clone(url, path)?;
// The initial clone uses the default refspecs. Also fetch the `refs/nostr/*` PR refs.
fetch_all(&repo).ok();
Ok(())
})
}
/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace.
pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
let options = gix::remote::ref_map::Options {
extra_refspecs: vec![
gix::refspec::parse(
gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"),
gix::refspec::parse::Operation::Fetch,
)?
.to_owned(),
],
..Default::default()
};
repo.find_remote("origin")?
.connect(gix::remote::Direction::Fetch)?
.prepare_fetch(Discard, options)?
.receive(Discard, &IS_INTERRUPTED)?;
Ok(())
}
pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["push"])
.arg(url)
.arg(format!("{commit}:{reference}"))
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped())
.output()
.context("failed to spawn `git push`")?;
if !output.status.success() {
bail!(
"git push failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
/// Rewrite a grasp server URL to the https URL the git transport actually uses.
///
/// GRASP servers announce `grasp://<host>/<owner>/<repo>` clone URLs.
/// The transport is git smart HTTP, so the scheme is rewritten for gix.
fn transport_url(url: &str) -> String {
url.strip_prefix("grasp://")
.map(|rest| format!("https://{rest}"))
.unwrap_or_else(|| url.to_owned())
}
/// Run `attempt` against each URL in `urls` until one succeeds.
///
/// Returns the last error wrapped in `failed to {verb} from any mirror`,
/// or `no clone URLs provided` when the list is empty.
fn try_each_url<U: AsRef<str>, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()>
where
F: FnMut(&str) -> Result<()>,
{
let mut last_err: Option<anyhow::Error> = None;
for url in urls {
match attempt(url.as_ref()) {
Ok(()) => return Ok(()),
Err(e) => last_err = Some(e),
}
}
match last_err {
Some(e) => Err(e).context(format!("failed to {verb} from any mirror")),
None => bail!("no clone URLs provided"),
}
}
fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
let url = transport_url(url);
let url = gix::url::parse(url).context("invalid clone URL")?;
let mut prepare = gix::prepare_clone(url, path)?;
let (mut checkout, _fetch) = prepare.fetch_then_checkout(Discard, &IS_INTERRUPTED)?;
let (repo, _checkout) = checkout.main_worktree(Discard, &IS_INTERRUPTED)?;
Ok(repo)
}
pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
push_refspecs(
repo_path,
base_url,
owner,
repo_id,
&["refs/heads/main:refs/heads/main"],
)
}
/// Push every local branch and tag of the repository at `repo_path` to a grasp server.
///
/// This mirrors an initialized repository's whole history.
pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
push_refspecs(
repo_path,
base_url,
owner,
repo_id,
&["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
)
}
fn push_refspecs(
repo_path: &Path,
base_url: &str,
owner: &str,
repo_id: &str,
refspecs: &[&str],
) -> Result<()> {
let url = format!("{base_url}/{owner}/{repo_id}.git");
let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2);
args.push("push");
args.push(&url);
args.extend_from_slice(refspecs);
let output = git_output(repo_path, &args, "git push")?;
if !output.status.success() {
bail!(
"git push to {base_url} failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
/// Whether `url` advertises every ref in `expected` at the given commit.
///
/// Extra advertised refs are ignored: the question is whether the data this
/// push wanted to land is already there, not whether the remote is an exact mirror.
/// This is the convergence probe for a push that lost the compare-and-swap race
/// to the grasp server's own background ref alignment.
pub fn remote_has_refs(repo_path: &Path, url: &str, expected: &[(String, String)]) -> Result<bool> {
if expected.is_empty() {
return Ok(true);
}
let repo = gix::open(repo_path)?;
let url = transport_url(url);
// A URL-created remote has no configured fetch refspecs, and `ref_map` only
// keeps refs that match one. Match each expected ref by its exact name,
// like `git ls-remote <url> <name>` would; ref maps never write to the repository.
let refspecs = expected
.iter()
.map(|(name, _)| {
gix::refspec::parse(
gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()),
gix::refspec::parse::Operation::Fetch,
)
.map(|spec| spec.to_owned())
})
.collect::<Result<Vec<_>, _>>()
.context("invalid refspec")?;
let options = gix::remote::ref_map::Options {
extra_refspecs: refspecs,
..Default::default()
};
let (refs, _) = repo
.remote_at(url.as_str())
.with_context(|| format!("cannot use remote {url}"))?
.connect(gix::remote::Direction::Fetch)
.with_context(|| format!("cannot connect to {url}"))?
.ref_map(Discard, options)
.with_context(|| format!("listing refs of {url} failed"))?;
// Peeled tag entries carry the tag object in their direct oid, so mapping
// each advertised ref to its direct oid matches `git ls-remote` while
// skipping the duplicated `^{}` lines.
let advertised: HashMap<String, String> = refs
.remote_refs
.iter()
.filter_map(|reference| {
let (name, object, _peeled) = reference.unpack();
object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string()))
})
.collect();
Ok(expected
.iter()
.all(|(name, oid)| advertised.get(name.as_str()) == Some(oid)))
}
/// Add `origin` pointing at `url` when the repository has no remote yet.
///
/// No-op if `origin` already exists.
pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
let repo = gix::open(repo_path)?;
if repo.find_remote("origin").is_ok() {
return Ok(());
}
// `git remote add` also configures the default fetch refspec.
edit_local_config(&repo, |config| {
config.set_raw_value("remote.origin.url", url)?;
config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
Ok(())
})
}
/// Point `origin` at `url`, replacing an existing remote,
/// used after a clone whose `origin` points at the cloned-from path.
///
/// A working copy cloned from a local mirror is re-targeted at the grasp server.
pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> {
let repo = gix::open(repo_path)?;
let had_origin = repo.find_remote("origin").is_ok();
edit_local_config(&repo, |config| {
// Replaces the existing url, like `git remote set-url origin <url>`.
// A pre-existing fetch refspec is left untouched.
config.set_raw_value("remote.origin.url", url)?;
if !had_origin {
config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
}
Ok(())
})
}
/// Apply `edit` to the repository-local configuration and persist it.
pub(crate) fn edit_local_config(
repo: &gix::Repository,
edit: impl FnOnce(&mut gix::config::File) -> Result<()>,
) -> Result<()> {
let config_path = repo.common_dir().join("config");
let mut lock = gix::lock::File::acquire_to_update_resource(
&config_path,
gix::lock::acquire::Fail::Immediately,
None,
)
.context("failed to lock repository config")?;
let mut config =
match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) {
Ok(config) => config,
// A repository without a config file yet starts from scratch.
Err(gix::config::file::init::from_paths::Error::Io { source, .. })
if source.kind() == std::io::ErrorKind::NotFound =>
{
gix::config::File::default()
}
Err(error) => return Err(error).context("failed to read repository config"),
};
edit(&mut config)?;
config
.write_to(&mut lock)
.context("failed to write repository config")?;
lock.commit().context("failed to save repository config")?;
Ok(())
}
/// Fetch `refspec` into `repo_path` from the first working URL in `urls`.
/// When no URL works, the last error is returned.
///
/// Never touches the checked-out refs or the worktree.
pub fn fetch_repo_refs<U: AsRef<str>>(repo_path: &Path, urls: &[U], refspec: &str) -> Result<()> {
let repo = gix::open(repo_path)?;
let refspec = gix::refspec::parse(
gix::bstr::BStr::new(refspec),
gix::refspec::parse::Operation::Fetch,
)
.context("invalid fetch refspec")?
.to_owned();
try_each_url(urls, "fetch", |url| {
let url = transport_url(url);
let options = gix::remote::ref_map::Options {
extra_refspecs: vec![refspec.clone()],
..Default::default()
};
repo.remote_at(url.as_str())
.with_context(|| format!("fetch from {url} failed"))?
.connect(gix::remote::Direction::Fetch)
.with_context(|| format!("fetch from {url} failed"))?
.prepare_fetch(Discard, options)
.with_context(|| format!("fetch from {url} failed"))?
.receive(Discard, &IS_INTERRUPTED)
.with_context(|| format!("fetch from {url} failed"))?;
Ok(())
})
}
/// The URL of the `origin` remote of the repository at `workdir`.
///
/// `None` when it has no `origin` yet.
pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
let Ok(repo) = gix::open(workdir) else {
return Ok(None);
};
let Ok(remote) = repo.find_remote("origin") else {
return Ok(None);
};
Ok(remote
.url(gix::remote::Direction::Fetch)
.map(|url| url.to_string()))
}
/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr.
///
/// `what` names the command in the spawn error.
pub(crate) fn git_output(dir: &Path, args: &[&str], what: &str) -> Result<std::process::Output> {
Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped())
.output()
.with_context(|| format!("failed to spawn `{what}`"))
}
File diff suppressed because it is too large Load Diff
+55 -29
View File
@@ -1,56 +1,82 @@
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use ignore::WalkBuilder;
use crate::nip34::{Nip34Binding, detect_nip34};
use crate::nip34::Nip34Binding;
use crate::repo::Repo;
/// Caps nesting so pathological trees can't stall the scan.
const SCAN_MAX_DEPTH: usize = 12;
/// A git repository discovered under a scan root.
#[derive(Debug, Clone)]
pub struct LocalRepo {
pub path: PathBuf,
/// `None` for a plain repository.
pub nip34: Option<Nip34Binding>,
}
/// Walk `root` recursively and collect the git repositories below it.
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
if !root.is_dir() {
return Vec::new();
impl LocalRepo {
/// Creates a local repository entry from its path and optional NIP-34 binding.
pub fn new(path: PathBuf, nip34: Option<Nip34Binding>) -> Self {
Self { path, nip34 }
}
}
let walker = WalkBuilder::new(root)
/// Finds git repositories under `root` up to `SCAN_MAX_DEPTH` deep, without descending into them.
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
let Ok(root) = root.canonicalize() else {
return Vec::new();
};
let found = Arc::new(Mutex::new(Vec::<PathBuf>::new()));
let walker = WalkBuilder::new(&root)
.max_depth(Some(SCAN_MAX_DEPTH))
// Honour `.gitignore` even when the scan root is not itself a repository.
.require_git(false)
.filter_entry({
let found = found.clone();
move |entry| {
let Some(kind) = entry.file_type() else {
return true;
};
if !kind.is_dir() {
return true;
}
let dir = entry.path();
let Ok(repo) = gix::discover(dir) else {
return true;
};
let Some(workdir) = repo.workdir() else {
return false;
};
if workdir != dir {
return true;
}
match found.lock() {
Ok(mut repos) => repos.push(dir.to_path_buf()),
Err(poisoned) => poisoned.into_inner().push(dir.to_path_buf()),
}
false
}
})
.build();
let mut repos: Vec<PathBuf> = walker
.flatten()
.filter(|entry| entry.file_type().is_some_and(|kind| kind.is_dir()))
.map(ignore::DirEntry::into_path)
.filter(|dir| dir.join(".git").exists())
.filter_map(|dir| dir.canonicalize().ok())
.collect();
for _ in walker {}
let mut repos = match found.lock() {
Ok(mut repos) => std::mem::take(&mut *repos),
Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
};
repos.sort();
repos.dedup();
// A repository nested inside another, like a submodule worktree, is not reported.
let mut roots: Vec<PathBuf> = Vec::with_capacity(repos.len());
for repo in repos {
if !roots.iter().any(|kept| repo.starts_with(kept)) {
roots.push(repo);
}
}
roots
repos
.into_iter()
.map(|path| {
let nip34 = detect_nip34(&path);
LocalRepo { path, nip34 }
let nip34 = Repo::open(&path).ok().and_then(|repo| repo.nip34_binding());
LocalRepo::new(path, nip34)
})
.collect()
}
File diff suppressed because it is too large Load Diff
+23 -321
View File
@@ -1,334 +1,36 @@
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use anyhow::{Context, Result};
use gix::progress::Discard;
use crate::history::FileCommit;
use crate::history::{FileCommit, head_commit};
use crate::repo::{current_branch, repository_signature};
/// Whether the worktree of `workdir` has uncommitted changes.
///
/// Best-effort: any read failure is reported as clean.
pub fn worktree_dirty(workdir: &Path) -> bool {
let Ok(repo) = gix::open(workdir) else {
return false;
};
// Changes to tracked files, staged or not; untracked files are excluded.
match repo.is_dirty() {
Ok(true) => return true,
Ok(false) => {}
Err(_) => return false,
}
// Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk,
// tracked files only appear there when modified.
let Ok(platform) = repo.status(Discard) else {
return false;
};
let Ok(mut changes) = platform.into_index_worktree_iter(Vec::<gix::bstr::BString>::new())
else {
return false;
};
for change in changes.by_ref() {
match change {
Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true,
Ok(_) => {}
Err(_) => return false,
}
}
false
}
/// Commits in `base..branch` of the checkout at `workdir`.
///
/// Best-effort: 0 when the range cannot be computed.
pub fn worktree_commits_ahead(workdir: &Path, base: &str, branch: &str) -> u32 {
let Ok(repo) = gix::open(workdir) else {
return 0;
};
let (Some(base), Some(branch)) = (resolve_commit(&repo, base), resolve_commit(&repo, branch))
else {
return 0;
};
let Ok(walk) = repo.rev_walk([branch]).with_hidden([base]).all() else {
return 0;
};
walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32
}
/// 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>> {
repo.rev_parse_single(rev.as_bytes()).ok()
}
/// Relative paths of all entries in the worktree, files and directories.
///
/// The `.git` directory is skipped.
pub fn worktree_entries(repo: &gix::Repository) -> Result<Vec<PathBuf>> {
let workdir = repo.workdir().context("repository has no worktree")?;
let mut entries: Vec<(PathBuf, bool)> = Vec::new();
collect_entries(workdir, workdir, &mut entries)?;
entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| {
b_is_dir
.cmp(a_is_dir)
.then_with(|| a.as_os_str().cmp(b.as_os_str()))
});
Ok(entries.into_iter().map(|(path, _)| path).collect())
}
/// Read a file from the worktree.
///
/// Returns `Ok(None)` if the path is missing or not a regular file.
pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result<Option<Vec<u8>>> {
let workdir = repo.workdir().context("repository has no worktree")?;
let path = workdir.join(rel);
match std::fs::read(&path) {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None),
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
}
}
/// Find the README file in the repository root.
///
/// Falls back to any other file whose name starts with `readme`.
pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
let Some(workdir) = repo.workdir() else {
return Ok(None);
};
let mut candidates: Vec<PathBuf> = Vec::new();
for entry in std::fs::read_dir(workdir)? {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if name.to_ascii_lowercase().starts_with("readme") {
candidates.push(entry.path());
}
}
candidates.sort_by_key(|path| {
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_ascii_lowercase());
match ext.as_deref() {
Some("md") => 0,
Some("markdown") => 1,
Some("mdown") => 2,
Some("mkdn") => 3,
Some(_) => 5,
None => 4,
}
});
Ok(candidates
.into_iter()
.next()
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
}
/// Everything the browser needs to refresh after a branch or tag switch.
pub struct WorktreeSnapshot {
/// Relative paths of all worktree entries, directories first.
pub entries: Vec<PathBuf>,
/// README path relative to the worktree, if any.
pub readme_path: Option<PathBuf>,
/// Contents of the README, if any.
pub readme: Option<Vec<u8>>,
/// Branch HEAD points to, `None` when detached, for example on a tag.
pub current_branch: Option<String>,
/// Commit HEAD points to, if any, see [`head_commit`].
pub head_commit: Option<FileCommit>,
pub branches: Vec<String>,
pub tags: Vec<String>,
}
/// Collects entries, the README, the branch HEAD points to and its commit.
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
let repo = gix::open(workdir)?;
let readme_path = find_readme(&repo)?;
let readme = match &readme_path {
Some(path) => worktree_read(&repo, path)?,
None => None,
};
Ok(WorktreeSnapshot {
entries: worktree_entries(&repo)?,
readme_path,
readme,
current_branch: current_branch(&repo)?,
head_commit: head_commit(&repo)?,
})
}
pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> {
let workdir = repo
.workdir()
.context("repository has no worktree")?
.to_path_buf();
let mut index = repo.index_from_tree(tree)?;
// Files the previous index tracked but `tree` no longer contains are removed,
// like git deleting files that vanish between branches.
if let Ok(previous) = repo.index_or_empty() {
let keep: HashSet<PathBuf> = index
.entries()
.iter()
.map(|entry| PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned()))
.collect();
for entry in previous.entries() {
let rel = entry.path(&previous);
let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned());
if keep.contains(&rel) {
continue;
}
let path = workdir.join(&rel);
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error)
.with_context(|| format!("failed to remove {}", path.display()));
}
}
impl WorktreeSnapshot {
/// Creates a snapshot of the worktree contents and ref state.
pub(crate) fn new(
entries: Vec<PathBuf>,
readme_path: Option<PathBuf>,
readme: Option<Vec<u8>>,
current_branch: Option<String>,
head_commit: Option<FileCommit>,
branches: Vec<String>,
tags: Vec<String>,
) -> Self {
Self {
entries,
readme_path,
readme,
current_branch,
head_commit,
branches,
tags,
}
}
let mut options =
repo.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?;
options.overwrite_existing = true;
let objects = repo.objects.clone().into_arc()?;
let files = gix::progress::Discard;
let bytes = gix::progress::Discard;
gix_worktree_state::checkout(
&mut index,
workdir,
objects,
&files,
&bytes,
&gix::interrupt::IS_INTERRUPTED,
options,
)?;
index.write(gix::index::write::Options::default())?;
Ok(())
}
/// Point `HEAD` at `target` and record the switch in the reflog.
fn move_head(
repo: &gix::Repository,
signature: gix::actor::SignatureRef<'_>,
target: gix::refs::Target,
message: &str,
) -> Result<()> {
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
let head = gix::refs::FullName::try_from("HEAD")
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
repo.edit_references_as(
[RefEdit {
change: Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: message.into(),
},
expected: PreviousValue::Any,
new: target,
},
name: head,
deref: false,
}],
Some(signature),
)?;
Ok(())
}
/// Check out the local branch `name`, HEAD stays attached to it.
pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> {
let repo = gix::open(workdir)?;
let full = format!("refs/heads/{name}");
let branch = gix::refs::FullName::try_from(full.as_str())
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
let mut reference = repo.find_reference(&full)?;
let tree = reference.peel_to_tree()?.id;
let (signature, mut time_buf) = repository_signature();
let signature = signature.to_ref(&mut time_buf);
move_head(
&repo,
signature,
gix::refs::Target::Symbolic(branch),
&format!("checkout: moving to {name}"),
)?;
force_checkout(&repo, &tree)?;
Ok(())
}
/// Check out the tag `name`, HEAD becomes detached at the tagged commit.
pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> {
let repo = gix::open(workdir)?;
let full = format!("refs/tags/{name}");
let mut reference = repo.find_reference(&full)?;
let commit = reference.peel_to_id()?;
let tree = reference.peel_to_tree()?.id;
let (signature, mut time_buf) = repository_signature();
let signature = signature.to_ref(&mut time_buf);
move_head(
&repo,
signature,
gix::refs::Target::Object(commit.detach()),
&format!("checkout: moving to {name}"),
)?;
force_checkout(&repo, &tree)?;
Ok(())
}
fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
if entry.file_name() == ".git" {
continue;
}
let is_dir = entry.file_type()?.is_dir();
let path = entry.path();
let rel = path.strip_prefix(root)?.to_path_buf();
out.push((rel, is_dir));
if is_dir {
collect_entries(root, &path, out)?;
}
}
Ok(())
}
+1 -6
View File
@@ -8,12 +8,7 @@ publish.workspace = true
nostr-sdk.workspace = true
nostr-connect.workspace = true
nostr-gossip-memory.workspace = true
nostr-lmdb.workspace = true
anyhow.workspace = true
webbrowser.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
nostr-memory.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
nostr-lmdb.workspace = true
+31 -35
View File
@@ -1,51 +1,47 @@
#[cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result};
use nostr_gossip_memory::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use nostr_lmdb::prelude::*;
#[cfg(target_arch = "wasm32")]
use nostr_memory::prelude::*;
use nostr_sdk::prelude::*;
use crate::signer::UniversalSigner;
#[cfg(not(target_arch = "wasm32"))]
pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, UniversalSigner)> {
let signer = UniversalSigner::new(Keys::generate());
let database = NostrLmdb::open(db_path)
.await
.context("failed to open nostr database")?;
Ok(with_database(signer, database))
pub struct NostrBackend {
pub client: Client,
pub signer: UniversalSigner,
}
/// In-memory database on wasm, LMDB is unavailable there.
#[cfg(target_arch = "wasm32")]
pub fn new_backend() -> Result<(Client, UniversalSigner)> {
let signer = UniversalSigner::new(Keys::generate());
Ok(with_database(signer, MemoryDatabase::unbounded()))
}
impl NostrBackend {
pub async fn open(db_path: impl AsRef<Path>) -> Result<Self> {
let db_path = db_path.as_ref();
let signer = UniversalSigner::new(Keys::generate());
let database = NostrLmdb::open(db_path)
.await
.context("failed to open nostr database")?;
Ok(Self::with_database(signer, database))
}
fn with_database<D>(signer: UniversalSigner, database: D) -> (Client, UniversalSigner)
where
D: IntoNostrDatabase,
{
let authenticator = SignerAuthenticator::new(signer.clone());
fn with_database<D>(signer: UniversalSigner, database: D) -> Self
where
D: IntoNostrDatabase,
{
let authenticator = SignerAuthenticator::new(signer.clone());
let client = ClientBuilder::default()
.database(database)
.authenticator(authenticator)
.gossip(NostrGossipMemory::unbounded())
.gossip_config(GossipConfig::default().no_background_refresh())
.connect_timeout(Duration::from_secs(10))
.verify_subscriptions(true)
.ban_relay_on_mismatch(true)
.sleep_when_idle(SleepWhenIdle::Enabled {
timeout: Duration::from_secs(600),
})
.build();
let client = ClientBuilder::default()
.database(database)
.authenticator(authenticator)
.gossip(NostrGossipMemory::unbounded())
.gossip_config(GossipConfig::default().no_background_refresh())
.connect_timeout(Duration::from_secs(10))
.verify_subscriptions(true)
.ban_relay_on_mismatch(true)
.sleep_when_idle(SleepWhenIdle::Enabled {
timeout: Duration::from_secs(600),
})
.build();
(client, signer)
Self { client, signer }
}
}
+1 -1
View File
@@ -2,6 +2,6 @@ mod backend;
mod signer;
mod update;
pub use backend::new_backend;
pub use backend::NostrBackend;
pub use signer::{SignedAuthUrlHandler, UniversalSigner};
pub use update::Update;
-3
View File
@@ -8,7 +8,6 @@ use nostr_connect::client::AuthUrlHandler;
use nostr_sdk::error::Error as SignerError;
use nostr_sdk::prelude::*;
/// A type-erased signer whose inner signer can be swapped in-place.
#[derive(Clone, Debug)]
pub struct UniversalSigner {
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
@@ -27,7 +26,6 @@ impl UniversalSigner {
}
}
/// Swap the inner signer in-place. All clones see the new signer.
pub fn swap_inner<T>(&self, new_signer: T)
where
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
@@ -160,7 +158,6 @@ impl AsyncNip44 for UniversalSigner {
}
}
/// Opens the NIP-46 auth URL in the default browser.
#[derive(Debug, Clone)]
pub struct SignedAuthUrlHandler;
-2
View File
@@ -1,10 +1,8 @@
use nostr_sdk::prelude::*;
/// A lightweight change notification for the UI.
#[derive(Debug, Clone)]
pub struct Update {
pub kind: Kind,
/// First `a` tag value of the event, if any, for example the repository coordinate.
pub coordinate: Option<Coordinate>,
pub author: PublicKey,
}
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
use std::collections::HashMap;
use std::time::Duration;
use anyhow::Error;
use nostr_connect::prelude::*;
use nostr_sdk::client::SyncSummary;
use nostr_sdk::prelude::*;
use signed_core::Filters;
pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"];
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
pub(crate) async fn ensure_bootstrap_relays(client: &Client) -> Result<(), Error> {
for url in BOOTSTRAP_RELAYS {
client.add_relay(url).and_connect().await?;
}
for url in INDEXER_RELAYS {
client
.add_relay(url)
.capabilities(RelayCapabilities::DISCOVERY)
.and_connect()
.await?;
}
Ok(())
}
pub(crate) async fn subscribe_bootstrap_only(
client: &Client,
filters: Vec<Filter>,
) -> Result<(), Error> {
ensure_bootstrap_relays(client).await?;
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(10)));
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
.iter()
.map(|relay| (*relay, filters.clone()))
.collect();
client.subscribe(target).close_on(opts).await?;
Ok(())
}
pub(crate) async fn sync_bootstrap_only(
client: &Client,
filter: Filter,
opts: SyncOptions,
) -> Result<SyncSummary, Error> {
ensure_bootstrap_relays(client).await?;
let output = client
.sync(filter)
.with(BOOTSTRAP_RELAYS)
.opts(opts)
.await?;
Ok(output.value)
}
fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
event
.tags
.iter()
.filter(|tag| tag.kind() == "g")
.filter_map(|tag| tag.content())
.filter_map(|url| RelayUrl::parse(url).ok())
.collect()
}
pub async fn user_grasp_list_servers(
client: &Client,
user: PublicKey,
) -> Result<Vec<RelayUrl>, Error> {
let events: Vec<Event> = client
.database()
.query(Filters::grasp_list(user))
.await?
.into_iter()
.collect();
let latest = events
.into_iter()
.max_by_key(|event| event.created_at)
.map(|event| grasp_list_servers(&event))
.unwrap_or_default();
Ok(latest)
}
+212 -389
View File
@@ -5,88 +5,58 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::Error;
use gpui::{App, AppContext, Context, Entity, Global, Subscription};
use nostr::prelude::*;
use settings::{CheckoutRecord, SettingsStore};
use settings::{CheckoutRecord, CheckoutsSettings, SettingsStore};
use signed_core::{Announcement, RepoAddr};
use signed_git::Repo;
use utils::same_repo_url;
use crate::backend::{Backend, BackendEvent};
use crate::git_store::repo_mirror_root;
use crate::git_store::Mirrors;
use crate::local_repos::LocalReposStore;
use crate::refresh::{RefreshGate, RefreshRequest};
use crate::repos::RepoListStore;
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// How often the statuses are recomputed against the local refs.
const LOCAL_POLL: Duration = Duration::from_secs(2);
/// How often a full pass refreshes the remotes while any repository panel is open.
const STATUS_POLL: Duration = Duration::from_secs(15);
/// Remote refresh interval for the `ready to push` badges of the user's own repositories.
const PUSH_POLL: Duration = Duration::from_secs(60);
const MAX_STATUS_CHECKOUTS: usize = 8;
const LOCAL_POLL: Duration = Duration::from_secs(2);
const STATUS_POLL: Duration = Duration::from_secs(15);
const PUSH_POLL: Duration = Duration::from_secs(60);
struct GlobalCheckoutsStore(Entity<CheckoutsStore>);
impl Global for GlobalCheckoutsStore {}
/// One associated local checkout of a repository.
///
/// Carries the git facts needed to suggest a pull request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckoutStatus {
pub path: PathBuf,
/// The branch checked out. A detached checkout is idle and yields no status.
/// A detached checkout is idle and yields no status.
pub branch: String,
/// Commit the branch points at, for tip-based PR dedupe.
/// For tip-based PR dedupe.
pub head: String,
/// What the branch is compared against.
///
/// It is `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
/// `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
pub base: String,
/// Commits in `base..branch`.
///
/// Zero-ahead checkouts are dropped, so this is always above zero.
/// Zero-ahead checkouts are dropped, so always above zero.
pub ahead: u32,
}
/// A remembered record, with the address already parsed.
struct Remembered {
path: PathBuf,
addr: RepoAddr,
last_used: u64,
}
/// Global store of local-checkout associations and per-checkout statuses.
pub struct CheckoutsStore {
/// Checkout paths per announced repository.
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
/// Ready-to-contribute statuses of the requested repositories.
///
/// Those are the repository detail panels currently open.
statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
/// Repositories whose statuses are recomputed on every input change.
///
/// Those are the repository detail panels currently open.
status_requested: HashSet<RepoAddr>,
/// Repositories whose `ready to push` statuses are recomputed on the same cycle.
///
/// The sidebar rows of the user's own repositories and their detail panels.
push_requested: HashSet<RepoAddr>,
/// The ready-to-push statuses of the requested own repositories.
push_statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
/// Last announced head branch per requested repository.
///
/// 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.
///
/// The local pass runs a full pass again once this is older than the
/// reconciliation cadence, so remote moves still land.
last_full_sync: Option<Instant>,
checkouts_settings: CheckoutsSettings,
_subscriptions: Vec<Subscription>,
}
@@ -101,6 +71,7 @@ impl CheckoutsStore {
pub fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = Vec::new();
let mut checkouts_settings = CheckoutsSettings::default();
if !cfg!(target_arch = "wasm32") {
let settings = SettingsStore::global(cx);
@@ -108,7 +79,15 @@ impl CheckoutsStore {
let repos = RepoListStore::global(cx);
let backend = Backend::global(cx);
subscriptions.push(cx.observe(&settings, |this, _settings, cx| {
checkouts_settings = settings.read(cx).settings().checkouts.clone();
subscriptions.push(cx.observe(&settings, |this, settings, cx| {
let checkouts = settings.read(cx).settings().checkouts.clone();
if this.checkouts_settings == checkouts {
return;
}
// Only edits to the checkouts section affect the derived state
this.checkouts_settings = checkouts;
this.refresh(cx);
}));
@@ -155,6 +134,7 @@ impl CheckoutsStore {
debounce_pending: false,
local_pending: false,
last_full_sync: None,
checkouts_settings,
_subscriptions: subscriptions,
}
}
@@ -187,17 +167,10 @@ impl CheckoutsStore {
});
}
/// The associated checkouts of `addr`, freshest first.
///
/// Empty when none are known or the resolution has not run yet.
pub fn associations_of(&self, addr: &RepoAddr) -> Vec<PathBuf> {
self.by_repo.get(addr).cloned().unwrap_or_default()
}
/// Ask for the `ready to contribute` statuses of `addr` to stay current.
/// Called while the repository's detail panel is open.
///
/// `announced_head` is the announced HEAD branch, used to default the base.
pub fn request_statuses(
&mut self,
addr: &RepoAddr,
@@ -211,26 +184,15 @@ impl CheckoutsStore {
self.refresh(cx);
}
/// The ready-to-contribute statuses of `addr`.
///
/// Empty while none are known or nothing is ahead.
pub fn ready_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
self.statuses.get(addr).cloned().unwrap_or_default()
}
/// Ask for the `ready to push` statuses of `addr` to stay current.
pub fn request_push_statuses(&mut self, addr: &RepoAddr, cx: &mut Context<Self>) {
self.push_requested.insert(addr.clone());
self.refresh(cx);
}
/// The checkout at `path` was just pushed to the remote.
///
/// Its ready-to-push status is obsolete. Drop it from the cached statuses
/// and notify observers right away, so the sidebar badge and the push
/// banner update immediately instead of waiting for the next background
/// pass, which re-scans and re-fetches the remote. The debounced refresh
/// reconciles the remaining checkouts of the repository afterwards.
pub fn checkout_pushed(&mut self, addr: &RepoAddr, path: &Path, cx: &mut Context<Self>) {
let mut removed = false;
@@ -248,14 +210,9 @@ impl CheckoutsStore {
cx.notify();
}
// The other checkouts of this repository still need re-deriving
// against the remote, now that the pushed refs landed there.
self.request_push_statuses(addr, cx);
}
/// The ready-to-push statuses of `addr`.
///
/// Empty while none are known or nothing is unpushed.
pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
self.push_statuses.get(addr).cloned().unwrap_or_default()
}
@@ -267,7 +224,6 @@ impl CheckoutsStore {
.unwrap_or(0)
}
/// Re-resolve the associations and the requested statuses.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.debounce_pending || self.refresh.request() != RefreshRequest::Schedule {
return;
@@ -277,12 +233,13 @@ impl CheckoutsStore {
cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx))
this.update(cx, |this, cx| {
this.run_refresh(cx);
})
})
.detach();
}
/// One full resolve and apply cycle, the debounced entry point.
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.debounce_pending = false;
self.refresh.begin();
@@ -306,7 +263,7 @@ impl CheckoutsStore {
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
let scanned = LocalReposStore::global(cx).read(cx).repos.clone();
let cache_root = repo_mirror_root().canonicalize().ok();
let cache_root = Mirrors::root().canonicalize().ok();
let requested: Vec<(RepoAddr, Option<String>)> = self
.status_requested
@@ -323,35 +280,41 @@ impl CheckoutsStore {
let poll = !self.status_requested.is_empty() || !self.push_requested.is_empty();
let work = cx.background_spawn(async move {
// Read the git facts of every scanned repository off the main thread.
//
// The facts are the origin URL and the root commit, both CLI reads.
let mut facts: Vec<(PathBuf, Option<String>, Option<String>)> = Vec::new();
for scanned in scanned.iter() {
let path = &scanned.path;
// The browser's mirror clones share the announce URLs and EUCs. They are not user checkouts.
// The browser's mirror clones are not user checkouts.
if cache_root
.as_ref()
.is_some_and(|root| path.starts_with(root))
{
continue;
}
let origin = signed_git::origin_url(path).ok().flatten();
let root = signed_git::root_commit(path).ok().flatten();
let origin = Repo::open(path)
.and_then(|repo| repo.origin_url())
.ok()
.flatten();
let root = Repo::open(path)
.and_then(|repo| repo.root_commit())
.ok()
.flatten();
facts.push((path.clone(), origin, root));
}
let associations = resolve_associations(&remembered, &facts, announcements.iter());
let associations =
CheckoutsStore::resolve_associations(&remembered, &facts, announcements.iter());
// Missing directories are stale records, drop them.
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
.into_iter()
.map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect()))
.collect();
let (statuses, push_statuses) =
compute_statuses(&associations, &requested, &push_requested, true);
CheckoutsStore::compute_statuses(&associations, &requested, &push_requested, true);
Ok::<_, Error>((associations, statuses, push_statuses))
});
@@ -360,7 +323,6 @@ impl CheckoutsStore {
let (associations, statuses, push_statuses) = match work.await {
Ok(results) => results,
Err(_) => {
// Git reads are best-effort, keep the last results.
return this.update(cx, |this, cx| {
this.refresh.abort();
if poll {
@@ -379,8 +341,7 @@ impl CheckoutsStore {
this.statuses = statuses;
this.push_statuses = push_statuses;
// Notify only when something actually changed, so observers
// skip the no-op heartbeats.
// Notify only when something actually changed.
if associations_changed || statuses_changed || push_statuses_changed {
cx.notify();
}
@@ -390,12 +351,11 @@ impl CheckoutsStore {
})?;
if again {
this.update(cx, |this, cx| this.refresh(cx))?;
this.update(cx, |this, cx| {
this.refresh(cx);
})?;
}
// Restart the fast local pass so the freshly resolved
// associations drive it. The pass itself decides when the next
// full pass runs.
this.update(cx, |this, cx| {
if poll {
this.schedule_local_pass(cx);
@@ -407,7 +367,6 @@ impl CheckoutsStore {
.detach();
}
/// Schedule the fast local status pass, unless one is already pending.
fn schedule_local_pass(&mut self, cx: &mut Context<Self>) {
if self.local_pending {
return;
@@ -424,20 +383,16 @@ impl CheckoutsStore {
.detach();
}
/// The fast local status pass.
fn local_tick(&mut self, cx: &mut Context<Self>) {
// Nothing watched: the chain idles out until a new request restarts it.
if self.status_requested.is_empty() && self.push_requested.is_empty() {
return;
}
// A full pass or a fresh request covers this tick, skip it.
if self.refresh.running() || self.debounce_pending {
self.schedule_local_pass(cx);
return;
}
// Open panels get the faster remote cadence.
let cadence = if self.status_requested.is_empty() {
PUSH_POLL
} else {
@@ -458,7 +413,6 @@ impl CheckoutsStore {
self.schedule_local_pass(cx);
}
/// Recompute the requested statuses against the tracking refs only.
fn run_local_statuses(&mut self, cx: &mut Context<Self>) {
let associations = self.by_repo.clone();
@@ -477,19 +431,16 @@ impl CheckoutsStore {
let work = cx.background_spawn(async move {
let (statuses, push_statuses) =
compute_statuses(&associations, &requested, &push_requested, false);
CheckoutsStore::compute_statuses(&associations, &requested, &push_requested, false);
Ok::<_, Error>((statuses, push_statuses))
});
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let Ok((statuses, push_statuses)) = work.await else {
// Git reads are best-effort, keep the last results.
return Ok(());
};
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.debounce_pending {
return;
}
@@ -511,217 +462,201 @@ impl CheckoutsStore {
}
}
fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
let parsed = Url::parse(url).ok()?;
let host = parsed.host_str()?.to_ascii_lowercase();
let mut path = parsed.path().trim_matches('/').to_owned();
if let Some(stripped) = path.strip_suffix(".git") {
path = stripped.to_owned();
}
Some((host, parsed.port(), path))
}
impl CheckoutsStore {
fn resolve_associations<'a>(
remembered: &[Remembered],
scanned: &[(PathBuf, Option<String>, Option<String>)],
announcements: impl IntoIterator<Item = &'a Announcement>,
) -> HashMap<RepoAddr, Vec<PathBuf>> {
let announcements: Vec<&Announcement> = announcements.into_iter().collect();
let mut out: HashMap<RepoAddr, Vec<PathBuf>> = HashMap::new();
fn same_repo_url(a: &str, b: &str) -> bool {
match (url_identity(a), url_identity(b)) {
(Some(a), Some(b)) => a == b,
_ => a == b,
}
}
fn resolve_associations<'a>(
remembered: &[Remembered],
scanned: &[(PathBuf, Option<String>, Option<String>)],
announcements: impl IntoIterator<Item = &'a Announcement>,
) -> HashMap<RepoAddr, Vec<PathBuf>> {
let announcements: Vec<&Announcement> = announcements.into_iter().collect();
let mut out: HashMap<RepoAddr, Vec<PathBuf>> = HashMap::new();
let mut sorted: Vec<&Remembered> = remembered.iter().collect();
sorted.sort_by_key(|record| std::cmp::Reverse(record.last_used));
for record in sorted {
let paths = out.entry(record.addr.clone()).or_default();
if !paths.contains(&record.path) {
paths.push(record.path.clone());
let mut sorted: Vec<&Remembered> = remembered.iter().collect();
sorted.sort_by_key(|record| std::cmp::Reverse(record.last_used));
for record in sorted {
let paths = out.entry(record.addr.clone()).or_default();
if !paths.contains(&record.path) {
paths.push(record.path.clone());
}
}
}
for (path, origin, root) in scanned {
for announcement in &announcements {
let url_match = origin.as_deref().is_some_and(|origin| {
announcement
.clone
.iter()
.any(|url| same_repo_url(origin, url.as_str()))
});
for (path, origin, root) in scanned {
for announcement in &announcements {
let url_match = origin.as_deref().is_some_and(|origin| {
announcement
.clone
.iter()
.any(|url| same_repo_url(origin, url.as_str()))
});
let euc_match = root
.as_deref()
.is_some_and(|root| announcement.euc.as_deref() == Some(root));
let euc_match = root
.as_deref()
.is_some_and(|root| announcement.euc.as_deref() == Some(root));
if url_match || euc_match {
let paths = out.entry(announcement.addr()).or_default();
if !paths.contains(path) {
paths.push(path.clone());
if url_match || euc_match {
let paths = out.entry(announcement.addr()).or_default();
if !paths.contains(path) {
paths.push(path.clone());
}
}
}
}
out
}
out
}
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
let repo = Repo::try_open(path)?;
let branches = repo.branches().ok()?;
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
let branches = signed_git::worktree_branches(path).ok()?;
if branches.is_empty() || repo.is_dirty() {
return None;
}
if branches.is_empty() || signed_git::worktree_dirty(path) {
return None;
let branch = repo.current_branch()?;
let head = repo.head()?;
let base = announced_head
.filter(|name| branches.iter().any(|b| b == name))
.map(str::to_owned)
.or_else(|| branches.iter().find(|b| *b == "main").cloned())
.or_else(|| branches.first().cloned())?;
if base == branch {
return None;
}
let ahead = repo.commits_ahead(&base, &branch);
(ahead > 0).then_some(CheckoutStatus {
path: path.to_path_buf(),
branch,
head,
base,
ahead,
})
}
let branch = signed_git::worktree_current_branch(path)?;
let head = signed_git::head_commit_id(path).ok().flatten()?;
let base = announced_head
.filter(|name| branches.iter().any(|b| b == name))
.map(str::to_owned)
.or_else(|| branches.iter().find(|b| *b == "main").cloned())
.or_else(|| branches.first().cloned())?;
// Never fetches the checked-out refs; reads the tracking refs as-is.
fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
let repo = Repo::try_open(path)?;
if repo.is_dirty() {
return None;
}
if base == branch {
return None;
}
let branch = repo.current_branch()?;
let head = repo.head()?;
let origin = repo.origin_url().ok().flatten()?;
let ahead = signed_git::worktree_commits_ahead(path, &base, &branch);
(ahead > 0).then_some(CheckoutStatus {
path: path.to_path_buf(),
branch,
head,
base,
ahead,
})
}
if fetch {
repo.fetch_refs(&[origin], "+refs/heads/*:refs/remotes/origin/*")
.ok();
}
/// The `ready to push` status of one checkout of the user's own repository.
fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
if signed_git::worktree_dirty(path) {
return None;
}
let remote = format!("refs/remotes/origin/{branch}");
let branch = signed_git::worktree_current_branch(path)?;
let head = signed_git::head_commit_id(path).ok().flatten()?;
let origin = signed_git::origin_url(path).ok().flatten()?;
if fetch {
signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok();
}
let remote = format!("refs/remotes/origin/{branch}");
// A branch never fetched or pushed yet compares against the remote HEAD.
// The remote HEAD is the fork point in practice.
let base = if signed_git::worktree_ref_exists(path, &remote) {
remote
} else if signed_git::worktree_ref_exists(path, "refs/remotes/origin/HEAD") {
"refs/remotes/origin/HEAD".to_owned()
} else {
return None;
};
let ahead = signed_git::worktree_commits_ahead(path, &base, &branch);
(ahead > 0).then_some(CheckoutStatus {
path: path.to_path_buf(),
branch,
head,
base,
ahead,
})
}
/// Compute the requested statuses against the checkout paths of `associations`.
fn compute_statuses(
associations: &HashMap<RepoAddr, Vec<PathBuf>>,
requested: &[(RepoAddr, Option<String>)],
push_requested: &[RepoAddr],
fetch: bool,
) -> (
HashMap<RepoAddr, Vec<CheckoutStatus>>,
HashMap<RepoAddr, Vec<CheckoutStatus>>,
) {
let mut statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
for (addr, announced_head) in requested {
let Some(paths) = associations.get(addr) else {
continue;
// A branch never fetched or pushed yet compares against the remote
// HEAD, the fork point in practice.
let base = if repo.ref_exists(&remote) {
remote
} else if repo.ref_exists("refs/remotes/origin/HEAD") {
"refs/remotes/origin/HEAD".to_owned()
} else {
return None;
};
let list: Vec<CheckoutStatus> = paths
.iter()
.take(MAX_STATUS_CHECKOUTS)
.filter_map(|path| checkout_status(path, announced_head.as_deref()))
.collect();
let ahead = repo.commits_ahead(&base, &branch);
if !list.is_empty() {
statuses.insert(addr.clone(), list);
(ahead > 0).then_some(CheckoutStatus {
path: path.to_path_buf(),
branch,
head,
base,
ahead,
})
}
fn compute_statuses(
associations: &HashMap<RepoAddr, Vec<PathBuf>>,
requested: &[(RepoAddr, Option<String>)],
push_requested: &[RepoAddr],
fetch: bool,
) -> (
HashMap<RepoAddr, Vec<CheckoutStatus>>,
HashMap<RepoAddr, Vec<CheckoutStatus>>,
) {
let mut statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
for (addr, announced_head) in requested {
let Some(paths) = associations.get(addr) else {
continue;
};
let list: Vec<CheckoutStatus> = paths
.iter()
.take(MAX_STATUS_CHECKOUTS)
.filter_map(|path| Self::checkout_status(path, announced_head.as_deref()))
.collect();
if !list.is_empty() {
statuses.insert(addr.clone(), list);
}
}
}
let mut push_statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
for addr in push_requested {
let Some(paths) = associations.get(addr) else {
continue;
};
let mut push_statuses: HashMap<RepoAddr, Vec<CheckoutStatus>> = HashMap::new();
for addr in push_requested {
let Some(paths) = associations.get(addr) else {
continue;
};
let list: Vec<CheckoutStatus> = paths
.iter()
.take(MAX_STATUS_CHECKOUTS)
.filter_map(|path| checkout_push_status(path, fetch))
.collect();
let list: Vec<CheckoutStatus> = paths
.iter()
.take(MAX_STATUS_CHECKOUTS)
.filter_map(|path| Self::checkout_push_status(path, fetch))
.collect();
if !list.is_empty() {
push_statuses.insert(addr.clone(), list);
if !list.is_empty() {
push_statuses.insert(addr.clone(), list);
}
}
(statuses, push_statuses)
}
(statuses, push_statuses)
}
pub fn pr_proposes_checkout(
pr: &Event,
open: bool,
user: PublicKey,
checkout: &CheckoutStatus,
) -> bool {
if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user {
return false;
}
pub fn pr_proposes_checkout(
pr: &Event,
open: bool,
user: PublicKey,
checkout: &CheckoutStatus,
) -> bool {
if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user {
return false;
let branch_matches = pr
.tags
.iter()
.find(|t| t.kind() == "branch-name")
.and_then(|t| t.content())
.is_some_and(|name| name == checkout.branch);
// A renamed branch falls back to the proposed tip commit.
let tip_matches = pr
.tags
.iter()
.find(|t| t.kind() == "c")
.and_then(|t| t.content())
.is_some_and(|tip| tip == checkout.head);
branch_matches || tip_matches
}
let branch_matches = pr
.tags
.iter()
.find(|t| t.kind() == "branch-name")
.and_then(|t| t.content())
.is_some_and(|name| name == checkout.branch);
// A renamed branch falls back to the proposed tip commit.
let tip_matches = pr
.tags
.iter()
.find(|t| t.kind() == "c")
.and_then(|t| t.content())
.is_some_and(|tip| tip == checkout.head);
branch_matches || tip_matches
}
#[cfg(test)]
mod tests {
use std::process::Command;
use signed_core::{RepoAddr, repo_addr};
use super::*;
#[test]
fn same_repo_url_ignores_the_transport_scheme() {
// grasp announce vs https origin, with and without `.git`.
assert!(same_repo_url(
"grasp://relay.ngit.dev/npub1test/repo",
"https://relay.ngit.dev/npub1test/repo.git"
@@ -730,7 +665,6 @@ mod tests {
"ws://localhost:8080/npub1test/repo",
"http://localhost:8080/npub1test/repo"
));
// The port and the path matter.
assert!(!same_repo_url(
"wss://localhost:8081/npub1test/repo",
"wss://localhost:8080/npub1test/repo"
@@ -739,113 +673,15 @@ mod tests {
"wss://host/npub1test/repo",
"wss://host/npub1other/repo"
));
// Unparseable URLs compare literally.
assert!(same_repo_url("/local/path", "/local/path"));
assert!(!same_repo_url("/local/path", "/local/other"));
}
fn remembered(path: &str, id: &str, last_used: u64) -> Remembered {
Remembered {
path: PathBuf::from(path),
addr: addr(id),
last_used,
}
}
fn scanned(
path: &str,
origin: Option<&str>,
root: Option<&str>,
) -> (PathBuf, Option<String>, Option<String>) {
(
PathBuf::from(path),
origin.map(str::to_owned),
root.map(str::to_owned),
)
}
const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001";
fn owner() -> PublicKey {
Keys::new(SecretKey::from_hex(KEY).expect("secret")).public_key()
}
fn addr(id: &str) -> RepoAddr {
repo_addr(owner(), id)
}
/// Build one announcement by the fixed test owner.
/// Takes `clone` URLs and an EUC.
fn announcement(id: &str, clones: &[&str], euc: Option<&str>) -> Announcement {
let keys = Keys::new(SecretKey::from_hex(KEY).expect("secret"));
let mut tags = vec![Tag::parse(vec!["d", id]).expect("tag")];
for url in clones {
tags.push(Tag::parse(vec!["clone", *url]).expect("tag"));
}
if let Some(euc) = euc {
tags.push(Tag::parse(vec!["r", euc, "euc"]).expect("tag"));
}
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(tags)
.finalize(&keys)
.expect("signed");
Announcement::from_event(&event).expect("parsed")
}
#[test]
fn resolve_orders_remembered_freshest_first() {
let announcements = vec![announcement("repo", &[], None)];
let base = addr("repo");
let resolved = resolve_associations(
&[
remembered("/old", "repo", 100),
remembered("/fresh", "repo", 200),
remembered("/other", "unrelated", 300),
],
&[],
&announcements,
);
let paths = resolved.get(&base).expect("associations");
assert_eq!(paths, &vec![PathBuf::from("/fresh"), PathBuf::from("/old")]);
// Records for repositories without announcements stay inert.
assert_eq!(resolved.len(), 2);
}
#[test]
fn resolve_deduplicates_paths_remembering_first() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let announcements = vec![announcement(
"repo",
&["https://host/npub1x/repo.git"],
Some(euc),
)];
let base = addr("repo");
// The same path is both remembered and scanned, its origin matches.
// The remembered occurrence wins and the path is listed once.
let resolved = resolve_associations(
&[remembered("/shared", "repo", 100)],
&[
scanned("/shared", Some("https://host/npub1x/repo"), None),
scanned("/scanned-only", Some("https://host/npub1x/repo.git"), None),
],
&announcements,
);
let paths = resolved.get(&base).expect("associations");
assert_eq!(
paths,
&vec![PathBuf::from("/shared"), PathBuf::from("/scanned-only")]
);
}
#[test]
fn checkout_status_reports_ahead_branches_only() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
let _initial = signed_git::init_repository(&path, "My Repo", "").expect("init");
let _initial = Repo::init(&path, "My Repo", "").expect("init");
let run = |args: &[&str]| {
let status = Command::new("git")
.current_dir(&path)
@@ -864,35 +700,28 @@ mod tests {
run(&["commit", "-m", message]);
};
// A feature branch ahead of main, ready to contribute.
run(&["checkout", "-b", "feature"]);
std::fs::write(path.join("feature.txt"), "x\n").expect("write");
commit("feature work");
let status = checkout_status(&path, Some("main")).expect("status");
let status = CheckoutsStore::checkout_status(&path, Some("main")).expect("status");
assert_eq!(status.branch, "feature");
assert_eq!(status.base, "main");
assert_eq!(status.ahead, 1);
assert_eq!(status.head.len(), 40);
// Dirty worktrees are never suggested.
std::fs::write(path.join("uncommitted.txt"), "y\n").expect("write");
assert!(checkout_status(&path, Some("main")).is_none());
assert!(CheckoutsStore::checkout_status(&path, Some("main")).is_none());
run(&["checkout", "--", "."]);
// Even on main, nothing to propose.
run(&["checkout", "main"]);
assert_eq!(checkout_status(&path, Some("main")), None);
assert_eq!(CheckoutsStore::checkout_status(&path, Some("main")), None);
}
#[test]
fn checkout_push_status_counts_unpushed_commits_only() {
// The `grasp remote` is a plain repository the checkout clones from.
// Its origin URL is a local path, so the whole cycle runs offline.
// Git refuses pushes to a checked-out branch by default.
// Act like a grasp server and allow them.
let dir = tempfile::tempdir().expect("tempdir");
let remote = dir.path().join("remote");
signed_git::init_repository(&remote, "My Repo", "").expect("init");
Repo::init(&remote, "My Repo", "").expect("init");
let config = Command::new("git")
.args(["config", "receive.denyCurrentBranch", "ignore"])
.current_dir(&remote)
@@ -927,29 +756,23 @@ mod tests {
};
// A fresh clone has nothing to push.
assert_eq!(checkout_push_status(&checkout, true), None);
assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None);
// One local commit, ready to push, counted against the remote.
std::fs::write(checkout.join("work.txt"), "x\n").expect("write");
run(&["add", "-A"]);
run(&["commit", "-m", "local work"]);
let status = checkout_push_status(&checkout, true).expect("status");
let status = CheckoutsStore::checkout_push_status(&checkout, true).expect("status");
assert_eq!(status.branch, "main");
assert_eq!(status.base, "refs/remotes/origin/main");
assert_eq!(status.ahead, 1);
assert_eq!(status.head.len(), 40);
// The local-only pass reads the tracking refs, no fetch needed:
// a commit lands locally long before the remote is reconciled.
let local = checkout_push_status(&checkout, false).expect("local status");
let local = CheckoutsStore::checkout_push_status(&checkout, false).expect("local status");
assert_eq!(local.ahead, 1);
// After the push the same commit is on the remote, idle again.
run(&["push", "origin", "main"]);
assert_eq!(checkout_push_status(&checkout, true), None);
assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None);
// A commit made by someone else on the remote must not count as local work.
// It is behind, not ahead.
let remote_run = |args: &[&str]| {
let status = Command::new("git")
.current_dir(&remote)
@@ -965,6 +788,6 @@ mod tests {
std::fs::write(remote.join("other.txt"), "y\n").expect("write");
remote_run(&["add", "-A"]);
remote_run(&["commit", "-m", "remote work"]);
assert_eq!(checkout_push_status(&checkout, true), None);
assert_eq!(CheckoutsStore::checkout_push_status(&checkout, true), None);
}
}
+26 -26
View File
@@ -2,40 +2,40 @@ use std::path::PathBuf;
use std::sync::OnceLock;
use anyhow::Result;
use gix::Repository;
use nostr::prelude::Url;
use signed_core::RepoAddr;
use signed_git::GitCache;
use signed_git::{GitCache, Repo};
static GIT_CACHE: OnceLock<GitCache> = OnceLock::new();
fn git_cache() -> &'static GitCache {
GIT_CACHE
.get()
.expect("git cache is initialized by signed_state::init")
}
pub struct Mirrors;
/// The root directory of the repository mirrors.
pub(crate) fn repo_mirror_root() -> PathBuf {
git_cache().root().to_path_buf()
}
impl Mirrors {
pub fn install(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");
}
}
/// The on-disk path of the mirror of `addr`.
pub fn repo_mirror_path(addr: &RepoAddr) -> PathBuf {
git_cache().repo_path(addr)
}
fn cache() -> &'static GitCache {
GIT_CACHE
.get()
.expect("git cache is initialized by signed_state::init")
}
/// Open the mirror of `addr`, if it has been cloned.
pub fn open_repo_mirror(addr: &RepoAddr) -> Result<Option<Repository>> {
git_cache().open(addr)
}
pub(crate) fn root() -> PathBuf {
Self::cache().root().to_path_buf()
}
/// 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 path(addr: &RepoAddr) -> PathBuf {
Self::cache().repo_path(addr)
}
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");
pub fn open(addr: &RepoAddr) -> Result<Option<Repo>> {
Self::cache().open(addr)
}
pub fn ensure(addr: &RepoAddr, clone_urls: &[Url]) -> Result<Repo> {
Self::cache().ensure_clone(addr, clone_urls)
}
}
+131 -160
View File
@@ -3,11 +3,10 @@ use std::collections::{HashMap, HashSet};
use anyhow::Error;
use gpui::{AppContext, Context, Task};
use nostr_sdk::prelude::*;
use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox};
use signed_core::{Deletions, Filters, GitEvent, InboxItem, InboxReadState, inbox};
use crate::backend::Backend;
/// The user's persisted inbox read state.
#[derive(Default)]
pub struct Inbox {
state: InboxReadState,
@@ -15,7 +14,6 @@ pub struct Inbox {
}
impl Inbox {
/// The current read/archive cutoffs.
pub fn state(&self) -> &InboxReadState {
&self.state
}
@@ -24,41 +22,6 @@ impl Inbox {
self.loaded
}
pub fn mark_read(
&mut self,
group: &[Event],
all: &[Event],
me: PublicKey,
cx: &mut Context<Self>,
) {
for event in group {
self.state.mark_read(event);
}
self.state.advance_read(all, me, Timestamp::now());
self.persist(cx);
cx.notify();
}
/// Archived events are always read too.
pub fn mark_archived(
&mut self,
group: &[Event],
all: &[Event],
me: PublicKey,
cx: &mut Context<Self>,
) {
for event in group {
self.state.mark_archived(event);
self.state.mark_read(event);
}
let now = Timestamp::now();
self.state.advance_archived(all, me, now);
self.state.advance_read(all, me, now);
self.persist(cx);
cx.notify();
}
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context<Self>) {
self.state.mark_all_read(all, me, Timestamp::now());
self.persist(cx);
@@ -71,7 +34,7 @@ impl Inbox {
cx.notify();
let backend = Backend::global(cx);
let work = cx.background_spawn(async move { load_state(&client, me).await });
let work = cx.background_spawn(async move { Self::load_state(&client, me).await });
cx.spawn(async move |this, cx| {
let loaded = work.await;
@@ -102,7 +65,6 @@ impl Inbox {
cx.notify();
}
/// Sign the state with a random key and store it locally.
fn persist(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let (me, client) = {
@@ -117,7 +79,7 @@ impl Inbox {
let state = self.state.clone();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
if let Err(error) = save_state(&client, me, &state).await {
if let Err(error) = Self::save_state(&client, me, &state).await {
log::warn!("failed to save inbox state: {error}");
}
Ok(())
@@ -125,135 +87,144 @@ impl Inbox {
task.detach();
}
}
pub async fn query_inbox(
client: &Client,
me: PublicKey,
state: &InboxReadState,
) -> Result<(Vec<InboxItem>, usize), Error> {
let deletion_events = client.database().query(filters::deletions()).await?;
let deletions = Deletions::from_events(deletion_events);
pub async fn query(
client: &Client,
me: PublicKey,
state: &InboxReadState,
) -> Result<(Vec<InboxItem>, usize), Error> {
let deletion_events = client.database().query(Filters::deletions()).await?;
let deletions = Deletions::from_events(deletion_events);
let (notification_events, mut by_id) = fetch_notifications(client, me, &deletions).await?;
let (notification_events, mut by_id) =
Self::fetch_notifications(client, me, &deletions).await?;
let mut activity = Vec::new();
for event in client
.database()
.query(filters::authored_activity(me))
.await?
{
if deletions.is_deleted(&event) || !filters::is_git_activity(&event) {
continue;
}
by_id.entry(event.id).or_insert_with(|| event.clone());
activity.push(event);
}
let items = inbox::group(notification_events, activity, me, state, &|id| {
by_id.get(&id).cloned()
});
let unread_count = items.iter().filter(|item| item.is_unread()).count();
Ok((items, unread_count))
}
/// `d` tag identifying the inbox state event of `me`.
fn inbox_state_d_tag(me: PublicKey) -> String {
format!("signed-inbox-state:{}", me.to_hex())
}
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(inbox_state_d_tag(me));
let events = client.database().query(filter).await?;
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
return Ok(None);
};
match serde_json::from_str(&event.content) {
Ok(state) => Ok(Some(state)),
Err(error) => {
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
Ok(None)
}
}
}
/// Sign with a random key and store locally.
async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> {
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
.tags([Tag::identifier(inbox_state_d_tag(me))])
.finalize(&Keys::generate())?;
client.database().save_event(&event).await?;
Ok(())
}
/// Notification events and a lookup of every ancestor they reference.
async fn fetch_notifications(
client: &Client,
me: PublicKey,
deletions: &Deletions,
) -> Result<(Vec<Event>, HashMap<EventId, Event>), Error> {
let mut notifications: Vec<Event> = Vec::new();
let mut by_id: HashMap<EventId, Event> = HashMap::new();
for filter in filters::notifications(me) {
for event in client.database().query(filter).await? {
if deletions.is_deleted(&event) {
continue;
}
if by_id.insert(event.id, event.clone()).is_none() {
notifications.push(event);
}
}
}
let mut pending: Vec<EventId> = notifications.iter().flat_map(event_references).collect();
let mut seen: HashSet<EventId> = by_id.keys().copied().collect();
loop {
pending.retain(|id| seen.insert(*id));
if pending.is_empty() {
break;
}
let ancestors = client
let mut activity = Vec::new();
for event in client
.database()
.query(Filter::new().ids(pending.iter().copied()))
.await?;
let mut next = Vec::new();
for event in ancestors {
if deletions.is_deleted(&event) {
.query(Filters::authored_activity(me))
.await?
{
if deletions.is_deleted(&event) || !event.is_git_activity() {
continue;
}
next.extend(event_references(&event).filter(|id| !seen.contains(id)));
by_id.entry(event.id).or_insert(event);
by_id.entry(event.id).or_insert_with(|| event.clone());
activity.push(event);
}
pending = next;
let items = inbox::group(notification_events, activity, me, state, &|id| {
by_id.get(&id).cloned()
});
let unread_count = items.iter().filter(|item| item.is_unread()).count();
Ok((items, unread_count))
}
}
impl Inbox {
fn inbox_state_d_tag(me: PublicKey) -> String {
format!("signed-inbox-state:{}", me.to_hex())
}
Ok((notifications, by_id))
}
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(Self::inbox_state_d_tag(me));
/// Event ids referenced by `event` through its `e` and `E` tags.
fn event_references(event: &Event) -> impl Iterator<Item = EventId> + '_ {
event.tags.iter().filter_map(|tag| {
if tag.kind() != "e" && tag.kind() != "E" {
return None;
let events = client.database().query(filter).await?;
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
return Ok(None);
};
match serde_json::from_str(&event.content) {
Ok(state) => Ok(Some(state)),
Err(error) => {
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
Ok(None)
}
}
tag.content()
.and_then(|content| EventId::from_hex(content).ok())
})
}
// Signed with a random key: the state is local-only, authorship does not
// matter and no key material needs to be kept.
async fn save_state(
client: &Client,
me: PublicKey,
state: &InboxReadState,
) -> Result<(), Error> {
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
.tags([Tag::identifier(Self::inbox_state_d_tag(me))])
.finalize(&Keys::generate())?;
client.database().save_event(&event).await?;
Ok(())
}
async fn fetch_notifications(
client: &Client,
me: PublicKey,
deletions: &Deletions,
) -> Result<(Vec<Event>, HashMap<EventId, Event>), Error> {
let mut notifications: Vec<Event> = Vec::new();
let mut by_id: HashMap<EventId, Event> = HashMap::new();
for filter in Filters::notifications(me) {
for event in client.database().query(filter).await? {
if deletions.is_deleted(&event) {
continue;
}
if by_id.insert(event.id, event.clone()).is_none() {
notifications.push(event);
}
}
}
let mut pending: Vec<EventId> = notifications
.iter()
.flat_map(Self::event_references)
.collect();
let mut seen: HashSet<EventId> = by_id.keys().copied().collect();
loop {
pending.retain(|id| seen.insert(*id));
if pending.is_empty() {
break;
}
let ancestors = client
.database()
.query(Filter::new().ids(pending.iter().copied()))
.await?;
let mut next = Vec::new();
for event in ancestors {
if deletions.is_deleted(&event) {
continue;
}
next.extend(Self::event_references(&event).filter(|id| !seen.contains(id)));
by_id.entry(event.id).or_insert(event);
}
pending = next;
}
Ok((notifications, by_id))
}
fn event_references(event: &Event) -> impl Iterator<Item = EventId> + '_ {
event.tags.iter().filter_map(|tag| {
if tag.kind() != "e" && tag.kind() != "E" {
return None;
}
tag.content()
.and_then(|content| EventId::from_hex(content).ok())
})
}
}
+18 -40
View File
@@ -1,31 +1,33 @@
mod backend;
mod bootstrap;
mod checkouts;
mod git_store;
mod inbox;
mod local_repos;
mod profile;
mod push;
mod refresh;
mod repo;
mod repos;
use std::path::{Path, PathBuf};
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
use git_store::set_git_cache;
pub use git_store::{ensure_repo_mirror, open_repo_mirror, repo_mirror_path};
pub use backend::{Backend, BackendEvent};
pub use bootstrap::user_grasp_list_servers;
pub use checkouts::{CheckoutStatus, CheckoutsStore};
pub use git_store::Mirrors;
use gpui::{App, AppContext};
pub use inbox::{Inbox, query_inbox};
pub use local_repos::{LocalReposStore, ResolvedLocalRepo, local_repo_addr, resolve_local_repos};
pub use inbox::Inbox;
pub use local_repos::{LocalReposStore, ResolvedLocalRepo};
pub use nostr_sdk::prelude::Timestamp;
pub use profile::{Profile, ProfileStore};
pub use push::{GraspServer, PushOutcome};
pub use refresh::{RefreshGate, RefreshRequest};
pub use repo::RepoStore;
pub use repos::{RepoActivityCounts, RepoListStore};
pub use signed_git::{GraspSignals, LocalRepo, Nip34Binding, Nip34Kind};
use signed_nostr::new_backend;
pub use repos::RepoListStore;
pub use signed_git::{Nip34Binding, Nip34Kind};
use signed_nostr::NostrBackend;
#[cfg(not(target_arch = "wasm32"))]
pub fn init(
db_path: impl AsRef<Path>,
repos_root: impl Into<PathBuf>,
@@ -35,41 +37,17 @@ 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)
.await
.expect("failed to initialize nostr backend")
});
let backend = cx
.foreground_executor()
.block_on(async move { NostrBackend::open(db_path.as_ref()).await });
let backend = backend.expect("failed to initialize nostr backend");
let (client, signer) = (backend.client, backend.signer);
// Set Git cache for the repos root
set_git_cache(repos_root);
Mirrors::install(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);
// 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);
}
/// Initialize the backend with an in-memory database on wasm.
#[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);
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
}
+35 -96
View File
@@ -4,17 +4,15 @@ use std::sync::Arc;
use anyhow::Error;
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
use signed_core::{Announcement, RepoAddr, repo_addr};
use signed_core::{Announcement, RepoAddr};
use signed_git::{LocalRepo, Nip34Binding, find_git_repos};
struct GlobalLocalReposStore(Entity<LocalReposStore>);
impl Global for GlobalLocalReposStore {}
/// Store of the git repositories discovered under a set of scan paths.
pub struct LocalReposStore {
pub roots: Arc<Vec<PathBuf>>,
/// Git repositories discovered under [`Self::roots`], sorted by path.
pub repos: Arc<Vec<LocalRepo>>,
pub scanning: bool,
scan_dirty: bool,
@@ -45,7 +43,6 @@ impl LocalReposStore {
}
}
/// Forget a repository that has just been published to NIP-34.
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
self.repos = Arc::new(
self.repos
@@ -94,7 +91,6 @@ impl LocalReposStore {
dirty
})?;
// Scans requested while this one ran are coalesced into one follow-up scan.
if again {
this.update(cx, |this, cx| this.rescan(cx))?;
}
@@ -106,28 +102,22 @@ impl LocalReposStore {
}
}
/// The NIP-34 coordinate a repository's detection resolved, when both the owner
/// and the identifier were recovered.
pub fn local_repo_addr(repo: &LocalRepo) -> Option<RepoAddr> {
let binding = repo.nip34.as_ref()?;
let owner = binding.owner?;
let identifier = binding.identifier.as_deref()?;
Some(repo_addr(owner, identifier))
Some(RepoAddr::new(owner, identifier))
}
/// A scanned repository resolved against the known announcements.
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedLocalRepo {
pub path: PathBuf,
/// `None` for a plain repository.
pub nip34: Option<Nip34Binding>,
/// The known announcement this repository is bound to, when one matched.
pub announcement: Option<Announcement>,
}
impl ResolvedLocalRepo {
/// The repository's directory name, or `Untitled` when the path has none.
pub fn name(&self) -> SharedString {
self.path
.file_name()
@@ -136,41 +126,42 @@ impl ResolvedLocalRepo {
}
}
/// Resolve the scanned repositories against the known announcements.
pub fn resolve_local_repos(
repos: &[LocalRepo],
known: &[Announcement],
own: &[Announcement],
) -> Vec<ResolvedLocalRepo> {
let shown: HashSet<RepoAddr> = own.iter().map(Announcement::addr).collect();
impl LocalReposStore {
pub fn resolve(
repos: &[LocalRepo],
known: &[Announcement],
own: &[Announcement],
) -> Vec<ResolvedLocalRepo> {
let shown: HashSet<RepoAddr> = own.iter().map(Announcement::addr).collect();
repos
.iter()
.filter_map(|repo| {
let addr = local_repo_addr(repo);
repos
.iter()
.filter_map(|repo| {
let addr = local_repo_addr(repo);
if let Some(addr) = &addr
&& shown.contains(addr)
{
return None;
}
if let Some(addr) = &addr
&& shown.contains(addr)
{
return None;
}
let announcement = addr
.as_ref()
.and_then(|addr| {
known
.iter()
.find(|announcement| announcement.addr() == *addr)
let announcement = addr
.as_ref()
.and_then(|addr| {
known
.iter()
.find(|announcement| announcement.addr() == *addr)
})
.cloned();
Some(ResolvedLocalRepo {
path: repo.path.clone(),
nip34: repo.nip34.clone(),
announcement,
})
.cloned();
Some(ResolvedLocalRepo {
path: repo.path.clone(),
nip34: repo.nip34.clone(),
announcement,
})
})
.collect()
.collect()
}
}
#[cfg(test)]
@@ -222,7 +213,7 @@ mod tests {
let repo = bound(KEY, "mine");
let own = std::slice::from_ref(&own);
assert!(resolve_local_repos(&[repo], own, own).is_empty());
assert!(LocalReposStore::resolve(&[repo], own, own).is_empty());
}
#[test]
@@ -230,61 +221,9 @@ mod tests {
let known = announcement(OTHER_KEY, "theirs");
let repo = bound(OTHER_KEY, "theirs");
let resolved = resolve_local_repos(&[repo], std::slice::from_ref(&known), &[]);
let resolved = LocalReposStore::resolve(&[repo], std::slice::from_ref(&known), &[]);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].announcement.as_ref(), Some(&known));
}
#[test]
fn an_unmatched_repository_keeps_its_binding() {
let repo = bound(KEY, "unlisted");
let resolved = resolve_local_repos(&[repo], &[], &[]);
assert_eq!(resolved.len(), 1);
assert!(resolved[0].announcement.is_none());
assert_eq!(
resolved[0].nip34.as_ref().map(|binding| binding.kind),
Some(Nip34Kind::Initialized)
);
}
#[test]
fn a_plain_repository_is_kept_without_a_binding() {
let repo = LocalRepo {
path: PathBuf::from("plain"),
nip34: None,
};
let resolved = resolve_local_repos(&[repo], &[], &[]);
assert_eq!(resolved.len(), 1);
assert!(resolved[0].nip34.is_none());
assert!(resolved[0].announcement.is_none());
}
#[test]
fn the_name_is_the_directory_name() {
let repo = LocalRepo {
path: PathBuf::from("/tmp/my-repo"),
nip34: None,
};
let resolved = resolve_local_repos(&[repo], &[], &[]);
assert_eq!(resolved[0].name(), SharedString::from("my-repo"));
}
#[test]
fn a_path_without_a_directory_name_is_untitled() {
let repo = LocalRepo {
path: PathBuf::from("/"),
nip34: None,
};
let resolved = resolve_local_repos(&[repo], &[], &[]);
assert_eq!(resolved[0].name(), SharedString::from("Untitled"));
}
}
+4 -17
View File
@@ -10,16 +10,13 @@ use gpui::{
use nostr_sdk::prelude::*;
use utils::shorten_pubkey;
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
use crate::backend::{Backend, BackendEvent};
use crate::bootstrap::sync_bootstrap_only;
/// How long to wait for more requests before firing a batched fetch.
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
/// Max authors per profile request, keeping each filter within relay limits.
const REQUEST_CHUNK: usize = 100;
/// Recent profiles prefetched at startup and read back from the cache.
const WARM_LIMIT: usize = 500;
/// A user profile as plain data for the UI, from the kind-0 metadata.
#[derive(Debug, Clone)]
pub struct Profile {
public_key: PublicKey,
@@ -42,7 +39,7 @@ impl Profile {
&self.metadata
}
/// Display name, falling back to `name`, then a shortened npub.
// Falls back to `name`, then a shortened npub.
pub fn name(&self) -> SharedString {
if let Some(display_name) = self.metadata.display_name.as_ref()
&& !display_name.is_empty()
@@ -68,14 +65,9 @@ impl Profile {
}
}
/// Global profile cache.
///
/// Profiles are fetched in batches and kept as plain data.
pub struct ProfileStore {
profiles: HashMap<PublicKey, Profile>,
/// Public keys requested this session, main thread only.
seen: RefCell<HashSet<PublicKey>>,
/// Sender for queuing fetch requests, batched by a background task.
sender: Sender<PublicKey>,
_subscription: Subscription,
}
@@ -125,9 +117,7 @@ impl ProfileStore {
}
}
/// Get a profile.
///
/// Returns a placeholder with default metadata. Queues a fetch when the profile is not cached yet.
// Returns a placeholder until fetched; queues a fetch when uncached.
pub fn get(&self, public_key: &PublicKey) -> Profile {
if let Some(profile) = self.profiles.get(public_key) {
return profile.clone();
@@ -178,7 +168,6 @@ impl ProfileStore {
.detach();
}
/// Re-read the latest metadata of `authors` from the local database in one query.
fn apply_authors(&mut self, authors: Vec<PublicKey>, cx: &mut Context<Self>) {
if authors.is_empty() {
return;
@@ -231,13 +220,11 @@ impl ProfileStore {
.detach();
}
/// Re-read the latest metadata of every requested author from the local database.
fn apply_seen(&mut self, cx: &mut Context<Self>) {
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
self.apply_authors(authors, cx);
}
/// Fetch metadata for requested authors in batches, debounced to collect requests.
async fn handle_requests(
this: WeakEntity<ProfileStore>,
client: &Client,
+528
View File
@@ -0,0 +1,528 @@
use std::path::Path;
use std::time::Duration;
use anyhow::{Error, bail};
use gpui::BackgroundExecutor;
use nostr::event::IntoEventBuilder;
use nostr::prelude::Url;
use nostr_sdk::prelude::*;
use signed_core::RepoState;
use signed_git::Repo;
use signed_nostr::UniversalSigner;
pub(crate) const GRASP_PUSH_ATTEMPTS: usize = 3;
const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1);
/// `ws://` grasp servers, like ngit, use `http(s)://<host>`.
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
let parsed = Url::parse(relay.as_str()).ok()?;
let host = parsed.host_str()?;
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
// `ws://` grasp servers, e.g. local dev relays, speak plain HTTP.
let scheme = if relay.scheme().is_secure() {
"https"
} else {
"http"
};
Some(format!("{scheme}://{host}{port}"))
}
pub(crate) fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url> {
let base = grasp_base_url(relay)?;
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
}
/// GRASP-06 contributor namespace URL of a pull request tip.
pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String {
format!("{base_url}/prs/{npub}/{repo_id}.git")
}
/// The author's GRASP-06 `/prs/` URLs come first.
pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Vec<Url> {
let mut seen = std::collections::HashSet::new();
let mut urls = Vec::new();
for url in prs_urls.into_iter().chain(base_clone_urls) {
if seen.insert(url.to_string()) {
urls.push(url);
}
}
urls
}
#[derive(Debug, Clone)]
pub struct GraspServer {
relay: RelayUrl,
reason: Option<String>,
}
impl GraspServer {
fn ok(relay: RelayUrl) -> Self {
Self {
relay,
reason: None,
}
}
fn failed(relay: RelayUrl, reason: impl Into<String>) -> Self {
Self {
relay,
reason: Some(reason.into()),
}
}
pub fn relay(&self) -> &RelayUrl {
&self.relay
}
pub fn reason(&self) -> Option<&str> {
self.reason.as_deref()
}
}
#[derive(Debug, Clone, Default)]
pub struct PushOutcome {
pub servers: Vec<GraspServer>,
/// The newest state event a grasp relay accepted for this push.
pub state_event: Option<Event>,
}
impl PushOutcome {
pub fn accepted(&self) -> usize {
self.servers
.iter()
.filter(|server| server.reason.is_none())
.count()
}
fn failing(&self) -> impl Iterator<Item = &GraspServer> {
self.servers.iter().filter(|server| server.reason.is_some())
}
pub fn failure_summary(&self) -> String {
self.failing()
.map(|server| {
let reason =
utils::flatten_whitespace(server.reason.as_deref().unwrap_or("unknown error"));
format!("{}: {reason}", server.relay)
})
.collect::<Vec<_>>()
.join("; ")
}
// `None` when every server accepted the push or nothing was pushed.
pub fn partial_warning(&self) -> Option<String> {
let accepted = self.accepted();
if self.servers.is_empty() || accepted == self.servers.len() {
return None;
}
Some(format!(
"Pushed to {accepted} of {} grasp servers: {}. Republish to sync.",
self.servers.len(),
self.failure_summary()
))
}
}
// Reasons a push attempt should be retried with a freshly staged state event
// and a fresh git advertisement. Two families are retried:
//
// - **Purgatory denials**: the grasp server sends these when the state event
// for the push has not reached its purgatory yet. Re-staging a fresh event
// resolves them.
// - **Stale advertisement races**: `git receive-pack` compares each ref
// update against the value it advertised when the push started. The grasp
// server's own background sync can move a ref in between - typically by
// aligning the repository to a parked state event once the objects of an
// earlier attempt land - so the compare-and-swap fails with `cannot lock
// ref` / `incorrect old value provided`. A retry against the fresh
// advertisement converges, and when the race is lost the pushed data is
// usually already on the server (see `is_stale_advertisement_race` and
// the convergence probe in `push_staged_to_grasps`).
//
// Other rejections are not retried.
fn is_transient_grasp_denial(stderr: &str) -> bool {
let error = stderr.to_lowercase();
[
"no state events in purgatory",
"no matching state event",
"doesn't match push",
"none from authorized publishers",
"no repository announcement found",
"cannot lock ref",
"incorrect old value provided",
]
.iter()
.any(|marker| error.contains(marker))
}
// A push rejected because `git receive-pack`'s compare-and-swap lost to the
// grasp server's own background ref alignment: the ref moved between this
// push's advertisement and its ref transaction (`cannot lock ref ... is at
// ... but expected ...` / `incorrect old value provided`). The pushed data
// is usually already on the server by then.
fn is_stale_advertisement_race(stderr: &str) -> bool {
let error = stderr.to_lowercase();
error.contains("cannot lock ref") || error.contains("incorrect old value provided")
}
// All staged events carry the same refs; the newest timestamp wins on the relays.
fn keep_newest(state_event: &mut Option<Event>, event: Event) {
if state_event
.as_ref()
.is_none_or(|current| event.created_at > current.created_at)
{
*state_event = Some(event);
}
}
#[derive(Clone)]
pub(crate) struct GraspPush {
client: Client,
signer: UniversalSigner,
}
impl GraspPush {
pub(crate) fn new(client: Client, signer: UniversalSigner) -> Self {
Self { client, signer }
}
pub(crate) fn require_relay_accepted(
output: SendEventOutput,
event: Event,
) -> Result<Event, Error> {
if output.success.is_empty() && !output.failed.is_empty() {
let reasons = output
.failed
.values()
.cloned()
.collect::<Vec<String>>()
.join(", ");
bail!("event not accepted by any relay: {reasons}");
}
Ok(event)
}
// A relay hiccup should not block sign-up, so failures are only logged.
pub(crate) async fn publish_best_effort(&self, builder: EventBuilder) {
let result: Result<(), Error> = async {
let event = builder.finalize_async(&self.signer).await?;
let output = self.client.send_event(&event).broadcast().await?;
Self::require_relay_accepted(output, event)?;
Ok(())
}
.await;
if let Err(e) = result {
log::warn!("failed to publish identity bootstrap event: {e}");
}
}
pub(crate) async fn publish_one(&self, builder: EventBuilder) -> Result<Event, Error> {
let event = builder.finalize_async(&self.signer).await?;
self.send_accepted(event).await
}
pub(crate) async fn send_accepted(&self, event: Event) -> Result<Event, Error> {
let output = self.client.send_event(&event).broadcast().await?;
Self::require_relay_accepted(output, event)
}
pub(crate) async fn retract_event(&self, event: &Event) -> Result<(), Error> {
let builder = EventDeletionRequest::new()
.id(event.id)
.into_event_builder();
let deletion = builder.finalize_async(&self.signer).await?;
self.client.send_event(&deletion).broadcast().await?;
Ok(())
}
// Retries within the same second get the next second: a grasp relay
// treats a same-id resend as a duplicate and does not re-run its ingest,
// so an identical resend cannot re-park a state event lost from its purgatory.
async fn sign_state_event(
&self,
repo_id: &str,
refs: &[(String, String)],
head: Option<&str>,
last_created_at: u64,
) -> Result<(Event, u64), String> {
let now = Timestamp::now().as_secs();
let created_at = if now > last_created_at {
now
} else {
last_created_at + 1
};
let event = RepoState::build(repo_id, refs, head)
.custom_created_at(Timestamp::from_secs(created_at))
.finalize_async(&self.signer)
.await
.map_err(|e| format!("could not sign the state event: {e}"))?;
Ok((event, created_at))
}
// `Ok` only when the relay confirmed the event. On a grasp relay the
// accept parks the event in purgatory, which authorizes the paired git push.
async fn stage_event_on_relay(&self, relay: &RelayUrl, event: &Event) -> Result<(), String> {
self.client
.add_relay(relay)
.and_connect()
.await
.map_err(|e| format!("could not add relay {relay}: {e}"))?;
let output = self
.client
.send_event(event)
.to([relay.clone()])
.await
.map_err(|e| format!("could not send the state event to {relay}: {e}"))?;
if output.success.contains_key(relay) {
Ok(())
} else {
let reason = output
.failed
.get(relay)
.cloned()
.unwrap_or_else(|| "relay did not confirm the event".to_owned());
Err(reason)
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn push_staged_to_grasps(
&self,
repo_id: &str,
refs: &[(String, String)],
head: Option<&str>,
path: &Path,
owner: &str,
servers: &[RelayUrl],
executor: &BackgroundExecutor,
push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error>,
) -> PushOutcome {
let mut outcome = PushOutcome::default();
for relay in servers {
let Some(base) = grasp_base_url(relay) else {
outcome
.servers
.push(GraspServer::failed(relay.clone(), "no domain"));
continue;
};
let git_url = format!("{base}/{owner}/{repo_id}.git");
let mut reason = None;
let mut last_created_at = 0;
// For the convergence probe when every push attempt lost the
// stale-ref race.
let mut staged_event = None;
'server: for attempt in 1..=GRASP_PUSH_ATTEMPTS {
if attempt > 1 {
executor.timer(GRASP_RETRY_DELAY).await;
}
let (event, created_at) = match self
.sign_state_event(repo_id, refs, head, last_created_at)
.await
{
Ok(signed) => signed,
Err(e) => {
reason = Some(e);
break 'server;
}
};
last_created_at = created_at;
// A failed stage means the grasp never parked the state, so
// the git push would be denied anyway: skip it.
if let Err(e) = self.stage_event_on_relay(relay, &event).await {
// One retry absorbs a relay connect blip, on the first
// attempt only.
if attempt == 1 && self.stage_event_on_relay(relay, &event).await.is_ok() {
} else {
reason = Some(e);
break 'server;
}
}
staged_event = Some(event.clone());
match push(path, &base, owner, repo_id) {
Ok(()) => {
keep_newest(&mut outcome.state_event, event);
break 'server;
}
Err(e) => {
let text = e.to_string();
if attempt < GRASP_PUSH_ATTEMPTS && is_transient_grasp_denial(&text) {
reason = Some(text);
continue 'server;
}
reason = Some(text);
break 'server;
}
}
}
// The grasp's own background sync aligns refs to staged state
// events as soon as the objects land, which can beat every push
// attempt's compare-and-swap. When the last denial was that race
// the sync has usually finished by now: verify the advertised refs
// and accept the server when the pushed data is already there.
if let Some(last_reason) = &reason
&& is_stale_advertisement_race(last_reason)
&& Repo::open(path)
.and_then(|repo| repo.remote_has_refs(&git_url, refs))
.unwrap_or(false)
{
if let Some(event) = staged_event {
keep_newest(&mut outcome.state_event, event);
}
reason = None;
}
match reason {
Some(reason) => {
log::warn!("grasp push failed: {relay}: {reason}");
outcome
.servers
.push(GraspServer::failed(relay.clone(), reason));
}
None => outcome.servers.push(GraspServer::ok(relay.clone())),
}
}
outcome
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grasp_base_url_maps_schemes_like_ngit() {
let wss = RelayUrl::parse("wss://relay.ngit.dev").expect("url");
assert_eq!(
grasp_base_url(&wss).as_deref(),
Some("https://relay.ngit.dev")
);
let ws = RelayUrl::parse("ws://localhost:8080").expect("url");
assert_eq!(
grasp_base_url(&ws).as_deref(),
Some("http://localhost:8080")
);
}
#[test]
fn grasp_clone_url_matches_ngit_format() {
let relay = RelayUrl::parse("wss://gitnostr.com").expect("url");
let url = grasp_clone_url(&relay, "npub1test", "my-repo").expect("url");
assert_eq!(
url.to_string(),
"https://gitnostr.com/npub1test/my-repo.git"
);
}
#[test]
fn grasp06_prs_url_matches_ngit_format() {
assert_eq!(
grasp06_prs_url("https://relay.ngit.dev", "npub1author", "my-repo"),
"https://relay.ngit.dev/prs/npub1author/my-repo.git"
);
assert_eq!(
grasp06_prs_url("http://localhost:8080", "npub1author", "my-repo"),
"http://localhost:8080/prs/npub1author/my-repo.git"
);
}
#[test]
fn pr_clone_urls_orders_author_first_and_deduplicates() {
let prs = vec![
Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"),
Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"),
];
let base = vec![
Url::parse("https://a.example/npub1owner/repo.git").expect("url"),
Url::parse("https://b.example/npub1owner/repo.git").expect("url"),
Url::parse("https://b.example/npub1owner/repo.git").expect("url"),
];
let urls = pr_clone_urls(prs, base);
assert_eq!(
urls.iter().map(ToString::to_string).collect::<Vec<_>>(),
vec![
"https://a.example/prs/npub1me/repo.git",
"https://a.example/npub1owner/repo.git",
"https://b.example/npub1owner/repo.git",
]
);
}
#[test]
fn transient_grasp_denials_are_classified() {
let reported = "remote: ERR authorisation failed: No state events in purgatory\n\
fatal: the remote end hung up unexpectedly\n\
error: failed to push some refs to 'https://relay.ngit.dev/...git'";
assert!(is_transient_grasp_denial(reported));
assert!(is_transient_grasp_denial(
"remote: ERR authorisation failed: No matching state event found in purgatory"
));
assert!(is_transient_grasp_denial(
"remote: ERR authorisation failed: 1 state event in purgatory from authorized \
publisher but doesn't match push"
));
assert!(is_transient_grasp_denial(
"remote: ERR authorisation failed: 2 state events in purgatory but none from \
authorized publishers"
));
assert!(is_transient_grasp_denial(
"remote: ERR authorisation failed: No repository announcement found"
));
assert!(!is_transient_grasp_denial(
"remote: ERR authorisation failed: not a maintainer of this repository"
));
assert!(!is_transient_grasp_denial(
"fatal: unable to access 'https://relay.ngit.dev/...': The requested URL returned \
error: 403"
));
assert!(!is_transient_grasp_denial(
"fatal: unable to access 'https://relay.ngit.dev/...': Could not resolve host"
));
}
#[test]
fn stale_ref_races_are_retried() {
let reported = "remote: error: cannot lock ref 'refs/heads/main': is at \
cac2ac91b6f5fb8dfcb6962785babc6e65350cb3 but expected \
bc5e892aa84dc6240a5fbcd59367a4857d26f49b\n\
To https://relay.ngit.dev/npub1owner/signed-test.git\n\
! [remote rejected] main -> main (incorrect old value provided)\n\
error: failed to push some refs to 'https://relay.ngit.dev/npub1owner/signed-test.git'";
assert!(is_transient_grasp_denial(reported));
assert!(is_stale_advertisement_race(reported));
assert!(is_stale_advertisement_race(
"cannot lock ref 'refs/heads/main'"
));
assert!(is_stale_advertisement_race(
"! [remote rejected] main -> main (incorrect old value provided)"
));
assert!(!is_stale_advertisement_race("No state events in purgatory"));
assert!(!is_transient_grasp_denial(
" ! [rejected] main -> main (non-fast-forward)"
));
}
}
+1 -50
View File
@@ -1,4 +1,3 @@
/// Refresh coalescing shared by the event stores.
#[derive(Debug, Default)]
pub struct RefreshGate {
running: bool,
@@ -7,9 +6,7 @@ pub struct RefreshGate {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshRequest {
/// No run covers the request, start one now.
Schedule,
/// A run is in flight and covers the request, fold it into a follow-up.
Fold,
}
@@ -31,59 +28,13 @@ impl RefreshGate {
self.running = true;
}
/// The run ended. Whether a request arrived while it ran.
pub fn finish(&mut self) -> bool {
self.running = false;
std::mem::take(&mut self.dirty)
}
/// The run was abandoned, e.g. on error. Pending follow-up requests survive.
// Pending follow-up requests survive an abandoned run.
pub fn abort(&mut self) {
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());
}
}
File diff suppressed because it is too large Load Diff
+21 -41
View File
@@ -5,7 +5,7 @@ use std::time::Duration;
use anyhow::Error;
use gpui::{App, AppContext, Context, Entity, Global, Subscription};
use nostr_sdk::prelude::*;
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
use signed_core::{Announcement, Deletions, Filters, RepoAddr, filters};
use crate::backend::{Backend, BackendEvent};
use crate::refresh::{RefreshGate, RefreshRequest};
@@ -17,37 +17,23 @@ struct GlobalRepoListStore(Entity<RepoListStore>);
impl Global for GlobalRepoListStore {}
/// NIP-34 activity event counts per repository, ranking the explore list by popularity.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RepoActivityCounts {
/// Root `30611` issue events addressed to the repository.
pub issues: u32,
/// Root `3063` pull request events addressed to the repository.
///
/// PR updates are not new PRs and do not count.
pub pull_requests: u32,
/// `1617` patch events addressed to the repository.
pub commits: u32,
}
impl RepoActivityCounts {
/// Total issues, pull requests and commits, the popularity ranking key.
pub fn score(self) -> u32 {
self.issues + self.pull_requests + self.commits
}
}
/// Store listing the discovered repository announcements, newest first.
pub struct RepoListStore {
/// Shared so views can clone the list per frame without a deep copy.
pub announcements: Arc<Vec<Announcement>>,
/// Latest known activity timestamp per repository.
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
/// Issues, pull requests and commits per repository.
///
/// Used for the Popular ranking of the explore list.
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
/// Own repositories whose state events were fetched from their announced relays.
state_synced_repos: HashSet<RepoAddr>,
refresh: RefreshGate,
_subscription: Subscription,
@@ -100,7 +86,6 @@ impl RepoListStore {
}
}
/// The announcements of `user`, newest first.
pub fn announcements_of(&self, user: &PublicKey) -> Vec<Announcement> {
self.announcements
.iter()
@@ -115,17 +100,16 @@ impl RepoListStore {
backend.update(cx, |backend, cx| {
backend.sync_bootstraps(
vec![
filters::all_announcements(),
filters::all_states(),
Filters::all_announcements(),
Filters::all_states(),
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
filters::deletions(),
Filters::deletions(),
],
cx,
);
});
}
/// Fetch the state events of the user's own repositories.
fn sync_own_repo_states(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let Some(me) = backend.read(cx).current_user() else {
@@ -144,15 +128,13 @@ impl RepoListStore {
self.state_synced_repos.insert(addr.clone());
backend.update(cx, |backend, cx| {
backend.connect_repo_relays(relays, vec![filters::state(&addr)], cx);
backend.connect_repo_relays(relays, vec![addr.state_filter()], cx);
});
}
}
/// Re-query the local database.
///
/// Runs immediately. The backend pump already batches the relay events that
/// trigger a refresh, so no per-store debounce is needed.
// Runs immediately: the backend pump already batches the relay events
// that trigger a refresh, so no per-store debounce is needed.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refresh.request() != RefreshRequest::Schedule {
return;
@@ -168,14 +150,14 @@ impl RepoListStore {
let client = backend.read(cx).client();
let work = cx.background_spawn(async move {
let filter = filters::all_announcements();
let filter = Filters::all_announcements();
let events = client.database().query(filter).await?;
let deletion_events = client.database().query(filters::deletions()).await?;
let deletion_events = client.database().query(Filters::deletions()).await?;
let deletions = Deletions::from_events(deletion_events);
// Dedup and sort off the main thread.
// Only the final list crosses back into the entity.
// Dedup and sort off the main thread; only the final list crosses
// back into the entity.
let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new();
for event in events {
@@ -213,14 +195,13 @@ impl RepoListStore {
let Some(id) = event.tags.identifier() else {
continue;
};
let addr = repo_addr(event.pubkey, id);
let addr = RepoAddr::new(event.pubkey, id);
let Some(entry) = last_activity.get_mut(&addr) else {
continue;
};
*entry = (*entry).max(event.created_at);
}
// Bound the activity query to a recent window.
// Older repos fall back to their announcement or state timestamps.
let activity_filter = Filter::new()
.kinds(filters::ACTIVITY_KINDS)
@@ -230,10 +211,11 @@ impl RepoListStore {
if deletions.is_deleted(&event) {
continue;
}
for addr in event.tags.coordinates() {
if addr.kind != Kind::GitRepoAnnouncement {
for coordinate in event.tags.coordinates() {
if coordinate.kind != Kind::GitRepoAnnouncement {
continue;
}
let addr = RepoAddr::from(coordinate.clone());
let Some(entry) = last_activity.get_mut(&addr) else {
continue;
};
@@ -241,8 +223,8 @@ impl RepoListStore {
}
}
// Popularity counts per repository, issues, pull requests and patches.
// Unbounded, unlike the windowed activity query above, so totals are exact.
// Unbounded, unlike the windowed activity query above, so totals
// are exact.
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
let count_filter =
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
@@ -251,12 +233,13 @@ impl RepoListStore {
if deletions.is_deleted(&event) {
continue;
}
for addr in event.tags.coordinates() {
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
for coordinate in event.tags.coordinates() {
if coordinate.kind != Kind::GitRepoAnnouncement
|| !last_activity.contains_key(&RepoAddr::from(coordinate.clone()))
{
continue;
}
let entry = counts.entry(addr).or_default();
let entry = counts.entry(RepoAddr::from(coordinate)).or_default();
match event.kind {
Kind::GitIssue => entry.issues += 1,
Kind::GitPullRequest => entry.pull_requests += 1,
@@ -272,7 +255,6 @@ impl RepoListStore {
cx.spawn(async move |this, cx| {
let (announcements, last_activity, counts) = match work.await {
Ok(results) => results,
// Database errors are transient, keep the last list.
Err(_) => {
return this.update(cx, |this, _cx| {
this.refresh.abort();
@@ -290,8 +272,6 @@ impl RepoListStore {
this.refresh.finish()
})?;
// Requests that arrived while the refresh was running.
// They are coalesced into one follow-up refresh.
if again {
this.update(cx, |this, cx| this.refresh(cx))?;
}
-2
View File
@@ -11,7 +11,6 @@ mod title_bar;
mod tree_row;
pub mod copy_row;
pub mod util;
pub use avatar::Avatar;
pub use copy_row::{copy_row, menu_copy_row};
@@ -25,4 +24,3 @@ pub use setting::{SelectOption, setting_block, setting_row};
pub use status_badge::status_badge;
pub use title_bar::title_bar_drag_handlers;
pub use tree_row::tree_row;
pub use util::middle_truncate;
-2
View File
@@ -4,8 +4,6 @@ use gpui_base::{Button as BaseButton, StyledExt};
use gpui_component::ActiveTheme;
/// A small count badge shown after a label.
/// Used on segmented filter buttons, like `All 12`, and on tabs.
/// Rendered from theme tokens and sized for the compact header buttons it lives on.
#[derive(IntoElement)]
pub struct CountBadge {
count: usize,
-3
View File
@@ -4,8 +4,6 @@ use gpui_component::searchable_list::SearchableListItem;
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
/// A dropdown option with a display label and a stored value.
/// The trigger and menu render the `label`.
/// The `value` is what [`gpui_component::select::SelectState`] reports as the selection.
#[derive(Clone)]
pub struct SelectOption {
value: SharedString,
@@ -80,7 +78,6 @@ pub fn setting_row(
}
/// A full-width settings block with title and subtitle in one header.
/// `gap_3` separates the header from the control below.
pub fn setting_block(
cx: &App,
title: impl Into<SharedString>,
+1 -2
View File
@@ -5,8 +5,7 @@ use gpui_component::tooltip::Tooltip;
use gpui_component::{ActiveTheme, Icon, Sizable, v_flex};
use signed_core::RepoStatus;
/// The status badge shown next to an issue or pull request.
/// It has an icon and a colored square, with a tooltip describing the status.
/// Status badge shown next to an issue or pull request.
pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
let (icon, label, tooltip, bg, fg) = match status {
RepoStatus::Open => (
-5
View File
@@ -9,11 +9,6 @@ struct WindowDragState {
}
/// Make an element behave like a window title bar.
/// Dragging it moves the window.
/// Double-clicking zooms the window.
/// On macOS it runs the platform's default title-bar double-click action.
/// Only the bar's non-interactive areas should get this.
/// Tabs are draggable to reorder panels and must not move the window.
pub fn title_bar_drag_handlers(
this: Stateful<Div>,
window: &mut Window,
-3
View File
@@ -4,9 +4,6 @@ use gpui_component::list::ListItem;
use gpui_component::tree::TreeEntry;
use gpui_component::{Icon, IconName, Sizable, h_flex};
/// One row of a file tree, an icon and a name indented by depth.
/// Clicking a file runs `on_click`.
/// Folders expand and collapse via the tree itself.
pub fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
where
F: Fn(&mut Window, &mut App) + 'static,
-38
View File
@@ -1,38 +0,0 @@
/// `[head chars]...[tail chars]` middle truncation.
///
/// Values too short for the ellipsis to save space are left alone.
pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
let len = value.chars().count();
if len <= head + tail + 3 {
return value.to_string();
}
let head: String = value.chars().take(head).collect();
let tail: String = value.chars().skip(len - tail).collect();
format!("{head}...{tail}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn middle_truncates_long_values_only() {
assert_eq!(
middle_truncate(
"a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d",
10,
10,
),
"a008def157...ad57a3564d"
);
assert_eq!(
middle_truncate(
"30617:a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d:ngit",
10,
10
),
"30617:a008...3564d:ngit"
);
assert_eq!(middle_truncate("short", 10, 10), "short");
}
}
+5 -1
View File
@@ -1,5 +1,9 @@
mod pubkey;
mod repo_url;
mod text;
mod time;
pub use pubkey::shorten_pubkey;
pub use time::{relative_time, relative_time_secs};
pub use repo_url::same_repo_url;
pub use text::{flatten_whitespace, middle_truncate};
pub use time::{latest, relative_time, relative_time_secs, sort_newest_first};
+6 -41
View File
@@ -1,49 +1,14 @@
use nostr::prelude::*;
/// Shorten a [`PublicKey`] to `npub1abc...wxyz` form.
use crate::text::middle_truncate;
const HEAD_CHARS: usize = 9;
const TAIL_CHARS: usize = 4;
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");
}
middle_truncate(&encoded, HEAD_CHARS, TAIL_CHARS)
}
+20
View File
@@ -0,0 +1,20 @@
use nostr::prelude::*;
/// Compare git URLs ignoring scheme, trailing `.git` and path slashes,
/// so a grasp announce URL matches its https origin.
pub fn same_repo_url(a: &str, b: &str) -> bool {
match (url_identity(a), url_identity(b)) {
(Some(a), Some(b)) => a == b,
_ => a == b,
}
}
fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
let parsed = Url::parse(url).ok()?;
let host = parsed.host_str()?.to_ascii_lowercase();
let mut path = parsed.path().trim_matches('/').to_owned();
if let Some(stripped) = path.strip_suffix(".git") {
path = stripped.to_owned();
}
Some((host, parsed.port(), path))
}
+21
View File
@@ -0,0 +1,21 @@
pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
let len = value.chars().count();
if len <= head + tail + 3 {
return value.to_string();
}
let head: String = value.chars().take(head).collect();
let tail: String = value.chars().skip(len - tail).collect();
format!("{head}...{tail}")
}
pub fn flatten_whitespace(text: &str) -> String {
const MAX_CHARS: usize = 200;
let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() <= MAX_CHARS {
flat
} else {
let mut clipped: String = flat.chars().take(MAX_CHARS).collect();
clipped.push('…');
clipped
}
}
+15 -20
View File
@@ -1,6 +1,20 @@
use nostr::prelude::*;
/// Format a timestamp as a short relative time, e.g. `3h ago`.
pub fn sort_newest_first(events: &mut [Event]) {
events.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
});
}
pub fn latest<I>(events: I) -> Option<Event>
where
I: IntoIterator<Item = Event>,
{
events.into_iter().max_by_key(|e| e.created_at)
}
pub fn relative_time(timestamp: Timestamp) -> String {
let now = Timestamp::now().as_secs();
let secs = now.saturating_sub(timestamp.as_secs());
@@ -20,25 +34,6 @@ pub fn relative_time(timestamp: Timestamp) -> String {
}
}
/// Format a unix timestamp in seconds as a short relative time, e.g. `3h ago`.
pub fn relative_time_secs(secs: i64) -> String {
relative_time(Timestamp::from_secs(secs.max(0) as u64))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_relative_time() {
let now = Timestamp::now();
assert_eq!(relative_time(now), "just now");
assert_eq!(relative_time(now - 300), "5m ago");
assert_eq!(relative_time(now - 7_200), "2h ago");
assert_eq!(relative_time(now - 3 * 86_400), "3d ago");
assert_eq!(relative_time(now - 60 * 86_400), "2mo ago");
assert_eq!(relative_time(now - 800 * 86_400), "2y ago");
assert_eq!(relative_time(now + 600), "just now");
}
}
@@ -17,7 +17,9 @@ use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree};
use gpui_component::{
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
};
use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff};
use signed_git::{
CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo,
};
use signed_state::RepoStore;
use signed_ui::{placeholder, tree_row};
use utils::relative_time_secs;
@@ -355,14 +357,14 @@ impl CommitDiffView {
.background_spawn({
let worktree = worktree.clone();
let id = id.clone();
async move { signed_git::worktree_commit(&worktree, &id) }
async move { Repo::open(&worktree).and_then(|repo| repo.commit(&id)) }
})
.await;
let diff = cx
.background_spawn({
let worktree = worktree.clone();
let id = id.clone();
async move { signed_git::worktree_commit_diff(&worktree, &id) }
async move { Repo::open(&worktree).and_then(|repo| repo.commit_diff(&id)) }
})
.await;
+2 -2
View File
@@ -13,7 +13,7 @@ use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt,
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
use signed_core::{InboxItem, InboxReadState, RepoAddr};
use signed_state::{
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
Backend, BackendEvent, Inbox, ProfileStore, RefreshGate, RefreshRequest, RepoListStore,
};
use signed_ui::{Avatar, CountBadge};
use utils::relative_time;
@@ -199,7 +199,7 @@ impl InboxView {
let state = self.state.clone();
let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await });
let work = cx.background_spawn(async move { Inbox::query(&client, me, &state).await });
self.tasks.push(cx.spawn(async move |this, cx| {
let (threads, unread_count) = match work.await {
+2 -2
View File
@@ -8,7 +8,7 @@ use gpui_component::input::TextareaState;
use gpui_component::scroll::ScrollableElement;
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
use nostr::prelude::EventId;
use signed_core::activity_subject;
use signed_core::GitEvent;
use signed_state::{ProfileStore, RepoStore};
use signed_ui::{Avatar, placeholder, status_badge};
use utils::relative_time;
@@ -103,7 +103,7 @@ impl Render for IssueDetailView {
};
(
activity_subject(issue),
issue.activity_subject(),
profile.name(),
profile.picture(),
store.status_of(issue),
+2 -2
View File
@@ -16,7 +16,7 @@ use gpui_component::{
ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
};
use nostr::prelude::EventId;
use signed_core::{RepoStatus, activity_subject};
use signed_core::{GitEvent, RepoStatus};
use signed_state::{ProfileStore, RepoStore};
use signed_ui::{Avatar, SegmentButton, placeholder, status_badge};
use utils::relative_time;
@@ -146,7 +146,7 @@ impl IssuesView {
fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context<Self>) -> AnyElement {
let issue = &self.store.read(cx).issues[issue_ix];
let title = activity_subject(issue);
let title = issue.activity_subject();
let id_hex = issue.id.to_hex();
let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey);
let author = profile.name();
+2
View File
@@ -3,6 +3,7 @@ mod dialog_state;
pub(crate) mod discussion;
mod inbox;
mod issues;
mod placeholder_panel;
mod pull_requests;
mod repo;
mod repo_list;
@@ -15,6 +16,7 @@ use gpui::prelude::*;
use gpui::{AnyElement, App, div};
use gpui_component::{Sizable as _, h_flex};
pub use inbox::InboxView;
pub(crate) use placeholder_panel::PlaceholderPanel;
pub use repo::RepoDetailView;
pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel};
pub use repo_list::RepoListView;
@@ -0,0 +1,48 @@
use dock::{BasePanel, Panel, PanelEvent};
use gpui::{
App, Context, EventEmitter, FocusHandle, Focusable, IntoElement, Render, SharedString, Window,
};
use signed_ui::placeholder;
/// A center panel reserving the place of a view that is not built yet.
pub struct PlaceholderPanel {
name: &'static str,
title: SharedString,
focus_handle: FocusHandle,
}
impl PlaceholderPanel {
pub(crate) fn new(name: &'static str, title: &'static str, cx: &mut Context<Self>) -> Self {
Self {
name,
title: SharedString::from(title),
focus_handle: cx.focus_handle(),
}
}
}
impl BasePanel for PlaceholderPanel {
fn panel_name(&self) -> &'static str {
self.name
}
}
impl Panel for PlaceholderPanel {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
self.title.clone()
}
}
impl EventEmitter<PanelEvent> for PlaceholderPanel {}
impl Focusable for PlaceholderPanel {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for PlaceholderPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
placeholder("Nothing here yet.", cx)
}
}
@@ -21,12 +21,9 @@ use gpui_component::{
v_virtual_list,
};
use nostr::prelude::{Event, EventId, Kind, Url};
use signed_core::{
RepoAddr, activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update,
merge_base_of, pull_request_patch,
};
use signed_git::{FileCommit, patch_commits, patch_diffs};
use signed_state::{Backend, ProfileStore, RepoStore, ensure_repo_mirror};
use signed_core::{GitEvent, PullRequest, RepoAddr};
use signed_git::{FileCommit, PatchParser};
use signed_state::{Backend, Mirrors, ProfileStore, RepoStore};
use signed_ui::{Avatar, CountBadge, placeholder, status_badge};
use utils::{relative_time, relative_time_secs};
@@ -145,23 +142,24 @@ impl PullRequestDetailView {
.iter()
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
.map(|root| {
let update = latest_update(store.pull_requests.iter(), root);
let update = PullRequest::latest_update(store.pull_requests.iter(), root);
let tip = update
.and_then(current_commit_of)
.or_else(|| current_commit_of(root));
.and_then(GitEvent::current_commit)
.or_else(|| root.current_commit());
let base = update
.and_then(merge_base_of)
.or_else(|| merge_base_of(root));
.and_then(GitEvent::merge_base)
.or_else(|| root.merge_base());
let clone_urls = clone_urls_of(root)
let clone_urls = root
.clone_urls()
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()))
.unwrap_or_default();
PrBinding {
description: root.content.clone(),
patch: pull_request_patch(root, store.patches.iter()),
patch: PullRequest::new(root).patch(store.patches.iter()),
tip,
base,
clone_urls,
@@ -235,14 +233,14 @@ impl PullRequestDetailView {
let nostr_diff = cx
.background_spawn({
let patch = patch.clone();
async move { patch_diffs(&patch) }
async move { PatchParser::patch_diffs(&patch) }
})
.await;
let nostr_commits = cx
.background_spawn({
let patch = patch.clone();
async move { patch_commits(&patch) }
async move { PatchParser::patch_commits(&patch) }
})
.await;
@@ -263,9 +261,10 @@ impl PullRequestDetailView {
Some(
cx.background_spawn(async move {
let repo = ensure_repo_mirror(&addr, &clone_urls)?;
let repo = Mirrors::ensure(&addr, &clone_urls)?;
let gix_repo = repo.inner();
let workdir = repo
let workdir = gix_repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
.to_path_buf();
@@ -277,17 +276,16 @@ impl PullRequestDetailView {
Some(base) => base,
// No `merge-base` tag. Use the merge base of the tip and the default branch.
None => {
let head = repo
let head = gix_repo
.head_id()
.map_err(|_| anyhow::anyhow!("repository has no HEAD"))?;
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
repo.merge_base(tip_id, head)?.to_string()
let tip_id = gix_repo.rev_parse_single(tip.as_bytes())?;
gix_repo.merge_base(tip_id, head)?.to_string()
}
};
let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
let commits =
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
let diff = repo.range_diff(&base, &tip)?;
let commits = repo.commit_range(&base, &tip)?;
Ok::<_, anyhow::Error>((diff, commits, workdir))
})
@@ -620,9 +618,9 @@ impl PullRequestDetailView {
return div().into_any_element();
};
(
activity_subject(root),
root.activity_subject(),
store.status_of(root),
branch_name_of(root),
root.branch_name(),
root.pubkey,
)
};
@@ -14,7 +14,7 @@ use gpui_component::{
ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
};
use nostr::prelude::{EventId, Kind};
use signed_core::{RepoStatus, activity_subject};
use signed_core::{GitEvent, RepoStatus};
use signed_state::{ProfileStore, RepoStore};
use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge};
use utils::relative_time;
@@ -159,7 +159,7 @@ impl PullRequestsView {
fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context<Self>) -> AnyElement {
let pr = &self.store.read(cx).pull_requests[pr_ix];
let pr_id = pr.id;
let title = activity_subject(pr);
let title = pr.activity_subject();
let id_hex = pr.id.to_hex();
let age = relative_time(pr.created_at);
+25 -43
View File
@@ -21,15 +21,11 @@ use gpui_component::{
v_virtual_list,
};
use nostr::prelude::*;
use signed_core::{Announcement, RepoAddr, fork_candidates};
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, RepoListStore, RepoStore, ensure_repo_mirror, repo_mirror_path,
};
use signed_core::{Announcement, RepoAddr};
use signed_git::{GitCache, Repo};
use signed_state::{Backend, CheckoutsStore, Mirrors, RepoListStore, RepoStore};
use signed_ui::{CountBadge, placeholder, ref_selector_trigger};
use utils::middle_truncate;
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row};
use crate::views::{repo_tab_avatar, tab_title};
@@ -104,17 +100,8 @@ fn shorten_owner(owner: &PublicKey) -> String {
hex.chars().take(10).collect()
}
/// Truncate a label for the fixed-width controls of the compare bar.
fn truncate_label(label: &str) -> SharedString {
const MAX: usize = 18;
let mut chars = label.chars();
let (prefix, rest) = (chars.by_ref().take(MAX).collect::<String>(), chars.next());
let label = if rest.is_some() {
format!("{}…", &prefix[..prefix.len().saturating_sub(1)])
} else {
prefix
};
SharedString::from(label)
SharedString::from(middle_truncate(label, 18, 0))
}
fn checkout_source_item(
@@ -407,10 +394,12 @@ impl NewPullRequestView {
.background_spawn({
let path = path.clone();
async move {
let repo = gix::open(Path::new(&path)).ok()?;
let branches =
signed_git::worktree_branches(Path::new(&path)).unwrap_or_default();
let current = signed_git::current_branch(&repo).ok().flatten();
let repo = Repo::try_open(Path::new(&path));
let branches = repo
.as_ref()
.and_then(|repo| repo.branches().ok())
.unwrap_or_default();
let current = repo.as_ref().and_then(|repo| repo.current_branch());
Some((branches, current))
}
})
@@ -520,7 +509,7 @@ impl NewPullRequestView {
};
let user = Backend::global(cx).read(cx).current_user();
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
fork_candidates(&announcements, &base, euc.as_deref(), user)
Announcement::forks_in(&announcements, &base, euc.as_deref(), user)
.into_iter()
.cloned()
.collect()
@@ -540,8 +529,8 @@ impl NewPullRequestView {
let Some((base, _euc)) = self.base_repo(cx) else {
return;
};
let mirror_path = repo_mirror_path(&base);
let namespace = fork_namespace(&announcement);
let mirror_path = Mirrors::path(&base);
let namespace = GitCache::fork_namespace(&announcement);
let clone_urls = announcement.clone.clone();
let base_clone_urls: Vec<Url> = self
@@ -575,14 +564,14 @@ impl NewPullRequestView {
let clone_urls = clone_urls.clone();
let mirror_path = mirror_path.clone();
async move {
ensure_repo_mirror(&base, &base_clone_urls)?;
Mirrors::ensure(&base, &base_clone_urls)?;
let repo = Repo::open(&mirror_path)?;
// Prune stale imports of any fork. Then import this fork's heads under its namespace.
delete_refs_with_prefix(&mirror_path, "refs/fork")?;
repo.delete_refs_with_prefix("refs/fork")?;
// Fetch the fork's refs and import them under the fork's namespace.
fetch_repo_refs(
&mirror_path,
repo.fetch_refs(
&clone_urls,
&format!("+refs/heads/*:refs/fork/{namespace}/*"),
)?;
@@ -602,12 +591,12 @@ impl NewPullRequestView {
};
let base_branches = strip(
refs_with_prefix(&mirror_path, "refs/remotes/origin")?,
repo.refs_with_prefix("refs/remotes/origin")?,
"refs/remotes/origin",
);
let compare_branches = strip(
refs_with_prefix(&mirror_path, &format!("refs/fork/{namespace}"))?,
repo.refs_with_prefix(&format!("refs/fork/{namespace}"))?,
&format!("refs/fork/{namespace}"),
);
@@ -781,24 +770,17 @@ impl NewPullRequestView {
let base_name = base_name.clone();
let compare_name = compare_name.clone();
async move {
let merge_base = merge_base(Path::new(&repo_path), &base, &compare)?
.ok_or_else(|| {
let repo = Repo::open(Path::new(&repo_path))?;
let merge_base =
repo.merge_base(&base, &compare)?.ok_or_else(|| {
anyhow::anyhow!(
"{base_name} and {compare_name} share no common ancestor"
)
})?;
let commits = worktree_commit_range_commits(
Path::new(&repo_path),
&merge_base,
&compare,
)?;
let commits = repo.commit_range(&merge_base, &compare)?;
let diff = worktree_commit_range_diff(
Path::new(&repo_path),
&merge_base,
&compare,
)?;
let diff = repo.range_diff(&merge_base, &compare)?;
Ok::<_, anyhow::Error>((merge_base, commits, diff))
}
+2 -1
View File
@@ -5,7 +5,8 @@ use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex};
use nostr::prelude::PublicKey;
use signed_core::Announcement;
use signed_state::ProfileStore;
use signed_ui::{Avatar, middle_truncate};
use signed_ui::Avatar;
use utils::middle_truncate;
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |dialog, _window, cx| {
+4 -4
View File
@@ -11,7 +11,7 @@ use gpui_component::spinner::Spinner;
use gpui_component::text::{TextView, TextViewState};
use gpui_component::tree::{TreeEntry, TreeState, tree};
use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
use signed_git::{FileCommit, WorktreeSnapshot};
use signed_git::{FileCommit, Repo, WorktreeSnapshot};
use signed_ui::{placeholder, tree_row};
use crate::views::tree::{TreeItemSeed, tree_items};
@@ -611,9 +611,9 @@ impl RepoFilesView {
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let rels: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
let result = cx
.background_spawn(
async move { signed_git::worktree_last_commits(&worktree, &rels) },
)
.background_spawn(async move {
Repo::open_cached(&worktree).and_then(|repo| repo.last_commits(&rels))
})
.await;
this.update(cx, |this, cx| {
+4 -2
View File
@@ -8,7 +8,7 @@ use gpui::{Context, Entity, Pixels, Render, Size, Task, WeakEntity, Window, div,
use gpui_component::scroll::Scrollbar;
use gpui_component::spinner::Spinner;
use gpui_component::{ActiveTheme, Sizable, VirtualListScrollHandle, v_flex, v_virtual_list};
use signed_git::CommitList;
use signed_git::{CommitList, Repo};
use signed_state::RepoStore;
use signed_ui::placeholder;
@@ -69,7 +69,9 @@ impl RepoHistoryView {
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let result = cx
.background_spawn(async move { signed_git::worktree_all_commits(&worktree) })
.background_spawn(async move {
Repo::open_cached(&worktree).and_then(|repo| repo.all_commits())
})
.await;
this.update(cx, |this, cx| {
+73 -2
View File
@@ -2,16 +2,19 @@ use std::path::PathBuf;
use assets::CustomIconName;
use gpui::prelude::*;
use gpui::{App, Entity, WeakEntity, Window, px};
use gpui::{App, Entity, SharedString, WeakEntity, Window, px};
use gpui_base::h_flex;
use gpui_base::input::TextareaState;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputState, Textarea};
use gpui_component::select::{Select, SelectState};
use gpui_component::{ActiveTheme, Disableable, WindowExt};
use settings::SettingsStore;
use signed_git::Repo;
use signed_state::Backend;
use signed_ui::SelectOption;
use super::RepoDetailView;
use crate::views::dialog_state::{DialogProgress, error_row};
@@ -48,8 +51,11 @@ pub fn open(
.auto_grow(3, 5)
.placeholder("Short description")
});
let branch_select: Entity<SelectState<Vec<SelectOption>>> =
cx.new(|cx| SelectState::new(Vec::new(), None, window, cx));
load_user_grasp_servers(grasp_state.clone(), window, cx);
load_branches(local_path.clone(), branch_select.clone(), window, cx);
window.open_dialog(cx, move |dialog, _window, _cx| {
const DESC: &str = "Publish this local repository to Nostr.";
@@ -57,6 +63,7 @@ pub fn open(
let name_input = name_input.clone();
let desc_input = desc_input.clone();
let relay_input = relay_input.clone();
let branch_select = branch_select.clone();
let state = state.clone();
let grasp_state = grasp_state.clone();
let local_path = local_path.clone();
@@ -105,6 +112,12 @@ pub fn open(
.child(local_path.display().to_string()),
),
)
.child(
field()
.label("Default branch")
.description("The local branch to announce as the default")
.child(Select::new(&branch_select).w_full()),
)
.child(grasp_servers_field(&grasp_state, &relay_input, cx)),
)
.children(error_row(&error, cx))
@@ -120,6 +133,7 @@ pub fn open(
.on_click({
let name_input = name_input.clone();
let desc_input = desc_input.clone();
let branch_select = branch_select.clone();
let state = state.clone();
let grasp_state = grasp_state.clone();
let local_path = local_path.clone();
@@ -129,6 +143,7 @@ pub fn open(
init_repository(
local_path.clone(),
(name_input.clone(), desc_input.clone()),
branch_select.clone(),
state.clone(),
grasp_state.clone(),
view.clone(),
@@ -143,9 +158,11 @@ pub fn open(
});
}
#[allow(clippy::too_many_arguments)]
fn init_repository(
local_path: PathBuf,
inputs: (Entity<InputState>, Entity<TextareaState>),
branch_select: Entity<SelectState<Vec<SelectOption>>>,
state: Entity<InitRepoState>,
grasp_state: Entity<GraspServersState>,
view: WeakEntity<RepoDetailView>,
@@ -156,6 +173,11 @@ fn init_repository(
let name = name_input.read(cx).value().trim().to_owned();
let description = desc_input.read(cx).value().trim().to_owned();
let servers = grasp_state.read(cx).grasp_servers.clone();
let default_branch = branch_select
.read(cx)
.selected_value()
.map(|value| value.trim().to_owned())
.filter(|branch| !branch.is_empty());
if name.is_empty() {
state.update(cx, |state, _| state.fail("Repository name is required"));
@@ -171,7 +193,14 @@ fn init_repository(
let backend = Backend::global(cx);
let task = backend.update(cx, |backend, cx| {
backend.publish_local_repo(local_path.clone(), &name, &description, servers, cx)
backend.publish_local_repo(
local_path.clone(),
&name,
&description,
servers,
default_branch,
cx,
)
});
let handle = window.window_handle();
@@ -199,3 +228,45 @@ fn init_repository(
})
.detach();
}
/// Fills the default-branch selector with the repository's local branches,
/// preselecting the checked-out one.
fn load_branches(
local_path: PathBuf,
select: Entity<SelectState<Vec<SelectOption>>>,
window: &mut Window,
cx: &mut App,
) {
let handle = window.window_handle();
cx.spawn(async move |cx| {
let loaded = cx
.background_spawn(async move {
let repo = Repo::open(&local_path)?;
let current: Option<SharedString> = repo.current_branch().map(Into::into);
Ok::<_, anyhow::Error>((repo.branches()?, current))
})
.await;
let _ = cx.update_window(handle, |_, window, cx| {
let Ok((branches, current)) = loaded.map_err(|error| {
log::warn!("failed to list branches for the default branch selector: {error:#}")
}) else {
return;
};
let options: Vec<SelectOption> = branches
.into_iter()
.map(|branch| SelectOption::new(branch.clone(), branch))
.collect();
select.update(cx, |state, cx| {
state.set_items(options, window, cx);
if let Some(current) = current.as_ref() {
state.set_selected_value(current, window, cx);
}
});
});
})
.detach();
}
+50 -79
View File
@@ -5,7 +5,6 @@ use std::rc::Rc;
use anyhow::Error;
use assets::CustomIconName;
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
use gix::Repository;
use gpui::prelude::*;
use gpui::{
Action, Anchor, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
@@ -24,16 +23,15 @@ use gpui_component::{
use nostr::nips::nip19::Nip19Coordinate;
use nostr::prelude::{RelayUrl, ToBech32, Url};
use signed_core::{Announcement, RepoAddr, RepoStatus};
use signed_git::FileCommit;
use signed_git::{FileCommit, GitCache, Repo};
use signed_state::{
Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind,
ProfileStore, RepoListStore, RepoStore, ensure_repo_mirror, open_repo_mirror,
pr_proposes_checkout,
Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Mirrors, Nip34Binding, Nip34Kind,
ProfileStore, RepoListStore, RepoStore,
};
use signed_ui::{
Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, middle_truncate,
ref_selector_trigger,
Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, ref_selector_trigger,
};
use utils::middle_truncate;
mod about;
mod actions;
@@ -326,7 +324,7 @@ impl RepoDetailView {
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
let data = cx
.background_spawn(async move {
let repo = gix::open(&local_path)?;
let repo = Repo::open(&local_path)?;
load_repo_data(&repo)
})
.await;
@@ -360,7 +358,7 @@ impl RepoDetailView {
let disk = {
let addr = addr.clone();
cx.background_spawn(async move {
match open_repo_mirror(&addr)? {
match Mirrors::open(&addr)? {
Some(repo) => Ok(Some(load_repo_data(&repo)?)),
None => Ok(None),
}
@@ -377,7 +375,7 @@ impl RepoDetailView {
let addr = addr.clone();
let clone_urls = clone_urls.clone();
cx.background_spawn(async move {
let repo = ensure_repo_mirror(&addr, &clone_urls)?;
let repo = Mirrors::ensure(&addr, &clone_urls)?;
load_repo_data(&repo)
})
.await
@@ -404,42 +402,27 @@ impl RepoDetailView {
let addr = addr.clone();
cx.background_spawn(async move {
let Some(repo) = open_repo_mirror(&addr)? else {
let Some(repo) = Mirrors::open(&addr)? else {
return Ok::<_, Error>(None);
};
// Best-effort, a fetch failure, e.g. offline, keeps the cached state.
signed_git::fetch_all(&repo).ok();
repo.fetch().ok();
let worktree = repo.workdir().map(Path::to_path_buf);
let moved = repo.fast_forward_branches().unwrap_or(false);
let moved = match &worktree {
Some(worktree) => {
signed_git::fast_forward_branches(worktree).unwrap_or(false)
}
None => false,
};
let (branches, tags) = match &worktree {
Some(_) => (
signed_git::repo_branches(&repo).unwrap_or_default(),
signed_git::repo_tags(&repo).unwrap_or_default(),
),
None => (Vec::new(), Vec::new()),
};
let current_branch = signed_git::current_branch(&repo).unwrap_or(None);
let head_commit = signed_git::head_commit(&repo).unwrap_or(None);
Ok::<_, Error>(Some((moved, branches, tags, current_branch, head_commit)))
Ok::<_, Error>(Some((moved, repo.snapshot()?)))
})
}
.await;
this.update_in(cx, |this, window, cx| {
if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh {
let branches: Vec<SharedString> = branches.iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.iter().map(Into::into).collect();
if let Ok(Some((moved, snapshot))) = refresh {
let branches: Vec<SharedString> =
snapshot.branches.iter().map(Into::into).collect();
let tags: Vec<SharedString> = snapshot.tags.iter().map(Into::into).collect();
let current_branch = snapshot.current_branch.clone();
let head_commit = snapshot.head_commit.clone();
let branches_changed = this.refs.set_branches(
branches,
@@ -531,8 +514,8 @@ impl RepoDetailView {
.as_ref()
.map(|name| name.to_string())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| addr.identifier.clone());
let name = signed_git::sanitize_path_component(&name);
.unwrap_or_else(|| addr.identifier().to_owned());
let name = GitCache::sanitize_path_component(&name);
if name.is_empty() {
"repository".to_owned()
@@ -617,13 +600,12 @@ impl RepoDetailView {
let task: gpui::Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
let result = cx
.background_spawn(async move {
let repo = Repo::open(&worktree);
match kind {
RefKind::Branch => {
signed_git::worktree_checkout_branch(&worktree, &checkout_name)
}
RefKind::Tag => {
signed_git::worktree_checkout_tag(&worktree, &checkout_name)
repo.and_then(|repo| repo.checkout_branch(&checkout_name))
}
RefKind::Tag => repo.and_then(|repo| repo.checkout_tag(&checkout_name)),
}
})
.await;
@@ -665,7 +647,7 @@ impl RepoDetailView {
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let result = cx
.background_spawn(async move {
let snapshot = signed_git::worktree_snapshot(&worktree)?;
let snapshot = Repo::open(&worktree)?.snapshot()?;
// Build the tree off the main thread, like [`Self::load_repo`].
let tree = build_tree_items(&snapshot.entries);
let paths = sorted_worktree_paths(&snapshot.entries);
@@ -716,7 +698,7 @@ impl RepoDetailView {
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let result = cx
.background_spawn(async move {
let snapshot = signed_git::worktree_snapshot(&worktree)?;
let snapshot = Repo::open(&worktree)?.snapshot()?;
let tree = build_tree_items(&snapshot.entries);
let paths = sorted_worktree_paths(&snapshot.entries);
Ok::<_, Error>((snapshot, tree, paths))
@@ -846,10 +828,6 @@ impl RepoDetailView {
}
/// Open the upstream repository, the `u` tag of this fork's announcement.
///
/// The announcement may not be in the local database yet. The panel opens
/// from the address and fills in when the store loads it; the store's
/// `subscribe_remote` fetches it from the bootstrap relays.
pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(addr) = self
.announcement(cx)
@@ -1523,8 +1501,12 @@ impl RepoDetailView {
continue;
}
for pr in &store.pull_requests {
if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status)
{
if CheckoutsStore::pr_proposes_checkout(
pr,
store.status_of(pr) == RepoStatus::Open,
user,
&status,
) {
continue 'status;
}
}
@@ -1802,6 +1784,12 @@ impl RepoDetailView {
/// The Files tab body, or the clone/initial-load spinner.
fn render_files_tab(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading {
let label = if self.store.read(cx).path.is_some() {
"Loading repository..."
} else {
"Cloning repository..."
};
return v_flex()
.flex_1()
.size_full()
@@ -1813,7 +1801,7 @@ impl RepoDetailView {
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("Cloning repository..."),
.child(label),
)
.into_any_element();
}
@@ -1857,7 +1845,7 @@ fn fork_row(announcement: &Announcement, cx: &mut Context<RepoDetailView>) -> Op
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(a.id.clone()))
})
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
.unwrap_or_else(|| SharedString::from(addr.identifier().to_owned()));
(SharedString::from(format!("Forked from {name}")), true)
}
None => (SharedString::from(upstream.display().as_str()), false),
@@ -1900,7 +1888,7 @@ impl ShareTargets {
fn from_announcement(announcement: &Announcement) -> Self {
let addr = announcement.addr();
let coordinate = addr.to_string();
let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned())
let naddr = Nip19Coordinate::new(addr.into(), announcement.relays.iter().cloned())
.to_bech32()
.expect("a complete coordinate always encodes to naddr");
@@ -1952,38 +1940,21 @@ fn truncate_naddr_link(url: &str, tail: usize) -> String {
format!("{}...{}", &url[..end], &url[url.len() - tail..])
}
fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
let entries = signed_git::worktree_entries(repo)?;
let tree = build_tree_items(&entries);
let readme_path = signed_git::find_readme(repo)?;
let readme = match &readme_path {
Some(path) => signed_git::worktree_read(repo, path)?,
None => None,
};
fn load_repo_data(repo: &Repo) -> Result<RepoData, Error> {
let snapshot = repo.snapshot()?;
let worktree = repo.workdir().map(Path::to_path_buf);
let head_commit = signed_git::head_commit(repo).unwrap_or(None);
let (branches, tags, current_branch) = match &worktree {
Some(_) => (
signed_git::repo_branches(repo).unwrap_or_default(),
signed_git::repo_tags(repo).unwrap_or_default(),
signed_git::current_branch(repo).unwrap_or(None),
),
None => (Vec::new(), Vec::new(), None),
};
let tree = build_tree_items(&snapshot.entries);
Ok(RepoData {
tree,
entries,
readme_path,
readme,
entries: snapshot.entries,
readme_path: snapshot.readme_path,
readme: snapshot.readme,
worktree,
branches,
tags,
current_branch,
head_commit,
branches: snapshot.branches,
tags: snapshot.tags,
current_branch: snapshot.current_branch,
head_commit: snapshot.head_commit,
})
}
+8 -17
View File
@@ -169,21 +169,6 @@ impl RepoListView {
cx.notify();
}
fn open_repo(
&mut self,
announcement: &Announcement,
window: &mut Window,
cx: &mut Context<Self>,
) {
open_repo_panel(
&self.dock_area,
&announcement.addr(),
Some(announcement),
window,
cx,
);
}
fn render_card(
&self,
ix: usize,
@@ -228,7 +213,7 @@ impl RepoListView {
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(a.id.clone()))
})
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
.unwrap_or_else(|| SharedString::from(addr.identifier().to_owned()));
Some(SharedString::from(format!("forked from {name}")))
});
@@ -311,7 +296,13 @@ impl RepoListView {
.on_click(cx.listener({
let announcement = announcement.clone();
move |this, _ev, window, cx| {
this.open_repo(&announcement, window, cx);
open_repo_panel(
&this.dock_area,
&announcement.addr(),
Some(&announcement),
window,
cx,
);
}
}))
.into_any_element()
@@ -10,7 +10,6 @@ use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputState, Textarea};
use gpui_component::{Disableable, IconName, WindowExt, h_flex};
use settings::SettingsStore;
use signed_core::Announcement;
use signed_state::{Backend, CheckoutsStore};
use super::super::open_repo_panel;
@@ -232,7 +231,13 @@ fn create_repository(
store.record(local_path.clone(), announcement.addr(), cx);
});
cx.open_with_system(&local_path);
open_repo(dock_area, announcement, window, cx);
open_repo_panel(
&dock_area,
&announcement.addr(),
Some(&announcement),
window,
cx,
);
})
.ok();
}
@@ -245,18 +250,3 @@ fn create_repository(
})
.detach();
}
fn open_repo(
dock_area: WeakEntity<DockArea>,
announcement: Announcement,
window: &mut Window,
cx: &mut App,
) {
open_repo_panel(
&dock_area,
&announcement.addr(),
Some(&announcement),
window,
cx,
);
}
@@ -1,8 +1,337 @@
use gpui::{App, Window, px};
use gpui_component::WindowExt;
use gpui::prelude::*;
use gpui::{AnyWindowHandle, App, Entity, Subscription, Task, Window, px};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputEvent, InputState};
use gpui_component::{Disableable, WindowExt};
use signed_state::Backend;
use crate::views::dialog_state::{DialogProgress, error_row};
#[derive(Clone, Copy, Default)]
enum Stage {
#[default]
Credential,
/// An `nsec` was entered; ask for a passphrase to encrypt it with.
Encrypt,
/// An `ncryptsec` was entered; ask for its passphrase to decrypt it.
Decrypt,
}
/// The credential entered on the first step.
enum Credential {
Nsec(String),
Ncryptsec(String),
Bunker(String),
}
impl Credential {
fn parse(input: &str) -> Option<Self> {
let input = input.trim();
if input.starts_with("nsec1") {
Some(Self::Nsec(input.to_owned()))
} else if input.starts_with("ncryptsec1") {
Some(Self::Ncryptsec(input.to_owned()))
} else if input.starts_with("bunker://") {
Some(Self::Bunker(input.to_owned()))
} else {
None
}
}
}
/// State of the import dialog, so the current step and async results can be rendered.
#[derive(Default)]
pub struct ImportState {
stage: Stage,
credential: String,
progress: DialogProgress,
/// Keeps the subscriptions alive while the dialog is open.
subscriptions: Vec<Subscription>,
}
pub fn open(window: &mut Window, cx: &mut App) {
let secret_input =
cx.new(|cx| InputState::new(window, cx).placeholder("secret key or bunker://"));
let pass_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Passphrase to protect your key")
.masked(true)
});
let repass_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Repeat passphrase")
.masked(true)
});
let handle = window.window_handle();
let state = cx.new(|_| ImportState::default());
// Enter in any field submits the step the dialog is on.
let mut subscriptions = Vec::new();
for input in [&secret_input, &pass_input, &repass_input] {
let secret_input = secret_input.clone();
let pass_input = pass_input.clone();
let repass_input = repass_input.clone();
let state = state.clone();
subscriptions.push(cx.subscribe(input, move |_input, event, cx| {
if matches!(event, InputEvent::PressEnter { .. }) {
submit(
&secret_input,
&pass_input,
&repass_input,
&state,
&handle,
cx,
);
}
}));
}
state.update(cx, |state, _cx| {
state.subscriptions = subscriptions;
});
window.open_dialog(cx, move |dialog, _window, _cx| {
dialog.title("Import identity").width(px(400.))
let secret_input = secret_input.clone();
let pass_input = pass_input.clone();
let repass_input = repass_input.clone();
let state = state.clone();
dialog
.width(px(520.))
.margin_top(px(50.))
.content(move |content, _window, cx| {
let stage = state.read(cx).stage;
let busy = state.read(cx).progress.busy;
let error = state.read(cx).progress.error.clone();
content
.child(
DialogHeader::new()
.child(DialogTitle::new().child("Import identity"))
.child(DialogDescription::new().child(match stage {
Stage::Credential => {
"Paste the secret key or bunker URI of an existing identity."
}
Stage::Encrypt => {
"Choose a passphrase to encrypt your key on this device."
}
Stage::Decrypt => "Enter the passphrase used to encrypt this key.",
})),
)
.child(match stage {
Stage::Credential => v_form().child(
field()
.label("Secret key or bunker URI")
.required(true)
.child(Input::new(&secret_input)),
),
Stage::Encrypt => v_form()
.child(
field()
.label("Passphrase")
.required(true)
.child(Input::new(&pass_input)),
)
.child(field().required(true).child(Input::new(&repass_input))),
Stage::Decrypt => v_form().child(
field()
.label("Passphrase")
.required(true)
.child(Input::new(&pass_input)),
),
})
.children(error_row(&error, cx))
.child(
DialogFooter::new().justify_end().child(
Button::new("import")
.primary()
.label(match stage {
Stage::Credential => "Continue",
Stage::Encrypt => "Import identity",
Stage::Decrypt => "Unlock",
})
.loading(busy)
.disabled(busy)
.on_click({
let secret_input = secret_input.clone();
let pass_input = pass_input.clone();
let repass_input = repass_input.clone();
let state = state.clone();
move |_ev, _window, cx| {
submit(
&secret_input,
&pass_input,
&repass_input,
&state,
&handle,
cx,
);
}
}),
),
)
})
});
}
fn submit(
secret_input: &Entity<InputState>,
pass_input: &Entity<InputState>,
repass_input: &Entity<InputState>,
state: &Entity<ImportState>,
handle: &AnyWindowHandle,
cx: &mut App,
) {
if state.read(cx).progress.busy {
return;
}
match state.read(cx).stage {
Stage::Credential => start_import(secret_input, state, handle, cx),
Stage::Encrypt => import_nsec(pass_input, repass_input, state, handle, cx),
Stage::Decrypt => import_ncryptsec(pass_input, state, handle, cx),
}
}
/// Parses the entered credential and either moves to the passphrase step or connects to a bunker.
fn start_import(
secret_input: &Entity<InputState>,
state: &Entity<ImportState>,
handle: &AnyWindowHandle,
cx: &mut App,
) {
let credential = match Credential::parse(&secret_input.read(cx).value()) {
Some(credential) => credential,
None => {
state.update(cx, |state, _| {
state
.progress
.fail("Enter an nsec, ncryptsec or bunker:// URI");
});
return;
}
};
match credential {
Credential::Nsec(secret) => {
state.update(cx, |state, _| {
state.credential = secret;
state.stage = Stage::Encrypt;
state.progress.error = None;
});
}
Credential::Ncryptsec(secret) => {
state.update(cx, |state, _| {
state.credential = secret;
state.stage = Stage::Decrypt;
state.progress.error = None;
});
}
Credential::Bunker(uri) => {
state.update(cx, |state, _| state.progress.begin());
let backend = Backend::global(cx);
let task = backend.update(cx, |backend, cx| backend.import_bunker(&uri, cx));
finish_import(task, state, handle, cx);
}
}
}
/// Encrypts the entered `nsec` with the passphrase and signs in with it.
fn import_nsec(
pass_input: &Entity<InputState>,
repass_input: &Entity<InputState>,
state: &Entity<ImportState>,
handle: &AnyWindowHandle,
cx: &mut App,
) {
let backend = Backend::global(cx);
let nsec = state.read(cx).credential.clone();
let pass = pass_input.read(cx).value().to_string();
let repass = repass_input.read(cx).value().to_string();
if pass.is_empty() {
state.update(cx, |state, _| {
state.progress.fail("Passphrase must not be empty");
});
return;
}
if pass != repass {
state.update(cx, |state, _| {
state.progress.fail("Passphrases do not match");
});
return;
}
state.update(cx, |state, _cx| {
state.progress.begin();
});
let task = backend.update(cx, |backend, cx| backend.import_nsec(&nsec, &pass, cx));
finish_import(task, state, handle, cx);
}
/// Decrypts the entered `ncryptsec` with the passphrase and signs in with it.
fn import_ncryptsec(
pass_input: &Entity<InputState>,
state: &Entity<ImportState>,
handle: &AnyWindowHandle,
cx: &mut App,
) {
let backend = Backend::global(cx);
let ncryptsec = state.read(cx).credential.clone();
let pass = pass_input.read(cx).value().to_string();
if pass.is_empty() {
state.update(cx, |state, _| {
state.progress.fail("Passphrase must not be empty");
});
return;
}
state.update(cx, |state, _cx| {
state.progress.begin();
});
let task = backend.update(cx, |backend, cx| {
backend.import_ncryptsec(&ncryptsec, &pass, cx)
});
finish_import(task, state, handle, cx);
}
/// Runs the import task to completion, closing the dialog on success.
fn finish_import<T: 'static>(
task: Task<Result<T, anyhow::Error>>,
state: &Entity<ImportState>,
handle: &AnyWindowHandle,
cx: &mut App,
) {
let handle = *handle;
let state = state.clone();
cx.spawn(async move |cx| match task.await {
Ok(_) => {
cx.update_window(handle, |_, window, cx| {
window.close_dialog(cx);
})
.ok();
}
Err(e) => {
cx.update_window(handle, |_, _window, cx| {
state.update(cx, |state, _| state.progress.fail(e.to_string()));
})
.ok();
}
})
.detach();
}
+135 -41
View File
@@ -14,18 +14,20 @@ use gpui::{
SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, white,
};
use gpui_base::Button as BaseButton;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::button::{Button, ButtonVariant, ButtonVariants};
use gpui_component::dialog::DialogButtonProps;
use gpui_component::input::InputState;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use gpui_component::menu::{DropdownMenu, PopupMenuItem};
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, WindowExt, h_flex, v_flex};
use nostr::prelude::RelayUrl;
use signed_core::{Announcement, RepoAddr};
use signed_state::{
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile,
ProfileStore, RepoListStore, ResolvedLocalRepo, resolve_local_repos,
ProfileStore, RepoListStore, ResolvedLocalRepo,
};
use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers};
use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel};
use super::{InboxView, PlaceholderPanel, RepoDetailView, RepoListView, open_repo_panel};
mod create_repo_dialog;
pub(crate) mod grasp_servers;
@@ -54,6 +56,8 @@ pub struct SidebarPanel {
dock_area: WeakEntity<DockArea>,
inbox: Option<WeakEntity<InboxView>>,
explore: Option<WeakEntity<RepoListView>>,
profile: Option<WeakEntity<PlaceholderPanel>>,
relays: Option<WeakEntity<PlaceholderPanel>>,
banner: SharedString,
/// User's announced repositories.
announcements: Arc<Vec<Announcement>>,
@@ -113,6 +117,8 @@ impl SidebarPanel {
dock_area,
inbox: None,
explore: None,
profile: None,
relays: None,
banner: pick_banner(),
announcements: Arc::new(Vec::new()),
local_repos: Arc::new(Vec::new()),
@@ -136,7 +142,7 @@ impl SidebarPanel {
let local = LocalReposStore::global(cx).read(cx);
let local_repos =
resolve_local_repos(&local.repos, &repo_list.announcements, &announcements);
LocalReposStore::resolve(&local.repos, &repo_list.announcements, &announcements);
(announcements, local_repos, local.scanning)
};
@@ -221,6 +227,51 @@ impl SidebarPanel {
.ok();
}
fn open_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) {
Self::open_placeholder_panel(
&self.dock_area,
&mut self.profile,
"profile",
"Profile",
window,
cx,
);
}
fn open_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
Self::open_placeholder_panel(
&self.dock_area,
&mut self.relays,
"relays",
"Relays",
window,
cx,
);
}
/// Opens one of the not-yet-built panels, reusing the already-open one.
fn open_placeholder_panel(
dock_area: &WeakEntity<DockArea>,
slot: &mut Option<WeakEntity<PlaceholderPanel>>,
name: &'static str,
title: &'static str,
window: &mut Window,
cx: &mut Context<Self>,
) {
if slot.as_ref().and_then(WeakEntity::upgrade).is_some() {
return;
}
let panel = cx.new(|cx| PlaceholderPanel::new(name, title, cx));
*slot = Some(panel.downgrade());
dock_area
.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(panel), window, cx);
})
.ok();
}
fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Enter desired name"));
let pass_input = cx.new(|cx| {
@@ -238,25 +289,6 @@ impl SidebarPanel {
onboarding_dialog::open(name_input, pass_input, repass_input, state, window, cx);
}
fn open_create_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
create_repo_dialog::open(self.dock_area.clone(), window, cx);
}
fn open_repo(
&mut self,
announcement: &Announcement,
window: &mut Window,
cx: &mut Context<Self>,
) {
open_repo_panel(
&self.dock_area,
&announcement.addr(),
Some(announcement),
window,
&mut *cx,
);
}
/// The detail view offers to publish it to NIP-34.
fn open_local_repo(
&mut self,
@@ -370,7 +402,11 @@ impl SidebarPanel {
.small()
.ghost()
.on_click(cx.listener(|this, _ev, window, cx| {
this.open_create_repo(window, cx);
create_repo_dialog::open(
this.dock_area.clone(),
window,
cx,
);
})),
),
),
@@ -470,9 +506,15 @@ impl SidebarPanel {
);
}
row.on_click(
cx.listener(move |this, _ev, window, cx| this.open_repo(&announcement, window, cx)),
)
row.on_click(cx.listener(move |this, _ev, window, cx| {
open_repo_panel(
&this.dock_area,
&announcement.addr(),
Some(&announcement),
window,
&mut *cx,
);
}))
}
fn render_local_row(
@@ -524,10 +566,6 @@ impl SidebarPanel {
}))
}
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
import_dialog::open(window, cx);
}
/// The user avatar and name, wired into the titlebar drag area.
fn render_user(
&self,
@@ -537,6 +575,7 @@ impl SidebarPanel {
) -> impl IntoElement {
let name = profile.name();
let picture = profile.picture();
let panel = cx.entity().downgrade();
title_bar_drag_handlers(
h_flex()
@@ -545,12 +584,44 @@ impl SidebarPanel {
.when(cfg!(target_os = "macos"), |this| this.pl(px(80.)))
.child(
div().child(
Button::new("user").text().dropdown_caret(true).child(
h_flex()
.gap_1()
.child(Avatar::new(name.clone()).picture(picture))
.child(div().text_xs().font_semibold().child(name)),
),
Button::new("user")
.text()
.dropdown_caret(true)
.child(
h_flex()
.gap_1()
.child(Avatar::new(name.clone()).picture(picture))
.child(div().text_xs().font_semibold().child(name)),
)
.dropdown_menu(move |menu, _, _| {
menu.item(PopupMenuItem::new("View Profile").on_click({
let panel = panel.clone();
move |_, window, cx| {
let _ = panel.update(cx, |this, cx| {
this.open_profile(window, cx);
});
}
}))
.item(PopupMenuItem::new("View Relays").on_click({
let panel = panel.clone();
move |_, window, cx| {
let _ = panel.update(cx, |this, cx| {
this.open_relays(window, cx);
});
}
}))
.separator()
.item(
PopupMenuItem::element(|_, cx| {
div().text_color(cx.theme().danger).child("Exit")
})
.on_click(
|_, window, cx| {
open_exit_dialog(window, cx);
},
),
)
}),
),
),
window,
@@ -610,7 +681,7 @@ impl SidebarPanel {
})),
)
.child(
BaseButton::new("onboarding")
BaseButton::new("import")
.h_flex()
.h_8()
.px_2()
@@ -619,8 +690,8 @@ impl SidebarPanel {
.hover(|this| this.bg(gpui::white().opacity(0.2)))
.active(|this| this.bg(gpui::white().opacity(0.4)))
.child(div().text_sm().child("Import identity"))
.on_click(cx.listener(|this, _ev, window, cx| {
this.open_import(window, cx)
.on_click(cx.listener(|_this, _ev, window, cx| {
import_dialog::open(window, cx)
})),
),
),
@@ -794,3 +865,26 @@ impl Render for SidebarPanel {
)
}
}
fn open_exit_dialog(window: &mut Window, cx: &mut App) {
window.open_alert_dialog(cx, |alert, _, _| {
alert
.title("Exit Signed?")
.description(
"You will be signed out on this device and will need to sign in again on the next session.",
)
.button_props(
DialogButtonProps::default()
.ok_text("Exit")
.ok_variant(ButtonVariant::Danger)
.show_cancel(true),
)
.on_ok(|_, _, cx| {
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| {
backend.sign_out(cx);
});
true
})
});
}
+7 -7
View File
@@ -1,7 +1,8 @@
use dock::{DockArea, DockEvent, DockLayout, DockPlacement, SignedDockSkin, panel_handle};
use gpui::prelude::*;
use gpui::{Context, Entity, Render, Subscription, Window, div, px};
use gpui_component::{Root, StyledExt, Theme};
use gpui_component::notification::NotificationType;
use gpui_component::{StyledExt, Theme, WindowExt};
use settings::{AppearanceMode, SettingsStore};
use signed_state::{Backend, BackendEvent};
@@ -66,6 +67,10 @@ impl Workspace {
if matches!(event, BackendEvent::PassphraseRequired) {
passphrase_dialog::open(window, cx);
}
if let BackendEvent::Error(message) = event {
window.push_notification((NotificationType::Error, message.clone()), cx);
}
},
));
@@ -101,17 +106,12 @@ impl Workspace {
}
impl Render for Workspace {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let dialog_layer = Root::render_dialog_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
.id("workspace")
.v_flex()
.size_full()
.relative()
.child(self.dock.clone())
.children(notification_layer)
.children(dialog_layer)
}
}
+5
View File
@@ -7,6 +7,8 @@ use gpui_component::{Theme, ThemeMode, ThemeRegistry, theme};
use gpui_platform::application;
use settings::{AppearanceMode, SettingsStore};
actions!(signed, [Quit]);
fn main() {
tracing_subscriber::fmt::init();
@@ -17,6 +19,9 @@ fn main() {
gpui_component::init(cx);
theme::init(cx);
cx.bind_keys([KeyBinding::new("secondary-q", Quit, None)]);
cx.on_action(|_: &Quit, cx| cx.quit());
// The persisted settings must load before the theme is applied.
let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx));
SettingsStore::set_global(store.clone(), cx);
+2 -16
View File
@@ -1,18 +1,4 @@
# 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`.
- Profile panel: the sidebar user menu's "View profile" opens an empty placeholder panel; build the actual profile view.
- Relays panel: the sidebar user menu's "View relays" opens an empty placeholder panel; build the actual relays view.