This commit is contained in:
2026-09-04 08:16:05 +07:00
parent 212f35d6bb
commit 17de4f6376
43 changed files with 376 additions and 721 deletions
+9 -10
View File
@@ -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<PathBuf>,
}
@@ -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)]
+8 -6
View File
@@ -9,7 +9,9 @@ struct GlobalSettingsStore(Entity<SettingsStore>);
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<Path>, _cx: &mut Context<Self>) -> 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)?;
+3 -2
View File
@@ -1,8 +1,9 @@
use nostr::prelude::*;
/// Address of a NIP-34 repository announcement, `30617:<owner-pubkey>:<repo-id>`.
/// The Rust Nostr SDK's [`Coordinate`] parses, formats and hashes this.
/// The alias reuses the SDK type while keeping repository-specific vocabulary.
///
/// 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.
+21 -20
View File
@@ -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", "<value>", "#t"]` label.
/// Whether a kind-1985 label event declares the `#t` namespace,
/// it must also carry at least one `["l", "<value>", "#t"]` label.
fn has_hashtag_labels(event: &Event) -> bool {
event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"])
&& event.tags.iter().any(|tag| {
@@ -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<String> {
let mut labels: Vec<String> = 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,
+8 -6
View File
@@ -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<EventId> {
.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<CommentThread> {
// Index comments by their parent id.
// Comments without a parent tag reply to the root event itself.
+6 -2
View File
@@ -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
+9 -12
View File
@@ -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<Item = EventId>) -> Filter {
Filter::new()
@@ -58,7 +58,9 @@ pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> 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<Item = EventId>) -> 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<Item = EventId>) -> Vec<Filter> {
let roots: Vec<String> = 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<Filter> {
vec![
+7 -22
View File
@@ -28,7 +28,6 @@ pub struct Announcement {
pub euc: Option<String>,
/// Other recognized maintainers.
pub maintainers: Vec<PublicKey>,
/// Value of a `u` tag, if any.
/// Marks the repository as a subordinate fork of the upstream, per NIP-34.
pub upstream: Option<Upstream>,
/// 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:<pubkey>:<id>`.
/// 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<Item = &'a Event>) -> 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<String> {
}
/// 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<Self> {
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<PublicKey> {
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<SharedString> {
let mut seen = HashSet::new();
self.clone
+4 -4
View File
@@ -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/<branch>`.
/// Build a kind `30618` repository state event from refs and HEAD,
/// it is published as `ref: refs/heads/<branch>`.
///
/// The `d` tag matches the repository id.
pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
@@ -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<String>) {
+2 -2
View File
@@ -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,
+66 -91
View File
@@ -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<gix::Repository> {
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<PathBuf> {
let mut repos = Vec::new();
if !root.is_dir() {
@@ -125,10 +120,7 @@ pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
}
/// 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<Option<String>> {
let output = Command::new("git")
@@ -230,8 +223,8 @@ pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result<Option<String>>
/// 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<String> {
let output = Command::new("git")
.arg("-C")
@@ -256,9 +249,6 @@ pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result<S
}
/// Whether `patch` applies to the working tree of `repo_path` without modifying anything.
/// `patch` is a `git format-patch` series.
/// Checks with `git apply --check --3way`.
/// Best-effort, useful to surface conflicts before a patch is published or applied.
pub fn patch_applies(repo_path: &Path, patch: &str) -> 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/<event-id>`.
/// 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<Option<String>> {
let output = Command::new("git")
@@ -363,8 +350,8 @@ pub fn head_commit_id(repo_path: &Path) -> Result<Option<String>> {
/// 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<Vec<String>> {
let output = match base {
Some(base) => git_in(
@@ -402,9 +389,10 @@ fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
/// 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<String> {
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<Option<String>> {
let output = Command::new("git")
@@ -523,8 +512,7 @@ pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
}
/// 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/<owner>/<id>/*`.
/// 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<anyhow::Error> = 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/<owner>/<id>`.
///
/// Returns an empty list when nothing matches.
pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
// `for-each-ref` patterns match whole path components.
@@ -629,7 +617,9 @@ pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
/// Delete every ref under `prefix` of the repository at `repo_path`.
/// `prefix` is a ref namespace like `refs/fork/<owner>/<id>`.
///
/// 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<Option<String>> {
let output = Command::new("git")
@@ -685,14 +676,7 @@ pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
}
/// Fast-forward local branches that trail their remote-tracking counterpart.
/// The counterpart ref is `refs/remotes/origin/<name>` 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<bool> {
let current = git_in(workdir, &["branch", "--show-current"]).unwrap_or_default();
@@ -734,6 +718,7 @@ pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
}
/// 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<String> {
let output = Command::new("git")
@@ -757,6 +742,7 @@ fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
}
/// 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<Vec<PathBuf>> {
let workdir = repo.workdir().context("repository has no worktree")?;
@@ -816,6 +803,7 @@ pub fn worktree_entries(repo: &gix::Repository) -> Result<Vec<PathBuf>> {
}
/// Read a file from the worktree.
///
/// Returns `Ok(None)` if the path is missing or not a regular file.
pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result<Option<Vec<u8>>> {
let workdir = repo.workdir().context("repository has no worktree")?;
@@ -830,9 +818,7 @@ pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result<Option<Vec<u8
}
/// Find the README file in the repository root.
/// Returned as a path relative to the worktree.
/// Matching is case-insensitive.
/// Prefers `README.md`, then `.markdown`, `.mdown`, `.mkdn`.
///
/// Falls back to any other file whose name starts with `readme`.
pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
let Some(workdir) = repo.workdir() else {
@@ -878,7 +864,10 @@ fn open_with_cache(workdir: &Path) -> Result<gix::Repository> {
/// 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<FileCommit> {
let author = commit.author()?;
@@ -900,9 +889,7 @@ fn file_commit(commit: &gix::Commit<'_>, include_description: bool) -> Result<Fi
}
/// Find the most recent commit that changed `rel`, a path relative to the worktree.
/// Like `git log -1 -- <rel>`.
/// 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<Option<FileCommit>> {
let rel = rel.to_path_buf();
@@ -914,7 +901,7 @@ pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileComm
/// Newest commit touching each of `rels`, like `git log -1 -- <rel>` 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<Vec<(PathBuf, FileCommit)>> {
use gix::traverse::commit::simple::CommitTimeOrder;
@@ -984,14 +972,13 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf
Ok(found)
}
/// Cap on [`CommitList::commits`].
/// The virtual list renders a window at a time, the tab badge shows the real count.
/// Cap on [`CommitList::commits`]. The virtual list renders a window at a time,
/// the tab badge shows the real count.
///
/// A huge history is never fully materialized in memory.
pub const MAX_LISTED_COMMITS: usize = 20_000;
/// Commits reachable from `HEAD`, newest first, possibly capped.
/// `commits` holds at most [`MAX_LISTED_COMMITS`] entries.
/// `total` is the real count, for the tab badge.
pub struct CommitList {
/// Number of commits reachable from HEAD.
pub total: usize,
@@ -1000,6 +987,7 @@ pub struct CommitList {
}
/// All commits reachable from `HEAD`, newest first, with author and summary.
///
/// Returns an empty list for a repository without any commits yet.
pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
use gix::traverse::commit::simple::CommitTimeOrder;
@@ -1029,6 +1017,7 @@ pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
}
/// 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<CommitList> {
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<FileDiff>,
}
/// The changes of the commit `id`, short or full, in the repository at `workdir`.
///
/// Compared against its first parent, the empty tree for the root commit.
/// Like `git show`, files are sorted by path.
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
commit_diff(&open_with_cache(workdir)?, id)
}
@@ -1127,7 +1113,7 @@ fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
}
/// 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<CommitDiff> {
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<CommitDiff> {
let lines: Vec<&str> = patch.lines().collect();
let mut files = Vec::new();
@@ -1315,8 +1294,7 @@ pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
}
/// 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<FileCommit> {
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<DiffLineKind> {
match line.as_bytes().first()? {
@@ -1554,6 +1534,7 @@ fn line_prefix_kind(line: &str) -> Option<DiffLineKind> {
}
/// 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<String> {
let line = line.trim_end_matches('\t');
if line.starts_with('"') {
@@ -1630,8 +1605,8 @@ fn diff_line_path(line: &str, prefix: &str) -> Result<String> {
/// 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<String> {
}
/// 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<DiffHunk>,
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<Option<FileCommit>> {
let Some(head) = repo.head_id().ok() else {
@@ -1795,9 +1767,8 @@ pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
/// 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<Option<FileCommit>> {
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<Vec<String>> {
}
/// 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<Option<String>> {
let head = repo.head()?;
@@ -1852,6 +1824,7 @@ pub fn current_branch(repo: &gix::Repository) -> Result<Option<String>> {
}
/// 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<RepoRefState> {
@@ -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<WorktreeSnapshot> {
let repo = open_with_cache(workdir)?;
let readme_path = find_readme(&repo)?;
@@ -1933,6 +1907,7 @@ pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
}
/// 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")
-4
View File
@@ -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<Path>) -> Result<(Client, UniversalSigner)> {
let signer = UniversalSigner::new(Keys::generate());
-2
View File
@@ -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<RwLock<Arc<dyn InnerSigner>>>,
-1
View File
@@ -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,
+31 -155
View File
@@ -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=<nsec>` 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<u64, Instant>,
/// 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<Mutex<HashSet<RepoAddr>>>,
tasks: Vec<Task<Result<(), Error>>>,
}
@@ -115,6 +96,7 @@ struct GlobalBackend(Entity<Backend>);
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<Mutex<HashSet<RepoAddr>>>,
@@ -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<Self>) {
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<Self>) {
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 `<folder>/<name>`, 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<Self>) {
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<Self>) {
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<Self>) {
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=<nsec>`.
/// 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<Self>) {
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<Self>) {
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<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
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<String>, cx: &mut Context<Self>) {
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<Self>) {
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<RelayUrl>,
@@ -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<Filter>, cx: &mut Context<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<RelayUrl>,
@@ -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<Filter>,
@@ -1648,16 +1542,14 @@ fn with_master_key(uri: &str, keys: &Keys) -> String {
}
/// Base URL of a grasp server, `https://<host>`.
///
/// `ws://` grasp servers use `http://<host>`, like ngit.
/// The repository then lives at `{base}/{npub}/{repo-id}.git`.
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
// `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<String> {
}
/// GRASP clone URL of a repository on a grasp server.
/// Matches the format ngit announces, `https://<host>/<npub>/<repo-id>.git`.
fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url> {
let base = grasp_base_url(relay)?;
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
}
/// GRASP-06 contributor namespace URL of a pull request tip.
/// The pattern is `{base}/prs/<author-npub>/<repo-id>.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<Url>, base_clone_urls: Vec<Url>) -> Vec<Url> {
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<Url>, base_clone_urls: Vec<Url>) -> 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<RelayUrl> {
event
.tags
@@ -1709,8 +1595,6 @@ fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
}
/// 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<Event>) -> Vec<RelayUrl> {
events
.into_iter()
@@ -1720,9 +1604,6 @@ fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
}
/// 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)) => {
+27 -48
View File
@@ -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<CheckoutsStore>);
impl Global for GlobalCheckoutsStore {}
/// One associated local checkout of a repository.
///
/// Carries the git facts needed to suggest a pull request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckoutStatus {
@@ -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/<branch>`, else `origin/HEAD` for new branches.
pub base: String,
/// Commits in `base..branch`.
///
/// Zero-ahead checkouts are dropped, so this is always above zero.
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<HashMap<RepoAddr, Vec<PathBuf>>>,
/// Ready-to-contribute statuses of the requested repositories.
statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
/// Repositories whose statuses are recomputed on every input change.
///
/// Those are the repository detail panels currently open.
status_requested: HashSet<RepoAddr>,
/// Repositories whose `ready to push` statuses are recomputed on the same cycle.
///
/// The sidebar rows of the user's own repositories and their detail panels.
push_requested: HashSet<RepoAddr>,
/// Ready-to-push statuses of the requested own repositories.
push_statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
/// Last announced head branch per requested repository.
///
/// A recompute defaults the base the same way.
requested_head: HashMap<RepoAddr, Option<String>>,
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>) -> 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<Self>) {
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<PathBuf> {
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<CheckoutStatus> {
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>) {
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<CheckoutStatus> {
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<Self>) {
if self.refreshing {
@@ -268,6 +266,7 @@ impl CheckoutsStore {
let settings = SettingsStore::global(cx);
settings.read(cx).settings().checkouts.records.clone()
};
let remembered: Vec<Remembered> = 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<String>)> = self
.status_requested
.iter()
@@ -292,16 +293,17 @@ impl CheckoutsStore {
)
})
.collect();
let push_requested: Vec<RepoAddr> = 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<String>, Option<String>)> = 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<RepoAddr, Vec<PathBuf>> = 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<u16>, 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<u16>, 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<String>, Option<String>)],
@@ -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<String> {
let output = Command::new("git")
.arg("-C")
@@ -532,10 +522,6 @@ fn current_branch_of(path: &Path) -> Option<String> {
}
/// 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<CheckoutStatus> {
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<Checkout
}
/// Whether the reference `name` exists in the checkout at `path`.
///
/// Example, `refs/remotes/origin/main`.
fn ref_exists(path: &Path, name: &str) -> 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<CheckoutStatus> {
if worktree_dirty(path) {
return None;
@@ -612,9 +594,6 @@ fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
}
/// 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,
+1 -3
View File
@@ -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<PathBuf>, 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::<GlobalGitStore>().0.clone())
}
-3
View File
@@ -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>) {
self.repos = Arc::new(
self.repos
+6 -5
View File
@@ -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<PublicKey, Profile>,
/// 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::<PublicKey>();
let (dispatch_tx, dispatch_rx) = flume::unbounded::<Dispatch>();
@@ -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<Self>) {
let authors: Vec<PublicKey> = 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,
+31 -74
View File
@@ -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<Event>,
/// 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<EventId, RepoStatus>,
/// 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<Event>,
labels: Vec<Event>,
/// 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<String>,
/// Non-fatal warning of the last action, if any.
///
/// Example, a PR published without its commit reaching a grasp server.
pub last_warning: Option<String>,
/// 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<RelayUrl>,
/// 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<EventId>,
@@ -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<Filter> {
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<Self>) {
let new: Vec<RelayUrl> = 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<Self>) {
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<String> {
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<String> {
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 <commit>` 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/<event-id>`, 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>) {
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>) {
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>) {
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>) {
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<String> = 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<Event> = 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<EventId, RepoStatus>, root: &Event) -> RepoStatus {
status_by_root
.get(&root.id)
@@ -1322,9 +1289,6 @@ fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, 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<RepoStore>,
@@ -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());
+9 -14
View File
@@ -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<RepoListStore>);
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<Vec<Announcement>>,
@@ -52,6 +49,7 @@ pub struct RepoListStore {
/// Covers announcements, state updates, patches, PRs, issues and statuses.
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
/// Issues, pull requests and commits per repository.
///
/// Used for the Popular ranking of the explore list.
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
author: Option<PublicKey>,
@@ -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<Self> {
cx.global::<GlobalRepoListStore>().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<Self>) {
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<Self>) {
if self.refreshing {
self.refresh_dirty = true;
-5
View File
@@ -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<E>(copy_id: E, command: &SharedString, cx: &App) -> Div
where
E: Into<ElementId>,
@@ -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,
+1
View File
@@ -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();
@@ -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<AnyElement> = 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<Item = String>, cx: &App) -> AnyElement {
v_flex()
@@ -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<SharedString>,
@@ -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<SharedString>,
@@ -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<Self>) -> 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,
@@ -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<Self>) -> AnyElement {
let Some(list) = self.all_commits.as_ref() else {
return if self.loading_all_commits {
+2 -10
View File
@@ -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<CommitDiff>,
@@ -39,7 +37,6 @@ pub struct DiffPane {
/// Path of the file whose diff is shown in the detail column.
selected_file: Option<SharedString>,
/// Rows of the selected file's diff, hunk headers and lines.
/// Backing the virtual list in the detail column.
rows: Vec<DiffRow>,
/// Per-row heights of [`Self::rows`].
item_sizes: Rc<Vec<Size<Pixels>>>,
@@ -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>) {
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<Self>, 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>) {
self.loading = true;
self.error = None;
@@ -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<TreeItemSeed>,
}
/// 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<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
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<TreeItemSeed>, 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<TreeItemSeed> {
// Node indices by full path, so parents resolve in constant time while inserting.
let mut index: HashMap<String, usize> = HashMap::new();
@@ -96,8 +85,6 @@ pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
}
/// 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 `<url>/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 {
@@ -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<RepoDetailView>,
@@ -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,
@@ -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<usize>,
/// 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<Self>) -> 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<Self>) -> 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<RepoStore>, 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);
@@ -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) {
@@ -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<PathBuf>,
/// Branches of the checkout, backing both selectors in checkout mode.
branches: Vec<SharedString>,
/// Fork-backed compare state.
/// `Some` switches the panel into fork mode.
/// The checkout above is kept so the user can switch back.
fork: Option<ForkCompare>,
/// 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/<namespace>/*`.
/// 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<NewPullRequestView>,
@@ -229,6 +213,7 @@ fn choose_folder_source_item(view: WeakEntity<NewPullRequestView>) -> 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<NewPullRequestView>,
@@ -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<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| {
let base_select = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::new()),
Vec::new(),
@@ -320,7 +305,7 @@ impl NewPullRequestView {
.searchable(true)
});
let compare_select: Entity<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| {
let compare_select = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::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<PathBuf> {
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/<namespace>` 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<Self>) {
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<Self>) {
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>,
) {
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<String>) {
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<Announcement> {
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>,
) {
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<SharedString> =
base_branches.into_iter().map(SharedString::from).collect();
let compare_branches: Vec<SharedString> = 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<Self>) {
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<Self>) {
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<Self>) -> 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<Self>,
) -> impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> 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<Self>) -> 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<SearchableVec<SharedString>>,
icon: CustomIconName,
@@ -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<PathBuf>,
/// 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<EventId, SharedString>,
/// In-flight tasks, finished tasks are pruned on every push.
/// The vec stays bounded by the number of concurrent loads.
tasks: Vec<Task<Result<(), anyhow::Error>>>,
/// Subscriptions keeping the view live as the store refreshes.
_subscriptions: Vec<Subscription>,
}
impl PullRequestDetailView {
@@ -109,26 +100,12 @@ impl PullRequestDetailView {
window: &mut Window,
cx: &mut Context<Self>,
) -> 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>) {
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<PathBuf> = 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<Self>) -> 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<Self>) -> 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<Self>, 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<RepoStore>,
root: Event,
@@ -1188,6 +1169,7 @@ fn current_commit_of(event: &Event) -> Option<String> {
}
/// 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<String> {
event
@@ -1200,6 +1182,7 @@ fn merge_base_of(event: &Event) -> Option<String> {
}
/// 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<Vec<String>> {
event
@@ -1223,9 +1206,6 @@ fn branch_name_of(event: &Event) -> Option<String> {
}
/// 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<Item = &'a Event>, root: &Event) -> Option<&'a Event> {
let root_hex = root.id.to_hex();
events
@@ -1240,6 +1220,7 @@ fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, 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();
@@ -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<Vec<Size<Pixels>>>,
/// 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<usize>,
/// 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<Self>) -> AnyElement {
let pr = &self.store.read(cx).pull_requests[pr_ix];
@@ -205,7 +198,6 @@ impl PullRequestsView {
fn render_header(&self, cx: &mut Context<Self>) -> 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();
@@ -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<SharedString>,
_subscriptions: Vec<Subscription>,
}
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<Self>) {
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();
}
+2 -2
View File
@@ -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<usize> {
let announcements = &store.announcements;
let mut indices: Vec<usize> = (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<usize>,
/// Search box filtering repositories by name.
search: Entity<InputState>,
@@ -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<Self>) {
let filter = self.filter;
@@ -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<DockArea>, 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<DockArea>, 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<InputState>, 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<InputState>, 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(
@@ -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<String> = 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<GraspServersState>,
relay_input: &Entity<InputState>,
@@ -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<GraspServersState>,
@@ -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.))
@@ -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<InputState>,
pass_input: Entity<InputState>,
@@ -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<InputState>,
state: &Entity<PassphraseState>,
@@ -49,7 +49,6 @@ fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
}
/// Stateful controls of the settings dialog, created once when it opens.
/// Their values survive re-renders of the dialog content.
struct SettingsControls {
appearance: Entity<SelectState<Vec<SelectOption>>>,
light_theme: Entity<SelectState<Vec<SelectOption>>>,
-4
View File
@@ -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);