diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index 122981b..4d64b56 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -2,8 +2,8 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; -/// The default grasp servers. -/// Offered while the user has not published a grasp list, kind `10317`. +/// The default grasp servers, +/// offered while the user has not published a grasp list. pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [ "wss://relay.ngit.dev", "wss://gitnostr.com", @@ -23,10 +23,8 @@ pub enum AppearanceMode { Dark, } -/// Theme configuration. -/// Fields mirror the gpui-component `Theme` surface customized at startup. -/// Applying the settings is then a field-for-field copy. -/// Theme names identify entries in the gpui-component theme registry. +/// Theme configuration, +/// fields mirror the gpui-component `Theme` surface customized at startup. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ThemeSettings { @@ -86,8 +84,8 @@ impl Default for GraspServersSettings { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct LocalReposSettings { - /// The directories scanned for local git repositories. - /// Defaults to the user's Desktop and Documents folders. + /// The directories scanned for local git repositories, + /// defaults to the user's Desktop and Documents folders. pub scan_paths: Vec, } @@ -103,8 +101,9 @@ impl Default for LocalReposSettings { } } -/// A remembered association between a local checkout folder and an announced repository. -/// Recorded when the user clones a repository or picks a folder in the New PR panel. +/// A remembered association between a local checkout folder and an announced repository, +/// recorded when the user clones a repository or picks a folder in the New PR panel. +/// /// The panel can then prefill the folder later without asking again. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] diff --git a/crates/settings/src/store.rs b/crates/settings/src/store.rs index 37898ae..87bc1de 100644 --- a/crates/settings/src/store.rs +++ b/crates/settings/src/store.rs @@ -9,7 +9,9 @@ struct GlobalSettingsStore(Entity); impl Global for GlobalSettingsStore {} -/// The application settings, loaded from disk at startup and saved whenever they change. +/// The application settings, +/// loaded from disk at startup and saved whenever they change. +/// /// Installed as a global by the app so any part of the UI can read and edit them. pub struct SettingsStore { path: PathBuf, @@ -27,10 +29,11 @@ impl SettingsStore { cx.set_global(GlobalSettingsStore(entity)); } - /// Load the settings from `path`. - /// Falls back to defaults when the file is missing or unreadable. - /// Missing keys merge with the defaults. - /// Older settings files keep working as new settings are added. + /// Load the settings from `path`, + /// falls back to defaults when the file is missing or unreadable. + /// + /// Missing keys merge with the defaults, + /// older settings files keep working as new settings are added. pub fn new(path: impl AsRef, _cx: &mut Context) -> Self { Self { path: path.as_ref().to_path_buf(), @@ -80,7 +83,6 @@ impl SettingsStore { } /// Write the settings to disk, replacing the file atomically. - /// A crash mid-write cannot corrupt the settings. fn save(&self) -> Result<()> { if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent)?; diff --git a/crates/signed_core/src/addr.rs b/crates/signed_core/src/addr.rs index 43bbf2d..729322b 100644 --- a/crates/signed_core/src/addr.rs +++ b/crates/signed_core/src/addr.rs @@ -1,8 +1,9 @@ use nostr::prelude::*; /// Address of a NIP-34 repository announcement, `30617::`. -/// The Rust Nostr SDK's [`Coordinate`] parses, formats and hashes this. -/// The alias reuses the SDK type while keeping repository-specific vocabulary. +/// +/// 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; /// Build the address of a NIP-34 repository announcement. diff --git a/crates/signed_core/src/annotations.rs b/crates/signed_core/src/annotations.rs index 456c343..affa928 100644 --- a/crates/signed_core/src/annotations.rs +++ b/crates/signed_core/src/annotations.rs @@ -1,13 +1,15 @@ use nostr::prelude::*; -/// ngit and GitWorkshop cover-note extension, kind 1624. -/// A markdown note attached to an issue, patch or PR by its author or a maintainer. -/// Not part of the NIP-34 draft, read support for interop. +/// GitWorkshop and `ngit` cover-note extension, kind 1624. +/// +/// A markdown note attached to an issue, patch or PR by its author or a maintainer, +/// not part of the NIP-34 draft, read support for interop. pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624); /// Whether a kind-1985 label event is a valid annotation of `root`. -/// The event references the root with a lowercase `e` tag. -/// Its author must be the root author or a maintainer. +/// +/// The event references the root with a lowercase `e` tag, +/// its author must be the root author or a maintainer. fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> bool { if event.kind != Kind::Label { return false; @@ -22,8 +24,8 @@ fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> .any(|tag| tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id)) } -/// Whether a kind-1985 label event declares the `#t` namespace. -/// It must also carry at least one `["l", "", "#t"]` label. +/// Whether a kind-1985 label event declares the `#t` namespace, +/// it must also carry at least one `["l", "", "#t"]` label. fn has_hashtag_labels(event: &Event) -> bool { event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"]) && event.tags.iter().any(|tag| { @@ -32,11 +34,12 @@ fn has_hashtag_labels(event: &Event) -> bool { }) } -/// Effective hashtag labels of `root`. -/// The `t` tags on the event itself, self-reported by its author. -/// Authorized NIP-32 kind-1985 events in the `#t` namespace add more. -/// Labels are additive, so all valid label events contribute. -/// There is no latest-wins semantics. +/// Effective hashtag labels of `root`, +/// the `t` tags on the event itself, self-reported by its author, +/// authorized NIP-32 kind-1985 events in the `#t` namespace add more. +/// +/// Labels are additive, so all valid label events contribute, +/// there is no latest-wins semantics. pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -> Vec { let mut labels: Vec = root .tags @@ -62,10 +65,9 @@ pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) - labels } -/// Subject or title override of `root` from authorized kind-1985 label events. -/// Only label events in the `#subject` namespace count. -/// The latest event wins, per NIP-01 replaceable semantics. -/// The tiebreak is the lexicographically larger event id. +/// Subject or title override of `root` from authorized kind-1985 label events, +/// only label events in the `#subject` namespace count. +/// /// Returns `None` when no valid override exists. pub fn subject_override( root: &Event, @@ -105,8 +107,8 @@ pub fn subject_override( }) } -/// Effective hashtag labels and subject override of `root` in one pass. -/// Mirrors ngit's `get_labels_and_subject`. +/// Effective hashtag labels and subject override of `root` in one pass, +/// mirrors ngit's `get_labels_and_subject`. pub fn labels_and_subject( root: &Event, label_events: &[Event], @@ -119,8 +121,7 @@ pub fn labels_and_subject( } /// Effective cover note of `root`. -/// The latest authorized kind-1624 event wins. -/// The tiebreak is the lexicographically larger event id, per NIP-01 replaceable semantics. +/// /// Returns `None` when no valid cover note exists. pub fn cover_note<'a>( root: &Event, diff --git a/crates/signed_core/src/comments.rs b/crates/signed_core/src/comments.rs index d664ae8..4aa285b 100644 --- a/crates/signed_core/src/comments.rs +++ b/crates/signed_core/src/comments.rs @@ -2,8 +2,8 @@ use std::collections::{HashMap, HashSet}; use nostr::prelude::*; -/// A NIP-22 comment thread, a top-level comment on the root event. -/// Nested replies are ordered oldest first at every level. +/// A NIP-22 comment thread, a top-level comment on the root event, +/// nested replies are ordered oldest first at every level. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommentThread { /// The thread's top-level comment. @@ -23,11 +23,13 @@ fn comment_parent(event: &Event) -> Option { .and_then(|id| EventId::parse(id).ok()) } -/// Group the comments on a root issue, patch or PR into NIP-22 threads. -/// A comment whose parent is the root starts a thread. +/// Group the comments on a root issue, patch or PR into NIP-22 threads, +/// a comment whose parent is the root starts a thread. +/// /// Other comments nest under their parent comment. -/// Threads and replies are ordered oldest first. -/// Replies with a missing parent are made top-level threads, so none are dropped. +/// +/// Threads and replies are ordered oldest first, +/// replies with a missing parent are made top-level threads, so none are dropped. pub fn comment_threads(root: &Event, comments: &[Event]) -> Vec { // Index comments by their parent id. // Comments without a parent tag reply to the root event itself. diff --git a/crates/signed_core/src/deletions.rs b/crates/signed_core/src/deletions.rs index bf1d5ed..1fbcd66 100644 --- a/crates/signed_core/src/deletions.rs +++ b/crates/signed_core/src/deletions.rs @@ -2,14 +2,17 @@ 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. +/// 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. @@ -48,6 +51,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`. pub fn is_deleted(&self, event: &Event) -> bool { if self diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index a82049f..be79314 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -42,8 +42,8 @@ pub fn activity(addr: &RepoAddr) -> Filter { } /// Status events, kinds `1630..=1633`, referencing any of the given root events. -/// They are matched via the `#e` tag. -/// One filter covers all roots. +/// 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) -> Filter { Filter::new() @@ -58,7 +58,9 @@ pub fn statuses_for(roots: impl IntoIterator) -> Filter { /// Cover notes and NIP-32 label events referencing any of the given root events. /// These are kinds 1624 and 1985, matched via the `#e` tag. +/// /// Because they carry no repository `a` tag, they are fetched by root like comments. +/// /// Batched, like [`statuses_for`]. pub fn annotations_for(roots: impl IntoIterator) -> Filter { Filter::new() @@ -75,9 +77,7 @@ pub fn grasp_list(public_key: PublicKey) -> Filter { /// NIP-22 comments, kind `1111`, referencing any of the given root events. /// The roots are issues, patches and PRs. -/// Comments carry no repository `a` tag, so they are fetched by root reference. -/// NIP-22 names the uppercase `E` tag as the thread root, used by ngit. -/// Some clients, including Signed, use a lowercase `e` tag, so both are matched. +/// /// Returns two filters, since combining `#E` and `#e` would AND the conditions. pub fn comments_for(roots: impl IntoIterator) -> Vec { let roots: Vec = roots.into_iter().map(|id| id.to_hex()).collect(); @@ -102,22 +102,16 @@ pub fn announcements_by(public_key: PublicKey) -> Filter { } /// All repository announcements, for global discovery. -/// Unbounded, intended for negentropy sync, which reconciles sets regardless of size. -/// Local queries with this filter are served by LMDB, staying fast as the database grows. pub fn all_announcements() -> Filter { Filter::new().kind(Kind::GitRepoAnnouncement) } /// How far back deletion requests are fetched and stored. -/// A deletion request can only target events created before it. -/// NIP-34 events are far younger than this window. -/// Older requests can never match anything shown. -/// Bounding the window keeps the kind-5 and kind-62 set from a full sync reconciliation. -/// That set is one of the largest on public relays. 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(); @@ -126,6 +120,7 @@ fn deletions_since() -> Timestamp { /// 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() @@ -134,7 +129,9 @@ pub fn deletions() -> Filter { } /// 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 { vec![ diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 2da218a..963202c 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -28,7 +28,6 @@ pub struct Announcement { pub euc: Option, /// Other recognized maintainers. pub maintainers: Vec, - /// Value of a `u` tag, if any. /// Marks the repository as a subordinate fork of the upstream, per NIP-34. pub upstream: Option, /// Hashtags labelling the repository, the `t` tags. @@ -36,9 +35,6 @@ pub struct Announcement { } /// The `u` tag of a fork announcement, per NIP-34. -/// The first value is the upstream coordinate or a git URL. -/// The coordinate form is `30617::`. -/// The second value is an optional relay hint for the upstream. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Upstream { /// Raw first value of the `u` tag, a coordinate or git URL. @@ -52,10 +48,6 @@ pub struct Upstream { impl Upstream { /// Parse the `u` tag values. - /// The first value is the upstream coordinate or a git URL. - /// The coordinate form may append `|git-url`. - /// The coordinate is the part before the first `|`. - /// The second value is an optional relay hint. fn parse(raw: &str, relay_hint: Option<&str>) -> Self { let coordinate = raw.split('|').next().unwrap_or(raw); let addr = coordinate @@ -70,7 +62,6 @@ impl Upstream { } /// Text for display. - /// The upstream coordinate for a NIP-34 repository, else the raw `u` value. pub fn display(&self) -> SharedString { match &self.addr { Some(addr) => SharedString::from(addr.to_string()), @@ -104,12 +95,7 @@ pub fn activity_subject(event: &Event) -> SharedString { } /// The patch set of a pull request. -/// The PR references the root patch event, kind `1617`, via its `e` tag. -/// Every patch of the set is chained to the previous one with NIP-10 `e` reply tags. -/// They are returned in series order, oldest first. -/// A PR without an `e` tag falls back to the patch producing its tip commit. -/// The tip commit is the PR's `commit` or `r` tag, per NIP-34. -/// The reply chain is then walked backward to the root. +/// /// Returns an empty list when no patch event can be linked to the PR. pub fn pull_request_patches<'a>( pr: &Event, @@ -160,8 +146,6 @@ pub fn pull_request_patches<'a>( } /// The patch content of a pull request. -/// The contents of its patch set, see [`pull_request_patches`], joined in series order. -/// Older PRs that carried the patch inline fall back to their own content. pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator) -> String { let patches: Vec<&'a Event> = patches.into_iter().collect(); let series = pull_request_patches(pr, patches.iter().copied()); @@ -209,6 +193,7 @@ fn current_commit_of(event: &Event) -> Option { } /// 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 @@ -222,6 +207,7 @@ fn patch_produces_commit(patch: &Event, commit: &str) -> bool { 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 { if event.kind != Kind::GitRepoAnnouncement { @@ -289,8 +275,8 @@ impl Announcement { /// 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. - /// Read-only discovery input, nothing here is published back to nostr. pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool { if self.addr() == *base { return false; @@ -308,9 +294,9 @@ impl Announcement { .unwrap_or(SharedString::from("No description")) } - /// The effective maintainers of this repository. - /// The announced `maintainers` plus the announcement author. - /// The author asserts themselves as a maintainer of the primary project. + /// 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 { let mut maintainers = self.maintainers.clone(); @@ -321,7 +307,6 @@ impl Announcement { } /// The `git clone` URLs for this repository, deduplicated. - /// The announced order is preserved, making output deterministic across calls. pub fn clone_urls(&self) -> Vec { let mut seen = HashSet::new(); self.clone diff --git a/crates/signed_core/src/state.rs b/crates/signed_core/src/state.rs index 258487a..53e9c9d 100644 --- a/crates/signed_core/src/state.rs +++ b/crates/signed_core/src/state.rs @@ -1,9 +1,8 @@ use nostr::prelude::*; -/// Build a kind `30618` repository state event from refs and HEAD. -/// `refs` are `(refname, commit-id)` pairs, e.g. `refs/heads/main`. -/// `head` is the short branch name HEAD points to. -/// It is published as `ref: refs/heads/`. +/// Build a kind `30618` repository state event from refs and HEAD, +/// it is published as `ref: refs/heads/`. +/// /// The `d` tag matches the repository id. pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder { let mut tags: Vec = vec![Tag::identifier(id.to_owned())]; @@ -19,6 +18,7 @@ pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> E } /// 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) { diff --git a/crates/signed_core/src/status.rs b/crates/signed_core/src/status.rs index 26498b9..abc24d2 100644 --- a/crates/signed_core/src/status.rs +++ b/crates/signed_core/src/status.rs @@ -30,8 +30,8 @@ impl RepoStatus { } } -/// Whether an event references the given root event via an `e` or `E` tag. /// 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(); @@ -42,7 +42,7 @@ pub fn references_root(event: &Event, root: &EventId) -> bool { } /// Resolve the status of a root event per NIP-34. -/// The most recent status event from the root author or a maintainer wins. +/// /// Defaults to [`RepoStatus::Open`]. pub fn resolve_status<'a, I>( status_events: I, diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 748ecd1..8e398f7 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -44,8 +44,6 @@ impl GitCache { } /// Open the existing clone, fetching it first. - /// Otherwise clone from the first working URL in `clone_urls`. - /// `clone_urls` holds the announcement's `clone` tag. pub fn ensure_clone(&self, addr: &RepoAddr, clone_urls: &[String]) -> Result { let path = self.repo_path(addr); @@ -66,19 +64,16 @@ impl GitCache { } /// Maximum directory nesting depth when scanning for local repositories. +/// /// Pathological trees can't stall the scan. const SCAN_MAX_DEPTH: usize = 12; /// Directories never descended into during a scan. +/// /// Dependency caches can be enormous without ever containing user repositories. const SCAN_SKIPPED_DIRS: [&str; 1] = ["node_modules"]; /// Walk `root` recursively and collect the paths of git repositories below it. -/// A repository is a directory containing a `.git` entry. -/// Hidden entries and symlinks are skipped. -/// Repositories are not descended into. -/// Nested ones like submodule worktrees are not reported. -/// Results are canonicalized, deduplicated and sorted. pub fn find_git_repos(root: &Path) -> Vec { let mut repos = Vec::new(); if !root.is_dir() { @@ -125,10 +120,7 @@ pub fn find_git_repos(root: &Path) -> Vec { } /// Clone into `path` from the first working URL in `clone_urls`. -/// `clone_urls` holds the announcement's `clone` tag. -/// Then fetch the `refs/nostr/*` PR refs like the cache clone does. -/// The destination must not exist yet. -/// When no URL works, the last error is returned. +/// /// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache. pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> { if path.exists() { @@ -156,8 +148,6 @@ pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> { } /// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace. -/// GRASP mirrors serve pull request branches there, one ref per PR event id. -/// This mirrors the layout used by ngit. pub fn fetch_all(repo: &gix::Repository) -> Result<()> { let options = gix::remote::ref_map::Options { extra_refspecs: vec![ @@ -176,9 +166,10 @@ pub fn fetch_all(repo: &gix::Repository) -> Result<()> { Ok(()) } -/// Apply a `git format-patch` patch or series with `git am`. -/// Uses the git CLI because it handles the mbox format natively. -/// Can be replaced with a pure-Rust implementation later without changing callers. +/// Apply a `git format-patch` patch or series with `git am`, +/// uses the git CLI because it handles the mbox format natively. +/// +/// TODO: Replaced 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") @@ -201,9 +192,11 @@ pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> { Ok(()) } -/// The merge base of two revisions in the repository at `repo_path`. -/// Revisions may be branch names, remote-tracking refs or commit ids. +/// The merge base of two revisions in the repository at `repo_path`, +/// revisions may be branch names, remote-tracking refs or commit ids. +/// /// `Ok(None)` when the revisions share no common ancestor. +/// /// Unresolvable revisions are errors. pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result> { let output = Command::new("git") @@ -230,8 +223,8 @@ pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result> /// 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. +/// +/// 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 { let output = Command::new("git") .arg("-C") @@ -256,9 +249,6 @@ pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result Result<()> { let mut child = Command::new("git") .arg("apply") @@ -282,14 +272,11 @@ pub fn patch_applies(repo_path: &Path, patch: &str) -> Result<()> { String::from_utf8_lossy(&output.stderr).trim() ); } + Ok(()) } /// Push `commit` to `reference` on the server at `url`, from `repo_path`. -/// `reference` is a ref name like `refs/nostr/`. -/// GRASP servers host the `refs/nostr` namespace so anyone can contribute a commit. -/// nak pushes pull request tips there before publishing the PR event. -/// Readers fetch the ref to get the commit behind a PR's `c` tag. pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> { let output = Command::new("git") .arg("-C") @@ -312,8 +299,7 @@ pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &st } /// Split a `git format-patch` series into its individual patches, mbox messages. -/// Each message begins with a `From <40-hex> ` boundary line. -/// `>From` quoting inside bodies means no false positives. +/// /// A single patch yields one element. /// A malformed input yields one element covering it. pub fn split_patch_series(patch: &str) -> Vec<&str> { @@ -342,6 +328,7 @@ pub fn split_patch_series(patch: &str) -> Vec<&str> { } /// The commit HEAD points to in the repository at `repo_path`. +/// /// `None` when the repository has no commits yet, an unborn HEAD. pub fn head_commit_id(repo_path: &Path) -> Result> { let output = Command::new("git") @@ -363,8 +350,8 @@ pub fn head_commit_id(repo_path: &Path) -> Result> { /// The commits in `base..HEAD` of the repository at `repo_path`, oldest first. /// This is the order `git am` creates them. +/// /// `HEAD` alone when `base` is `None`. -/// An empty range yields an empty list. pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result> { let output = match base { Some(base) => git_in( @@ -402,9 +389,10 @@ fn clone(url: &str, path: &Path) -> Result { /// Create a repository at `path` with an initial `main` branch. /// Write a `README.md` from `name` and `description`, then create the initial commit. +/// /// Returns the initial commit id. +/// /// Uses the git CLI, like [`apply_patch`]. -/// The CLI handles index writes, ref updates and default branch selection natively. pub fn init_repository(path: &Path, name: &str, description: &str) -> Result { std::fs::create_dir_all(path) .with_context(|| format!("failed to create {}", path.display()))?; @@ -470,6 +458,7 @@ pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) - } /// 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<()> { let url = format!("{base_url}/{owner}/{repo_id}.git"); @@ -496,8 +485,8 @@ pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> } /// The earliest unique commit of the repository at `repo_path`. -/// A root commit, like `git rev-list --max-parents=0 HEAD`. /// Used as the NIP-34 announcement's `euc` marker. +/// /// `None` for a repository without commits. pub fn root_commit(repo_path: &Path) -> Result> { let output = Command::new("git") @@ -523,8 +512,7 @@ pub fn root_commit(repo_path: &Path) -> Result> { } /// Add `origin` pointing at `url` when the repository has no remote yet. -/// Uses the standard fetch mapping. -/// Later `git fetch origin` and the cache's `fetch_all` update `refs/remotes/origin/*`. +/// /// No-op if `origin` already exists. pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> { // `git remote get-url origin` exits non-zero when the remote is absent. @@ -543,8 +531,9 @@ pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> { Ok(()) } -/// Point `origin` at `url`, replacing an existing remote. -/// Used after a clone whose `origin` points at the cloned-from path. +/// 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<()> { // `git remote get-url origin` exits non-zero when the remote is absent. @@ -557,10 +546,8 @@ pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> { } /// Fetch `refspec` into `repo_path` from the first working URL in `urls`. -/// An example refspec is `+refs/heads/*:refs/fork///*`. -/// Like [`clone_repo`], `grasp://` URLs are rewritten to `https://`. -/// The terminal prompt is disabled. /// When no URL works, the last error is returned. +/// /// Never touches the checked-out refs or the worktree. pub fn fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) -> Result<()> { let mut last_err: Option = None; @@ -599,6 +586,7 @@ pub fn fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) -> Resu /// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`. /// `prefix` is a ref namespace like `refs/fork//`. +/// /// Returns an empty list when nothing matches. pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result> { // `for-each-ref` patterns match whole path components. @@ -629,7 +617,9 @@ pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result> { /// Delete every ref under `prefix` of the repository at `repo_path`. /// `prefix` is a ref namespace like `refs/fork//`. +/// /// Lets a stale import be pruned before a re-import. +/// /// No-op when nothing matches. pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> { let refs = refs_with_prefix(repo_path, prefix)?; @@ -666,6 +656,7 @@ pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> { } /// 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> { let output = Command::new("git") @@ -685,14 +676,7 @@ pub fn origin_url(workdir: &Path) -> Result> { } /// Fast-forward local branches that trail their remote-tracking counterpart. -/// The counterpart ref is `refs/remotes/origin/` in the repository at `workdir`. -/// Like a `git pull --ff-only` on each branch. -/// A mirror clone used for browsing catches up without rewriting history. -/// The checked-out branch is moved with a merge so its worktree follows. -/// A dirty worktree fails the merge cleanly and is left for the next refresh. -/// Other branches are updated directly. -/// Branches without a remote-tracking counterpart are left alone. -/// Local commits of their own also keep a branch untouched. +/// /// Returns whether any branch moved. pub fn fast_forward_branches(workdir: &Path) -> Result { let current = git_in(workdir, &["branch", "--show-current"]).unwrap_or_default(); @@ -734,6 +718,7 @@ pub fn fast_forward_branches(workdir: &Path) -> Result { } /// Run a git command in `dir`, returning trimmed stdout. +/// /// The terminal prompt is disabled so a credential request fails instead of hanging. fn git_in(dir: &Path, args: &[&str]) -> Result { let output = Command::new("git") @@ -757,6 +742,7 @@ fn git_in(dir: &Path, args: &[&str]) -> Result { } /// Map an untrusted repository id or display name to a safe single path component. +/// /// Everything outside `[A-Za-z0-9._-]` becomes `_`. /// An id that maps to exactly `.` or `..` becomes `_`. pub fn sanitize_path_component(id: &str) -> String { @@ -779,6 +765,7 @@ pub fn sanitize_path_component(id: &str) -> String { } /// In-memory object cache for history walks, see [`open_with_cache`]. +/// /// Without one, every walk re-decodes the same commit objects from the object database. const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; @@ -799,7 +786,7 @@ pub struct FileCommit { } /// Relative paths of all entries in the worktree, files and directories. -/// Directories first, then alphabetically within each group. +/// /// The `.git` directory is skipped. pub fn worktree_entries(repo: &gix::Repository) -> Result> { let workdir = repo.workdir().context("repository has no worktree")?; @@ -816,6 +803,7 @@ pub fn worktree_entries(repo: &gix::Repository) -> Result> { } /// 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>> { let workdir = repo.workdir().context("repository has no worktree")?; @@ -830,9 +818,7 @@ pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result Result> { let Some(workdir) = repo.workdir() else { @@ -878,7 +864,10 @@ fn open_with_cache(workdir: &Path) -> Result { /// A [`FileCommit`] from a walk commit, with author, message title and shortened id. /// `include_description` controls whether the message body is copied. -/// History lists never display it, so skipping it saves an allocation per listed commit. +/// +/// History lists never display it, +/// so skipping it saves an allocation per listed commit. +/// /// The diff panel fetches the full commit on demand. fn file_commit(commit: &gix::Commit<'_>, include_description: bool) -> Result { let author = commit.author()?; @@ -900,9 +889,7 @@ fn file_commit(commit: &gix::Commit<'_>, include_description: bool) -> Result`. -/// Walks newest-first from `HEAD`. -/// Reports the first commit whose tree entry for `rel` differs from its first parent's. +/// /// `Ok(None)` when no commit touched the file, e.g. an untracked file. pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result> { let rel = rel.to_path_buf(); @@ -914,7 +901,7 @@ pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result` per path. /// `rels` are paths relative to the worktree. -/// A single walk decodes every commit once and shares it across all paths. +/// /// Paths without any commit, like untracked files, are absent from the result. pub fn worktree_last_commits( workdir: &Path, @@ -924,6 +911,7 @@ pub fn worktree_last_commits( } /// The walk behind [`last_commit`] and [`worktree_last_commits`]. +/// /// Stops as soon as every pending path has its commit. fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result> { use gix::traverse::commit::simple::CommitTimeOrder; @@ -984,14 +972,13 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result Result { use gix::traverse::commit::simple::CommitTimeOrder; @@ -1029,6 +1017,7 @@ pub fn all_commits(repo: &gix::Repository) -> Result { } /// Like [`all_commits`], but opens the repository at `workdir` first. +/// /// For non-bare clones the clone root is the worktree. pub fn worktree_all_commits(workdir: &Path) -> Result { all_commits(&open_with_cache(workdir)?) @@ -1058,7 +1047,6 @@ pub struct DiffLine { } /// A hunk of a file diff, like `@@ -a,b +c,d @@`. -/// Context around each change, then removals and additions. #[derive(Debug, Clone)] pub struct DiffHunk { /// 1-based start line in the old version. @@ -1101,16 +1089,14 @@ pub struct FileDiff { } /// The changes of one commit. -/// Lists every file it added, modified, deleted or renamed. -/// Text files carry line-level hunks. #[derive(Debug, Clone)] pub struct CommitDiff { pub files: Vec, } /// 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. -/// Like `git show`, files are sorted by path. pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result { commit_diff(&open_with_cache(workdir)?, id) } @@ -1127,7 +1113,7 @@ fn commit_diff(repo: &gix::Repository, id: &str) -> Result { } /// The changes between two commits, `base`..`tip`, like `git diff base tip`. -/// Same file handling as [`worktree_commit_diff`]. +/// /// Directories and submodules are skipped, files are sorted by path. pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result { let repo = open_with_cache(workdir)?; @@ -1170,8 +1156,7 @@ pub fn worktree_commit_range_commits( Ok(commits) } -/// The changes between two trees. -/// Used by both [`commit_diff`] and [`worktree_commit_range_diff`]. +/// The changes between two trees. Used by both [`commit_diff`] and [`worktree_commit_range_diff`]. fn tree_diff( repo: &gix::Repository, old_tree: Option<&gix::Tree<'_>>, @@ -1290,12 +1275,6 @@ fn tree_diff( } /// Parse `git format-patch` output, a single patch or a series. -/// Produces the same [`CommitDiff`] structure used for commit diffs. -/// The mbox envelope is skipped, the From and Subject headers, commit body and diffstat. -/// Every `diff --git` section becomes one [`FileDiff`]. -/// Paths come from the section headers, with git's C-style quoting undone. -/// Sections without hunks are reported without lines. -/// That covers pure renames, mode changes and binary files. pub fn patch_diffs(patch: &str) -> Result { let lines: Vec<&str> = patch.lines().collect(); let mut files = Vec::new(); @@ -1315,8 +1294,7 @@ pub fn patch_diffs(patch: &str) -> Result { } /// Commits of a `git format-patch` output, a single patch or a series. -/// Parsed from each patch's mbox envelope headers. -/// Yields the commit id, author, summary and author time. +/// /// Entries appear in patch order, oldest first as `git format-patch` produces them. pub fn patch_commits(patch: &str) -> Vec { let lines: Vec<&str> = patch.lines().collect(); @@ -1379,6 +1357,7 @@ fn name_from_address(from: &str) -> String { } /// 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(); @@ -1396,7 +1375,7 @@ fn strip_patch_prefix(subject: &str) -> String { } /// Parse one file's diff section. -/// Everything after the `diff --git` header up to the next section or the end of the patch. +/// /// Returns the section and the index of the first unconsumed line. fn parse_diff_section(header: &str, lines: &[&str], start: usize) -> Result<(FileDiff, usize)> { let (header_old, header_new) = header_paths(header)?; @@ -1484,7 +1463,7 @@ fn parse_diff_section(header: &str, lines: &[&str], start: usize) -> Result<(Fil } /// Parse one hunk, the `@@ -a,b +c,d @@` header plus every body line. -/// Lines end at the next hunk header, `diff --git` section or the end of the patch. +/// /// Returns the hunk and the index of the first unconsumed line. fn parse_hunk(lines: &[&str], start: usize) -> Result<(DiffHunk, usize)> { let (old_start, old_lines, new_start, new_lines) = hunk_header(lines[start])?; @@ -1543,6 +1522,7 @@ fn parse_hunk(lines: &[&str], start: usize) -> Result<(DiffHunk, usize)> { } /// The kind of a hunk body line, from its first character. +/// /// Lines outside a hunk, headers, `\ No newline...` and the next section, yield `None`. fn line_prefix_kind(line: &str) -> Option { match line.as_bytes().first()? { @@ -1554,6 +1534,7 @@ fn line_prefix_kind(line: &str) -> Option { } /// Parse a unified-diff hunk header, `@@ -a,b +c,d @@`. +/// /// Omitted line counts default to 1. fn hunk_header(header: &str) -> Result<(u32, u32, u32, u32)> { let rest = header @@ -1577,9 +1558,6 @@ fn hunk_header(header: &str) -> Result<(u32, u32, u32, u32)> { } /// The old and new paths of a `diff --git a/X b/Y` header. -/// Git's C-style quoting is undone. -/// Git only quotes paths that need escaping, non-ASCII bytes, `"` and `\`. -/// Plain spaces stay unquoted, so an unquoted header splits at the last ` b/`. fn header_paths(header: &str) -> Result<(String, String)> { if header.starts_with('"') { // Quoted paths include the `a/` / `b/` prefix inside the quotes. @@ -1609,9 +1587,6 @@ fn header_paths(header: &str) -> Result<(String, String)> { } /// The path of a `--- a/X` or `+++ b/Y` line. -/// The prefix is stripped, the trailing tab removed, C-style quoting undone. -/// Git adds a trailing padding tab for paths containing spaces. -/// These lines name the two sides unambiguously, unlike the `diff --git` header. fn diff_line_path(line: &str, prefix: &str) -> Result { let line = line.trim_end_matches('\t'); if line.starts_with('"') { @@ -1630,8 +1605,8 @@ fn diff_line_path(line: &str, prefix: &str) -> Result { /// The content of a git C-style quoted path and the rest of the input. /// The path spans the opening `"`, escaped content and closing `"`. +/// /// `None` if unterminated. -/// Iterates by character so slices land on UTF-8 boundaries even for non-ASCII paths. fn take_quoted(input: &str) -> Option<(&str, &str)> { let mut end = 1; // byte after the opening quote let mut rest = &input[1..]; @@ -1708,10 +1683,6 @@ fn unquote_path(path: &str) -> Result { } /// Collects the hunks of one blob diff while tracking per-line numbers. -/// The unified-diff headers give the 1-based start line of the hunk in each file. -/// Context lines advance both counters. -/// Removals advance only the old counter, additions only the new one. -/// Each line then carries its real numbers in both versions. struct HunkCollector<'a> { hunks: &'a mut Vec, insertions: &'a mut usize, @@ -1784,6 +1755,7 @@ impl ConsumeHunk for HunkCollector<'_> { } /// 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> { let Some(head) = repo.head_id().ok() else { @@ -1795,9 +1767,8 @@ pub fn head_commit(repo: &gix::Repository) -> Result> { /// 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. -/// The commit list, [`all_commits`], omits message bodies to keep the walk cheap. -/// The diff panel uses this to fetch the full commit on demand. pub fn worktree_commit(workdir: &Path, id: &str) -> Result> { let repo = open_with_cache(workdir)?; match repo.rev_parse_single(id.as_bytes()) { @@ -1842,6 +1813,7 @@ pub fn worktree_tags(workdir: &Path) -> Result> { } /// Short name of the branch HEAD points to, or `None` when detached. +/// /// Detached after checking out a tag or a commit directly. pub fn current_branch(repo: &gix::Repository) -> Result> { let head = repo.head()?; @@ -1852,6 +1824,7 @@ pub fn current_branch(repo: &gix::Repository) -> Result> { } /// Branch, tag and HEAD refs of a repository. +/// /// Ready for a NIP-34 kind-30618 repository state announcement. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepoRefState { @@ -1862,6 +1835,7 @@ pub struct RepoRefState { } /// Collect the refs of `repo`. +/// /// Local branches and tags become `(refname, commit-id)` pairs. /// Also reports the branch HEAD points to. pub fn repo_ref_state(repo: &gix::Repository) -> Result { @@ -1914,8 +1888,8 @@ pub struct WorktreeSnapshot { } /// Snapshot the worktree after a branch or tag switch. +/// /// Collects entries, the README, the branch HEAD points to and its commit. -/// Opens the repository once. pub fn worktree_snapshot(workdir: &Path) -> Result { let repo = open_with_cache(workdir)?; let readme_path = find_readme(&repo)?; @@ -1933,6 +1907,7 @@ pub fn worktree_snapshot(workdir: &Path) -> Result { } /// Switch the checked-out ref and update the worktree, like `git checkout --force`. +/// /// Local modifications are discarded, these clones are read-only browser copies. fn checkout(workdir: &Path, args: &[&str]) -> Result<()> { let output = Command::new("git") diff --git a/crates/signed_nostr/src/backend.rs b/crates/signed_nostr/src/backend.rs index 75cf19a..ee66bfa 100644 --- a/crates/signed_nostr/src/backend.rs +++ b/crates/signed_nostr/src/backend.rs @@ -12,10 +12,6 @@ use nostr_sdk::prelude::*; use crate::signer::UniversalSigner; -/// Open or create the LMDB database at `db_path`. -/// Build a Signed client and a fresh signer for it. -/// The SDK manages its own internal tokio runtime. -/// The returned client can be driven by GPUI's executors. #[cfg(not(target_arch = "wasm32"))] pub async fn new_backend(db_path: impl AsRef) -> Result<(Client, UniversalSigner)> { let signer = UniversalSigner::new(Keys::generate()); diff --git a/crates/signed_nostr/src/signer.rs b/crates/signed_nostr/src/signer.rs index d85c8e5..6f00bf6 100644 --- a/crates/signed_nostr/src/signer.rs +++ b/crates/signed_nostr/src/signer.rs @@ -32,8 +32,6 @@ impl UniversalSignerError { } /// A type-erased signer whose inner signer can be swapped in-place. -/// Swaps happen after login or logout. -/// All clones see the swap. #[derive(Clone, Debug)] pub struct UniversalSigner { inner: Arc>>, diff --git a/crates/signed_nostr/src/update.rs b/crates/signed_nostr/src/update.rs index 4b605bb..5784d21 100644 --- a/crates/signed_nostr/src/update.rs +++ b/crates/signed_nostr/src/update.rs @@ -1,7 +1,6 @@ use nostr_sdk::prelude::*; /// A lightweight change notification for the UI. -/// Heavy data stays in the database, consumers re-query on receipt. #[derive(Debug, Clone)] pub struct Update { pub kind: Kind, diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index e38c111..4735668 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -19,8 +19,6 @@ use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; use crate::git_store::GitStore; /// Keyring entry for the user credential. -/// It is an `nsec1...` key or a `bunker://...` URI. -/// The URI embeds a `?master=` NIP-46 session key. pub const USER_KEYRING: &str = "Signed Safe Storage"; /// Timeout for NIP-46 signer responses. pub const NOSTR_CONNECT_TIMEOUT: u64 = 60; @@ -37,17 +35,13 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [ pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"]; /// How long an identical fetch or sync request is suppressed after it started. -/// A second panel for the same repository does not duplicate a live sync. -/// The global and per-author list stores at login share this dedup. -/// After the window, re-fetching is allowed again so data stays fresh. const FETCH_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60); #[derive(Debug, Clone)] pub enum BackendEvent { /// User has no signer configured. SignerRequired, - /// The stored identity is NIP-49 encrypted, an `ncryptsec1...` key. - /// A passphrase is required to decrypt it before the session can resume. + /// The stored identity is NIP-49 encrypted key. PassphraseRequired, /// The signer changed on login, logout or account switch. SignerChanged, @@ -56,12 +50,8 @@ pub enum BackendEvent { /// A new event was received from a relay and stored in the database. NostrUpdate(Update), /// A negentropy sync completed. - /// The database was updated directly, so stores should re-query. - /// No [`BackendEvent::NostrUpdate`] is fired for synced events. Synced, /// A negentropy sync is in flight. - /// Stores may re-query to render incrementally. - /// UI can show `current` and `total` progress. SyncProgress { /// Total events to process. total: u64, @@ -84,9 +74,8 @@ impl BackendEvent { } /// The global backend entity. +/// /// Owns the nostr client, the signer and the notification pump. -/// Stores subscribe to [`BackendEvent`]. -/// They re-query the local database when relevant updates arrive. pub struct Backend { client: Client, signer: UniversalSigner, @@ -94,18 +83,10 @@ pub struct Backend { connected: bool, sync_progress: Option<(u64, u64)>, /// True when the stored credential is NIP-49 encrypted. - /// A passphrase is still needed to resume the session. passphrase_required: bool, /// Fingerprints of recently started fetches and syncs, a relay plus filter set. - /// Duplicate requests within [`FETCH_DEDUP_WINDOW`] collapse into one. - /// Entries are pruned lazily on the next request. recent_fetches: HashMap, /// Repositories with a push in flight, mirror or checkout based. - /// Concurrent pushes of the same refs make the losing push fail server-side. - /// The rejection is a compare-and-swap error from the server. - /// Two panels of the same repository can race. - /// The banner push can also race the header's Republish. - /// Pushes are single-flight per repository. pushing_repos: Arc>>, tasks: Vec>>, } @@ -115,6 +96,7 @@ struct GlobalBackend(Entity); impl Global for GlobalBackend {} /// Removes its repository from the in-flight push set when dropped. +/// /// A push task cancelled by its panel closing cannot leave the repository locked. struct PushGuard { repos: Arc>>, @@ -182,7 +164,7 @@ impl Backend { } /// Bootstrap the client. - /// Connect to the default relays, with the indexers as discovery-only. + /// /// Restore the saved session, if any. fn bootstrap(&mut self, cx: &mut Context) { let client = self.client.clone(); @@ -221,7 +203,9 @@ impl Backend { } /// Restore the saved session from the keyring. + /// /// Emits [`BackendEvent::SignerRequired`] when no credential is stored. + /// /// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity. pub fn restore_session(&mut self, cx: &mut Context) { if cfg!(target_arch = "wasm32") { @@ -283,9 +267,6 @@ impl Backend { } /// Decrypt the NIP-49 keyring credential with the given passphrase. - /// Resume the session on success. - /// The scrypt decryption runs off the UI thread. - /// The task yields the public key or the failure reason, e.g. a wrong passphrase. pub fn restore_with_passphrase( &mut self, password: &str, @@ -321,11 +302,6 @@ impl Backend { } /// Create a new identity. - /// Generate keys and encrypt the secret key with the passphrase, NIP-49. - /// Persist it in the keyring. - /// Then publish the NIP-65 relay list, metadata and grasp list. - /// The encryption runs off the UI thread. - /// The task yields the new public key. pub fn create_identity( &mut self, name: &str, @@ -412,19 +388,8 @@ impl Backend { }) } - /// Create a repository. /// Initialize a local clone with a `main` branch and a `README.md`. - /// Publish the NIP-34 announcement and the repository state to the grasp relays. - /// Push the initial commit to each grasp server. - /// Also create a working copy at `/`, like the header's Clone action. - /// Its `origin` points at the first grasp server. - /// The new project exists in the chosen folder right away. - /// The events must reach the grasp relays before the push. - /// GRASP servers hold the signed state event in purgatory. - /// They accept the push only while the authorization is pending. - /// The pushed repository must not exist yet. - /// The authorization expires after 30 minutes, like gitworkshop and ngit. - /// The git work runs on background threads. + /// /// The task yields the announcement and the path of the working copy. pub fn create_repository( &mut self, @@ -453,16 +418,15 @@ impl Backend { return Task::ready(Err(anyhow!("Sign in to create a repository"))); }; - // The repository identifier is derived from the name, like ngit and gitworkshop. - // Spaces become hyphens. - // Other non-alphanumeric characters become hyphens, except `/`. - // Case is preserved. + // The repository identifier is derived from the name. let repo_id = identifier_from_name(&name); + if repo_id.is_empty() || repo_id.len() > 100 { return Task::ready(Err(anyhow!( "Repository name must produce an identifier of 1-100 characters" ))); } + if !repo_id.chars().any(|c| c.is_ascii_alphanumeric()) { return Task::ready(Err(anyhow!( "Repository name must contain at least one alphanumeric character" @@ -472,9 +436,7 @@ impl Backend { let addr = repo_addr(public_key, repo_id.clone()); let cache = GitStore::global(cx).cache().clone(); let path = cache.repo_path(&addr); - let owner = public_key - .to_bech32() - .unwrap_or_else(|_| public_key.to_hex()); + let owner = public_key.to_bech32().unwrap(); let servers = grasp_servers.clone(); cx.spawn(async move |this, cx| { @@ -544,8 +506,7 @@ impl Backend { this.add_relays(urls, cx); })?; - // The state event is the push authorization. - // It must be accepted before the push below. + // The state event is the push authorization. It must be accepted before the push below. let announcement = GitRepositoryAnnouncement { id: repo_id.clone(), name: Some(name.clone()), @@ -601,8 +562,7 @@ impl Backend { }); if let Err(e) = push.await { - // The events are already published. - // Retract them so the repository is not left announced without content. + // The events are already published. Retract them so the repository is not left announced without content. this.update(cx, |this, cx| { this.retract_events(&[event.clone(), state_event.clone()], cx); }) @@ -622,11 +582,6 @@ impl Backend { } /// Publish an existing local repository to NIP-34. - /// Read its current branches, tags and HEAD. - /// Publish the announcement and the repository state to the grasp relays. - /// Then push every branch and tag to each grasp server. - /// Also point `origin` at the first grasp server. - /// The state event must be accepted before the push, like [`Self::create_repository`]. pub fn publish_local_repo( &mut self, path: PathBuf, @@ -685,8 +640,7 @@ impl Backend { this.add_relays(urls, cx); })?; - // The state event is the push authorization. - // It must be accepted before the push below. + // The state event is the push authorization. It must be accepted before the push below. let announcement = GitRepositoryAnnouncement { id: repo_id.clone(), name: Some(name.clone()), @@ -730,8 +684,8 @@ impl Backend { } }; - // Push every branch and tag to each grasp server. - // The push fails only when no server accepted it. + // Push every branch and tag to each grasp server. The push fails only when no server accepted it. + // // An empty repository has nothing to push. if !refs.is_empty() { let push = cx.background_spawn({ @@ -769,9 +723,6 @@ impl Backend { } /// Re-push the repository's current refs to the grasp servers in its `relays` tag. - /// Publish a fresh state event, the push authorization. - /// Then push every branch and tag, like the init flow. - /// The repository must have a local clone in the cache. pub fn push_repository( &mut self, announcement: Announcement, @@ -784,10 +735,8 @@ impl Backend { /// Push the refs of a local checkout to the grasp servers in its `relays` tag. /// The checkout is the working copy of the user's own repository. + /// /// Publish a fresh state event, then push every branch and tag of the checkout. - /// That mirrors the init flow. - /// `announced_head` keeps the state event's `HEAD` on the announced default branch. - /// That matters when the checkout is on a different branch. pub fn push_checkout( &mut self, announcement: Announcement, @@ -799,12 +748,6 @@ impl Backend { } /// Shared body of the mirror-based and checkout-based pushes. - /// Publish the repository state, the push authorization. - /// Then push every branch and tag of `path` to each announced grasp server. - /// Pushes are single-flight per repository. - /// Concurrent pushes of the same refs fail server-side. - /// The rejection is a compare-and-swap error from the server. - /// Two panels of the same repository can produce the race. fn push_repo_from( &mut self, announcement: Announcement, @@ -828,16 +771,13 @@ impl Backend { addr: addr.clone(), } }; - let owner = announcement - .owner - .to_bech32() - .unwrap_or_else(|_| announcement.owner.to_hex()); + + let owner = announcement.owner.to_bech32().unwrap(); let repo_id = announcement.id.clone(); let relays = announcement.relays.clone(); cx.spawn(async move |this, cx| { - // Held for the whole task. - // Dropped on completion, on error and on cancellation alike. + // Held for the whole task. Dropped on completion, on error and on cancellation alike. let _guard = guard; let mut state = { @@ -850,12 +790,14 @@ impl Backend { // The state event announces the pushed refs. // Keep the announced default branch in `HEAD` when it is among the pushed refs. + // // Otherwise `HEAD` stays the checkout's current branch. let heads: Vec<&str> = state .refs .iter() .filter_map(|(name, _)| name.strip_prefix("refs/heads/")) .collect(); + if let Some(head) = announced_head && heads.iter().any(|branch| *branch == head) { @@ -865,6 +807,7 @@ impl Backend { // Grasp servers authorize a push by the state they have seen. let refs = state.refs.clone(); let head = state.head.clone(); + this.update(cx, |this, cx| { let builder = build_state(&repo_id, &refs, head.as_deref()); this.send(builder, cx) @@ -890,8 +833,7 @@ impl Backend { } /// Delete the repository from nostr. - /// Publish NIP-09 deletions for its announcement, state and activity events. - /// Those are issues, pull requests, patches, statuses and comments. + /// /// Only the repository owner may delete it. pub fn delete_repository( &mut self, @@ -934,7 +876,6 @@ impl Backend { } /// Login with an `nsec1...` key or a `bunker://...` URI. - /// Dispatch on the credential's prefix. pub fn login(&mut self, credential: &str, cx: &mut Context) { let credential = credential.trim(); @@ -950,7 +891,6 @@ impl Backend { } /// Create a fresh identity and login with it. - /// The generated key is persisted in the keyring like any other `nsec` credential. pub fn login_with_new_identity(&mut self, cx: &mut Context) { let nsec = Keys::generate() .secret_key() @@ -960,7 +900,6 @@ impl Backend { } /// Login with an `nsec1...` secret key. - /// The credential is verified by the signer flow and persisted in the keyring. pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context) { let keys = match SecretKey::parse(nsec) { Ok(secret) => Keys::new(secret), @@ -985,10 +924,6 @@ impl Backend { } /// Login with a `bunker://...` URI, NIP-46. - /// A fresh session key is embedded into the stored URI as `?master=`. - /// No separate keyring entry is needed. - /// The auth URL, if any, is opened in the default browser. - /// The credential is persisted in the keyring after the signer proves reachable. pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context) { let uri_string = uri.trim().to_owned(); @@ -1052,8 +987,7 @@ impl Backend { })); } - /// Fetch the user's grasp list of kind `10317`. - /// Add the listed grasp servers as relays. + /// Fetch the user's grasp list and add the listed grasp servers as relays. fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context) { let client = self.client.clone(); @@ -1106,7 +1040,6 @@ impl Backend { } /// True when the stored credential is NIP-49 encrypted. - /// A passphrase is still needed to resume the session. pub fn passphrase_required(&self) -> bool { self.passphrase_required } @@ -1122,14 +1055,11 @@ impl Backend { } /// Progress of the in-flight negentropy sync, if any. - /// Reported as `total` and `current`. pub fn sync_progress(&self) -> Option<(u64, u64)> { self.sync_progress } /// Update the signer. - /// Any type implementing the async signer traits works. - /// Examples are `Keys`, `NostrConnect` and a browser extension proxy. pub fn set_signer(&mut self, new_signer: T, cx: &mut Context) where T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static, @@ -1191,7 +1121,6 @@ impl Backend { } /// Add discovery-only relays, e.g. NIP-65 indexers, and connect to them. - /// No subscriptions or writes are routed through them. pub fn add_discovery_relays(&mut self, urls: Vec, cx: &mut Context) { let client = self.client.clone(); @@ -1215,8 +1144,6 @@ impl Backend { } /// Start a persistent subscription. - /// Matching events are stored in the database automatically. - /// They surface as [`BackendEvent::NostrUpdate`]. pub fn subscribe(&mut self, filter: Filter, cx: &mut Context) { let client = self.client.clone(); @@ -1231,6 +1158,7 @@ impl Backend { } /// Whether an identical fetch started within [`FETCH_DEDUP_WINDOW`] is still recent. + /// /// Records the fingerprint when returning `false`, pruning expired entries first. fn fetch_recently_started(&mut self, fingerprint: u64) -> bool { self.recent_fetches @@ -1243,12 +1171,6 @@ impl Backend { } /// Connect to a repository's announced relays, its NIP-34 `relays` tag. - /// Fetch the repository's events from them. - /// Run a one-shot auto-closing subscription for `filters`. - /// Then a negentropy sync covers issues, patches and PRs stored only on those relays. - /// An identical request within [`FETCH_DEDUP_WINDOW`] is skipped. - /// The relays stay in the pool, so later publishes for this repository reach them too. - /// Failures are logged, not surfaced. pub fn connect_repo_relays( &mut self, relays: Vec, @@ -1278,9 +1200,6 @@ impl Backend { } /// One-shot subscription on the bootstrap relays only. - /// Auto-closes after EOSE or a short timeout. - /// Matching events are stored in the database. - /// They surface as [`BackendEvent::NostrUpdate`] while the subscription is open. pub fn subscribe_bootstrap(&mut self, filters: Vec, cx: &mut Context) { let client = self.client.clone(); @@ -1296,12 +1215,6 @@ impl Backend { } /// Negentropy-sync the given filter against the bootstrap relays. - /// Reconciles the local database with the relays in both directions. - /// Emits [`BackendEvent::SyncProgress`] while running. - /// Throttled to whole-percent changes. - /// Emits [`BackendEvent::Synced`] on completion. - /// An identical sync started within [`FETCH_DEDUP_WINDOW`] is skipped. - /// Observers still see the original sync's progress and completion events. pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context) { let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter)); if self.fetch_recently_started(fingerprint) { @@ -1377,9 +1290,6 @@ impl Backend { } /// Sign, broadcast and locally store an event. - /// Emits [`BackendEvent::Published`] on success so stores can refresh. - /// The task yields the outcome of this specific action for inline progress or errors. - /// The caller owns the task, dropping it cancels the publish. pub fn send( &mut self, builder: EventBuilder, @@ -1430,9 +1340,6 @@ impl Backend { } /// Broadcast and locally store an already-signed event. - /// Like [`Self::send`] without the signing step. - /// Callers that signed early use this. - /// They may need the event id before pushing a commit to the grasp servers. pub fn publish_event( &mut self, event: Event, @@ -1479,8 +1386,6 @@ impl Backend { } /// Publish a NIP-34 repository announcement, kind 30617, with the current signer. - /// The returned task yields the published event. - /// Callers can show inline progress or errors. pub fn publish_announcement( &mut self, announcement: GitRepositoryAnnouncement, @@ -1490,8 +1395,6 @@ impl Backend { } /// Sign, broadcast and store an event without awaiting the result. - /// Failures surface through [`BackendEvent::Error`]. - /// The backend owns the spawned task, so dropping it cancels the task. fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context) { let task = self.send(builder, cx); @@ -1507,9 +1410,6 @@ impl Backend { } /// Publish NIP-09 deletions for `events`, best-effort. - /// A publish that fails midway retracts the events already broadcast to relays. - /// Failures are logged, not surfaced. - /// The caller's error already told the user what happened. fn retract_events(&mut self, events: &[Event], cx: &mut Context) { if events.is_empty() { return; @@ -1534,6 +1434,7 @@ impl Backend { } /// Fingerprint of a relay and filter set, for fetch dedup. +/// /// Relays and filters are sorted first, so the fingerprint is order-independent. fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 { let mut relays: Vec<&str> = relays.to_vec(); @@ -1548,9 +1449,6 @@ fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 { } /// Add the given relays, connect and fetch the filters. -/// Run a one-shot subscription, auto-closing after EOSE, then a negentropy sync per filter. -/// The second pass catches events that race the subscription or flaky EOSE behavior. -/// Relays without NEG-XX support fail the sync step, the subscription already covered them. async fn connect_repo_relays_only( client: &Client, relays: Vec, @@ -1564,8 +1462,8 @@ async fn connect_repo_relays_only( for url in &relays { added |= client.add_relay(url).await?; } + // Connect only when the pool grew. - // Connected relays no-op, but the call still iterates every relay in the pool. if added { client.connect().await; } @@ -1581,8 +1479,6 @@ async fn connect_repo_relays_only( client.subscribe(target).close_on(opts).await?; // Sync the filters concurrently. - // Each reconciles against every relay either way. - // Without NEG-XX a relay would serialize its initial timeout behind every other filter. let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5)); let syncs = filters.into_iter().map(|filter| { let client = &client; @@ -1599,15 +1495,13 @@ async fn connect_repo_relays_only( } } }); + futures::future::join_all(syncs).await; Ok(()) } /// Subscribe only on the bootstrap relays. -/// Auto-closes after EOSE or a short timeout. -/// Use for one-shot data fetches, repo events and profiles. -/// Not for persistent gossip-routed subscriptions. pub(crate) async fn subscribe_bootstrap_only( client: &Client, filters: Vec, @@ -1648,16 +1542,14 @@ fn with_master_key(uri: &str, keys: &Keys) -> String { } /// Base URL of a grasp server, `https://`. +/// /// `ws://` grasp servers use `http://`, like ngit. -/// The repository then lives at `{base}/{npub}/{repo-id}.git`. pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option { // `domain()` drops the port. - // Parse the full URL to keep it, local dev grasp servers often run on a custom port. 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. - // Everything else is HTTPS, matching ngit. let scheme = if relay.scheme().is_secure() { "https" } else { @@ -1667,24 +1559,19 @@ pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option { } /// GRASP clone URL of a repository on a grasp server. -/// Matches the format ngit announces, `https:////.git`. fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option { 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. -/// The pattern is `{base}/prs//.git`. -/// The npub sits in the URL, the server stores it under the hex form. -/// Anyone may push there, no announcement or maintainer rights are involved. pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String { format!("{base_url}/prs/{npub}/{repo_id}.git") } /// Assemble the `clone` URLs of a pull request. +/// /// The author's GRASP-06 `/prs/` URLs come first. -/// They are author-controlled and most likely to accept the tip push. -/// The base announcement's clone URLs follow, deduplicated while preserving order. pub(crate) fn pr_clone_urls(prs_urls: Vec, base_clone_urls: Vec) -> Vec { let mut seen = std::collections::HashSet::new(); let mut urls = Vec::new(); @@ -1697,7 +1584,6 @@ pub(crate) fn pr_clone_urls(prs_urls: Vec, base_clone_urls: Vec) -> Ve } /// The `g` tag servers of one kind-10317 grasp list event, in tag order. -/// Unparseable URLs are dropped, the UI only writes well-formed servers. fn grasp_list_servers(event: &Event) -> Vec { event .tags @@ -1709,8 +1595,6 @@ fn grasp_list_servers(event: &Event) -> Vec { } /// Grasp servers of the newest kind-10317 grasp list among `events`. -/// The latest event wins, like other latest-wins resolutions in the app. -/// Empty when there is no list, so the caller falls back to the settings defaults. fn latest_grasp_list_servers(events: Vec) -> Vec { events .into_iter() @@ -1720,9 +1604,6 @@ fn latest_grasp_list_servers(events: Vec) -> Vec { } /// Resolve the user's published grasp servers. -/// Read the `g` tags of their latest kind-10317 grasp list in the local database. -/// Returns an empty list when the user has no published list. -/// The caller can then fall back to the settings defaults. pub(crate) async fn user_grasp_list_servers( client: Client, user: PublicKey, @@ -1737,10 +1618,6 @@ pub(crate) async fn user_grasp_list_servers( } /// Push the repository at `path` to every grasp server. -/// Rejecting servers are logged, the push only fails when no server accepted it. -/// `push` performs the single-server push. -/// [`signed_git::push_main`] serves the create flow. -/// [`signed_git::push_all`] serves the init flow. async fn push_to_grasp_servers( path: PathBuf, owner: String, @@ -1777,7 +1654,6 @@ async fn push_to_grasp_servers( } /// Split a stored bunker credential into the plain URI and the session key. -/// Credentials without an embedded key, legacy, get a fresh one. fn extract_master_key(credential: &str) -> (&str, Keys) { match credential.split_once("master=") { Some((base, nsec)) => { diff --git a/crates/signed_state/src/checkouts.rs b/crates/signed_state/src/checkouts.rs index cefba37..ce858eb 100644 --- a/crates/signed_state/src/checkouts.rs +++ b/crates/signed_state/src/checkouts.rs @@ -16,16 +16,12 @@ use crate::local_repos::LocalReposStore; use crate::repo_list::RepoListStore; /// Delay between a refresh request and the actual re-computation. -/// Bursts of notifications, settings edits and rescan ticks, collapse into one pass. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); /// How often the statuses of open repository panels are refreshed. -/// A commit or pull in external git surfaces in the banner without reopening the panel. const STATUS_POLL: Duration = Duration::from_secs(15); /// Background poll interval for the `ready to push` badges of the user's own repositories. -/// Used when no repository panel is open. -/// Each cycle refreshes the remote view of the checkouts with a git fetch. const PUSH_POLL: Duration = Duration::from_secs(60); /// Maximum checkouts considered per repository when computing statuses. @@ -36,6 +32,7 @@ struct GlobalCheckoutsStore(Entity); 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 { @@ -50,9 +47,11 @@ pub struct CheckoutStatus { /// The fallbacks are `main`, then the first local branch. /// For ready-to-push statuses, the remote-tracking ref. /// Unpushed commits are counted against it. + /// /// It is `refs/remotes/origin/`, else `origin/HEAD` for new branches. pub base: String, /// Commits in `base..branch`. + /// /// Zero-ahead checkouts are dropped, so this is always above zero. pub ahead: u32, } @@ -67,21 +66,21 @@ struct Remembered { /// Global store of local-checkout associations and per-checkout statuses. pub struct CheckoutsStore { /// Checkout paths per announced repository. - /// Remembered records, freshest first, plus scanned repos matched implicitly. - /// Deduplicated by path. - /// Missing directories are dropped before publishing. by_repo: Arc>>, /// Ready-to-contribute statuses of the requested repositories. statuses: Arc>>, /// Repositories whose statuses are recomputed on every input change. + /// /// Those are the repository detail panels currently open. status_requested: HashSet, /// 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, /// Ready-to-push statuses of the requested own repositories. push_statuses: Arc>>, /// Last announced head branch per requested repository. + /// /// A recompute defaults the base the same way. requested_head: HashMap>, refreshing: bool, @@ -103,9 +102,6 @@ impl CheckoutsStore { } /// Create the store. - /// Observe the inputs, settings records, the local scan and the announcement list. - /// Signer changes also trigger a refresh. - /// Associations are resolved right away. pub fn new(cx: &mut Context) -> Self { let mut subscriptions = Vec::new(); @@ -118,12 +114,15 @@ impl CheckoutsStore { subscriptions.push(cx.observe(&settings, |this, _settings, cx| { this.refresh(cx); })); + subscriptions.push(cx.observe(&local, |this, _local, cx| { this.refresh(cx); })); + subscriptions.push(cx.observe(&repos, |this, _repos, cx| { this.refresh(cx); })); + // Another identity's repositories must not keep the old statuses alive. // Their polls stop too. subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| { @@ -155,12 +154,11 @@ impl CheckoutsStore { if !cfg!(target_arch = "wasm32") { store.refresh(cx); } + store } /// Remember a successful local-checkout use. - /// Re-insert the record with a fresh timestamp. - /// Freshest-first ordering then follows actual use. pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context) { if cfg!(target_arch = "wasm32") { return; @@ -190,6 +188,7 @@ 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 { self.by_repo.get(addr).cloned().unwrap_or_default() @@ -197,6 +196,7 @@ impl CheckoutsStore { /// 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, @@ -212,16 +212,13 @@ impl CheckoutsStore { } /// The ready-to-contribute statuses of `addr`. + /// /// Empty while none are known or nothing is ahead. pub fn statuses_of(&self, addr: &RepoAddr) -> Vec { self.statuses.get(addr).cloned().unwrap_or_default() } /// Ask for the `ready to push` statuses of `addr` to stay current. - /// The sidebar and the detail panels call this for the user's own repositories. - /// Recomputed on every input change and on a background poll. - /// Each cycle refreshes the remote view first. - /// A commit made in external git surfaces within one poll interval. pub fn request_push_statuses(&mut self, addr: &RepoAddr, cx: &mut Context) { self.push_requested.insert(addr.clone()); self.refresh(cx); @@ -229,13 +226,14 @@ impl CheckoutsStore { /// The ready-to-push statuses of `addr`. /// Only meaningful for repositories announced by the signed-in user. + /// /// Empty while none are known or nothing is unpushed. pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec { self.push_statuses.get(addr).cloned().unwrap_or_default() } /// Re-resolve the associations and the requested statuses. - /// Debounced, bursts of notifications collapse into one pass. + /// /// Requests arriving while a pass runs fold into a follow-up. pub fn refresh(&mut self, cx: &mut Context) { if self.refreshing { @@ -268,6 +266,7 @@ impl CheckoutsStore { let settings = SettingsStore::global(cx); settings.read(cx).settings().checkouts.records.clone() }; + let remembered: Vec = records .into_iter() .filter_map(|record| { @@ -279,9 +278,11 @@ impl CheckoutsStore { }) }) .collect(); + let announcements = RepoListStore::global(cx).read(cx).announcements.clone(); let scanned = LocalReposStore::global(cx).read(cx).repos.clone(); let cache_root = GitStore::global(cx).cache().root().canonicalize().ok(); + let requested: Vec<(RepoAddr, Option)> = self .status_requested .iter() @@ -292,16 +293,17 @@ impl CheckoutsStore { ) }) .collect(); + let push_requested: Vec = self.push_requested.iter().cloned().collect(); 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, Option)> = Vec::new(); for path in scanned.iter() { - // The browser's mirror clones share the announce URLs and EUCs. - // They are not user checkouts. + // The browser's mirror clones share the announce URLs and EUCs. They are not user checkouts. if cache_root .as_ref() .is_some_and(|root| path.starts_with(root)) @@ -314,6 +316,7 @@ impl CheckoutsStore { } let associations = resolve_associations(&remembered, &facts, announcements.iter()); + // Missing directories are stale records, drop them. let associations: HashMap> = associations .into_iter() @@ -384,20 +387,17 @@ impl CheckoutsStore { } // Keep the statuses current while any repository panel is open. - // The user's own repositories also count when watched for the sidebar badge. - // Local commits, pulls and branch switches happen outside the app. - // They are not otherwise observable. this.update(cx, |this, cx| { if poll && !this.debouncing && !this.refreshing { this.debouncing = true; // Open panels get the fast cadence. - // Sidebar-only badges poll less aggressively. // Each cycle fetches every watched checkout's remote. let delay = if this.status_requested.is_empty() { PUSH_POLL } else { STATUS_POLL }; + let task = cx.spawn(async move |this, cx| { cx.background_executor().timer(delay).await; this.update(cx, |this, cx| { @@ -405,6 +405,7 @@ impl CheckoutsStore { this.run_refresh(cx); }) }); + this.tasks.push(task); } })?; @@ -415,10 +416,6 @@ impl CheckoutsStore { } /// Identity of a repository URL. -/// Host, explicit port and path count, with a trailing `.git` and slashes stripped. -/// Scheme-insensitive, so `ws`, `wss`, `http`, `https` and `grasp` are one transport. -/// `None` for unparseable URLs, e.g. `git@`-style or plain paths. -/// Those then compare by raw string. fn url_identity(url: &str) -> Option<(String, Option, String)> { let parsed = Url::parse(url).ok()?; let host = parsed.host_str()?.to_ascii_lowercase(); @@ -430,7 +427,6 @@ fn url_identity(url: &str) -> Option<(String, Option, String)> { } /// Whether two repository URLs point at the same repository. -/// Ignores the transport scheme, see [`url_identity`]. fn same_repo_url(a: &str, b: &str) -> bool { match (url_identity(a), url_identity(b)) { (Some(a), Some(b)) => a == b, @@ -439,9 +435,6 @@ fn same_repo_url(a: &str, b: &str) -> bool { } /// Resolve the associations between local checkouts and announced repositories. -/// Remembered records come first, freshest first per repository. -/// Scanned repositories matched by origin URL or EUC follow. -/// Deduplicated by path, remembered entries win. fn resolve_associations<'a>( remembered: &[Remembered], scanned: &[(PathBuf, Option, Option)], @@ -467,9 +460,11 @@ fn resolve_associations<'a>( .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)); + if url_match || euc_match { let paths = out.entry(announcement.addr()).or_default(); if !paths.contains(path) { @@ -483,8 +478,6 @@ fn resolve_associations<'a>( } /// Whether the worktree of `path` has uncommitted changes. -/// A dirty checkout is never suggested. -/// The proposal should cover committed work. fn worktree_dirty(path: &Path) -> bool { let output = Command::new("git") .arg("-C") @@ -499,8 +492,6 @@ fn worktree_dirty(path: &Path) -> bool { } /// Commits in `base..branch` of the checkout at `path`. -/// Reads `git rev-list --count`. -/// `0` when the range is empty or cannot be computed. fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 { let output = Command::new("git") .arg("-C") @@ -518,7 +509,6 @@ fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 { } /// The branch checked out at `path`, read via `git branch --show-current`. -/// `None` when detached. fn current_branch_of(path: &Path) -> Option { let output = Command::new("git") .arg("-C") @@ -532,10 +522,6 @@ fn current_branch_of(path: &Path) -> Option { } /// The ready-to-contribute status of one checkout. -/// `None` when idle. -/// Idle means detached HEAD, no branches, a dirty worktree or nothing ahead of its base. -/// The base defaults like the New PR panel. -/// The announced HEAD branch when the checkout has it, else `main`, else the first branch. fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option { let branches = signed_git::worktree_branches(path).ok()?; if branches.is_empty() || worktree_dirty(path) { @@ -562,6 +548,7 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option bool { let output = Command::new("git") @@ -574,11 +561,6 @@ fn ref_exists(path: &Path, name: &str) -> bool { } /// The `ready to push` status of one checkout of the user's own repository. -/// The checked-out branch has commits the grasp servers do not have yet. -/// The remote view is refreshed first, best-effort. -/// Offline, the last known remote state still counts commits made since. -/// Detached checkouts, dirty worktrees and an unknown remote state yield no status. -/// Branches the remote does not have yet are counted against the remote HEAD. fn checkout_push_status(path: &Path) -> Option { if worktree_dirty(path) { return None; @@ -612,9 +594,6 @@ fn checkout_push_status(path: &Path) -> Option { } /// Whether the pull request `pr` already proposes the same change as `checkout`. -/// `pr` is a kind-1618 root, resolved `open` by the caller. -/// Matches when authored by `user` with a matching `branch-name` tag. -/// For renamed branches, a `c` tip tag matching the checkout's HEAD commit counts. pub fn pr_proposes_checkout( pr: &Event, open: bool, diff --git a/crates/signed_state/src/git_store.rs b/crates/signed_state/src/git_store.rs index 5dd0173..af9b50b 100644 --- a/crates/signed_state/src/git_store.rs +++ b/crates/signed_state/src/git_store.rs @@ -8,13 +8,12 @@ struct GlobalGitStore(GitCache); impl Global for GlobalGitStore {} /// Global access to the on-disk git clone cache, the grasp mirrors. -/// Installed at startup via [`GitStore::set_global`]. -/// See also [`signed_state::init`]. #[derive(Debug, Clone)] pub struct GitStore(GitCache); impl GitStore { /// Register the clone cache rooted at `root` as an app-wide global. + /// /// Replaces any installed store, [`signed_state::init`] installs an empty one. pub fn set_global(root: impl Into, cx: &mut App) -> Self { let store = Self::new(root); @@ -23,7 +22,6 @@ impl GitStore { } /// The app-wide clone cache. - /// Panics if [`GitStore::set_global`] was never called. pub fn global(cx: &App) -> Self { Self(cx.global::().0.clone()) } diff --git a/crates/signed_state/src/local_repos.rs b/crates/signed_state/src/local_repos.rs index 6f5a377..776943d 100644 --- a/crates/signed_state/src/local_repos.rs +++ b/crates/signed_state/src/local_repos.rs @@ -46,9 +46,6 @@ impl LocalReposStore { } /// Forget a repository that has just been published to NIP-34. - /// It leaves the local list immediately. - /// A later rescan re-discovers it from disk. - /// The sidebar also hides published repositories by identifier. pub fn remove(&mut self, path: &Path, cx: &mut Context) { self.repos = Arc::new( self.repos diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index 61040af..10537c9 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -63,6 +63,7 @@ impl Profile { /// Message from the fetch task to the main thread. enum Dispatch { /// A batched sync finished. + /// /// Re-read seen profiles from the database. Synced, } @@ -71,8 +72,8 @@ enum Dispatch { const BATCH_TIMEOUT: Duration = Duration::from_millis(500); /// Global profile cache. +/// /// Profiles are fetched in batches and kept as plain data. -/// The whole store notifies on change. pub struct ProfileStore { profiles: HashMap, /// Public keys requested this session, main thread only. @@ -114,7 +115,6 @@ impl ProfileStore { }); // Fetch requests are queued on a channel. - // A background task syncs them in batches. let client = backend.read(cx).client(); let (sender, receiver) = flume::unbounded::(); let (dispatch_tx, dispatch_rx) = flume::unbounded::(); @@ -146,8 +146,8 @@ impl ProfileStore { } /// Get a profile. - /// Returns a placeholder with default metadata. - /// Queues a fetch when the profile is not cached yet. + /// + /// Returns a placeholder with default metadata. Queues a fetch when the profile is not cached yet. pub fn get(&self, public_key: &PublicKey) -> Profile { if let Some(profile) = self.profiles.get(public_key) { return profile.clone(); @@ -237,6 +237,7 @@ impl ProfileStore { } /// Re-read the latest metadata of every requested author from the local database. + /// /// Used after a sync, which produces no NostrUpdate events. fn apply_seen(&mut self, cx: &mut Context) { let authors: Vec = self.seen.borrow().iter().copied().collect(); @@ -292,7 +293,7 @@ impl ProfileStore { } /// Sync metadata for requested authors in batches, debounced to collect requests. - /// Runs on a background thread. + /// /// Results are dispatched to the main thread, which re-reads the database. async fn handle_requests( client: &Client, diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 45c61c6..096af7f 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -20,14 +20,15 @@ use crate::backend::{ use crate::git_store::GitStore; /// Delay between a refresh request and the actual re-query. -/// Bursts of events, e.g. per-event `NostrUpdate`s, collapse into one query. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); /// Maximum size of one patch event. +/// /// NIP-34 suggests patches when each event is under 60kb. const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024; /// Per-repository store. +/// /// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses. /// Always derived from the local database. pub struct RepoStore { @@ -43,30 +44,32 @@ pub struct RepoStore { /// Comments on issues / PRs, oldest first. pub comments: Vec, /// Resolved status per root event, issue, patch or PR. - /// Recomputed on every refresh. - /// Render paths are HashMap lookups instead of per-root status scans. - /// Those scans are quadratic, with an allocation per pair. status_by_root: HashMap, /// Open issue and root PR counts. /// Computed with [`Self::status_by_root`] on every refresh. open_issue_count: usize, open_pr_count: usize, /// Kind-1624 cover notes and kind-1985 label events. + /// /// They reference this repository's roots, used by ngit and GitWorkshop. cover_notes: Vec, labels: Vec, /// Incremented on every applied refresh. + /// /// Views key their derived-data caches to it instead of recomputing on every render. version: u64, /// Error of the last action initiated from this store, if any. pub last_error: Option, /// Non-fatal warning of the last action, if any. + /// /// Example, a PR published without its commit reaching a grasp server. pub last_warning: Option, /// Relays already asked to connect to, from this repository's NIP-34 `relays` tag. + /// /// Avoids re-subscribing and re-fetching on every refresh. repo_relays: HashSet, /// Root events, issues, patches and PRs, already fetched per root. + /// /// The per-root fetches cover NIP-22 comments and statuses without an `a` tag. /// Also kind-1624 cover notes and kind-1985 labels. root_fetches: HashSet, @@ -172,6 +175,7 @@ impl RepoStore { } /// Filters that make up a repository. + /// /// Announcement, state, activity and deletions targeting it. fn repo_filters(addr: &RepoAddr) -> Vec { let mut filters = vec![ @@ -189,14 +193,13 @@ impl RepoStore { } /// Fetch this repository's events from the relays in its NIP-34 `relays` tag. - /// Deduplicated, each relay is contacted once per store. - /// Refreshes after the first are no-ops unless the announcement lists new relays. fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context) { let new: Vec = relays .iter() .filter(|url| !self.repo_relays.contains(*url)) .cloned() .collect(); + if new.is_empty() { return; } @@ -204,6 +207,7 @@ impl RepoStore { let backend = Backend::global(cx); let addr = self.addr.clone(); + backend.update(cx, |backend, cx| { backend.connect_repo_relays(new, Self::repo_filters(&addr), cx); }); @@ -220,8 +224,6 @@ impl RepoStore { } /// Re-query the local database and update all fields. - /// The query and processing run on a background thread. - /// Only the results are applied on the main thread. pub fn refresh(&mut self, cx: &mut Context) { if self.refreshing { self.refresh_dirty = true; @@ -527,7 +529,6 @@ impl RepoStore { } /// The effective cover note of `root`, kind 1624, if any. - /// The latest note authored by the root author or a maintainer. pub fn cover_note_of(&self, root: &Event) -> Option<&Event> { let maintainers = self .announcement @@ -539,7 +540,6 @@ impl RepoStore { } /// The effective hashtag labels of `root`. - /// Its own `t` tags plus labels from NIP-32 kind-1985 events in the `#t` namespace. pub fn labels_of(&self, root: &Event) -> Vec { let maintainers = self .announcement @@ -552,7 +552,6 @@ impl RepoStore { } /// The effective subject or title override of `root`, if any. - /// Comes from authorized kind-1985 events in the `#subject` namespace. pub fn subject_of(&self, root: &Event) -> Option { let maintainers = self .announcement @@ -564,6 +563,7 @@ impl RepoStore { } /// Number of open issues. + /// /// Issues whose resolved status is [`RepoStatus::Open`]. /// Issues without status events default to open. pub fn issue_count(&self) -> usize { @@ -571,6 +571,7 @@ impl RepoStore { } /// Number of open pull requests. + /// /// Only root PR events count, PR updates do not. /// They must resolve to [`RepoStatus::Open`]. pub fn pull_request_count(&self) -> usize { @@ -578,6 +579,7 @@ impl RepoStore { } /// Whether `user` is the author or owner of this repository. + /// /// The author is the public key of the repository address. /// Only the author may manage pull requests, close, reopen or merge. pub fn is_author(&self, user: &PublicKey) -> bool { @@ -610,6 +612,7 @@ impl RepoStore { } /// Reply to `parent`, a comment on `root`, with a NIP-22 threaded comment. + /// /// `None` publishes a top-level comment on the root itself. pub fn reply( &mut self, @@ -631,31 +634,6 @@ impl RepoStore { } /// Open a pull request on this repository. - /// A root PR event, kind 1618, carries the markdown description. - /// A root patch event, kind 1617, carries the `git format-patch` output. - /// The PR references the patch via an `e` tag, NIP-34. - /// The patch series is published first. - /// One kind-1617 event per commit, chained with NIP-10 `e` replies. - /// Each event stays under [`MAX_PATCH_EVENT_BYTES`]. - /// The PR then references the root patch's id. - /// The proposed commit is the series tip. - /// It comes from the last `From ` header. - /// Publishing is refused without one. - /// The PR's `c` tag must carry a real commit id. - /// Other NIP-34 clients verify and apply the proposal from it. - /// The `clone` tag lists the author's GRASP-06 `/prs/` URLs first. - /// Taken from the author's kind-10317 grasp list, else the settings defaults. - /// The announced mirror URLs follow. - /// The tip stays downloadable on the author's hosting. - /// This holds even when the base project accepts nothing. - /// With `push_from` set, the tip is pushed to those servers. - /// The ref is `refs/nostr/`, author servers first, best-effort. - /// The push happens before the PR is published. - /// The linked patch stays the source of truth either way. - /// `branch_name` lands in the PR's `branch-name` tag, NIP-34. - /// `draft` publishes a kind-1633 status right after the PR event. - /// `merge_base` is the hex commit the proposed branch forked from. - /// It is computed from a local checkout when the patch was generated there. #[allow(clippy::too_many_arguments)] pub fn open_pull_request( &mut self, @@ -712,13 +690,13 @@ impl RepoStore { return; }; // The author's npub names their GRASP-06 namespace, `/prs/...`. - let author_npub = user.to_bech32().unwrap_or_else(|_| user.to_hex()); + let author_npub = user.to_bech32().unwrap(); let addr = self.addr.clone(); let owner = self.addr.public_key; let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); let repo_id = addr.identifier.clone(); - let base_npub = owner.to_bech32().unwrap_or_else(|_| owner.to_hex()); + let base_npub = owner.to_bech32().unwrap(); let push_relays = self .announcement .as_ref() @@ -930,10 +908,7 @@ impl RepoStore { } /// Update a pull request. - /// Publish revision patch events chained to the original root patch. - /// The first event carries `t root-revision` and a NIP-10 `e` reply, per NIP-34. - /// Then a kind-1619 PR update event carries the new tip. - /// Only the PR author may update it. + /// /// Other authors must open a new PR. pub fn update_pull_request(&mut self, root: &Event, patch: String, cx: &mut Context) { self.last_error = None; @@ -1054,8 +1029,8 @@ impl RepoStore { } /// Set the status of a root event. + /// /// Only the root author or a maintainer may set it, per NIP-34. - /// Status events from anyone else are ignored by clients, so refuse them up front. pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context) { self.last_error = None; @@ -1092,19 +1067,18 @@ impl RepoStore { self.send(builder, cx); } - /// Publish a repository state announcement, kind 30618. - /// It carries the local clone's branches, tags and HEAD. - /// Only the repository owner may publish state. - /// A local clone must exist to read the refs from. + /// Publish a repository state announcement pub fn publish_state(&mut self, cx: &mut Context) { self.last_error = None; let backend = Backend::global(cx); + let Some(user) = backend.read(cx).current_user() else { self.last_error = Some("Sign in to publish repository state".into()); cx.notify(); return; }; + if !self.is_author(&user) { self.last_error = Some("Only the repository owner can publish state".into()); cx.notify(); @@ -1146,16 +1120,6 @@ impl RepoStore { } /// Merge a pull request. - /// Apply its patch, the linked root patch event's content, to the local clone. - /// Then publish a kind-1631 Applied status event with merge provenance. - /// The provenance covers the commits `git am` created. - /// They appear as `applied-as-commits` and `r` tags. - /// It also tags the applied patch events. - /// `q` tags per event and `e` replies for every patch beyond the root, NIP-34. - /// Only the repository author may merge. - /// The clone is created on demand from the announcement's clone URLs. - /// Patch application, `git am`, runs on a background thread. - /// Failures, e.g. a patch that no longer applies, surface in [`Self::last_error`]. pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context) { self.last_error = None; self.last_warning = None; @@ -1171,23 +1135,28 @@ impl RepoStore { let cache = GitStore::global(cx).cache().clone(); let addr = self.addr.clone(); + let clone_urls: Vec = self .announcement .as_ref() .map(|a| a.clone.iter().map(ToString::to_string).collect()) .unwrap_or_default(); + let patch = pull_request_patch(root, self.patches.iter()); + // The applied patch events, for the status tags below. let patches: Vec = pull_request_patches(root, self.patches.iter()) .into_iter() .cloned() .collect(); + let relay_hint = self .announcement .as_ref() .and_then(|a| a.relays.first()) .map(ToString::to_string) .unwrap_or_default(); + let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); let root = root.clone(); @@ -1231,9 +1200,6 @@ impl RepoStore { } /// Publish a kind-1631 Applied status event for `root` after a merge. - /// `applied-as-commits` and `r` tags name the commits `git am` created. - /// `q` tags name the applied patch events. - /// `e` reply tags cover every patch of the series beyond the root, NIP-34. fn publish_applied_status( &mut self, root: &Event, @@ -1249,11 +1215,13 @@ impl RepoStore { Tag::public_key(root.pubkey), Tag::coordinate(self.addr.clone(), None), ]; + if let Some(euc) = euc && let Ok(tag) = Tag::parse(["r", euc]) { tags.push(tag); } + // Tag each applied patch event. // `q` per event, `e` reply for events beyond the root, chain parts and revisions. // Their statuses then resolve to Applied too. @@ -1269,6 +1237,7 @@ impl RepoStore { tags.push(tag); } } + // The commits `git am` created on top of the previous HEAD. if !applied.is_empty() { let mut applied_tag = vec!["applied-as-commits".to_string()]; @@ -1312,8 +1281,6 @@ where } /// Status of `root` from the precomputed map. -/// Roots without status events default to [`RepoStatus::Open`]. -/// Matches [`signed_core::resolve_status`]. fn status_of(status_by_root: &HashMap, root: &Event) -> RepoStatus { status_by_root .get(&root.id) @@ -1322,9 +1289,6 @@ fn status_of(status_by_root: &HashMap, root: &Event) -> Rep } /// Resolve every root event's status in one pass. -/// Status events are indexed by the root they reference, the `e` or `E` tag. -/// Each root resolves against its own slice. -/// Linear in roots and statuses, per-root resolution is their product. fn resolve_statuses( issues: &[Event], patches: &[Event], @@ -1373,12 +1337,8 @@ fn patch_current_commit(patch: &str) -> Option<&str> { } /// Publish a `git format-patch` series as chained kind-1617 events. +/// /// Returns the root event, the one a PR references. -/// The first part carries `first_marker`. -/// That is `t root`, or `t root-revision` with an `e` reply to `reply_to` for revisions. -/// Every later part replies to the previous one, NIP-34. -/// Every part gets the repository coordinate, the owner and its `commit` and `r` tags. -/// The repository EUC is added when known. #[allow(clippy::too_many_arguments)] async fn publish_patch_series( this: &WeakEntity, @@ -1446,10 +1406,6 @@ async fn publish_patch_series( } /// Build a NIP-22 kind-1111 comment. -/// Uppercase `E`, `K` and `P` tags scope the thread root. -/// Lowercase `e`, `k` and `p` tag the direct parent, or the root for a top-level comment. -/// An `a` tag with the repository coordinate is added, not part of NIP-22. -/// Signed's own activity subscriptions then match it too. fn comment_builder( root: &Event, parent: Option<&Event>, @@ -1465,6 +1421,7 @@ fn comment_builder( relay_hint.cloned().map(Cow::Owned), ) }; + let root_target = target(root); let parent_target = parent.map(target).unwrap_or_else(|| root_target.clone()); diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index 981014d..3aa6861 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -10,6 +10,7 @@ use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr}; use crate::backend::{Backend, BackendEvent}; /// Delay between a refresh request and the actual re-query. +/// /// Bursts of events, e.g. sync progress ticks, collapse into one query. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); @@ -21,13 +22,12 @@ struct GlobalRepoListStore(Entity); impl Global for GlobalRepoListStore {} /// NIP-34 activity event counts per repository, ranking the explore list by popularity. -/// Each patch event is a pushed commit or a small series. -/// That is the closest proxy for commit count in the event data. #[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. @@ -42,9 +42,6 @@ impl RepoActivityCounts { } /// Store listing repository announcements, global discovery or per-author. -/// The all-repos store, `author: None`, is created at startup by [`crate::init`]. -/// Installed as a global. -/// The explore panel renders from the local database without waiting for relays. pub struct RepoListStore { /// Shared so views can clone the list per frame without a deep copy. pub announcements: Arc>, @@ -52,6 +49,7 @@ pub struct RepoListStore { /// Covers announcements, state updates, patches, PRs, issues and statuses. pub last_activity: Arc>, /// Issues, pull requests and commits per repository. + /// /// Used for the Popular ranking of the explore list. pub counts: Arc>, author: Option, @@ -65,7 +63,6 @@ pub struct RepoListStore { impl RepoListStore { /// Retrieve the global explore store. - /// It lists all announcements and is created at startup by [`crate::init`]. pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } @@ -96,12 +93,15 @@ impl RepoListStore { } } BackendEvent::Published(event) => { - let announcement = event.kind == Kind::GitRepoAnnouncement - && this.author.is_none_or(|a| a == event.pubkey); + let kind_match = event.kind == Kind::GitRepoAnnouncement; + let author_match = this.author.is_none_or(|a| a == event.pubkey); + let announcement = kind_match && author_match; + // Locally published deletions are already in the local database. // Refresh so they take effect immediately, like relay deletions. let deletion = event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; + announcement || deletion } BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true, @@ -156,9 +156,9 @@ impl RepoListStore { } /// One-shot initial load. + /// /// Query the local database immediately, no debounce. /// Stored announcements appear as soon as the app opens. - /// Only called from [`Self::new`], before any refresh can be pending. fn refresh_initial(&mut self, cx: &mut Context) { debug_assert!(!self.debouncing); if self.refreshing { @@ -169,11 +169,6 @@ impl RepoListStore { } /// Re-query the local database. - /// The latest announcement per repository wins. - /// A short debounce collapses bursts of requests, e.g. sync progress ticks. - /// Requests that arrive while a query runs fold into one follow-up query. - /// The query and processing run on a background thread. - /// Only the results are applied on the main thread. pub fn refresh(&mut self, cx: &mut Context) { if self.refreshing { self.refresh_dirty = true; diff --git a/crates/signed_ui/src/copy_row.rs b/crates/signed_ui/src/copy_row.rs index a5b136b..4ed20cb 100644 --- a/crates/signed_ui/src/copy_row.rs +++ b/crates/signed_ui/src/copy_row.rs @@ -5,7 +5,6 @@ use gpui_component::menu::PopupMenuItem; use gpui_component::{ActiveTheme, StyledExt, h_flex}; /// A muted command row with a copy button. -/// The value renders truncated and a [`Clipboard`] button copies the full value. pub fn copy_row(copy_id: E, command: &SharedString, cx: &App) -> Div where E: Into, @@ -35,10 +34,6 @@ where } /// One row of a copy menu, with a small title above the compact label. -/// A copy button flips to a check while the value is on the clipboard. -/// Clicking the row copies and dismisses the menu. -/// The copy button stops propagation so the menu stays open. -/// Both copy `copy`, never the label. pub fn menu_copy_row( id: &'static str, title: &'static str, diff --git a/crates/signed_ui/src/util.rs b/crates/signed_ui/src/util.rs index da9fe33..3e4d0e5 100644 --- a/crates/signed_ui/src/util.rs +++ b/crates/signed_ui/src/util.rs @@ -1,4 +1,5 @@ /// `[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(); diff --git a/crates/workspace/src/views/repo_detail/about.rs b/crates/workspace/src/views/repo_detail/about.rs index 7b19be1..da71285 100644 --- a/crates/workspace/src/views/repo_detail/about.rs +++ b/crates/workspace/src/views/repo_detail/about.rs @@ -8,7 +8,6 @@ use signed_state::ProfileStore; use signed_ui::{UserAvatar, middle_truncate}; /// Open the About dialog showing every field of the announcement event. -/// The event is NIP-34 kind 30617, parsed into [`Announcement`]. pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) { window.open_dialog(cx, move |dialog, _window, cx| { let announcement = announcement.clone(); @@ -23,7 +22,6 @@ pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, } /// The announcement's fields as labeled rows. -/// Hex identifiers carry a copy button, multi-value tags one line per value. fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement { let mut rows: Vec = Vec::new(); @@ -161,6 +159,7 @@ fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement { /// One row per maintainer with avatar and display name. /// The display name falls back to a shortened npub. +/// /// A copy button copies the full pubkey. fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement { let profile_store = ProfileStore::global(cx); @@ -192,6 +191,7 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement { } /// One row per item of a multi-value tag. +/// /// The value is truncated to a single line, with a copy button for the full value. fn list(id: &'static str, items: impl IntoIterator, cx: &App) -> AnyElement { v_flex() diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index 40cf3aa..2d44e1a 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -17,6 +17,7 @@ const TREE_WIDTH: f32 = 240.; /// Files larger than this are not previewed. pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024; /// Preview cache caps, a file count and a text byte count. +/// /// The oldest previews are evicted beyond the caps. pub(super) const MAX_PREVIEWED_FILES: usize = 32; pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024; @@ -34,9 +35,6 @@ pub(super) enum FileContent { } /// A markdown document loaded into a persistent [`TextViewState`]. -/// The state lives in the view rather than being created per render. -/// GPUI drops keyed element state after one absent frame. -/// A per-render state would re-parse the whole document on every pane switch. pub(super) struct MarkdownView { /// Source path, `None` means the repository README. pub(super) path: Option, @@ -44,9 +42,6 @@ pub(super) struct MarkdownView { } /// A code file loaded into a persistent [`InputState`]. -/// It renders as a disabled, read-only code editor. -/// Syntax highlighting, line numbers and search are included. -/// Persistent for the same reason as [`MarkdownView`]. pub(super) struct CodeView { /// Source path, relative to the worktree root. pub(super) path: SharedString, @@ -216,8 +211,6 @@ impl RepoDetailView { } /// Load `text` into the persistent markdown TextView state. - /// The state is created empty and fed via `push_str`, which parses on a background task. - /// Switching files never blocks the main thread. pub(super) fn set_markdown( &mut self, path: Option, @@ -230,15 +223,18 @@ impl RepoDetailView { } /// The persistent markdown TextView for `path`, where `None` is the README. + /// /// Shows a spinner while the document is being loaded or parsed. fn markdown_element(&self, path: Option<&str>, _cx: &mut Context) -> AnyElement { let Some(md) = &self.md else { return preview_spinner(); }; + let ready = match path { Some(path) => md.path.as_deref() == Some(path), None => md.path.is_none(), }; + if !ready { return preview_spinner(); } @@ -252,8 +248,8 @@ impl RepoDetailView { } /// Load `text` into the persistent code editor state for `path`. + /// /// Code editor mode makes the Input render it read-only and highlighted. - /// The tree-sitter parse runs on a background task like [`set_markdown`]'s. pub(super) fn set_code( &mut self, path: SharedString, diff --git a/crates/workspace/src/views/repo_detail/commits.rs b/crates/workspace/src/views/repo_detail/commits.rs index 5d7eb7b..49fb6ce 100644 --- a/crates/workspace/src/views/repo_detail/commits.rs +++ b/crates/workspace/src/views/repo_detail/commits.rs @@ -70,8 +70,6 @@ pub(super) fn commit_row( } impl RepoDetailView { - /// Full-height body of the Commits tab. - /// All commits in a virtual list, or a status message while loading or empty. pub(super) fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { let Some(list) = self.all_commits.as_ref() else { return if self.loading_all_commits { diff --git a/crates/workspace/src/views/repo_detail/diff.rs b/crates/workspace/src/views/repo_detail/diff.rs index 9aca129..201b913 100644 --- a/crates/workspace/src/views/repo_detail/diff.rs +++ b/crates/workspace/src/views/repo_detail/diff.rs @@ -29,8 +29,6 @@ use super::helpers::{ const TREE_WIDTH: f32 = 260.; /// Tree and per-file diff body, shared by the commit diff and compare views. -/// Owns the changed-files explorer and the virtual list of the selected file's hunks. -/// The host feeds it a [`CommitDiff`] via [`DiffPane::set_diff`]. pub struct DiffPane { /// Loaded diff, `None` until [`Self::set_diff`] is called. diff: Option, @@ -39,7 +37,6 @@ pub struct DiffPane { /// Path of the file whose diff is shown in the detail column. selected_file: Option, /// Rows of the selected file's diff, hunk headers and lines. - /// Backing the virtual list in the detail column. rows: Vec, /// Per-row heights of [`Self::rows`]. item_sizes: Rc>>, @@ -90,6 +87,7 @@ impl DiffPane { } /// Forget the diff, e.g. when the compared branches changed. + /// /// Clears the tree, the selection and the diff rows. pub fn clear(&mut self, cx: &mut Context) { self.diff = None; @@ -187,8 +185,6 @@ impl DiffPane { } /// The diff of one file, with a header showing status and stats. - /// The hunks render in a virtual list. - /// A large diff is never materialized per frame. fn render_file_diff(&self, file: &FileDiff, view: Entity, cx: &App) -> AnyElement { let status_label = match file.status { DiffStatus::Added => "A", @@ -316,7 +312,6 @@ impl Render for DiffPane { } /// Detail panel showing the diff of one commit. -/// A metadata header plus the shared [`DiffPane`] body. pub struct CommitDiffView { focus_handle: FocusHandle, /// Local clone the commit lives in. @@ -324,8 +319,6 @@ pub struct CommitDiffView { /// Display name of the repository the commit belongs to. repo_name: SharedString, /// The commit being shown in the header and tab title. - /// Starts as an id-only stub, the history list omits the full metadata. - /// [`Self::load`] replaces the stub with the full metadata. commit: FileCommit, /// The diff is being computed on a background task. loading: bool, @@ -369,8 +362,7 @@ impl CommitDiffView { } } - /// Load the commit diff and the full commit metadata on a background task. - /// Then populate the tree. + /// Load the commit diff and the full commit metadata. fn load(&mut self, window: &mut Window, cx: &mut Context) { self.loading = true; self.error = None; diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index d70ab9b..2e96102 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -11,9 +11,6 @@ use signed_core::Announcement; use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff}; use signed_ui::{menu_copy_row, middle_truncate}; -/// A `Send` file-tree node, the build runs on a background thread. -/// The main thread converts the seeds into [`TreeItem`]s. -/// [`TreeItem`]s hold `Rc` state and cannot cross threads. pub(super) struct TreeItemSeed { /// Path of the node, relative to the worktree root. id: String, @@ -22,10 +19,6 @@ pub(super) struct TreeItemSeed { children: Vec, } -/// Convert tree seeds into [`TreeItem`]s. -/// Every folder is expanded when `expand_folders` is set. -/// The commit diff explorer shows only changed files, typically a handful. -/// Its folders start expanded, the worktree explorer's folders collapsed. pub(super) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec { fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem { let mut item = TreeItem::new(seed.id, seed.label); @@ -47,10 +40,6 @@ pub(super) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec< } /// Build nested tree items from a flat entry list sorted dirs-first. -/// Returns [`TreeItemSeed`]s so the build can run off the main thread. -/// A worktree walk can yield tens of thousands of entries. -/// Nodes live in an arena, parents are found via a path-to-index map. -/// That keeps the build linear in the number of path components. pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec { // Node indices by full path, so parents resolve in constant time while inserting. let mut index: HashMap = HashMap::new(); @@ -96,8 +85,6 @@ pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec { } /// The markdown fence language for a file path, or `None` for plain text. -/// Names resolve in `gpui_component`'s highlighter. -/// `highlighter::Language::from_name` accepts short aliases like `rs` and `js`. pub(super) fn code_language(path: &str) -> Option<&'static str> { let name = Path::new(path) .file_name() @@ -192,6 +179,7 @@ impl ShareTargets { } /// The share dropdown menu, one row per target. + /// /// Each shows a compact label, the copy button and row click copy the full value. pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu { menu.min_w(px(340.)) @@ -223,8 +211,6 @@ impl ShareTargets { } /// Shorten an naddr link to `/naddr1...[last tail chars]`. -/// `https://gitworkshop.dev/naddr1...abcd` is an example. -/// Only the label is shortened, the copied value stays the full URL. fn truncate_naddr_link(url: &str, tail: usize) -> String { let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { return url.to_string(); @@ -241,6 +227,7 @@ pub(super) const GUTTER_WIDTH: f32 = 44.; pub(super) const DIFF_ROW_HEIGHT: f32 = 20.; /// One row of a virtual diff list, a hunk header or a line of a hunk. +/// /// Shared by the commit diff and pull request diff viewers. #[derive(Clone, Copy)] pub(super) enum DiffRow { @@ -300,6 +287,7 @@ pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> Any } /// One diff line, old and new line numbers in the gutters. +/// /// The content is tinted by kind, addition, deletion or context. pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { let bg = match line.kind { diff --git a/crates/workspace/src/views/repo_detail/init_dialog.rs b/crates/workspace/src/views/repo_detail/init_dialog.rs index f5edc7e..b98a478 100644 --- a/crates/workspace/src/views/repo_detail/init_dialog.rs +++ b/crates/workspace/src/views/repo_detail/init_dialog.rs @@ -26,9 +26,6 @@ pub struct InitRepoState { } /// Open the Init dialog for the local repository at `local_path`. -/// The dialog loads the user's default grasp servers, a kind `10317` grasp list. -/// It falls back to the shared defaults when the user has none set. -/// On success the dialog closes and `view` switches into NIP-34 mode. pub fn open( local_path: PathBuf, view: WeakEntity, @@ -39,23 +36,25 @@ pub fn open( .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_default(); + + let grasp_settings = SettingsStore::global(cx) + .read(cx) + .settings() + .grasp_servers + .clone(); + + let state = cx.new(|_| InitRepoState::default()); + let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings)); + + let relay_input = cx.new(|cx| InputState::new(window, cx).placeholder("relay.example.com")); let name_input = cx.new(|cx| InputState::new(window, cx).default_value(default_name)); let desc_input = cx.new(|cx| { TextareaState::new(window, cx) .auto_grow(3, 5) .placeholder("Short description") }); - let relay_input = cx.new(|cx| { - InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com") - }); - let state = cx.new(|_| InitRepoState::default()); - let grasp_settings = SettingsStore::global(cx) - .read(cx) - .settings() - .grasp_servers - .clone(); - let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings)); + // Load the user's grasp servers. load_user_grasp_servers(grasp_state.clone(), window, cx); window.open_dialog(cx, move |dialog, _window, _cx| { @@ -153,6 +152,7 @@ pub fn open( } /// Run the init flow. +/// /// Closes the dialog and switches the repository into NIP-34 mode on success. fn init_repository( local_path: PathBuf, diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/repo_detail/issues.rs index 77fd4e3..3886b3c 100644 --- a/crates/workspace/src/views/repo_detail/issues.rs +++ b/crates/workspace/src/views/repo_detail/issues.rs @@ -25,8 +25,6 @@ use utils::relative_time; use super::issue_detail::IssueDetailView; /// Height of one issue row in the virtual list. -/// `py_2` padding, a 32px `h_8` title line and a 24px `h_6` meta line. -/// Plus the 1px bottom border. const ISSUE_ROW_HEIGHT: f32 = 73.; /// Status filter of the issues list, chosen via the header's filter buttons. @@ -37,7 +35,6 @@ enum IssueFilter { /// Issues whose resolved status is [`RepoStatus::Open`]. Open, /// Issues whose resolved status is [`RepoStatus::Closed`]. - /// [`RepoStatus::Applied`] counts too, both are done states. Closed, } @@ -67,9 +64,6 @@ pub struct IssuesView { /// The filtered issue count [`Self::item_sizes`] was built for. issue_len: usize, /// Indices into the store's `issues` matching [`Self::filter`]. - /// The virtual list renders this slice. - /// Rebuilt only when the store version or the filter changes. - /// Keyed by [`Self::cache_key`]. visible_issues: Vec, /// Header counts `(total, open, closed)`, rebuilt with [`Self::visible_issues`]. counts: (usize, usize, usize), @@ -121,8 +115,6 @@ impl IssuesView { }); } - /// Render one row of the issue list. - /// `ix` is the row index, `issue_ix` the index in the store's `issues`. fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context) -> AnyElement { let issue = &self.store.read(cx).issues[issue_ix]; let title = activity_subject(issue); @@ -186,7 +178,6 @@ impl IssuesView { fn render_header(&self, cx: &mut Context) -> AnyElement { // Counts of the last list rebuild. - // `render` rebuilds first when the store version or filter changed, so never stale. let (total, open, closed) = self.counts; h_flex() @@ -245,7 +236,6 @@ impl IssuesView { } /// Open the new issue dialog, a title and a content input. -/// Confirming submits through [`RepoStore::open_issue`]. pub(super) fn open_new_issue_dialog(store: Entity, window: &mut Window, cx: &mut App) { let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title")); let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue...")); @@ -335,8 +325,8 @@ impl Render for IssuesView { let filter = self.filter; // Rows and counts are rebuilt only when the store refreshed or filter changed. - // Other renders reuse the cache. let version = self.store.read(cx).version(); + if self.cache_key != Some((version, filter)) { let store = self.store.read(cx); let mut counts = (0usize, 0usize, 0usize); diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 31e3dd9..4749d34 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -2419,7 +2419,7 @@ fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedSt let owner = announcement.owner; let user = nip05 .map(str::to_owned) - .unwrap_or_else(|| owner.to_bech32().unwrap_or_else(|_| owner.to_hex())); + .unwrap_or_else(|| owner.to_bech32().unwrap()); let mut url = format!("nostr://{user}"); if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) { diff --git a/crates/workspace/src/views/repo_detail/new_pull_request.rs b/crates/workspace/src/views/repo_detail/new_pull_request.rs index c67e1da..246a67b 100644 --- a/crates/workspace/src/views/repo_detail/new_pull_request.rs +++ b/crates/workspace/src/views/repo_detail/new_pull_request.rs @@ -14,7 +14,7 @@ use gpui_component::button::{Button, ButtonVariants}; use gpui_component::combobox::{ Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext, }; -use gpui_component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; +use gpui_component::input::{Input, InputState, Textarea, TextareaState}; use gpui_component::menu::{DropdownMenu, PopupMenu, PopupMenuItem}; use gpui_component::scroll::Scrollbar; use gpui_component::searchable_list::SearchableVec; @@ -36,14 +36,6 @@ use super::commits::{COMMIT_ROW_HEIGHT, commit_row}; use super::diff::{CommitDiffView, DiffPane}; /// The new pull request panel of a repository. -/// The compare side comes from a local checkout or an announced fork. -/// A local checkout lists its own branches in both selectors. -/// Git ops and the tip push run in the checkout. -/// An announced fork imports its branches into the target's GitCache mirror. -/// The base selector lists the mirror's `refs/remotes/origin/*` branches. -/// All git ops run in the mirror. -/// The Files and Commits tabs are built from `merge-base..compare` of the chosen refs. -/// The patch series published with the PR comes from the same range at submit time. pub struct NewPullRequestView { focus_handle: FocusHandle, /// Dock area the panel lives in, commit diffs are opened there. @@ -53,14 +45,10 @@ pub struct NewPullRequestView { /// Display name of the repository, for the panel title. repo_name: SharedString, /// The user's local checkout. - /// Both branches live there in checkout mode and the tip is pushed from there. - /// `None` until a folder is picked. repo_path: Option, /// Branches of the checkout, backing both selectors in checkout mode. branches: Vec, /// Fork-backed compare state. - /// `Some` switches the panel into fork mode. - /// The checkout above is kept so the user can switch back. fork: Option, /// Selected base branch, the PR target, stored as a short name. base: SharedString, @@ -96,8 +84,6 @@ pub struct NewPullRequestView { } /// A fork-backed compare. -/// The fork's heads are imported into the target mirror under `refs/fork//*`. -/// The mirror's own `refs/remotes/origin/*` refs track the base branches. struct ForkCompare { /// Fork announcement the compare branch is imported from. announcement: Announcement, @@ -129,10 +115,6 @@ fn fork_namespace(announcement: &Announcement) -> String { } /// The announced forks of `base` a New PR compare can be built from. -/// Related by `u` tag or shared EUC, excluding the base itself. -/// Announcements without `clone` URLs are unfetchable and excluded. -/// Own forks, announced by `user`, come first. -/// Newest first as `RepoListStore` keeps them, order is preserved within each group. fn fork_candidates<'a>( announcements: &'a [Announcement], base: &RepoAddr, @@ -154,6 +136,7 @@ fn fork_candidates<'a>( } /// The display name of an announcement. +/// /// Its human-readable name, falling back to the repository id. fn fork_display_name(announcement: &Announcement) -> SharedString { announcement @@ -182,6 +165,7 @@ fn truncate_label(label: &str) -> SharedString { } /// The compare-source menu entry of one local checkout folder. +/// /// Applies the folder directly, no picker. fn checkout_source_item( view: WeakEntity, @@ -229,6 +213,7 @@ fn choose_folder_source_item(view: WeakEntity) -> PopupMenuI } /// The compare-source menu entry of one announced fork. +/// /// Imports its branches into the target's mirror and switches the panel to fork mode. fn fork_source_item( view: WeakEntity, @@ -310,7 +295,7 @@ impl NewPullRequestView { let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title")); let description = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe...")); - let base_select: Entity>> = cx.new(|cx| { + let base_select = cx.new(|cx| { ComboboxState::new( SearchableVec::new(Vec::::new()), Vec::new(), @@ -320,7 +305,7 @@ impl NewPullRequestView { .searchable(true) }); - let compare_select: Entity>> = cx.new(|cx| { + let compare_select = cx.new(|cx| { ComboboxState::new( SearchableVec::new(Vec::::new()), Vec::new(), @@ -331,10 +316,6 @@ impl NewPullRequestView { }); let subscriptions = vec![ - // Re-evaluate the Create button's enabled state as the title changes. - cx.subscribe(&subject, |_this, _state, _event: &InputEvent, cx| { - cx.notify(); - }), cx.subscribe_in(&base_select, window, |this, _state, event, window, cx| { if let ComboboxEvent::Change(values) = event && let Some(name) = values.first() @@ -405,6 +386,7 @@ impl NewPullRequestView { } /// The path git ops run against. + /// /// The target's mirror in fork mode, the user's checkout otherwise. fn work_path(&self) -> Option { match &self.fork { @@ -415,6 +397,7 @@ impl NewPullRequestView { /// The full ref the selected base branch resolves to. /// The mirror's remote-tracking ref in fork mode. + /// /// The plain branch name in checkout mode, git resolves it through `refs/heads`. fn base_ref(&self) -> String { match &self.fork { @@ -425,6 +408,7 @@ impl NewPullRequestView { /// The full ref the selected compare branch resolves to. /// The imported `refs/fork/` ref in fork mode. + /// /// The plain branch name in checkout mode. fn compare_ref(&self) -> String { match &self.fork { @@ -434,9 +418,6 @@ impl NewPullRequestView { } /// Prompt for a local checkout. - /// On success populate the branch selectors and load the compare. - /// Defaults are the announced HEAD branch for the base. - /// The checkout's current branch is the default for the compare. fn choose_checkout(&mut self, window: &mut Window, cx: &mut Context) { let prompt = cx.prompt_for_paths(PathPromptOptions { files: false, @@ -452,6 +433,7 @@ impl NewPullRequestView { Ok(Ok(Some(mut paths))) => paths.pop(), _ => None, }; + let Some(path) = picked else { return Ok(()); }; @@ -466,6 +448,7 @@ impl NewPullRequestView { } /// Apply `path` as the local checkout, no picker. + /// /// Branches and current branch are read off the UI thread, then applied. fn apply_folder_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) { let path = path.to_string_lossy().to_string(); @@ -495,9 +478,6 @@ impl NewPullRequestView { } /// Apply a picked checkout, filling the selectors and loading the compare. - /// Leaves fork mode. - /// A fork applied earlier keeps its import in the mirror, harmless. - /// The panel switches back to the checkout. fn apply_checkout( &mut self, path: String, @@ -506,6 +486,7 @@ impl NewPullRequestView { cx: &mut Context, ) { self.fork = None; + let Some((branches, current)) = info else { self.error = Some("The chosen folder is not a git repository".into()); self.repo_path = None; @@ -516,6 +497,7 @@ impl NewPullRequestView { cx.notify(); return; }; + if branches.is_empty() { self.error = Some("The repository has no branches yet".into()); self.repo_path = None; @@ -528,12 +510,14 @@ impl NewPullRequestView { // Falling back to `main`, then the first branch. // The checkout's current branch is the compare side default. let announced = self.store.read(cx).head.clone(); + let base = announced .as_ref() .filter(|branch| branches.contains(branch)) .cloned() .or_else(|| branches.iter().find(|branch| *branch == "main").cloned()) .unwrap_or_else(|| branches[0].clone()); + let compare = current .filter(|branch| branches.contains(branch)) .unwrap_or_else(|| base.clone()); @@ -545,19 +529,23 @@ impl NewPullRequestView { // Remember this folder as a checkout of the target repository. // The next panel pre-fills it. let addr = self.store.read(cx).addr().clone(); - CheckoutsStore::global(cx).update(cx, |store, cx| { + let checkout_store = CheckoutsStore::global(cx); + checkout_store.update(cx, |store, cx| { store.record(PathBuf::from(&path), addr, cx); }); let branches = self.branches.clone(); let base = SharedString::from(base.clone()); let compare = SharedString::from(compare.clone()); + self.base = base.clone(); self.compare = compare.clone(); + self.base_select.update(cx, |state, cx| { state.set_items(SearchableVec::from(branches.clone()), window, cx); state.set_selected_values(&[base], window, cx); }); + self.compare_select.update(cx, |state, cx| { state.set_items(SearchableVec::from(branches), window, cx); state.set_selected_values(&[compare], window, cx); @@ -567,6 +555,7 @@ impl NewPullRequestView { } /// The base repository of the panel, its address and announced EUC. + /// /// Used to find fork candidates. fn base_repo(&self, cx: &App) -> (RepoAddr, Option) { let store = self.store.read(cx); @@ -575,6 +564,7 @@ impl NewPullRequestView { } /// Announced forks of the target repository a compare can use, own first. + /// /// Re-read whenever the picker opens. fn fork_candidates(&self, cx: &App) -> Vec { let (base, euc) = self.base_repo(cx); @@ -587,11 +577,6 @@ impl NewPullRequestView { } /// Compare against an announced fork. - /// The target's GitCache mirror is ensured, then the fork's heads land under `refs/fork/…`. - /// The base selector lists `refs/remotes/origin/*`, the compare the import. - /// Then the compare loads. - /// Picking the fork already applied refreshes it, re-import and reload. - /// The branch selection is kept. fn choose_fork( &mut self, announcement: Announcement, @@ -728,6 +713,7 @@ impl NewPullRequestView { cx: &mut Context, ) { self.loading = false; + let (base_branches, compare_branches) = match result { Ok(branches) => branches, Err(error) => { @@ -738,11 +724,13 @@ impl NewPullRequestView { return; } }; + if compare_branches.is_empty() { self.error = Some("The fork has no branches to compare".into()); cx.notify(); return; } + if base_branches.is_empty() { self.error = Some("Could not list the target repository's branches; try again later".into()); @@ -752,6 +740,7 @@ impl NewPullRequestView { let base_branches: Vec = base_branches.into_iter().map(SharedString::from).collect(); + let compare_branches: Vec = compare_branches .into_iter() .map(SharedString::from) @@ -764,8 +753,10 @@ impl NewPullRequestView { let announced = self.store.read(cx).head.clone(); let contains = |name: &str, list: &[SharedString]| list.iter().any(|branch| branch.as_ref() == name); + let keep_base = keep_base.filter(|name| contains(name, &base_branches)); let keep_compare = keep_compare.filter(|name| contains(name, &compare_branches)); + let base = keep_base .or_else(|| { announced @@ -780,6 +771,7 @@ impl NewPullRequestView { .cloned() }) .unwrap_or_else(|| base_branches[0].clone()); + let compare = keep_compare .or_else(|| { compare_branches @@ -794,13 +786,16 @@ impl NewPullRequestView { namespace, mirror_path, }); + self.error = None; self.base = base.clone(); self.compare = compare.clone(); + self.base_select.update(cx, |state, cx| { state.set_items(SearchableVec::from(base_branches), window, cx); state.set_selected_values(&[base], window, cx); }); + self.compare_select.update(cx, |state, cx| { state.set_items(SearchableVec::from(compare_branches), window, cx); state.set_selected_values(&[compare], window, cx); @@ -810,15 +805,14 @@ impl NewPullRequestView { } /// Recompute `merge_base..compare` of the selected branches on a background task. - /// Computes the merge base, the commit list and the diff. - /// Runs against the work path, the checkout or the mirror in fork mode. - /// Full refs keep base `main` and fork `main` distinct. fn reload_compare(&mut self, window: &mut Window, cx: &mut Context) { let Some(repo_path) = self.work_path() else { return; }; + let base = self.base_ref(); let compare = self.compare_ref(); + // Short names for the error copy, the full refs go to git. let base_name = self.base.to_string(); let compare_name = self.compare.to_string(); @@ -826,6 +820,7 @@ impl NewPullRequestView { self.loading = true; self.error = None; self.compare_generation += 1; + let generation = self.compare_generation; cx.notify(); @@ -871,11 +866,11 @@ impl NewPullRequestView { this.update_in(cx, |this, _window, cx| { // A stale result, branches changed mid-flight, must not clobber a newer compare. - // The newer task clears the flag. if generation != this.compare_generation { return; } this.loading = false; + match result { Ok((merge_base, commits, diff)) => { this.merge_base = Some(merge_base); @@ -891,18 +886,17 @@ impl NewPullRequestView { this.error = Some(error.to_string().into()); } } + cx.notify(); })?; Ok(()) }); + self.tasks.push(task); } /// Publish the pull request. - /// Generate the patch series on a background task and hand it to the store. - /// Close the panel once the publish is underway. - /// Errors surface in the pull request list. fn submit(&mut self, window: &mut Window, cx: &mut Context) { if self.submitting || self.loading { return; @@ -969,6 +963,7 @@ impl NewPullRequestView { this.update_in(cx, |this, window, cx| { this.submitting = false; + store.update(cx, |store, cx| { store.open_pull_request( (!subject.is_empty()).then_some(subject), @@ -981,6 +976,7 @@ impl NewPullRequestView { cx, ); }); + // Close the panel once the publish is underway. cx.defer_in(window, { let dock_area = dock_area.clone(); @@ -993,6 +989,7 @@ impl NewPullRequestView { } } }); + cx.notify(); })?; @@ -1026,8 +1023,6 @@ impl NewPullRequestView { }); } - /// The compare bar, base and compare selectors. - /// Plus the source picker, local checkout or announced fork, and the Create button. fn render_compare_bar(&self, cx: &mut Context) -> AnyElement { let has_source = self.has_source(); let can_submit = has_source @@ -1174,22 +1169,18 @@ impl NewPullRequestView { } /// Build the compare-source menu. - /// The local checkout entries first, then the announced forks, own forks first. - /// Picking the fork already applied re-fetches it. - /// Rebuilt every time the menu opens, so the candidates stay current. fn source_menu( &self, cx: &Context, ) -> impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static { let view = cx.entity().downgrade(); - // Associated local checkouts of the target repository, freshest first. - // The applied one is checked. - // The picker prompt stays available underneath for arbitrary folders. let addr = self.store.read(cx).addr().clone(); let associated = CheckoutsStore::global(cx).read(cx).associations_of(&addr); + let active_path = (self.fork.is_none()) .then(|| self.repo_path.clone()) .flatten(); + let candidates = self.fork_candidates(cx); let user = Backend::global(cx).read(cx).current_user(); let active_fork = self.fork.as_ref().map(|fork| fork.announcement.addr()); @@ -1338,8 +1329,6 @@ impl NewPullRequestView { } } - /// The Commits tab, `merge_base..compare` in a virtual list. - /// Clicking a row opens the commit's diff in a new panel. fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { let Some(commits) = self.commits.as_ref() else { return placeholder("No commits", cx); @@ -1417,8 +1406,6 @@ fn count_badge(count: usize, cx: &App) -> impl IntoElement { } /// The trigger of a branch selector. -/// Shows the icon, the current selection or placeholder, and the caret. -/// `Combobox` replaces its default trigger entirely. fn render_ref_trigger( ctx: &ComboboxTriggerContext>, icon: CustomIconName, diff --git a/crates/workspace/src/views/repo_detail/pull_request_detail.rs b/crates/workspace/src/views/repo_detail/pull_request_detail.rs index 1d66f36..5126948 100644 --- a/crates/workspace/src/views/repo_detail/pull_request_detail.rs +++ b/crates/workspace/src/views/repo_detail/pull_request_detail.rs @@ -7,8 +7,7 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - ScrollStrategy, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, relative, - size, + ScrollStrategy, SharedString, Size, Task, WeakEntity, Window, div, px, relative, size, }; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::clipboard::Clipboard; @@ -42,8 +41,7 @@ use super::helpers::{ const TREE_WIDTH: f32 = 260.; /// Height of one commit row in the commits tab's virtual list. -/// A single text line plus the 1px bottom border. -const PR_COMMIT_ROW_HEIGHT: f32 = 37.; +const ROW_HEIGHT: f32 = 37.; /// Detail panel of a single pull request. pub struct PullRequestDetailView { @@ -60,8 +58,6 @@ pub struct PullRequestDetailView { /// Display name of the repository, for panels opened from here. repo_name: SharedString, /// Local clone the PR's git changes come from. - /// `None` when the diff is parsed from the nostr patch set. - /// No commit diff viewer in that case. worktree: Option, /// Root PR's content, shown as plain text. description: SharedString, @@ -91,14 +87,9 @@ pub struct PullRequestDetailView { /// Virtual list state of the commits tab. commit_scroll_handle: VirtualListScrollHandle, /// Comment bodies as shared strings, keyed by comment event ID. - /// Re-renders don't clone full contents again. - /// Events are immutable, so the cache never needs invalidation. contents: HashMap, /// In-flight tasks, finished tasks are pruned on every push. - /// The vec stays bounded by the number of concurrent loads. tasks: Vec>>, - /// Subscriptions keeping the view live as the store refreshes. - _subscriptions: Vec, } impl PullRequestDetailView { @@ -109,26 +100,12 @@ impl PullRequestDetailView { window: &mut Window, cx: &mut Context, ) -> Self { + let repo_name = store.read(cx).name(); let tree_state = cx.new(|cx| TreeState::new(cx)); + let comment_input = cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment...")); - // Same display name as the repo detail panel's title. - let repo_name = store - .read(cx) - .announcement - .as_ref() - .map(|announcement| { - announcement - .name - .clone() - .unwrap_or_else(|| SharedString::from(announcement.id.clone())) - }) - .unwrap_or_default(); - - // Re-render when the store refreshes, new comments or status changes. - let subscriptions = vec![cx.observe(&store, |_this, _store, cx| cx.notify())]; - // Defer loading until the window is ready, like the commit diff view. cx.defer_in(window, |this, window, cx| { this.load(window, cx); @@ -158,16 +135,10 @@ impl PullRequestDetailView { commit_scroll_handle: VirtualListScrollHandle::new(), contents: HashMap::new(), tasks: Vec::new(), - _subscriptions: subscriptions, } } /// Snapshot the PR events from the store. - /// File changes and the commit list are computed on a background task. - /// The tree is populated from the result. - /// The changes come from the PR's patch set, NIP-34 `e`-linked patch events, when present. - /// Otherwise from the git repository, `c`, `clone` and `merge-base` tags. - /// Diffing the `merge-base..tip` range. fn load(&mut self, window: &mut Window, cx: &mut Context) { self.loading = true; self.error = None; @@ -177,6 +148,7 @@ impl PullRequestDetailView { let (description, patch, current_commit, merge_base, clone_urls, addr, has_patch_link) = { let store = self.store.read(cx); + let Some(root) = store .pull_requests .iter() @@ -187,19 +159,24 @@ impl PullRequestDetailView { cx.notify(); return; }; + let update = latest_update(store.pull_requests.iter(), root); + let tip = update .and_then(current_commit_of) .or_else(|| current_commit_of(root)); + let base = update .and_then(merge_base_of) .or_else(|| merge_base_of(root)); + let clone_urls = clone_urls_of(root).or_else(|| { store .announcement .as_ref() .map(|a| a.clone.iter().map(ToString::to_string).collect()) }); + ( root.content.clone(), pull_request_patch(root, store.patches.iter()), @@ -210,16 +187,17 @@ impl PullRequestDetailView { root.tags.event_ids().next().is_some(), ) }; + self.description = description.into(); let task = cx.spawn_in(window, async move |this, cx| { - // Parse the nostr patch set first. let nostr_diff = cx .background_spawn({ let patch = patch.clone(); async move { patch_diffs(&patch) } }) .await; + let nostr_commits = cx .background_spawn({ let patch = patch.clone(); @@ -233,6 +211,7 @@ impl PullRequestDetailView { Ok(diff) => has_patch_link || !diff.files.is_empty(), Err(_) => true, }; + let git = if use_nostr { None } else { @@ -241,19 +220,22 @@ impl PullRequestDetailView { let clone_urls = clone_urls.clone(); let base = merge_base.clone(); let tip = current_commit.clone(); + Some( cx.background_spawn(async move { let repo = cache.ensure_clone(&addr, &clone_urls)?; + let workdir = repo .workdir() .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? .to_path_buf(); + let tip = tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?; + let base = match base { Some(base) => base, - // No `merge-base` tag. - // Use the merge base of the tip and the default branch. + // No `merge-base` tag. Use the merge base of the tip and the default branch. None => { let head = repo .head_id() @@ -262,9 +244,11 @@ impl PullRequestDetailView { 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)?; + Ok::<_, anyhow::Error>((diff, commits, workdir)) }) .await, @@ -281,9 +265,9 @@ impl PullRequestDetailView { this.loading = false; this.worktree = worktree; this.current_commit = current_commit.map(SharedString::from); - this.commit_item_sizes = - Rc::new(vec![size(px(0.), px(PR_COMMIT_ROW_HEIGHT)); commits.len()]); + this.commit_item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]); this.commits = commits; + match diff { Ok(diff) => { let mut paths: Vec = diff @@ -312,6 +296,7 @@ impl PullRequestDetailView { this.error = Some(error.to_string().into()); } } + cx.notify(); })?; @@ -386,7 +371,6 @@ impl PullRequestDetailView { }) } - /// Left column showing the changed-files tree. fn render_tree_column(&self, cx: &mut Context) -> AnyElement { let tree_state = self.tree_state.clone(); let view = cx.entity().downgrade(); @@ -416,7 +400,6 @@ impl PullRequestDetailView { .into_any_element() } - /// Right column, header of the selected file plus its diff. fn render_detail_column(&self, cx: &mut Context) -> AnyElement { if self.loading { return v_flex() @@ -426,12 +409,15 @@ impl PullRequestDetailView { .child(Spinner::new().small()) .into_any_element(); } + if let Some(error) = self.error.clone() { return placeholder(&error, cx); } + let Some(diff) = self.diff.as_ref() else { return placeholder("Failed to load diff", cx); }; + let Some(path) = self.selected_file.clone() else { return if diff.files.is_empty() { placeholder("No files changed in this pull request", cx) @@ -439,15 +425,13 @@ impl PullRequestDetailView { placeholder("Select a file", cx) }; }; + let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else { return placeholder("File not found", cx); }; self.render_file_diff(file, cx.entity(), cx) } - /// The diff of one file, with a header showing status and stats. - /// The hunks render in a virtual list. - /// A large diff is never materialized per frame. fn render_file_diff(&self, file: &FileDiff, view: Entity, cx: &App) -> AnyElement { let status_label = match file.status { signed_git::DiffStatus::Added => "A", @@ -456,6 +440,7 @@ impl PullRequestDetailView { signed_git::DiffStatus::Renamed => "R", signed_git::DiffStatus::Copied => "C", }; + let status_color = match file.status { signed_git::DiffStatus::Added => cx.theme().success, signed_git::DiffStatus::Modified => cx.theme().info, @@ -464,6 +449,7 @@ impl PullRequestDetailView { cx.theme().muted_foreground } }; + let title = match &file.old_path { Some(old) => format!("{old} → {}", file.path), None => file.path.clone(), @@ -476,6 +462,7 @@ impl PullRequestDetailView { } else { let sizes = self.item_sizes.clone(); let scroll_handle = self.scroll_handle.clone(); + v_flex() .size_full() .relative() @@ -564,7 +551,6 @@ impl PullRequestDetailView { .into_any_element() } - /// Underline tab bar with the Discussion, Files and Commits tabs. fn render_tabs(&self, cx: &mut Context) -> AnyElement { let active = self.active_tab; let files_count = self.diff.as_ref().map(|diff| diff.files.len()); @@ -610,8 +596,6 @@ impl PullRequestDetailView { .into_any_element() } - /// Discussion tab, author, description and comments like the issue panel. - /// The comment form sits at the end, a sidebar on the right. fn render_discussion(&mut self, cx: &mut Context) -> AnyElement { if self.loading { return v_flex() @@ -776,8 +760,6 @@ impl PullRequestDetailView { .into_any_element() } - /// Files tab, the changed-files tree on the left. - /// The diff of the selected file on the right. fn render_files_tab(&self, cx: &mut Context) -> AnyElement { h_flex() .flex_1() @@ -790,6 +772,7 @@ impl PullRequestDetailView { } /// Full-height Commits tab. + /// /// Every commit of the patch series, or a status message while loading or empty. fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { if self.loading { @@ -839,6 +822,7 @@ impl PullRequestDetailView { } /// One row of the commits tab, id, summary, author and time. + /// /// Clicking a row opens the commit's diff in the bottom dock. fn render_commit_row( &self, @@ -852,7 +836,7 @@ impl PullRequestDetailView { h_flex() .id(ix) .px_4() - .h(px(PR_COMMIT_ROW_HEIGHT)) + .h(px(ROW_HEIGHT)) .gap_2() .items_center() .text_sm() @@ -893,7 +877,6 @@ impl PullRequestDetailView { } /// One comment card, same design as the issue panel. - /// Header row holds the avatar, author, commented and age, content below. fn render_comments(&mut self, id: &EventId, cx: &mut Context) -> AnyElement { let store = self.store.read(cx); let comments: Vec<&Event> = store.comments_of(id).collect(); @@ -1104,8 +1087,6 @@ impl PullRequestDetailView { } /// Open the update pull request dialog. -/// The patch input supplies the new revision. -/// Confirming calls [`RepoStore::update_pull_request`]. fn open_update_pull_request_dialog( store: Entity, root: Event, @@ -1188,6 +1169,7 @@ fn current_commit_of(event: &Event) -> Option { } /// The `merge-base` tag of a PR event, as hex. +/// /// The most recent common ancestor with the target branch. fn merge_base_of(event: &Event) -> Option { event @@ -1200,6 +1182,7 @@ fn merge_base_of(event: &Event) -> Option { } /// The `clone` tag of a PR event. +/// /// URLs where the proposed branch can be fetched, or `None` if the PR has none. fn clone_urls_of(event: &Event) -> Option> { event @@ -1223,9 +1206,6 @@ fn branch_name_of(event: &Event) -> Option { } /// The latest PR update, kind 1619, revising `root`. -/// Found via its NIP-22 `E` tag pointing at the root PR event. -/// Only updates by the PR author count. -/// The tip of a PR is only mutable by its author, NIP-34. fn latest_update<'a>(events: impl Iterator, root: &Event) -> Option<&'a Event> { let root_hex = root.id.to_hex(); events @@ -1240,6 +1220,7 @@ fn latest_update<'a>(events: impl Iterator, root: &Event) -> O } /// One-line commit metadata for the commits list. +/// /// Author and relative time, whichever is available. fn commit_meta(commit: &FileCommit) -> String { let author = commit.author.trim(); diff --git a/crates/workspace/src/views/repo_detail/pull_requests.rs b/crates/workspace/src/views/repo_detail/pull_requests.rs index f87b414..bea44bc 100644 --- a/crates/workspace/src/views/repo_detail/pull_requests.rs +++ b/crates/workspace/src/views/repo_detail/pull_requests.rs @@ -26,8 +26,7 @@ use super::pull_request_detail::PullRequestDetailView; use super::send_patch::open_send_patch_panel; /// Height of one pull request row in the virtual list. -/// Same layout as an issue row. -const PR_ROW_HEIGHT: f32 = 73.; +const ROW_HEIGHT: f32 = 73.; /// Status filter of the pull request list, chosen via the header's filter buttons. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -70,17 +69,10 @@ pub struct PullRequestsView { /// Per-row heights of the virtual list. item_sizes: Rc>>, /// The filtered pull request count [`Self::item_sizes`] was built for. - /// Rebuilt on change. pr_len: usize, /// Indices into the store's `pull_requests` matching [`Self::filter`]. - /// Root PR events only, updates are revisions of the root. - /// The virtual list renders this slice. - /// Rebuilt only when the store version or the filter changes. - /// Keyed by [`Self::cache_key`]. visible_prs: Vec, /// Header counts `(total, open, closed, draft, merged)`. - /// Root pull requests only, revisions are not separate PRs. - /// Rebuilt with [`Self::visible_prs`]. counts: (usize, usize, usize, usize, usize), /// Store version and filter the cached rows/counts were built from. cache_key: Option<(u64, PullRequestFilter)>, @@ -139,6 +131,7 @@ impl PullRequestsView { } /// Render one row of the pull request list. + /// /// `ix` is the row index, `pr_ix` the index in the store's `pull_requests`. fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context) -> AnyElement { let pr = &self.store.read(cx).pull_requests[pr_ix]; @@ -205,7 +198,6 @@ impl PullRequestsView { fn render_header(&self, cx: &mut Context) -> AnyElement { // Counts of the last list rebuild. - // `render` rebuilds first when the store version or filter changed, so never stale. let (total, open, closed, draft, merged) = self.counts; h_flex() @@ -341,8 +333,8 @@ impl Render for PullRequestsView { let filter = self.filter; // Rows and counts are rebuilt only when the store refreshed or filter changed. - // Other renders reuse the cache. let version = self.store.read(cx).version(); + if self.cache_key != Some((version, filter)) { let store = self.store.read(cx); let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize); @@ -351,24 +343,24 @@ impl Render for PullRequestsView { .iter() .enumerate() .filter_map(|(ix, pr)| { - // Kind-30620 patches are revisions of a root PR, NIP-34. - // They are not separate pull requests. - // Count root events only, or the counts inflate with every revision. - // Revisions also default to `Open` in `status_of`. if pr.kind != Kind::GitPullRequest { return None; } + let status = store.status_of(pr); counts.0 += 1; + match status { RepoStatus::Open => counts.1 += 1, RepoStatus::Closed => counts.2 += 1, RepoStatus::Draft => counts.3 += 1, RepoStatus::Applied => counts.4 += 1, } + filter.matches(status).then_some(ix) }) .collect(); + self.counts = counts; self.cache_key = Some((version, filter)); } @@ -379,7 +371,7 @@ impl Render for PullRequestsView { // Rebuild it whenever the filtered pull request count changes. if count != self.pr_len { self.pr_len = count; - self.item_sizes = Rc::new(vec![size(px(0.), px(PR_ROW_HEIGHT)); count]); + self.item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); count]); } let sizes = self.item_sizes.clone(); diff --git a/crates/workspace/src/views/repo_detail/send_patch.rs b/crates/workspace/src/views/repo_detail/send_patch.rs index fa13b52..669e0fa 100644 --- a/crates/workspace/src/views/repo_detail/send_patch.rs +++ b/crates/workspace/src/views/repo_detail/send_patch.rs @@ -2,10 +2,10 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, - Subscription, WeakEntity, Window, div, px, + WeakEntity, Window, div, px, }; use gpui_base::{Button as BaseButton, StyledExt}; -use gpui_component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; +use gpui_component::input::{Input, InputState, Textarea, TextareaState}; use gpui_component::scroll::ScrollableElement; use gpui_component::spinner::Spinner; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex}; @@ -29,7 +29,6 @@ pub struct SendPatchView { submitting: bool, /// Error of the last submit attempt, it keeps the panel open. error: Option, - _subscriptions: Vec, } impl SendPatchView { @@ -41,22 +40,14 @@ impl SendPatchView { ) -> Self { let repo_name = store.read(cx).name(); let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title")); + let description = cx .new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)")); + let patch = cx.new(|cx| { TextareaState::new(window, cx).placeholder("diff --git a/file.txt b/file.txt\nindex 1234567..abcdefg 100644\n--- a/file.txt\n+++ b/file.txt") }); - // Re-evaluate the Send button's enabled state as the inputs change. - let subscriptions = vec![ - cx.subscribe(&subject, |_this, _state, _event: &InputEvent, cx| { - cx.notify(); - }), - cx.subscribe(&patch, |_this, _state, _event: &InputEvent, cx| { - cx.notify(); - }), - ]; - Self { focus_handle: cx.focus_handle(), dock_area, @@ -67,25 +58,23 @@ impl SendPatchView { patch, submitting: false, error: None, - _subscriptions: subscriptions, } } /// Publish the pull request from the pasted patch. - /// The store validates synchronously, patch shape, per-part size and sign-in. - /// On failure the panel stays open with the error inline. - /// On success it closes. - /// Async publish failures surface in the pull request list's banner. fn submit(&mut self, window: &mut Window, cx: &mut Context) { if self.submitting { return; } + let subject = self.subject.read(cx).value().to_string(); let description = self.description.read(cx).value().to_string(); let patch = self.patch.read(cx).value().to_string(); + if patch.is_empty() { return; } + let store = self.store.clone(); let dock_area = self.dock_area.clone(); let entity = cx.entity().clone(); @@ -95,7 +84,6 @@ impl SendPatchView { cx.notify(); // Errors the store detects before publishing. - // Returned synchronously through `last_error`. let sync_error = store.update(cx, |store, cx| { store.open_pull_request( (!subject.is_empty()).then_some(subject), @@ -129,6 +117,7 @@ impl SendPatchView { } } }); + cx.notify(); } diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index a5ec859..3d69fa2 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -41,8 +41,8 @@ enum RepoFilter { impl RepoFilter { /// Indices into the store's `announcements` this filter includes, in display order. + /// /// Narrowed to repositories whose name or id contains `query`. - /// An empty query matches everything. fn visible(self, store: &RepoListStore, query: &str) -> Vec { let announcements = &store.announcements; let mut indices: Vec = (0..announcements.len()).collect(); @@ -96,7 +96,6 @@ pub struct RepoListView { /// Number of rows [`Self::item_sizes`] was built for, the filtered repo count. repo_len: usize, /// Indices matching [`Self::filter`] into the store's `announcements`. - /// The virtual list renders this slice in display order. visible: Vec, /// Search box filtering repositories by name. search: Entity, @@ -150,6 +149,7 @@ impl RepoListView { } /// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the store. + /// /// Uses the store contents, [`Self::filter`] and the search query. fn rebuild_rows(&mut self, cx: &mut Context) { let filter = self.filter; diff --git a/crates/workspace/src/views/sidebar/create_repo_dialog.rs b/crates/workspace/src/views/sidebar/create_repo_dialog.rs index 68d6f12..c34807b 100644 --- a/crates/workspace/src/views/sidebar/create_repo_dialog.rs +++ b/crates/workspace/src/views/sidebar/create_repo_dialog.rs @@ -24,9 +24,6 @@ pub struct CreateRepoState { } /// Open the Create Repository dialog. -/// Loads the user's default grasp servers, a kind `10317` grasp list. -/// Falls back to the shared defaults when none are set. -/// On success the dialog closes and the new repository opens in the dock. pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) { let settings = SettingsStore::global(cx); let default_folder = settings @@ -160,8 +157,6 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) } /// Pick the repository's storage folder with the platform's native folder picker. -/// Show the result in the disabled folder input. -/// The settings remember the picked folder as the default next time. fn choose_folder(folder_input: &Entity, window: &mut Window, cx: &mut App) { let handle = window.window_handle(); let folder_input = folder_input.clone(); @@ -197,6 +192,7 @@ fn choose_folder(folder_input: &Entity, window: &mut Window, cx: &mu } /// Run the create-repository flow. +/// /// Opens the new working copy and the repository panel on success. #[allow(clippy::too_many_arguments)] fn create_repository( diff --git a/crates/workspace/src/views/sidebar/grasp_servers.rs b/crates/workspace/src/views/sidebar/grasp_servers.rs index 0a824cf..c469376 100644 --- a/crates/workspace/src/views/sidebar/grasp_servers.rs +++ b/crates/workspace/src/views/sidebar/grasp_servers.rs @@ -23,6 +23,7 @@ pub struct GraspServersState { impl GraspServersState { /// Defaults used until the user's grasp list loads, which replaces them when non-empty. + /// /// Persisted settings supply the defaults, an empty list falls back to the built-ins. pub fn new_default(settings: &GraspServersSettings) -> Self { let urls: Vec = if settings.default_servers.is_empty() { @@ -46,8 +47,6 @@ impl GraspServersState { } /// The Grasp servers form field shared by the publish dialogs. -/// An expandable toggle, the configured servers each removable, and an add-relay input. -/// Shows a loading hint while the user's kind `10317` grasp list is fetched. pub fn grasp_servers_field( state: &Entity, relay_input: &Entity, @@ -223,6 +222,7 @@ fn add_relay( } /// Load the user's grasp list of kind `10317` from the local database. +/// /// It replaces the defaults when it lists any servers. pub fn load_user_grasp_servers( state: Entity, diff --git a/crates/workspace/src/views/sidebar/import_dialog.rs b/crates/workspace/src/views/sidebar/import_dialog.rs index 5f5c8de..992673d 100644 --- a/crates/workspace/src/views/sidebar/import_dialog.rs +++ b/crates/workspace/src/views/sidebar/import_dialog.rs @@ -2,7 +2,6 @@ use gpui::{App, Window, px}; use gpui_component::WindowExt; /// Open the Import Identity dialog. -/// Currently a placeholder, the dialog only shows a title. pub fn open(window: &mut Window, cx: &mut App) { window.open_dialog(cx, move |dialog, _window, _cx| { dialog.title("Import identity").width(px(400.)) diff --git a/crates/workspace/src/views/sidebar/onboarding_dialog.rs b/crates/workspace/src/views/sidebar/onboarding_dialog.rs index 4eca90d..9e70619 100644 --- a/crates/workspace/src/views/sidebar/onboarding_dialog.rs +++ b/crates/workspace/src/views/sidebar/onboarding_dialog.rs @@ -15,8 +15,6 @@ pub struct OnboardingState { } /// Open the Onboarding dialog for creating a new identity. -/// The caller creates the input and state entities and passes them in. -/// This function only builds the dialog UI and wires up the continue-button handler. pub fn open( name_input: Entity, pass_input: Entity, diff --git a/crates/workspace/src/views/sidebar/passphrase_dialog.rs b/crates/workspace/src/views/sidebar/passphrase_dialog.rs index f425f2f..3e540ca 100644 --- a/crates/workspace/src/views/sidebar/passphrase_dialog.rs +++ b/crates/workspace/src/views/sidebar/passphrase_dialog.rs @@ -18,8 +18,6 @@ pub struct PassphraseState { } /// Open the dialog asking for the passphrase that protects the stored identity. -/// The identity is NIP-49 encrypted, for example `ncryptsec1...`. -/// Called when the backend emits [`signed_state::BackendEvent::PassphraseRequired`]. pub fn open(window: &mut Window, cx: &mut App) { let pass_input = cx.new(|cx| { InputState::new(window, cx) @@ -98,8 +96,6 @@ pub fn open(window: &mut Window, cx: &mut App) { } /// Submit the passphrase to the backend. -/// On success the dialog closes. -/// On failure the error is rendered inline and the dialog stays open. fn unlock( pass_input: &Entity, state: &Entity, diff --git a/crates/workspace/src/views/sidebar/settings_dialog.rs b/crates/workspace/src/views/sidebar/settings_dialog.rs index 8fceb54..c365c88 100644 --- a/crates/workspace/src/views/sidebar/settings_dialog.rs +++ b/crates/workspace/src/views/sidebar/settings_dialog.rs @@ -49,7 +49,6 @@ fn theme_options(cx: &App) -> (Vec, Vec) { } /// Stateful controls of the settings dialog, created once when it opens. -/// Their values survive re-renders of the dialog content. struct SettingsControls { appearance: Entity>>, light_theme: Entity>>, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 73c87fe..05df0c0 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -46,8 +46,6 @@ impl Workspace { let mut subscriptions = vec![]; // A bottom or right dock whose last panel was dragged away is removed entirely. - // The emptied region would otherwise linger as a bare strip. - // The removal is deferred, the event arrives while the area is mid-update. let dock_for_pruning = dock.clone(); subscriptions.push(cx.subscribe_in( &dock, @@ -78,7 +76,6 @@ impl Workspace { let backend = Backend::global(cx); // Ask for the passphrase when the stored identity is NIP-49 encrypted. - // Subscribed via the window, since opening a dialog needs a window. let passphrase_subscription = window.subscribe(&backend, cx, |_backend, event, window, cx| { if matches!(event, BackendEvent::PassphraseRequired) { @@ -87,7 +84,6 @@ impl Workspace { }); // The event may have fired before this window existed. - // The backend is initialized before the first window opens. // Fall back to the backend state in that case. if backend.read(cx).passphrase_required() { passphrase_dialog::open(window, cx);