review
This commit is contained in:
@@ -2,8 +2,8 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// The default grasp servers.
|
/// The default grasp servers,
|
||||||
/// Offered while the user has not published a grasp list, kind `10317`.
|
/// offered while the user has not published a grasp list.
|
||||||
pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
|
pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
|
||||||
"wss://relay.ngit.dev",
|
"wss://relay.ngit.dev",
|
||||||
"wss://gitnostr.com",
|
"wss://gitnostr.com",
|
||||||
@@ -23,10 +23,8 @@ pub enum AppearanceMode {
|
|||||||
Dark,
|
Dark,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Theme configuration.
|
/// Theme configuration,
|
||||||
/// Fields mirror the gpui-component `Theme` surface customized at startup.
|
/// 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.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct ThemeSettings {
|
pub struct ThemeSettings {
|
||||||
@@ -86,8 +84,8 @@ impl Default for GraspServersSettings {
|
|||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct LocalReposSettings {
|
pub struct LocalReposSettings {
|
||||||
/// The directories scanned for local git repositories.
|
/// The directories scanned for local git repositories,
|
||||||
/// Defaults to the user's Desktop and Documents folders.
|
/// defaults to the user's Desktop and Documents folders.
|
||||||
pub scan_paths: Vec<PathBuf>,
|
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.
|
/// 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.
|
/// 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.
|
/// The panel can then prefill the folder later without asking again.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ struct GlobalSettingsStore(Entity<SettingsStore>);
|
|||||||
|
|
||||||
impl Global for GlobalSettingsStore {}
|
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.
|
/// Installed as a global by the app so any part of the UI can read and edit them.
|
||||||
pub struct SettingsStore {
|
pub struct SettingsStore {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
@@ -27,10 +29,11 @@ impl SettingsStore {
|
|||||||
cx.set_global(GlobalSettingsStore(entity));
|
cx.set_global(GlobalSettingsStore(entity));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the settings from `path`.
|
/// Load the settings from `path`,
|
||||||
/// Falls back to defaults when the file is missing or unreadable.
|
/// 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.
|
/// 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 {
|
pub fn new(path: impl AsRef<Path>, _cx: &mut Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
path: path.as_ref().to_path_buf(),
|
path: path.as_ref().to_path_buf(),
|
||||||
@@ -80,7 +83,6 @@ impl SettingsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Write the settings to disk, replacing the file atomically.
|
/// Write the settings to disk, replacing the file atomically.
|
||||||
/// A crash mid-write cannot corrupt the settings.
|
|
||||||
fn save(&self) -> Result<()> {
|
fn save(&self) -> Result<()> {
|
||||||
if let Some(parent) = self.path.parent() {
|
if let Some(parent) = self.path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// Address of a NIP-34 repository announcement, `30617:<owner-pubkey>:<repo-id>`.
|
/// 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;
|
pub type RepoAddr = Coordinate;
|
||||||
|
|
||||||
/// Build the address of a NIP-34 repository announcement.
|
/// Build the address of a NIP-34 repository announcement.
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// ngit and GitWorkshop cover-note extension, kind 1624.
|
/// 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.
|
/// 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);
|
pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624);
|
||||||
|
|
||||||
/// Whether a kind-1985 label event is a valid annotation of `root`.
|
/// 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 {
|
fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> bool {
|
||||||
if event.kind != Kind::Label {
|
if event.kind != Kind::Label {
|
||||||
return false;
|
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))
|
.any(|tag| tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a kind-1985 label event declares the `#t` namespace.
|
/// Whether a kind-1985 label event declares the `#t` namespace,
|
||||||
/// It must also carry at least one `["l", "<value>", "#t"]` label.
|
/// it must also carry at least one `["l", "<value>", "#t"]` label.
|
||||||
fn has_hashtag_labels(event: &Event) -> bool {
|
fn has_hashtag_labels(event: &Event) -> bool {
|
||||||
event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"])
|
event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"])
|
||||||
&& event.tags.iter().any(|tag| {
|
&& event.tags.iter().any(|tag| {
|
||||||
@@ -32,11 +34,12 @@ fn has_hashtag_labels(event: &Event) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Effective hashtag labels of `root`.
|
/// Effective hashtag labels of `root`,
|
||||||
/// The `t` tags on the event itself, self-reported by its author.
|
/// the `t` tags on the event itself, self-reported by its author,
|
||||||
/// Authorized NIP-32 kind-1985 events in the `#t` namespace add more.
|
/// 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.
|
/// 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> {
|
pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -> Vec<String> {
|
||||||
let mut labels: Vec<String> = root
|
let mut labels: Vec<String> = root
|
||||||
.tags
|
.tags
|
||||||
@@ -62,10 +65,9 @@ pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -
|
|||||||
labels
|
labels
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subject or title override of `root` from authorized kind-1985 label events.
|
/// Subject or title override of `root` from authorized kind-1985 label events,
|
||||||
/// Only label events in the `#subject` namespace count.
|
/// 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.
|
|
||||||
/// Returns `None` when no valid override exists.
|
/// Returns `None` when no valid override exists.
|
||||||
pub fn subject_override(
|
pub fn subject_override(
|
||||||
root: &Event,
|
root: &Event,
|
||||||
@@ -105,8 +107,8 @@ pub fn subject_override(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Effective hashtag labels and subject override of `root` in one pass.
|
/// Effective hashtag labels and subject override of `root` in one pass,
|
||||||
/// Mirrors ngit's `get_labels_and_subject`.
|
/// mirrors ngit's `get_labels_and_subject`.
|
||||||
pub fn labels_and_subject(
|
pub fn labels_and_subject(
|
||||||
root: &Event,
|
root: &Event,
|
||||||
label_events: &[Event],
|
label_events: &[Event],
|
||||||
@@ -119,8 +121,7 @@ pub fn labels_and_subject(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Effective cover note of `root`.
|
/// 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.
|
/// Returns `None` when no valid cover note exists.
|
||||||
pub fn cover_note<'a>(
|
pub fn cover_note<'a>(
|
||||||
root: &Event,
|
root: &Event,
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::collections::{HashMap, HashSet};
|
|||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// A NIP-22 comment thread, a top-level comment on the root event.
|
/// A NIP-22 comment thread, a top-level comment on the root event,
|
||||||
/// Nested replies are ordered oldest first at every level.
|
/// nested replies are ordered oldest first at every level.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CommentThread {
|
pub struct CommentThread {
|
||||||
/// The thread's top-level comment.
|
/// The thread's top-level comment.
|
||||||
@@ -23,11 +23,13 @@ fn comment_parent(event: &Event) -> Option<EventId> {
|
|||||||
.and_then(|id| EventId::parse(id).ok())
|
.and_then(|id| EventId::parse(id).ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Group the comments on a root issue, patch or PR into NIP-22 threads.
|
/// Group the comments on a root issue, patch or PR into NIP-22 threads,
|
||||||
/// A comment whose parent is the root starts a thread.
|
/// a comment whose parent is the root starts a thread.
|
||||||
|
///
|
||||||
/// Other comments nest under their parent comment.
|
/// 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> {
|
pub fn comment_threads(root: &Event, comments: &[Event]) -> Vec<CommentThread> {
|
||||||
// Index comments by their parent id.
|
// Index comments by their parent id.
|
||||||
// Comments without a parent tag reply to the root event itself.
|
// Comments without a parent tag reply to the root event itself.
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ use std::collections::HashSet;
|
|||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// NIP-09 deletion requests and NIP-62 vanish requests.
|
/// NIP-09 deletion requests and NIP-62 vanish requests,
|
||||||
/// Built from the kind-5 and kind-62 events in the local database.
|
/// built from the kind-5 and kind-62 events in the local database.
|
||||||
|
///
|
||||||
/// Deleted events are hidden before they reach the UI.
|
/// Deleted events are hidden before they reach the UI.
|
||||||
|
///
|
||||||
/// Pass any event through [`Deletions::is_deleted`] before showing it.
|
/// Pass any event through [`Deletions::is_deleted`] before showing it.
|
||||||
pub struct Deletions {
|
pub struct Deletions {
|
||||||
/// `(deleted event id, expected author)` from `e` tags of kind-5 events.
|
/// `(deleted event id, expected author)` from `e` tags of kind-5 events.
|
||||||
ids: HashSet<(EventId, PublicKey)>,
|
ids: HashSet<(EventId, PublicKey)>,
|
||||||
/// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events.
|
/// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events.
|
||||||
|
///
|
||||||
/// All versions of the addressable event up to `cutoff` are deleted.
|
/// All versions of the addressable event up to `cutoff` are deleted.
|
||||||
coords: Vec<(Coordinate, PublicKey, Timestamp)>,
|
coords: Vec<(Coordinate, PublicKey, Timestamp)>,
|
||||||
/// `(author, cutoff)` from kind-62 vanish requests.
|
/// `(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.
|
/// 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.
|
/// 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`.
|
/// Addressable events are deleted up to the request's `created_at`.
|
||||||
pub fn is_deleted(&self, event: &Event) -> bool {
|
pub fn is_deleted(&self, event: &Event) -> bool {
|
||||||
if self
|
if self
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ pub fn activity(addr: &RepoAddr) -> Filter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Status events, kinds `1630..=1633`, referencing any of the given root events.
|
/// Status events, kinds `1630..=1633`, referencing any of the given root events.
|
||||||
/// They are matched via the `#e` tag.
|
/// They are matched via the `#e` tag. One filter covers all roots.
|
||||||
/// One filter covers all roots.
|
///
|
||||||
/// A negentropy sync reconciles them in a single session, not one per root.
|
/// A negentropy sync reconciles them in a single session, not one per root.
|
||||||
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||||
Filter::new()
|
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.
|
/// 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.
|
/// 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.
|
/// Because they carry no repository `a` tag, they are fetched by root like comments.
|
||||||
|
///
|
||||||
/// Batched, like [`statuses_for`].
|
/// Batched, like [`statuses_for`].
|
||||||
pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||||
Filter::new()
|
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.
|
/// NIP-22 comments, kind `1111`, referencing any of the given root events.
|
||||||
/// The roots are issues, patches and PRs.
|
/// 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.
|
/// Returns two filters, since combining `#E` and `#e` would AND the conditions.
|
||||||
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
||||||
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
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.
|
/// 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 {
|
pub fn all_announcements() -> Filter {
|
||||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How far back deletion requests are fetched and stored.
|
/// 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);
|
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
||||||
|
|
||||||
/// `now` minus [`DELETIONS_LOOKBACK`].
|
/// `now` minus [`DELETIONS_LOOKBACK`].
|
||||||
/// Quantized to whole days so identical filters hash the same.
|
/// Quantized to whole days so identical filters hash the same.
|
||||||
|
///
|
||||||
/// This lets the backend's sync dedup match identical filters.
|
/// This lets the backend's sync dedup match identical filters.
|
||||||
fn deletions_since() -> Timestamp {
|
fn deletions_since() -> Timestamp {
|
||||||
let now = Timestamp::now().as_secs();
|
let now = Timestamp::now().as_secs();
|
||||||
@@ -126,6 +120,7 @@ fn deletions_since() -> Timestamp {
|
|||||||
|
|
||||||
/// All deletion-related events within [`DELETIONS_LOOKBACK`].
|
/// All deletion-related events within [`DELETIONS_LOOKBACK`].
|
||||||
/// These are NIP-09 kind `5` and NIP-62 kind `62`.
|
/// These are NIP-09 kind `5` and NIP-62 kind `62`.
|
||||||
|
///
|
||||||
/// Deletion requests must be known before any other event is shown.
|
/// Deletion requests must be known before any other event is shown.
|
||||||
pub fn deletions() -> Filter {
|
pub fn deletions() -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
@@ -134,7 +129,9 @@ pub fn deletions() -> Filter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Deletion events relevant to a single repository.
|
/// Deletion events relevant to a single repository.
|
||||||
|
///
|
||||||
/// Requests authored by the repository owner.
|
/// Requests authored by the repository owner.
|
||||||
|
///
|
||||||
/// Requests addressed to the repository coordinate via its `#a` tag.
|
/// Requests addressed to the repository coordinate via its `#a` tag.
|
||||||
pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
|
pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
|
||||||
vec![
|
vec![
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ pub struct Announcement {
|
|||||||
pub euc: Option<String>,
|
pub euc: Option<String>,
|
||||||
/// Other recognized maintainers.
|
/// Other recognized maintainers.
|
||||||
pub maintainers: Vec<PublicKey>,
|
pub maintainers: Vec<PublicKey>,
|
||||||
/// Value of a `u` tag, if any.
|
|
||||||
/// Marks the repository as a subordinate fork of the upstream, per NIP-34.
|
/// Marks the repository as a subordinate fork of the upstream, per NIP-34.
|
||||||
pub upstream: Option<Upstream>,
|
pub upstream: Option<Upstream>,
|
||||||
/// Hashtags labelling the repository, the `t` tags.
|
/// 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 `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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Upstream {
|
pub struct Upstream {
|
||||||
/// Raw first value of the `u` tag, a coordinate or git URL.
|
/// Raw first value of the `u` tag, a coordinate or git URL.
|
||||||
@@ -52,10 +48,6 @@ pub struct Upstream {
|
|||||||
|
|
||||||
impl Upstream {
|
impl Upstream {
|
||||||
/// Parse the `u` tag values.
|
/// 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 {
|
fn parse(raw: &str, relay_hint: Option<&str>) -> Self {
|
||||||
let coordinate = raw.split('|').next().unwrap_or(raw);
|
let coordinate = raw.split('|').next().unwrap_or(raw);
|
||||||
let addr = coordinate
|
let addr = coordinate
|
||||||
@@ -70,7 +62,6 @@ impl Upstream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Text for display.
|
/// Text for display.
|
||||||
/// The upstream coordinate for a NIP-34 repository, else the raw `u` value.
|
|
||||||
pub fn display(&self) -> SharedString {
|
pub fn display(&self) -> SharedString {
|
||||||
match &self.addr {
|
match &self.addr {
|
||||||
Some(addr) => SharedString::from(addr.to_string()),
|
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 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.
|
/// Returns an empty list when no patch event can be linked to the PR.
|
||||||
pub fn pull_request_patches<'a>(
|
pub fn pull_request_patches<'a>(
|
||||||
pr: &Event,
|
pr: &Event,
|
||||||
@@ -160,8 +146,6 @@ pub fn pull_request_patches<'a>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The patch content of a pull request.
|
/// 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 {
|
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 patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||||
let series = pull_request_patches(pr, patches.iter().copied());
|
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.
|
/// Whether `patch` produces `commit`, found via its `commit` or `r` tag.
|
||||||
|
///
|
||||||
/// It lets clients find existing patches for a specific commit.
|
/// It lets clients find existing patches for a specific commit.
|
||||||
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
||||||
patch
|
patch
|
||||||
@@ -222,6 +207,7 @@ fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
|||||||
|
|
||||||
impl Announcement {
|
impl Announcement {
|
||||||
/// Parse a kind `30617` event.
|
/// Parse a kind `30617` event.
|
||||||
|
///
|
||||||
/// Returns `None` when the kind is wrong or the `d` tag is missing.
|
/// Returns `None` when the kind is wrong or the `d` tag is missing.
|
||||||
pub fn from_event(event: &Event) -> Option<Self> {
|
pub fn from_event(event: &Event) -> Option<Self> {
|
||||||
if event.kind != Kind::GitRepoAnnouncement {
|
if event.kind != Kind::GitRepoAnnouncement {
|
||||||
@@ -289,8 +275,8 @@ impl Announcement {
|
|||||||
|
|
||||||
/// Whether this announcement is a fork of the repository at `base`.
|
/// 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.
|
/// 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.
|
/// 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 {
|
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
|
||||||
if self.addr() == *base {
|
if self.addr() == *base {
|
||||||
return false;
|
return false;
|
||||||
@@ -308,9 +294,9 @@ impl Announcement {
|
|||||||
.unwrap_or(SharedString::from("No description"))
|
.unwrap_or(SharedString::from("No description"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The effective maintainers of this repository.
|
/// The effective maintainers of this repository,
|
||||||
/// The announced `maintainers` plus the announcement author.
|
/// the announced `maintainers` plus the announcement author.
|
||||||
/// The author asserts themselves as a maintainer of the primary project.
|
///
|
||||||
/// A `u` tag that marks the repository as a subordinate fork excludes them, per NIP-34.
|
/// A `u` tag that marks the repository as a subordinate fork excludes them, per NIP-34.
|
||||||
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
|
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
|
||||||
let mut maintainers = self.maintainers.clone();
|
let mut maintainers = self.maintainers.clone();
|
||||||
@@ -321,7 +307,6 @@ impl Announcement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The `git clone` URLs for this repository, deduplicated.
|
/// 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> {
|
pub fn clone_urls(&self) -> Vec<SharedString> {
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
self.clone
|
self.clone
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// Build a kind `30618` repository state event from refs and HEAD.
|
/// Build a kind `30618` repository state event from refs and HEAD,
|
||||||
/// `refs` are `(refname, commit-id)` pairs, e.g. `refs/heads/main`.
|
/// it is published as `ref: refs/heads/<branch>`.
|
||||||
/// `head` is the short branch name HEAD points to.
|
///
|
||||||
/// It is published as `ref: refs/heads/<branch>`.
|
|
||||||
/// The `d` tag matches the repository id.
|
/// The `d` tag matches the repository id.
|
||||||
pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
|
pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
|
||||||
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
|
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.
|
/// Parse a kind `30618` repository state event into refs and HEAD.
|
||||||
|
///
|
||||||
/// `refs` are `(refname, commit-id)` pairs.
|
/// `refs` are `(refname, commit-id)` pairs.
|
||||||
/// `head` is the branch pointed to by the `HEAD` tag, if any.
|
/// `head` is the branch pointed to by the `HEAD` tag, if any.
|
||||||
pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
|
pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
|
||||||
|
|||||||
@@ -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-10 and NIP-34 use the lowercase `e` tag.
|
||||||
|
///
|
||||||
/// NIP-22 comments, kind `1111`, use the uppercase `E` tag for the thread root.
|
/// NIP-22 comments, kind `1111`, use the uppercase `E` tag for the thread root.
|
||||||
pub fn references_root(event: &Event, root: &EventId) -> bool {
|
pub fn references_root(event: &Event, root: &EventId) -> bool {
|
||||||
let root = root.to_hex();
|
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.
|
/// 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`].
|
/// Defaults to [`RepoStatus::Open`].
|
||||||
pub fn resolve_status<'a, I>(
|
pub fn resolve_status<'a, I>(
|
||||||
status_events: I,
|
status_events: I,
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ impl GitCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Open the existing clone, fetching it first.
|
/// 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> {
|
pub fn ensure_clone(&self, addr: &RepoAddr, clone_urls: &[String]) -> Result<gix::Repository> {
|
||||||
let path = self.repo_path(addr);
|
let path = self.repo_path(addr);
|
||||||
|
|
||||||
@@ -66,19 +64,16 @@ impl GitCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Maximum directory nesting depth when scanning for local repositories.
|
/// Maximum directory nesting depth when scanning for local repositories.
|
||||||
|
///
|
||||||
/// Pathological trees can't stall the scan.
|
/// Pathological trees can't stall the scan.
|
||||||
const SCAN_MAX_DEPTH: usize = 12;
|
const SCAN_MAX_DEPTH: usize = 12;
|
||||||
|
|
||||||
/// Directories never descended into during a scan.
|
/// Directories never descended into during a scan.
|
||||||
|
///
|
||||||
/// Dependency caches can be enormous without ever containing user repositories.
|
/// Dependency caches can be enormous without ever containing user repositories.
|
||||||
const SCAN_SKIPPED_DIRS: [&str; 1] = ["node_modules"];
|
const SCAN_SKIPPED_DIRS: [&str; 1] = ["node_modules"];
|
||||||
|
|
||||||
/// Walk `root` recursively and collect the paths of git repositories below it.
|
/// 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> {
|
pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
|
||||||
let mut repos = Vec::new();
|
let mut repos = Vec::new();
|
||||||
if !root.is_dir() {
|
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 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.
|
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache.
|
||||||
pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> {
|
pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> {
|
||||||
if path.exists() {
|
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.
|
/// 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<()> {
|
pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
|
||||||
let options = gix::remote::ref_map::Options {
|
let options = gix::remote::ref_map::Options {
|
||||||
extra_refspecs: vec![
|
extra_refspecs: vec![
|
||||||
@@ -176,9 +166,10 @@ pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a `git format-patch` patch or series with `git am`.
|
/// Apply a `git format-patch` patch or series with `git am`,
|
||||||
/// Uses the git CLI because it handles the mbox format natively.
|
/// uses the git CLI because it handles the mbox format natively.
|
||||||
/// Can be replaced with a pure-Rust implementation later without changing callers.
|
///
|
||||||
|
/// TODO: Replaced with a pure-Rust implementation later without changing callers.
|
||||||
pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
|
pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
|
||||||
let mut child = Command::new("git")
|
let mut child = Command::new("git")
|
||||||
.arg("am")
|
.arg("am")
|
||||||
@@ -201,9 +192,11 @@ pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The merge base of two revisions in the repository at `repo_path`.
|
/// The merge base of two revisions in the repository at `repo_path`,
|
||||||
/// Revisions may be branch names, remote-tracking refs or commit ids.
|
/// revisions may be branch names, remote-tracking refs or commit ids.
|
||||||
|
///
|
||||||
/// `Ok(None)` when the revisions share no common ancestor.
|
/// `Ok(None)` when the revisions share no common ancestor.
|
||||||
|
///
|
||||||
/// Unresolvable revisions are errors.
|
/// Unresolvable revisions are errors.
|
||||||
pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result<Option<String>> {
|
pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result<Option<String>> {
|
||||||
let output = Command::new("git")
|
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`.
|
/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`.
|
||||||
/// Fails when the range has no commits.
|
/// 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> {
|
pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result<String> {
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.arg("-C")
|
.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.
|
/// 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<()> {
|
pub fn patch_applies(repo_path: &Path, patch: &str) -> Result<()> {
|
||||||
let mut child = Command::new("git")
|
let mut child = Command::new("git")
|
||||||
.arg("apply")
|
.arg("apply")
|
||||||
@@ -282,14 +272,11 @@ pub fn patch_applies(repo_path: &Path, patch: &str) -> Result<()> {
|
|||||||
String::from_utf8_lossy(&output.stderr).trim()
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push `commit` to `reference` on the server at `url`, from `repo_path`.
|
/// 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<()> {
|
pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> {
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.arg("-C")
|
.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.
|
/// 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 single patch yields one element.
|
||||||
/// A malformed input yields one element covering it.
|
/// A malformed input yields one element covering it.
|
||||||
pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
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`.
|
/// The commit HEAD points to in the repository at `repo_path`.
|
||||||
|
///
|
||||||
/// `None` when the repository has no commits yet, an unborn HEAD.
|
/// `None` when the repository has no commits yet, an unborn HEAD.
|
||||||
pub fn head_commit_id(repo_path: &Path) -> Result<Option<String>> {
|
pub fn head_commit_id(repo_path: &Path) -> Result<Option<String>> {
|
||||||
let output = Command::new("git")
|
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.
|
/// The commits in `base..HEAD` of the repository at `repo_path`, oldest first.
|
||||||
/// This is the order `git am` creates them.
|
/// This is the order `git am` creates them.
|
||||||
|
///
|
||||||
/// `HEAD` alone when `base` is `None`.
|
/// `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>> {
|
pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result<Vec<String>> {
|
||||||
let output = match base {
|
let output = match base {
|
||||||
Some(base) => git_in(
|
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.
|
/// Create a repository at `path` with an initial `main` branch.
|
||||||
/// Write a `README.md` from `name` and `description`, then create the initial commit.
|
/// Write a `README.md` from `name` and `description`, then create the initial commit.
|
||||||
|
///
|
||||||
/// Returns the initial commit id.
|
/// Returns the initial commit id.
|
||||||
|
///
|
||||||
/// Uses the git CLI, like [`apply_patch`].
|
/// 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> {
|
pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<String> {
|
||||||
std::fs::create_dir_all(path)
|
std::fs::create_dir_all(path)
|
||||||
.with_context(|| format!("failed to create {}", path.display()))?;
|
.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.
|
/// Push every local branch and tag of the repository at `repo_path` to a grasp server.
|
||||||
|
///
|
||||||
/// This mirrors an initialized repository's whole history.
|
/// This mirrors an initialized repository's whole history.
|
||||||
pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
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");
|
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`.
|
/// 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.
|
/// Used as the NIP-34 announcement's `euc` marker.
|
||||||
|
///
|
||||||
/// `None` for a repository without commits.
|
/// `None` for a repository without commits.
|
||||||
pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
|
pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
|
||||||
let output = Command::new("git")
|
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.
|
/// 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.
|
/// No-op if `origin` already exists.
|
||||||
pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
|
pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
|
||||||
// `git remote get-url origin` exits non-zero when the remote is absent.
|
// `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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Point `origin` at `url`, replacing an existing remote.
|
/// Point `origin` at `url`, replacing an existing remote,
|
||||||
/// Used after a clone whose `origin` points at the cloned-from path.
|
/// 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.
|
/// 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<()> {
|
pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> {
|
||||||
// `git remote get-url origin` exits non-zero when the remote is absent.
|
// `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`.
|
/// 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.
|
/// When no URL works, the last error is returned.
|
||||||
|
///
|
||||||
/// Never touches the checked-out refs or the worktree.
|
/// Never touches the checked-out refs or the worktree.
|
||||||
pub fn fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) -> Result<()> {
|
pub fn fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) -> Result<()> {
|
||||||
let mut last_err: Option<anyhow::Error> = None;
|
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`.
|
/// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`.
|
||||||
/// `prefix` is a ref namespace like `refs/fork/<owner>/<id>`.
|
/// `prefix` is a ref namespace like `refs/fork/<owner>/<id>`.
|
||||||
|
///
|
||||||
/// Returns an empty list when nothing matches.
|
/// Returns an empty list when nothing matches.
|
||||||
pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
|
pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
|
||||||
// `for-each-ref` patterns match whole path components.
|
// `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`.
|
/// Delete every ref under `prefix` of the repository at `repo_path`.
|
||||||
/// `prefix` is a ref namespace like `refs/fork/<owner>/<id>`.
|
/// `prefix` is a ref namespace like `refs/fork/<owner>/<id>`.
|
||||||
|
///
|
||||||
/// Lets a stale import be pruned before a re-import.
|
/// Lets a stale import be pruned before a re-import.
|
||||||
|
///
|
||||||
/// No-op when nothing matches.
|
/// No-op when nothing matches.
|
||||||
pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> {
|
pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> {
|
||||||
let refs = refs_with_prefix(repo_path, prefix)?;
|
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`.
|
/// The URL of the `origin` remote of the repository at `workdir`.
|
||||||
|
///
|
||||||
/// `None` when it has no `origin` yet.
|
/// `None` when it has no `origin` yet.
|
||||||
pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
|
pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
|
||||||
let output = Command::new("git")
|
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.
|
/// 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.
|
/// Returns whether any branch moved.
|
||||||
pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
|
pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
|
||||||
let current = git_in(workdir, &["branch", "--show-current"]).unwrap_or_default();
|
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.
|
/// Run a git command in `dir`, returning trimmed stdout.
|
||||||
|
///
|
||||||
/// The terminal prompt is disabled so a credential request fails instead of hanging.
|
/// The terminal prompt is disabled so a credential request fails instead of hanging.
|
||||||
fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
|
fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
|
||||||
let output = Command::new("git")
|
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.
|
/// Map an untrusted repository id or display name to a safe single path component.
|
||||||
|
///
|
||||||
/// Everything outside `[A-Za-z0-9._-]` becomes `_`.
|
/// Everything outside `[A-Za-z0-9._-]` becomes `_`.
|
||||||
/// An id that maps to exactly `.` or `..` becomes `_`.
|
/// An id that maps to exactly `.` or `..` becomes `_`.
|
||||||
pub fn sanitize_path_component(id: &str) -> String {
|
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`].
|
/// 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.
|
/// Without one, every walk re-decodes the same commit objects from the object database.
|
||||||
const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
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.
|
/// Relative paths of all entries in the worktree, files and directories.
|
||||||
/// Directories first, then alphabetically within each group.
|
///
|
||||||
/// The `.git` directory is skipped.
|
/// The `.git` directory is skipped.
|
||||||
pub fn worktree_entries(repo: &gix::Repository) -> Result<Vec<PathBuf>> {
|
pub fn worktree_entries(repo: &gix::Repository) -> Result<Vec<PathBuf>> {
|
||||||
let workdir = repo.workdir().context("repository has no worktree")?;
|
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.
|
/// Read a file from the worktree.
|
||||||
|
///
|
||||||
/// Returns `Ok(None)` if the path is missing or not a regular file.
|
/// 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>>> {
|
pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result<Option<Vec<u8>>> {
|
||||||
let workdir = repo.workdir().context("repository has no worktree")?;
|
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.
|
/// 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`.
|
/// Falls back to any other file whose name starts with `readme`.
|
||||||
pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
|
pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
|
||||||
let Some(workdir) = repo.workdir() else {
|
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.
|
/// A [`FileCommit`] from a walk commit, with author, message title and shortened id.
|
||||||
/// `include_description` controls whether the message body is copied.
|
/// `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.
|
/// The diff panel fetches the full commit on demand.
|
||||||
fn file_commit(commit: &gix::Commit<'_>, include_description: bool) -> Result<FileCommit> {
|
fn file_commit(commit: &gix::Commit<'_>, include_description: bool) -> Result<FileCommit> {
|
||||||
let author = commit.author()?;
|
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.
|
/// 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.
|
/// `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>> {
|
pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileCommit>> {
|
||||||
let rel = rel.to_path_buf();
|
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.
|
/// Newest commit touching each of `rels`, like `git log -1 -- <rel>` per path.
|
||||||
/// `rels` are paths relative to the worktree.
|
/// `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.
|
/// Paths without any commit, like untracked files, are absent from the result.
|
||||||
pub fn worktree_last_commits(
|
pub fn worktree_last_commits(
|
||||||
workdir: &Path,
|
workdir: &Path,
|
||||||
@@ -924,6 +911,7 @@ pub fn worktree_last_commits(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The walk behind [`last_commit`] and [`worktree_last_commits`].
|
/// The walk behind [`last_commit`] and [`worktree_last_commits`].
|
||||||
|
///
|
||||||
/// Stops as soon as every pending path has its commit.
|
/// Stops as soon as every pending path has its commit.
|
||||||
fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
|
fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||||
@@ -984,14 +972,13 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf
|
|||||||
Ok(found)
|
Ok(found)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cap on [`CommitList::commits`].
|
/// Cap on [`CommitList::commits`]. The virtual list renders a window at a time,
|
||||||
/// The virtual list renders a window at a time, the tab badge shows the real count.
|
/// the tab badge shows the real count.
|
||||||
|
///
|
||||||
/// A huge history is never fully materialized in memory.
|
/// A huge history is never fully materialized in memory.
|
||||||
pub const MAX_LISTED_COMMITS: usize = 20_000;
|
pub const MAX_LISTED_COMMITS: usize = 20_000;
|
||||||
|
|
||||||
/// Commits reachable from `HEAD`, newest first, possibly capped.
|
/// 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 {
|
pub struct CommitList {
|
||||||
/// Number of commits reachable from HEAD.
|
/// Number of commits reachable from HEAD.
|
||||||
pub total: usize,
|
pub total: usize,
|
||||||
@@ -1000,6 +987,7 @@ pub struct CommitList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// All commits reachable from `HEAD`, newest first, with author and summary.
|
/// All commits reachable from `HEAD`, newest first, with author and summary.
|
||||||
|
///
|
||||||
/// Returns an empty list for a repository without any commits yet.
|
/// Returns an empty list for a repository without any commits yet.
|
||||||
pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
|
pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
|
||||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
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.
|
/// Like [`all_commits`], but opens the repository at `workdir` first.
|
||||||
|
///
|
||||||
/// For non-bare clones the clone root is the worktree.
|
/// For non-bare clones the clone root is the worktree.
|
||||||
pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> {
|
pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> {
|
||||||
all_commits(&open_with_cache(workdir)?)
|
all_commits(&open_with_cache(workdir)?)
|
||||||
@@ -1058,7 +1047,6 @@ pub struct DiffLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A hunk of a file diff, like `@@ -a,b +c,d @@`.
|
/// A hunk of a file diff, like `@@ -a,b +c,d @@`.
|
||||||
/// Context around each change, then removals and additions.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DiffHunk {
|
pub struct DiffHunk {
|
||||||
/// 1-based start line in the old version.
|
/// 1-based start line in the old version.
|
||||||
@@ -1101,16 +1089,14 @@ pub struct FileDiff {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The changes of one commit.
|
/// The changes of one commit.
|
||||||
/// Lists every file it added, modified, deleted or renamed.
|
|
||||||
/// Text files carry line-level hunks.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CommitDiff {
|
pub struct CommitDiff {
|
||||||
pub files: Vec<FileDiff>,
|
pub files: Vec<FileDiff>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The changes of the commit `id`, short or full, in the repository at `workdir`.
|
/// 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.
|
/// 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> {
|
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
|
||||||
commit_diff(&open_with_cache(workdir)?, id)
|
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`.
|
/// 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.
|
/// Directories and submodules are skipped, files are sorted by path.
|
||||||
pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result<CommitDiff> {
|
pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result<CommitDiff> {
|
||||||
let repo = open_with_cache(workdir)?;
|
let repo = open_with_cache(workdir)?;
|
||||||
@@ -1170,8 +1156,7 @@ pub fn worktree_commit_range_commits(
|
|||||||
Ok(commits)
|
Ok(commits)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The changes between two trees.
|
/// The changes between two trees. Used by both [`commit_diff`] and [`worktree_commit_range_diff`].
|
||||||
/// Used by both [`commit_diff`] and [`worktree_commit_range_diff`].
|
|
||||||
fn tree_diff(
|
fn tree_diff(
|
||||||
repo: &gix::Repository,
|
repo: &gix::Repository,
|
||||||
old_tree: Option<&gix::Tree<'_>>,
|
old_tree: Option<&gix::Tree<'_>>,
|
||||||
@@ -1290,12 +1275,6 @@ fn tree_diff(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `git format-patch` output, a single patch or a series.
|
/// 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> {
|
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||||
let lines: Vec<&str> = patch.lines().collect();
|
let lines: Vec<&str> = patch.lines().collect();
|
||||||
let mut files = Vec::new();
|
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.
|
/// 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.
|
/// Entries appear in patch order, oldest first as `git format-patch` produces them.
|
||||||
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
||||||
let lines: Vec<&str> = patch.lines().collect();
|
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.
|
/// Strip the patch prefix from a `Subject:` header.
|
||||||
|
///
|
||||||
/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`.
|
/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`.
|
||||||
fn strip_patch_prefix(subject: &str) -> String {
|
fn strip_patch_prefix(subject: &str) -> String {
|
||||||
let trimmed = subject.trim();
|
let trimmed = subject.trim();
|
||||||
@@ -1396,7 +1375,7 @@ fn strip_patch_prefix(subject: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse one file's diff section.
|
/// 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.
|
/// Returns the section and the index of the first unconsumed line.
|
||||||
fn parse_diff_section(header: &str, lines: &[&str], start: usize) -> Result<(FileDiff, usize)> {
|
fn parse_diff_section(header: &str, lines: &[&str], start: usize) -> Result<(FileDiff, usize)> {
|
||||||
let (header_old, header_new) = header_paths(header)?;
|
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.
|
/// 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.
|
/// Returns the hunk and the index of the first unconsumed line.
|
||||||
fn parse_hunk(lines: &[&str], start: usize) -> Result<(DiffHunk, usize)> {
|
fn parse_hunk(lines: &[&str], start: usize) -> Result<(DiffHunk, usize)> {
|
||||||
let (old_start, old_lines, new_start, new_lines) = hunk_header(lines[start])?;
|
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.
|
/// The kind of a hunk body line, from its first character.
|
||||||
|
///
|
||||||
/// Lines outside a hunk, headers, `\ No newline...` and the next section, yield `None`.
|
/// Lines outside a hunk, headers, `\ No newline...` and the next section, yield `None`.
|
||||||
fn line_prefix_kind(line: &str) -> Option<DiffLineKind> {
|
fn line_prefix_kind(line: &str) -> Option<DiffLineKind> {
|
||||||
match line.as_bytes().first()? {
|
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 @@`.
|
/// Parse a unified-diff hunk header, `@@ -a,b +c,d @@`.
|
||||||
|
///
|
||||||
/// Omitted line counts default to 1.
|
/// Omitted line counts default to 1.
|
||||||
fn hunk_header(header: &str) -> Result<(u32, u32, u32, u32)> {
|
fn hunk_header(header: &str) -> Result<(u32, u32, u32, u32)> {
|
||||||
let rest = header
|
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.
|
/// 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)> {
|
fn header_paths(header: &str) -> Result<(String, String)> {
|
||||||
if header.starts_with('"') {
|
if header.starts_with('"') {
|
||||||
// Quoted paths include the `a/` / `b/` prefix inside the quotes.
|
// 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 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> {
|
fn diff_line_path(line: &str, prefix: &str) -> Result<String> {
|
||||||
let line = line.trim_end_matches('\t');
|
let line = line.trim_end_matches('\t');
|
||||||
if line.starts_with('"') {
|
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 content of a git C-style quoted path and the rest of the input.
|
||||||
/// The path spans the opening `"`, escaped content and closing `"`.
|
/// The path spans the opening `"`, escaped content and closing `"`.
|
||||||
|
///
|
||||||
/// `None` if unterminated.
|
/// `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)> {
|
fn take_quoted(input: &str) -> Option<(&str, &str)> {
|
||||||
let mut end = 1; // byte after the opening quote
|
let mut end = 1; // byte after the opening quote
|
||||||
let mut rest = &input[1..];
|
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.
|
/// 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> {
|
struct HunkCollector<'a> {
|
||||||
hunks: &'a mut Vec<DiffHunk>,
|
hunks: &'a mut Vec<DiffHunk>,
|
||||||
insertions: &'a mut usize,
|
insertions: &'a mut usize,
|
||||||
@@ -1784,6 +1755,7 @@ impl ConsumeHunk for HunkCollector<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The commit HEAD points to, like `git log -1`.
|
/// The commit HEAD points to, like `git log -1`.
|
||||||
|
///
|
||||||
/// `Ok(None)` for a repository without commits yet, an unborn HEAD.
|
/// `Ok(None)` for a repository without commits yet, an unborn HEAD.
|
||||||
pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
|
pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
|
||||||
let Some(head) = repo.head_id().ok() else {
|
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`.
|
/// Full metadata of the commit `id`, short or full, in the repository at `workdir`.
|
||||||
/// Like [`head_commit`] for an arbitrary commit.
|
/// Like [`head_commit`] for an arbitrary commit.
|
||||||
|
///
|
||||||
/// `Ok(None)` when the id cannot be resolved.
|
/// `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>> {
|
pub fn worktree_commit(workdir: &Path, id: &str) -> Result<Option<FileCommit>> {
|
||||||
let repo = open_with_cache(workdir)?;
|
let repo = open_with_cache(workdir)?;
|
||||||
match repo.rev_parse_single(id.as_bytes()) {
|
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.
|
/// Short name of the branch HEAD points to, or `None` when detached.
|
||||||
|
///
|
||||||
/// Detached after checking out a tag or a commit directly.
|
/// Detached after checking out a tag or a commit directly.
|
||||||
pub fn current_branch(repo: &gix::Repository) -> Result<Option<String>> {
|
pub fn current_branch(repo: &gix::Repository) -> Result<Option<String>> {
|
||||||
let head = repo.head()?;
|
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.
|
/// Branch, tag and HEAD refs of a repository.
|
||||||
|
///
|
||||||
/// Ready for a NIP-34 kind-30618 repository state announcement.
|
/// Ready for a NIP-34 kind-30618 repository state announcement.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct RepoRefState {
|
pub struct RepoRefState {
|
||||||
@@ -1862,6 +1835,7 @@ pub struct RepoRefState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Collect the refs of `repo`.
|
/// Collect the refs of `repo`.
|
||||||
|
///
|
||||||
/// Local branches and tags become `(refname, commit-id)` pairs.
|
/// Local branches and tags become `(refname, commit-id)` pairs.
|
||||||
/// Also reports the branch HEAD points to.
|
/// Also reports the branch HEAD points to.
|
||||||
pub fn repo_ref_state(repo: &gix::Repository) -> Result<RepoRefState> {
|
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.
|
/// Snapshot the worktree after a branch or tag switch.
|
||||||
|
///
|
||||||
/// Collects entries, the README, the branch HEAD points to and its commit.
|
/// Collects entries, the README, the branch HEAD points to and its commit.
|
||||||
/// Opens the repository once.
|
|
||||||
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
|
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
|
||||||
let repo = open_with_cache(workdir)?;
|
let repo = open_with_cache(workdir)?;
|
||||||
let readme_path = find_readme(&repo)?;
|
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`.
|
/// Switch the checked-out ref and update the worktree, like `git checkout --force`.
|
||||||
|
///
|
||||||
/// Local modifications are discarded, these clones are read-only browser copies.
|
/// Local modifications are discarded, these clones are read-only browser copies.
|
||||||
fn checkout(workdir: &Path, args: &[&str]) -> Result<()> {
|
fn checkout(workdir: &Path, args: &[&str]) -> Result<()> {
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ use nostr_sdk::prelude::*;
|
|||||||
|
|
||||||
use crate::signer::UniversalSigner;
|
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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, UniversalSigner)> {
|
pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, UniversalSigner)> {
|
||||||
let signer = UniversalSigner::new(Keys::generate());
|
let signer = UniversalSigner::new(Keys::generate());
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ impl UniversalSignerError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A type-erased signer whose inner signer can be swapped in-place.
|
/// 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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct UniversalSigner {
|
pub struct UniversalSigner {
|
||||||
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
/// A lightweight change notification for the UI.
|
/// A lightweight change notification for the UI.
|
||||||
/// Heavy data stays in the database, consumers re-query on receipt.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Update {
|
pub struct Update {
|
||||||
pub kind: Kind,
|
pub kind: Kind,
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
|||||||
use crate::git_store::GitStore;
|
use crate::git_store::GitStore;
|
||||||
|
|
||||||
/// Keyring entry for the user credential.
|
/// 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";
|
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
||||||
/// Timeout for NIP-46 signer responses.
|
/// Timeout for NIP-46 signer responses.
|
||||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
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"];
|
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.
|
/// 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);
|
const FETCH_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum BackendEvent {
|
pub enum BackendEvent {
|
||||||
/// User has no signer configured.
|
/// User has no signer configured.
|
||||||
SignerRequired,
|
SignerRequired,
|
||||||
/// The stored identity is NIP-49 encrypted, an `ncryptsec1...` key.
|
/// The stored identity is NIP-49 encrypted key.
|
||||||
/// A passphrase is required to decrypt it before the session can resume.
|
|
||||||
PassphraseRequired,
|
PassphraseRequired,
|
||||||
/// The signer changed on login, logout or account switch.
|
/// The signer changed on login, logout or account switch.
|
||||||
SignerChanged,
|
SignerChanged,
|
||||||
@@ -56,12 +50,8 @@ pub enum BackendEvent {
|
|||||||
/// A new event was received from a relay and stored in the database.
|
/// A new event was received from a relay and stored in the database.
|
||||||
NostrUpdate(Update),
|
NostrUpdate(Update),
|
||||||
/// A negentropy sync completed.
|
/// A negentropy sync completed.
|
||||||
/// The database was updated directly, so stores should re-query.
|
|
||||||
/// No [`BackendEvent::NostrUpdate`] is fired for synced events.
|
|
||||||
Synced,
|
Synced,
|
||||||
/// A negentropy sync is in flight.
|
/// A negentropy sync is in flight.
|
||||||
/// Stores may re-query to render incrementally.
|
|
||||||
/// UI can show `current` and `total` progress.
|
|
||||||
SyncProgress {
|
SyncProgress {
|
||||||
/// Total events to process.
|
/// Total events to process.
|
||||||
total: u64,
|
total: u64,
|
||||||
@@ -84,9 +74,8 @@ impl BackendEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The global backend entity.
|
/// The global backend entity.
|
||||||
|
///
|
||||||
/// Owns the nostr client, the signer and the notification pump.
|
/// 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 {
|
pub struct Backend {
|
||||||
client: Client,
|
client: Client,
|
||||||
signer: UniversalSigner,
|
signer: UniversalSigner,
|
||||||
@@ -94,18 +83,10 @@ pub struct Backend {
|
|||||||
connected: bool,
|
connected: bool,
|
||||||
sync_progress: Option<(u64, u64)>,
|
sync_progress: Option<(u64, u64)>,
|
||||||
/// True when the stored credential is NIP-49 encrypted.
|
/// True when the stored credential is NIP-49 encrypted.
|
||||||
/// A passphrase is still needed to resume the session.
|
|
||||||
passphrase_required: bool,
|
passphrase_required: bool,
|
||||||
/// Fingerprints of recently started fetches and syncs, a relay plus filter set.
|
/// 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>,
|
recent_fetches: HashMap<u64, Instant>,
|
||||||
/// Repositories with a push in flight, mirror or checkout based.
|
/// 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>>>,
|
pushing_repos: Arc<Mutex<HashSet<RepoAddr>>>,
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
}
|
}
|
||||||
@@ -115,6 +96,7 @@ struct GlobalBackend(Entity<Backend>);
|
|||||||
impl Global for GlobalBackend {}
|
impl Global for GlobalBackend {}
|
||||||
|
|
||||||
/// Removes its repository from the in-flight push set when dropped.
|
/// Removes its repository from the in-flight push set when dropped.
|
||||||
|
///
|
||||||
/// A push task cancelled by its panel closing cannot leave the repository locked.
|
/// A push task cancelled by its panel closing cannot leave the repository locked.
|
||||||
struct PushGuard {
|
struct PushGuard {
|
||||||
repos: Arc<Mutex<HashSet<RepoAddr>>>,
|
repos: Arc<Mutex<HashSet<RepoAddr>>>,
|
||||||
@@ -182,7 +164,7 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Bootstrap the client.
|
/// Bootstrap the client.
|
||||||
/// Connect to the default relays, with the indexers as discovery-only.
|
///
|
||||||
/// Restore the saved session, if any.
|
/// Restore the saved session, if any.
|
||||||
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
@@ -221,7 +203,9 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Restore the saved session from the keyring.
|
/// Restore the saved session from the keyring.
|
||||||
|
///
|
||||||
/// Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
/// Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
||||||
|
///
|
||||||
/// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
|
/// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
|
||||||
pub fn restore_session(&mut self, cx: &mut Context<Self>) {
|
pub fn restore_session(&mut self, cx: &mut Context<Self>) {
|
||||||
if cfg!(target_arch = "wasm32") {
|
if cfg!(target_arch = "wasm32") {
|
||||||
@@ -283,9 +267,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt the NIP-49 keyring credential with the given passphrase.
|
/// 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(
|
pub fn restore_with_passphrase(
|
||||||
&mut self,
|
&mut self,
|
||||||
password: &str,
|
password: &str,
|
||||||
@@ -321,11 +302,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new identity.
|
/// 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(
|
pub fn create_identity(
|
||||||
&mut self,
|
&mut self,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -412,19 +388,8 @@ impl Backend {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a repository.
|
|
||||||
/// Initialize a local clone with a `main` branch and a `README.md`.
|
/// 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.
|
/// The task yields the announcement and the path of the working copy.
|
||||||
pub fn create_repository(
|
pub fn create_repository(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -453,16 +418,15 @@ impl Backend {
|
|||||||
return Task::ready(Err(anyhow!("Sign in to create a repository")));
|
return Task::ready(Err(anyhow!("Sign in to create a repository")));
|
||||||
};
|
};
|
||||||
|
|
||||||
// The repository identifier is derived from the name, like ngit and gitworkshop.
|
// The repository identifier is derived from the name.
|
||||||
// Spaces become hyphens.
|
|
||||||
// Other non-alphanumeric characters become hyphens, except `/`.
|
|
||||||
// Case is preserved.
|
|
||||||
let repo_id = identifier_from_name(&name);
|
let repo_id = identifier_from_name(&name);
|
||||||
|
|
||||||
if repo_id.is_empty() || repo_id.len() > 100 {
|
if repo_id.is_empty() || repo_id.len() > 100 {
|
||||||
return Task::ready(Err(anyhow!(
|
return Task::ready(Err(anyhow!(
|
||||||
"Repository name must produce an identifier of 1-100 characters"
|
"Repository name must produce an identifier of 1-100 characters"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !repo_id.chars().any(|c| c.is_ascii_alphanumeric()) {
|
if !repo_id.chars().any(|c| c.is_ascii_alphanumeric()) {
|
||||||
return Task::ready(Err(anyhow!(
|
return Task::ready(Err(anyhow!(
|
||||||
"Repository name must contain at least one alphanumeric character"
|
"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 addr = repo_addr(public_key, repo_id.clone());
|
||||||
let cache = GitStore::global(cx).cache().clone();
|
let cache = GitStore::global(cx).cache().clone();
|
||||||
let path = cache.repo_path(&addr);
|
let path = cache.repo_path(&addr);
|
||||||
let owner = public_key
|
let owner = public_key.to_bech32().unwrap();
|
||||||
.to_bech32()
|
|
||||||
.unwrap_or_else(|_| public_key.to_hex());
|
|
||||||
let servers = grasp_servers.clone();
|
let servers = grasp_servers.clone();
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
@@ -544,8 +506,7 @@ impl Backend {
|
|||||||
this.add_relays(urls, cx);
|
this.add_relays(urls, cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// The state event is the push authorization.
|
// The state event is the push authorization. It must be accepted before the push below.
|
||||||
// It must be accepted before the push below.
|
|
||||||
let announcement = GitRepositoryAnnouncement {
|
let announcement = GitRepositoryAnnouncement {
|
||||||
id: repo_id.clone(),
|
id: repo_id.clone(),
|
||||||
name: Some(name.clone()),
|
name: Some(name.clone()),
|
||||||
@@ -601,8 +562,7 @@ impl Backend {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if let Err(e) = push.await {
|
if let Err(e) = push.await {
|
||||||
// The events are already published.
|
// The events are already published. Retract them so the repository is not left announced without content.
|
||||||
// Retract them so the repository is not left announced without content.
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.retract_events(&[event.clone(), state_event.clone()], cx);
|
this.retract_events(&[event.clone(), state_event.clone()], cx);
|
||||||
})
|
})
|
||||||
@@ -622,11 +582,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Publish an existing local repository to NIP-34.
|
/// 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(
|
pub fn publish_local_repo(
|
||||||
&mut self,
|
&mut self,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
@@ -685,8 +640,7 @@ impl Backend {
|
|||||||
this.add_relays(urls, cx);
|
this.add_relays(urls, cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// The state event is the push authorization.
|
// The state event is the push authorization. It must be accepted before the push below.
|
||||||
// It must be accepted before the push below.
|
|
||||||
let announcement = GitRepositoryAnnouncement {
|
let announcement = GitRepositoryAnnouncement {
|
||||||
id: repo_id.clone(),
|
id: repo_id.clone(),
|
||||||
name: Some(name.clone()),
|
name: Some(name.clone()),
|
||||||
@@ -730,8 +684,8 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Push every branch and tag to each grasp server.
|
// Push every branch and tag to each grasp server. The push fails only when no server accepted it.
|
||||||
// The push fails only when no server accepted it.
|
//
|
||||||
// An empty repository has nothing to push.
|
// An empty repository has nothing to push.
|
||||||
if !refs.is_empty() {
|
if !refs.is_empty() {
|
||||||
let push = cx.background_spawn({
|
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.
|
/// 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(
|
pub fn push_repository(
|
||||||
&mut self,
|
&mut self,
|
||||||
announcement: Announcement,
|
announcement: Announcement,
|
||||||
@@ -784,10 +735,8 @@ impl Backend {
|
|||||||
|
|
||||||
/// Push the refs of a local checkout to the grasp servers in its `relays` tag.
|
/// 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.
|
/// 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.
|
/// 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(
|
pub fn push_checkout(
|
||||||
&mut self,
|
&mut self,
|
||||||
announcement: Announcement,
|
announcement: Announcement,
|
||||||
@@ -799,12 +748,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Shared body of the mirror-based and checkout-based pushes.
|
/// 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(
|
fn push_repo_from(
|
||||||
&mut self,
|
&mut self,
|
||||||
announcement: Announcement,
|
announcement: Announcement,
|
||||||
@@ -828,16 +771,13 @@ impl Backend {
|
|||||||
addr: addr.clone(),
|
addr: addr.clone(),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let owner = announcement
|
|
||||||
.owner
|
let owner = announcement.owner.to_bech32().unwrap();
|
||||||
.to_bech32()
|
|
||||||
.unwrap_or_else(|_| announcement.owner.to_hex());
|
|
||||||
let repo_id = announcement.id.clone();
|
let repo_id = announcement.id.clone();
|
||||||
let relays = announcement.relays.clone();
|
let relays = announcement.relays.clone();
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
// Held for the whole task.
|
// Held for the whole task. Dropped on completion, on error and on cancellation alike.
|
||||||
// Dropped on completion, on error and on cancellation alike.
|
|
||||||
let _guard = guard;
|
let _guard = guard;
|
||||||
|
|
||||||
let mut state = {
|
let mut state = {
|
||||||
@@ -850,12 +790,14 @@ impl Backend {
|
|||||||
|
|
||||||
// The state event announces the pushed refs.
|
// The state event announces the pushed refs.
|
||||||
// Keep the announced default branch in `HEAD` when it is among 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.
|
// Otherwise `HEAD` stays the checkout's current branch.
|
||||||
let heads: Vec<&str> = state
|
let heads: Vec<&str> = state
|
||||||
.refs
|
.refs
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|(name, _)| name.strip_prefix("refs/heads/"))
|
.filter_map(|(name, _)| name.strip_prefix("refs/heads/"))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if let Some(head) = announced_head
|
if let Some(head) = announced_head
|
||||||
&& heads.iter().any(|branch| *branch == head)
|
&& heads.iter().any(|branch| *branch == head)
|
||||||
{
|
{
|
||||||
@@ -865,6 +807,7 @@ impl Backend {
|
|||||||
// Grasp servers authorize a push by the state they have seen.
|
// Grasp servers authorize a push by the state they have seen.
|
||||||
let refs = state.refs.clone();
|
let refs = state.refs.clone();
|
||||||
let head = state.head.clone();
|
let head = state.head.clone();
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
let builder = build_state(&repo_id, &refs, head.as_deref());
|
let builder = build_state(&repo_id, &refs, head.as_deref());
|
||||||
this.send(builder, cx)
|
this.send(builder, cx)
|
||||||
@@ -890,8 +833,7 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete the repository from nostr.
|
/// 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.
|
/// Only the repository owner may delete it.
|
||||||
pub fn delete_repository(
|
pub fn delete_repository(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -934,7 +876,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Login with an `nsec1...` key or a `bunker://...` URI.
|
/// 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>) {
|
pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) {
|
||||||
let credential = credential.trim();
|
let credential = credential.trim();
|
||||||
|
|
||||||
@@ -950,7 +891,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a fresh identity and login with it.
|
/// 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>) {
|
pub fn login_with_new_identity(&mut self, cx: &mut Context<Self>) {
|
||||||
let nsec = Keys::generate()
|
let nsec = Keys::generate()
|
||||||
.secret_key()
|
.secret_key()
|
||||||
@@ -960,7 +900,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Login with an `nsec1...` secret key.
|
/// 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>) {
|
pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) {
|
||||||
let keys = match SecretKey::parse(nsec) {
|
let keys = match SecretKey::parse(nsec) {
|
||||||
Ok(secret) => Keys::new(secret),
|
Ok(secret) => Keys::new(secret),
|
||||||
@@ -985,10 +924,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Login with a `bunker://...` URI, NIP-46.
|
/// 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>) {
|
pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context<Self>) {
|
||||||
let uri_string = uri.trim().to_owned();
|
let uri_string = uri.trim().to_owned();
|
||||||
|
|
||||||
@@ -1052,8 +987,7 @@ impl Backend {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the user's grasp list of kind `10317`.
|
/// Fetch the user's grasp list and add the listed grasp servers as relays.
|
||||||
/// Add the listed grasp servers as relays.
|
|
||||||
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
|
||||||
@@ -1106,7 +1040,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// True when the stored credential is NIP-49 encrypted.
|
/// True when the stored credential is NIP-49 encrypted.
|
||||||
/// A passphrase is still needed to resume the session.
|
|
||||||
pub fn passphrase_required(&self) -> bool {
|
pub fn passphrase_required(&self) -> bool {
|
||||||
self.passphrase_required
|
self.passphrase_required
|
||||||
}
|
}
|
||||||
@@ -1122,14 +1055,11 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Progress of the in-flight negentropy sync, if any.
|
/// Progress of the in-flight negentropy sync, if any.
|
||||||
/// Reported as `total` and `current`.
|
|
||||||
pub fn sync_progress(&self) -> Option<(u64, u64)> {
|
pub fn sync_progress(&self) -> Option<(u64, u64)> {
|
||||||
self.sync_progress
|
self.sync_progress
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the signer.
|
/// 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>)
|
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
||||||
where
|
where
|
||||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||||
@@ -1191,7 +1121,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Add discovery-only relays, e.g. NIP-65 indexers, and connect to them.
|
/// 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>) {
|
pub fn add_discovery_relays(&mut self, urls: Vec<String>, cx: &mut Context<Self>) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
|
||||||
@@ -1215,8 +1144,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start a persistent subscription.
|
/// 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>) {
|
pub fn subscribe(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
|
||||||
@@ -1231,6 +1158,7 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether an identical fetch started within [`FETCH_DEDUP_WINDOW`] is still recent.
|
/// Whether an identical fetch started within [`FETCH_DEDUP_WINDOW`] is still recent.
|
||||||
|
///
|
||||||
/// Records the fingerprint when returning `false`, pruning expired entries first.
|
/// Records the fingerprint when returning `false`, pruning expired entries first.
|
||||||
fn fetch_recently_started(&mut self, fingerprint: u64) -> bool {
|
fn fetch_recently_started(&mut self, fingerprint: u64) -> bool {
|
||||||
self.recent_fetches
|
self.recent_fetches
|
||||||
@@ -1243,12 +1171,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to a repository's announced relays, its NIP-34 `relays` tag.
|
/// 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(
|
pub fn connect_repo_relays(
|
||||||
&mut self,
|
&mut self,
|
||||||
relays: Vec<RelayUrl>,
|
relays: Vec<RelayUrl>,
|
||||||
@@ -1278,9 +1200,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One-shot subscription on the bootstrap relays only.
|
/// 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>) {
|
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
|
||||||
@@ -1296,12 +1215,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Negentropy-sync the given filter against the bootstrap relays.
|
/// 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>) {
|
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||||
let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter));
|
let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter));
|
||||||
if self.fetch_recently_started(fingerprint) {
|
if self.fetch_recently_started(fingerprint) {
|
||||||
@@ -1377,9 +1290,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sign, broadcast and locally store an event.
|
/// 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(
|
pub fn send(
|
||||||
&mut self,
|
&mut self,
|
||||||
builder: EventBuilder,
|
builder: EventBuilder,
|
||||||
@@ -1430,9 +1340,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Broadcast and locally store an already-signed event.
|
/// 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(
|
pub fn publish_event(
|
||||||
&mut self,
|
&mut self,
|
||||||
event: Event,
|
event: Event,
|
||||||
@@ -1479,8 +1386,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a NIP-34 repository announcement, kind 30617, with the current signer.
|
/// 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(
|
pub fn publish_announcement(
|
||||||
&mut self,
|
&mut self,
|
||||||
announcement: GitRepositoryAnnouncement,
|
announcement: GitRepositoryAnnouncement,
|
||||||
@@ -1490,8 +1395,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sign, broadcast and store an event without awaiting the result.
|
/// 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>) {
|
fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
|
||||||
let task = self.send(builder, cx);
|
let task = self.send(builder, cx);
|
||||||
|
|
||||||
@@ -1507,9 +1410,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Publish NIP-09 deletions for `events`, best-effort.
|
/// 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>) {
|
fn retract_events(&mut self, events: &[Event], cx: &mut Context<Self>) {
|
||||||
if events.is_empty() {
|
if events.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -1534,6 +1434,7 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fingerprint of a relay and filter set, for fetch dedup.
|
/// Fingerprint of a relay and filter set, for fetch dedup.
|
||||||
|
///
|
||||||
/// Relays and filters are sorted first, so the fingerprint is order-independent.
|
/// Relays and filters are sorted first, so the fingerprint is order-independent.
|
||||||
fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
|
fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
|
||||||
let mut relays: Vec<&str> = relays.to_vec();
|
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.
|
/// 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(
|
async fn connect_repo_relays_only(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
relays: Vec<RelayUrl>,
|
relays: Vec<RelayUrl>,
|
||||||
@@ -1564,8 +1462,8 @@ async fn connect_repo_relays_only(
|
|||||||
for url in &relays {
|
for url in &relays {
|
||||||
added |= client.add_relay(url).await?;
|
added |= client.add_relay(url).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect only when the pool grew.
|
// Connect only when the pool grew.
|
||||||
// Connected relays no-op, but the call still iterates every relay in the pool.
|
|
||||||
if added {
|
if added {
|
||||||
client.connect().await;
|
client.connect().await;
|
||||||
}
|
}
|
||||||
@@ -1581,8 +1479,6 @@ async fn connect_repo_relays_only(
|
|||||||
client.subscribe(target).close_on(opts).await?;
|
client.subscribe(target).close_on(opts).await?;
|
||||||
|
|
||||||
// Sync the filters concurrently.
|
// 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 sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5));
|
||||||
let syncs = filters.into_iter().map(|filter| {
|
let syncs = filters.into_iter().map(|filter| {
|
||||||
let client = &client;
|
let client = &client;
|
||||||
@@ -1599,15 +1495,13 @@ async fn connect_repo_relays_only(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
futures::future::join_all(syncs).await;
|
futures::future::join_all(syncs).await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe only on the bootstrap relays.
|
/// 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(
|
pub(crate) async fn subscribe_bootstrap_only(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
filters: Vec<Filter>,
|
filters: Vec<Filter>,
|
||||||
@@ -1648,16 +1542,14 @@ fn with_master_key(uri: &str, keys: &Keys) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Base URL of a grasp server, `https://<host>`.
|
/// Base URL of a grasp server, `https://<host>`.
|
||||||
|
///
|
||||||
/// `ws://` grasp servers use `http://<host>`, like ngit.
|
/// `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> {
|
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
|
||||||
// `domain()` drops the port.
|
// `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 parsed = Url::parse(relay.as_str()).ok()?;
|
||||||
let host = parsed.host_str()?;
|
let host = parsed.host_str()?;
|
||||||
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
|
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
|
||||||
// `ws://` grasp servers, e.g. local dev relays, speak plain HTTP.
|
// `ws://` grasp servers, e.g. local dev relays, speak plain HTTP.
|
||||||
// Everything else is HTTPS, matching ngit.
|
|
||||||
let scheme = if relay.scheme().is_secure() {
|
let scheme = if relay.scheme().is_secure() {
|
||||||
"https"
|
"https"
|
||||||
} else {
|
} 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.
|
/// 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> {
|
fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url> {
|
||||||
let base = grasp_base_url(relay)?;
|
let base = grasp_base_url(relay)?;
|
||||||
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
|
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GRASP-06 contributor namespace URL of a pull request tip.
|
/// 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 {
|
pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String {
|
||||||
format!("{base_url}/prs/{npub}/{repo_id}.git")
|
format!("{base_url}/prs/{npub}/{repo_id}.git")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assemble the `clone` URLs of a pull request.
|
/// Assemble the `clone` URLs of a pull request.
|
||||||
|
///
|
||||||
/// The author's GRASP-06 `/prs/` URLs come first.
|
/// 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> {
|
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 seen = std::collections::HashSet::new();
|
||||||
let mut urls = Vec::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.
|
/// 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> {
|
fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
|
||||||
event
|
event
|
||||||
.tags
|
.tags
|
||||||
@@ -1709,8 +1595,6 @@ fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Grasp servers of the newest kind-10317 grasp list among `events`.
|
/// 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> {
|
fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
|
||||||
events
|
events
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1720,9 +1604,6 @@ fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the user's published grasp servers.
|
/// 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(
|
pub(crate) async fn user_grasp_list_servers(
|
||||||
client: Client,
|
client: Client,
|
||||||
user: PublicKey,
|
user: PublicKey,
|
||||||
@@ -1737,10 +1618,6 @@ pub(crate) async fn user_grasp_list_servers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Push the repository at `path` to every grasp server.
|
/// 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(
|
async fn push_to_grasp_servers(
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
owner: String,
|
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.
|
/// 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) {
|
fn extract_master_key(credential: &str) -> (&str, Keys) {
|
||||||
match credential.split_once("master=") {
|
match credential.split_once("master=") {
|
||||||
Some((base, nsec)) => {
|
Some((base, nsec)) => {
|
||||||
|
|||||||
@@ -16,16 +16,12 @@ use crate::local_repos::LocalReposStore;
|
|||||||
use crate::repo_list::RepoListStore;
|
use crate::repo_list::RepoListStore;
|
||||||
|
|
||||||
/// Delay between a refresh request and the actual re-computation.
|
/// 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);
|
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
/// How often the statuses of open repository panels are refreshed.
|
/// 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);
|
const STATUS_POLL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
/// Background poll interval for the `ready to push` badges of the user's own repositories.
|
/// 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);
|
const PUSH_POLL: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
/// Maximum checkouts considered per repository when computing statuses.
|
/// Maximum checkouts considered per repository when computing statuses.
|
||||||
@@ -36,6 +32,7 @@ struct GlobalCheckoutsStore(Entity<CheckoutsStore>);
|
|||||||
impl Global for GlobalCheckoutsStore {}
|
impl Global for GlobalCheckoutsStore {}
|
||||||
|
|
||||||
/// One associated local checkout of a repository.
|
/// One associated local checkout of a repository.
|
||||||
|
///
|
||||||
/// Carries the git facts needed to suggest a pull request.
|
/// Carries the git facts needed to suggest a pull request.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CheckoutStatus {
|
pub struct CheckoutStatus {
|
||||||
@@ -50,9 +47,11 @@ pub struct CheckoutStatus {
|
|||||||
/// The fallbacks are `main`, then the first local branch.
|
/// The fallbacks are `main`, then the first local branch.
|
||||||
/// For ready-to-push statuses, the remote-tracking ref.
|
/// For ready-to-push statuses, the remote-tracking ref.
|
||||||
/// Unpushed commits are counted against it.
|
/// Unpushed commits are counted against it.
|
||||||
|
///
|
||||||
/// It is `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
|
/// It is `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
|
||||||
pub base: String,
|
pub base: String,
|
||||||
/// Commits in `base..branch`.
|
/// Commits in `base..branch`.
|
||||||
|
///
|
||||||
/// Zero-ahead checkouts are dropped, so this is always above zero.
|
/// Zero-ahead checkouts are dropped, so this is always above zero.
|
||||||
pub ahead: u32,
|
pub ahead: u32,
|
||||||
}
|
}
|
||||||
@@ -67,21 +66,21 @@ struct Remembered {
|
|||||||
/// Global store of local-checkout associations and per-checkout statuses.
|
/// Global store of local-checkout associations and per-checkout statuses.
|
||||||
pub struct CheckoutsStore {
|
pub struct CheckoutsStore {
|
||||||
/// Checkout paths per announced repository.
|
/// 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>>>,
|
by_repo: Arc<HashMap<RepoAddr, Vec<PathBuf>>>,
|
||||||
/// Ready-to-contribute statuses of the requested repositories.
|
/// Ready-to-contribute statuses of the requested repositories.
|
||||||
statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
|
statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
|
||||||
/// Repositories whose statuses are recomputed on every input change.
|
/// Repositories whose statuses are recomputed on every input change.
|
||||||
|
///
|
||||||
/// Those are the repository detail panels currently open.
|
/// Those are the repository detail panels currently open.
|
||||||
status_requested: HashSet<RepoAddr>,
|
status_requested: HashSet<RepoAddr>,
|
||||||
/// Repositories whose `ready to push` statuses are recomputed on the same cycle.
|
/// 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.
|
/// The sidebar rows of the user's own repositories and their detail panels.
|
||||||
push_requested: HashSet<RepoAddr>,
|
push_requested: HashSet<RepoAddr>,
|
||||||
/// Ready-to-push statuses of the requested own repositories.
|
/// Ready-to-push statuses of the requested own repositories.
|
||||||
push_statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
|
push_statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
|
||||||
/// Last announced head branch per requested repository.
|
/// Last announced head branch per requested repository.
|
||||||
|
///
|
||||||
/// A recompute defaults the base the same way.
|
/// A recompute defaults the base the same way.
|
||||||
requested_head: HashMap<RepoAddr, Option<String>>,
|
requested_head: HashMap<RepoAddr, Option<String>>,
|
||||||
refreshing: bool,
|
refreshing: bool,
|
||||||
@@ -103,9 +102,6 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create the store.
|
/// 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 {
|
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||||
let mut subscriptions = Vec::new();
|
let mut subscriptions = Vec::new();
|
||||||
|
|
||||||
@@ -118,12 +114,15 @@ impl CheckoutsStore {
|
|||||||
subscriptions.push(cx.observe(&settings, |this, _settings, cx| {
|
subscriptions.push(cx.observe(&settings, |this, _settings, cx| {
|
||||||
this.refresh(cx);
|
this.refresh(cx);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
subscriptions.push(cx.observe(&local, |this, _local, cx| {
|
subscriptions.push(cx.observe(&local, |this, _local, cx| {
|
||||||
this.refresh(cx);
|
this.refresh(cx);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||||
this.refresh(cx);
|
this.refresh(cx);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Another identity's repositories must not keep the old statuses alive.
|
// Another identity's repositories must not keep the old statuses alive.
|
||||||
// Their polls stop too.
|
// Their polls stop too.
|
||||||
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
|
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
@@ -155,12 +154,11 @@ impl CheckoutsStore {
|
|||||||
if !cfg!(target_arch = "wasm32") {
|
if !cfg!(target_arch = "wasm32") {
|
||||||
store.refresh(cx);
|
store.refresh(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
store
|
store
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remember a successful local-checkout use.
|
/// 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>) {
|
pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context<Self>) {
|
||||||
if cfg!(target_arch = "wasm32") {
|
if cfg!(target_arch = "wasm32") {
|
||||||
return;
|
return;
|
||||||
@@ -190,6 +188,7 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The associated checkouts of `addr`, freshest first.
|
/// The associated checkouts of `addr`, freshest first.
|
||||||
|
///
|
||||||
/// Empty when none are known or the resolution has not run yet.
|
/// Empty when none are known or the resolution has not run yet.
|
||||||
pub fn associations_of(&self, addr: &RepoAddr) -> Vec<PathBuf> {
|
pub fn associations_of(&self, addr: &RepoAddr) -> Vec<PathBuf> {
|
||||||
self.by_repo.get(addr).cloned().unwrap_or_default()
|
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.
|
/// Ask for the `ready to contribute` statuses of `addr` to stay current.
|
||||||
/// Called while the repository's detail panel is open.
|
/// Called while the repository's detail panel is open.
|
||||||
|
///
|
||||||
/// `announced_head` is the announced HEAD branch, used to default the base.
|
/// `announced_head` is the announced HEAD branch, used to default the base.
|
||||||
pub fn request_statuses(
|
pub fn request_statuses(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -212,16 +212,13 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The ready-to-contribute statuses of `addr`.
|
/// The ready-to-contribute statuses of `addr`.
|
||||||
|
///
|
||||||
/// Empty while none are known or nothing is ahead.
|
/// Empty while none are known or nothing is ahead.
|
||||||
pub fn statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
pub fn statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||||
self.statuses.get(addr).cloned().unwrap_or_default()
|
self.statuses.get(addr).cloned().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask for the `ready to push` statuses of `addr` to stay current.
|
/// 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>) {
|
pub fn request_push_statuses(&mut self, addr: &RepoAddr, cx: &mut Context<Self>) {
|
||||||
self.push_requested.insert(addr.clone());
|
self.push_requested.insert(addr.clone());
|
||||||
self.refresh(cx);
|
self.refresh(cx);
|
||||||
@@ -229,13 +226,14 @@ impl CheckoutsStore {
|
|||||||
|
|
||||||
/// The ready-to-push statuses of `addr`.
|
/// The ready-to-push statuses of `addr`.
|
||||||
/// Only meaningful for repositories announced by the signed-in user.
|
/// Only meaningful for repositories announced by the signed-in user.
|
||||||
|
///
|
||||||
/// Empty while none are known or nothing is unpushed.
|
/// Empty while none are known or nothing is unpushed.
|
||||||
pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||||
self.push_statuses.get(addr).cloned().unwrap_or_default()
|
self.push_statuses.get(addr).cloned().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-resolve the associations and the requested statuses.
|
/// 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.
|
/// Requests arriving while a pass runs fold into a follow-up.
|
||||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.refreshing {
|
if self.refreshing {
|
||||||
@@ -268,6 +266,7 @@ impl CheckoutsStore {
|
|||||||
let settings = SettingsStore::global(cx);
|
let settings = SettingsStore::global(cx);
|
||||||
settings.read(cx).settings().checkouts.records.clone()
|
settings.read(cx).settings().checkouts.records.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let remembered: Vec<Remembered> = records
|
let remembered: Vec<Remembered> = records
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|record| {
|
.filter_map(|record| {
|
||||||
@@ -279,9 +278,11 @@ impl CheckoutsStore {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
||||||
let scanned = LocalReposStore::global(cx).read(cx).repos.clone();
|
let scanned = LocalReposStore::global(cx).read(cx).repos.clone();
|
||||||
let cache_root = GitStore::global(cx).cache().root().canonicalize().ok();
|
let cache_root = GitStore::global(cx).cache().root().canonicalize().ok();
|
||||||
|
|
||||||
let requested: Vec<(RepoAddr, Option<String>)> = self
|
let requested: Vec<(RepoAddr, Option<String>)> = self
|
||||||
.status_requested
|
.status_requested
|
||||||
.iter()
|
.iter()
|
||||||
@@ -292,16 +293,17 @@ impl CheckoutsStore {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let push_requested: Vec<RepoAddr> = self.push_requested.iter().cloned().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 poll = !self.status_requested.is_empty() || !self.push_requested.is_empty();
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
// Read the git facts of every scanned repository off the main thread.
|
// 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.
|
// The facts are the origin URL and the root commit, both CLI reads.
|
||||||
let mut facts: Vec<(PathBuf, Option<String>, Option<String>)> = Vec::new();
|
let mut facts: Vec<(PathBuf, Option<String>, Option<String>)> = Vec::new();
|
||||||
for path in scanned.iter() {
|
for path in scanned.iter() {
|
||||||
// The browser's mirror clones share the announce URLs and EUCs.
|
// The browser's mirror clones share the announce URLs and EUCs. They are not user checkouts.
|
||||||
// They are not user checkouts.
|
|
||||||
if cache_root
|
if cache_root
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|root| path.starts_with(root))
|
.is_some_and(|root| path.starts_with(root))
|
||||||
@@ -314,6 +316,7 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let associations = resolve_associations(&remembered, &facts, announcements.iter());
|
let associations = resolve_associations(&remembered, &facts, announcements.iter());
|
||||||
|
|
||||||
// Missing directories are stale records, drop them.
|
// Missing directories are stale records, drop them.
|
||||||
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
|
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -384,20 +387,17 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Keep the statuses current while any repository panel is open.
|
// 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| {
|
this.update(cx, |this, cx| {
|
||||||
if poll && !this.debouncing && !this.refreshing {
|
if poll && !this.debouncing && !this.refreshing {
|
||||||
this.debouncing = true;
|
this.debouncing = true;
|
||||||
// Open panels get the fast cadence.
|
// Open panels get the fast cadence.
|
||||||
// Sidebar-only badges poll less aggressively.
|
|
||||||
// Each cycle fetches every watched checkout's remote.
|
// Each cycle fetches every watched checkout's remote.
|
||||||
let delay = if this.status_requested.is_empty() {
|
let delay = if this.status_requested.is_empty() {
|
||||||
PUSH_POLL
|
PUSH_POLL
|
||||||
} else {
|
} else {
|
||||||
STATUS_POLL
|
STATUS_POLL
|
||||||
};
|
};
|
||||||
|
|
||||||
let task = cx.spawn(async move |this, cx| {
|
let task = cx.spawn(async move |this, cx| {
|
||||||
cx.background_executor().timer(delay).await;
|
cx.background_executor().timer(delay).await;
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -405,6 +405,7 @@ impl CheckoutsStore {
|
|||||||
this.run_refresh(cx);
|
this.run_refresh(cx);
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
this.tasks.push(task);
|
this.tasks.push(task);
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
@@ -415,10 +416,6 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Identity of a repository URL.
|
/// 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)> {
|
fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
|
||||||
let parsed = Url::parse(url).ok()?;
|
let parsed = Url::parse(url).ok()?;
|
||||||
let host = parsed.host_str()?.to_ascii_lowercase();
|
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.
|
/// 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 {
|
fn same_repo_url(a: &str, b: &str) -> bool {
|
||||||
match (url_identity(a), url_identity(b)) {
|
match (url_identity(a), url_identity(b)) {
|
||||||
(Some(a), Some(b)) => a == 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.
|
/// 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>(
|
fn resolve_associations<'a>(
|
||||||
remembered: &[Remembered],
|
remembered: &[Remembered],
|
||||||
scanned: &[(PathBuf, Option<String>, Option<String>)],
|
scanned: &[(PathBuf, Option<String>, Option<String>)],
|
||||||
@@ -467,9 +460,11 @@ fn resolve_associations<'a>(
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|url| same_repo_url(origin, url.as_str()))
|
.any(|url| same_repo_url(origin, url.as_str()))
|
||||||
});
|
});
|
||||||
|
|
||||||
let euc_match = root
|
let euc_match = root
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|root| announcement.euc.as_deref() == Some(root));
|
.is_some_and(|root| announcement.euc.as_deref() == Some(root));
|
||||||
|
|
||||||
if url_match || euc_match {
|
if url_match || euc_match {
|
||||||
let paths = out.entry(announcement.addr()).or_default();
|
let paths = out.entry(announcement.addr()).or_default();
|
||||||
if !paths.contains(path) {
|
if !paths.contains(path) {
|
||||||
@@ -483,8 +478,6 @@ fn resolve_associations<'a>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the worktree of `path` has uncommitted changes.
|
/// 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 {
|
fn worktree_dirty(path: &Path) -> bool {
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
@@ -499,8 +492,6 @@ fn worktree_dirty(path: &Path) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Commits in `base..branch` of the checkout at `path`.
|
/// 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 {
|
fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 {
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.arg("-C")
|
.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`.
|
/// The branch checked out at `path`, read via `git branch --show-current`.
|
||||||
/// `None` when detached.
|
|
||||||
fn current_branch_of(path: &Path) -> Option<String> {
|
fn current_branch_of(path: &Path) -> Option<String> {
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
@@ -532,10 +522,6 @@ fn current_branch_of(path: &Path) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The ready-to-contribute status of one checkout.
|
/// 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> {
|
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
|
||||||
let branches = signed_git::worktree_branches(path).ok()?;
|
let branches = signed_git::worktree_branches(path).ok()?;
|
||||||
if branches.is_empty() || worktree_dirty(path) {
|
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`.
|
/// Whether the reference `name` exists in the checkout at `path`.
|
||||||
|
///
|
||||||
/// Example, `refs/remotes/origin/main`.
|
/// Example, `refs/remotes/origin/main`.
|
||||||
fn ref_exists(path: &Path, name: &str) -> bool {
|
fn ref_exists(path: &Path, name: &str) -> bool {
|
||||||
let output = Command::new("git")
|
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 `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> {
|
fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
|
||||||
if worktree_dirty(path) {
|
if worktree_dirty(path) {
|
||||||
return None;
|
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`.
|
/// 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(
|
pub fn pr_proposes_checkout(
|
||||||
pr: &Event,
|
pr: &Event,
|
||||||
open: bool,
|
open: bool,
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ struct GlobalGitStore(GitCache);
|
|||||||
impl Global for GlobalGitStore {}
|
impl Global for GlobalGitStore {}
|
||||||
|
|
||||||
/// Global access to the on-disk git clone cache, the grasp mirrors.
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GitStore(GitCache);
|
pub struct GitStore(GitCache);
|
||||||
|
|
||||||
impl GitStore {
|
impl GitStore {
|
||||||
/// Register the clone cache rooted at `root` as an app-wide global.
|
/// Register the clone cache rooted at `root` as an app-wide global.
|
||||||
|
///
|
||||||
/// Replaces any installed store, [`signed_state::init`] installs an empty one.
|
/// Replaces any installed store, [`signed_state::init`] installs an empty one.
|
||||||
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
|
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
|
||||||
let store = Self::new(root);
|
let store = Self::new(root);
|
||||||
@@ -23,7 +22,6 @@ impl GitStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The app-wide clone cache.
|
/// The app-wide clone cache.
|
||||||
/// Panics if [`GitStore::set_global`] was never called.
|
|
||||||
pub fn global(cx: &App) -> Self {
|
pub fn global(cx: &App) -> Self {
|
||||||
Self(cx.global::<GlobalGitStore>().0.clone())
|
Self(cx.global::<GlobalGitStore>().0.clone())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,6 @@ impl LocalReposStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Forget a repository that has just been published to NIP-34.
|
/// 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>) {
|
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
||||||
self.repos = Arc::new(
|
self.repos = Arc::new(
|
||||||
self.repos
|
self.repos
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ impl Profile {
|
|||||||
/// Message from the fetch task to the main thread.
|
/// Message from the fetch task to the main thread.
|
||||||
enum Dispatch {
|
enum Dispatch {
|
||||||
/// A batched sync finished.
|
/// A batched sync finished.
|
||||||
|
///
|
||||||
/// Re-read seen profiles from the database.
|
/// Re-read seen profiles from the database.
|
||||||
Synced,
|
Synced,
|
||||||
}
|
}
|
||||||
@@ -71,8 +72,8 @@ enum Dispatch {
|
|||||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
/// Global profile cache.
|
/// Global profile cache.
|
||||||
|
///
|
||||||
/// Profiles are fetched in batches and kept as plain data.
|
/// Profiles are fetched in batches and kept as plain data.
|
||||||
/// The whole store notifies on change.
|
|
||||||
pub struct ProfileStore {
|
pub struct ProfileStore {
|
||||||
profiles: HashMap<PublicKey, Profile>,
|
profiles: HashMap<PublicKey, Profile>,
|
||||||
/// Public keys requested this session, main thread only.
|
/// Public keys requested this session, main thread only.
|
||||||
@@ -114,7 +115,6 @@ impl ProfileStore {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fetch requests are queued on a channel.
|
// Fetch requests are queued on a channel.
|
||||||
// A background task syncs them in batches.
|
|
||||||
let client = backend.read(cx).client();
|
let client = backend.read(cx).client();
|
||||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||||
let (dispatch_tx, dispatch_rx) = flume::unbounded::<Dispatch>();
|
let (dispatch_tx, dispatch_rx) = flume::unbounded::<Dispatch>();
|
||||||
@@ -146,8 +146,8 @@ impl ProfileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get a profile.
|
/// 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 {
|
pub fn get(&self, public_key: &PublicKey) -> Profile {
|
||||||
if let Some(profile) = self.profiles.get(public_key) {
|
if let Some(profile) = self.profiles.get(public_key) {
|
||||||
return profile.clone();
|
return profile.clone();
|
||||||
@@ -237,6 +237,7 @@ impl ProfileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Re-read the latest metadata of every requested author from the local database.
|
/// Re-read the latest metadata of every requested author from the local database.
|
||||||
|
///
|
||||||
/// Used after a sync, which produces no NostrUpdate events.
|
/// Used after a sync, which produces no NostrUpdate events.
|
||||||
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
||||||
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
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.
|
/// 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.
|
/// Results are dispatched to the main thread, which re-reads the database.
|
||||||
async fn handle_requests(
|
async fn handle_requests(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
|
|||||||
@@ -20,14 +20,15 @@ use crate::backend::{
|
|||||||
use crate::git_store::GitStore;
|
use crate::git_store::GitStore;
|
||||||
|
|
||||||
/// Delay between a refresh request and the actual re-query.
|
/// 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);
|
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
/// Maximum size of one patch event.
|
/// Maximum size of one patch event.
|
||||||
|
///
|
||||||
/// NIP-34 suggests patches when each event is under 60kb.
|
/// NIP-34 suggests patches when each event is under 60kb.
|
||||||
const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
|
const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
|
||||||
|
|
||||||
/// Per-repository store.
|
/// Per-repository store.
|
||||||
|
///
|
||||||
/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
|
/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
|
||||||
/// Always derived from the local database.
|
/// Always derived from the local database.
|
||||||
pub struct RepoStore {
|
pub struct RepoStore {
|
||||||
@@ -43,30 +44,32 @@ pub struct RepoStore {
|
|||||||
/// Comments on issues / PRs, oldest first.
|
/// Comments on issues / PRs, oldest first.
|
||||||
pub comments: Vec<Event>,
|
pub comments: Vec<Event>,
|
||||||
/// Resolved status per root event, issue, patch or PR.
|
/// 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>,
|
status_by_root: HashMap<EventId, RepoStatus>,
|
||||||
/// Open issue and root PR counts.
|
/// Open issue and root PR counts.
|
||||||
/// Computed with [`Self::status_by_root`] on every refresh.
|
/// Computed with [`Self::status_by_root`] on every refresh.
|
||||||
open_issue_count: usize,
|
open_issue_count: usize,
|
||||||
open_pr_count: usize,
|
open_pr_count: usize,
|
||||||
/// Kind-1624 cover notes and kind-1985 label events.
|
/// Kind-1624 cover notes and kind-1985 label events.
|
||||||
|
///
|
||||||
/// They reference this repository's roots, used by ngit and GitWorkshop.
|
/// They reference this repository's roots, used by ngit and GitWorkshop.
|
||||||
cover_notes: Vec<Event>,
|
cover_notes: Vec<Event>,
|
||||||
labels: Vec<Event>,
|
labels: Vec<Event>,
|
||||||
/// Incremented on every applied refresh.
|
/// Incremented on every applied refresh.
|
||||||
|
///
|
||||||
/// Views key their derived-data caches to it instead of recomputing on every render.
|
/// Views key their derived-data caches to it instead of recomputing on every render.
|
||||||
version: u64,
|
version: u64,
|
||||||
/// Error of the last action initiated from this store, if any.
|
/// Error of the last action initiated from this store, if any.
|
||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
/// Non-fatal warning of the last action, if any.
|
/// Non-fatal warning of the last action, if any.
|
||||||
|
///
|
||||||
/// Example, a PR published without its commit reaching a grasp server.
|
/// Example, a PR published without its commit reaching a grasp server.
|
||||||
pub last_warning: Option<String>,
|
pub last_warning: Option<String>,
|
||||||
/// Relays already asked to connect to, from this repository's NIP-34 `relays` tag.
|
/// Relays already asked to connect to, from this repository's NIP-34 `relays` tag.
|
||||||
|
///
|
||||||
/// Avoids re-subscribing and re-fetching on every refresh.
|
/// Avoids re-subscribing and re-fetching on every refresh.
|
||||||
repo_relays: HashSet<RelayUrl>,
|
repo_relays: HashSet<RelayUrl>,
|
||||||
/// Root events, issues, patches and PRs, already fetched per root.
|
/// Root events, issues, patches and PRs, already fetched per root.
|
||||||
|
///
|
||||||
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
|
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
|
||||||
/// Also kind-1624 cover notes and kind-1985 labels.
|
/// Also kind-1624 cover notes and kind-1985 labels.
|
||||||
root_fetches: HashSet<EventId>,
|
root_fetches: HashSet<EventId>,
|
||||||
@@ -172,6 +175,7 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Filters that make up a repository.
|
/// Filters that make up a repository.
|
||||||
|
///
|
||||||
/// Announcement, state, activity and deletions targeting it.
|
/// Announcement, state, activity and deletions targeting it.
|
||||||
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
|
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
|
||||||
let mut filters = vec![
|
let mut filters = vec![
|
||||||
@@ -189,14 +193,13 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch this repository's events from the relays in its NIP-34 `relays` tag.
|
/// 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>) {
|
fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context<Self>) {
|
||||||
let new: Vec<RelayUrl> = relays
|
let new: Vec<RelayUrl> = relays
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|url| !self.repo_relays.contains(*url))
|
.filter(|url| !self.repo_relays.contains(*url))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if new.is_empty() {
|
if new.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -204,6 +207,7 @@ impl RepoStore {
|
|||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let addr = self.addr.clone();
|
let addr = self.addr.clone();
|
||||||
|
|
||||||
backend.update(cx, |backend, cx| {
|
backend.update(cx, |backend, cx| {
|
||||||
backend.connect_repo_relays(new, Self::repo_filters(&addr), 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.
|
/// 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>) {
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.refreshing {
|
if self.refreshing {
|
||||||
self.refresh_dirty = true;
|
self.refresh_dirty = true;
|
||||||
@@ -527,7 +529,6 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The effective cover note of `root`, kind 1624, if any.
|
/// 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> {
|
pub fn cover_note_of(&self, root: &Event) -> Option<&Event> {
|
||||||
let maintainers = self
|
let maintainers = self
|
||||||
.announcement
|
.announcement
|
||||||
@@ -539,7 +540,6 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The effective hashtag labels of `root`.
|
/// 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> {
|
pub fn labels_of(&self, root: &Event) -> Vec<String> {
|
||||||
let maintainers = self
|
let maintainers = self
|
||||||
.announcement
|
.announcement
|
||||||
@@ -552,7 +552,6 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The effective subject or title override of `root`, if any.
|
/// 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> {
|
pub fn subject_of(&self, root: &Event) -> Option<String> {
|
||||||
let maintainers = self
|
let maintainers = self
|
||||||
.announcement
|
.announcement
|
||||||
@@ -564,6 +563,7 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Number of open issues.
|
/// Number of open issues.
|
||||||
|
///
|
||||||
/// Issues whose resolved status is [`RepoStatus::Open`].
|
/// Issues whose resolved status is [`RepoStatus::Open`].
|
||||||
/// Issues without status events default to open.
|
/// Issues without status events default to open.
|
||||||
pub fn issue_count(&self) -> usize {
|
pub fn issue_count(&self) -> usize {
|
||||||
@@ -571,6 +571,7 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Number of open pull requests.
|
/// Number of open pull requests.
|
||||||
|
///
|
||||||
/// Only root PR events count, PR updates do not.
|
/// Only root PR events count, PR updates do not.
|
||||||
/// They must resolve to [`RepoStatus::Open`].
|
/// They must resolve to [`RepoStatus::Open`].
|
||||||
pub fn pull_request_count(&self) -> usize {
|
pub fn pull_request_count(&self) -> usize {
|
||||||
@@ -578,6 +579,7 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `user` is the author or owner of this repository.
|
/// Whether `user` is the author or owner of this repository.
|
||||||
|
///
|
||||||
/// The author is the public key of the repository address.
|
/// The author is the public key of the repository address.
|
||||||
/// Only the author may manage pull requests, close, reopen or merge.
|
/// Only the author may manage pull requests, close, reopen or merge.
|
||||||
pub fn is_author(&self, user: &PublicKey) -> bool {
|
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.
|
/// Reply to `parent`, a comment on `root`, with a NIP-22 threaded comment.
|
||||||
|
///
|
||||||
/// `None` publishes a top-level comment on the root itself.
|
/// `None` publishes a top-level comment on the root itself.
|
||||||
pub fn reply(
|
pub fn reply(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -631,31 +634,6 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Open a pull request on this repository.
|
/// 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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn open_pull_request(
|
pub fn open_pull_request(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -712,13 +690,13 @@ impl RepoStore {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
// The author's npub names their GRASP-06 namespace, `/prs/...`.
|
// 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 addr = self.addr.clone();
|
||||||
let owner = self.addr.public_key;
|
let owner = self.addr.public_key;
|
||||||
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
||||||
let repo_id = addr.identifier.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
|
let push_relays = self
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -930,10 +908,7 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update a pull request.
|
/// 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.
|
/// Other authors must open a new PR.
|
||||||
pub fn update_pull_request(&mut self, root: &Event, patch: String, cx: &mut Context<Self>) {
|
pub fn update_pull_request(&mut self, root: &Event, patch: String, cx: &mut Context<Self>) {
|
||||||
self.last_error = None;
|
self.last_error = None;
|
||||||
@@ -1054,8 +1029,8 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set the status of a root event.
|
/// Set the status of a root event.
|
||||||
|
///
|
||||||
/// Only the root author or a maintainer may set it, per NIP-34.
|
/// 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>) {
|
pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context<Self>) {
|
||||||
self.last_error = None;
|
self.last_error = None;
|
||||||
|
|
||||||
@@ -1092,19 +1067,18 @@ impl RepoStore {
|
|||||||
self.send(builder, cx);
|
self.send(builder, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a repository state announcement, kind 30618.
|
/// Publish a repository state announcement
|
||||||
/// 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.
|
|
||||||
pub fn publish_state(&mut self, cx: &mut Context<Self>) {
|
pub fn publish_state(&mut self, cx: &mut Context<Self>) {
|
||||||
self.last_error = None;
|
self.last_error = None;
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
|
||||||
let Some(user) = backend.read(cx).current_user() else {
|
let Some(user) = backend.read(cx).current_user() else {
|
||||||
self.last_error = Some("Sign in to publish repository state".into());
|
self.last_error = Some("Sign in to publish repository state".into());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if !self.is_author(&user) {
|
if !self.is_author(&user) {
|
||||||
self.last_error = Some("Only the repository owner can publish state".into());
|
self.last_error = Some("Only the repository owner can publish state".into());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -1146,16 +1120,6 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Merge a pull request.
|
/// 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>) {
|
pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) {
|
||||||
self.last_error = None;
|
self.last_error = None;
|
||||||
self.last_warning = None;
|
self.last_warning = None;
|
||||||
@@ -1171,23 +1135,28 @@ impl RepoStore {
|
|||||||
|
|
||||||
let cache = GitStore::global(cx).cache().clone();
|
let cache = GitStore::global(cx).cache().clone();
|
||||||
let addr = self.addr.clone();
|
let addr = self.addr.clone();
|
||||||
|
|
||||||
let clone_urls: Vec<String> = self
|
let clone_urls: Vec<String> = self
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| a.clone.iter().map(ToString::to_string).collect())
|
.map(|a| a.clone.iter().map(ToString::to_string).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let patch = pull_request_patch(root, self.patches.iter());
|
let patch = pull_request_patch(root, self.patches.iter());
|
||||||
|
|
||||||
// The applied patch events, for the status tags below.
|
// The applied patch events, for the status tags below.
|
||||||
let patches: Vec<Event> = pull_request_patches(root, self.patches.iter())
|
let patches: Vec<Event> = pull_request_patches(root, self.patches.iter())
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let relay_hint = self
|
let relay_hint = self
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|a| a.relays.first())
|
.and_then(|a| a.relays.first())
|
||||||
.map(ToString::to_string)
|
.map(ToString::to_string)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
||||||
let root = root.clone();
|
let root = root.clone();
|
||||||
|
|
||||||
@@ -1231,9 +1200,6 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a kind-1631 Applied status event for `root` after a merge.
|
/// 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(
|
fn publish_applied_status(
|
||||||
&mut self,
|
&mut self,
|
||||||
root: &Event,
|
root: &Event,
|
||||||
@@ -1249,11 +1215,13 @@ impl RepoStore {
|
|||||||
Tag::public_key(root.pubkey),
|
Tag::public_key(root.pubkey),
|
||||||
Tag::coordinate(self.addr.clone(), None),
|
Tag::coordinate(self.addr.clone(), None),
|
||||||
];
|
];
|
||||||
|
|
||||||
if let Some(euc) = euc
|
if let Some(euc) = euc
|
||||||
&& let Ok(tag) = Tag::parse(["r", euc])
|
&& let Ok(tag) = Tag::parse(["r", euc])
|
||||||
{
|
{
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag each applied patch event.
|
// Tag each applied patch event.
|
||||||
// `q` per event, `e` reply for events beyond the root, chain parts and revisions.
|
// `q` per event, `e` reply for events beyond the root, chain parts and revisions.
|
||||||
// Their statuses then resolve to Applied too.
|
// Their statuses then resolve to Applied too.
|
||||||
@@ -1269,6 +1237,7 @@ impl RepoStore {
|
|||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The commits `git am` created on top of the previous HEAD.
|
// The commits `git am` created on top of the previous HEAD.
|
||||||
if !applied.is_empty() {
|
if !applied.is_empty() {
|
||||||
let mut applied_tag = vec!["applied-as-commits".to_string()];
|
let mut applied_tag = vec!["applied-as-commits".to_string()];
|
||||||
@@ -1312,8 +1281,6 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Status of `root` from the precomputed map.
|
/// 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 {
|
fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> RepoStatus {
|
||||||
status_by_root
|
status_by_root
|
||||||
.get(&root.id)
|
.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.
|
/// 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(
|
fn resolve_statuses(
|
||||||
issues: &[Event],
|
issues: &[Event],
|
||||||
patches: &[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.
|
/// Publish a `git format-patch` series as chained kind-1617 events.
|
||||||
|
///
|
||||||
/// Returns the root event, the one a PR references.
|
/// 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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn publish_patch_series(
|
async fn publish_patch_series(
|
||||||
this: &WeakEntity<RepoStore>,
|
this: &WeakEntity<RepoStore>,
|
||||||
@@ -1446,10 +1406,6 @@ async fn publish_patch_series(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a NIP-22 kind-1111 comment.
|
/// 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(
|
fn comment_builder(
|
||||||
root: &Event,
|
root: &Event,
|
||||||
parent: Option<&Event>,
|
parent: Option<&Event>,
|
||||||
@@ -1465,6 +1421,7 @@ fn comment_builder(
|
|||||||
relay_hint.cloned().map(Cow::Owned),
|
relay_hint.cloned().map(Cow::Owned),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let root_target = target(root);
|
let root_target = target(root);
|
||||||
let parent_target = parent.map(target).unwrap_or_else(|| root_target.clone());
|
let parent_target = parent.map(target).unwrap_or_else(|| root_target.clone());
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
|||||||
use crate::backend::{Backend, BackendEvent};
|
use crate::backend::{Backend, BackendEvent};
|
||||||
|
|
||||||
/// Delay between a refresh request and the actual re-query.
|
/// Delay between a refresh request and the actual re-query.
|
||||||
|
///
|
||||||
/// Bursts of events, e.g. sync progress ticks, collapse into one query.
|
/// Bursts of events, e.g. sync progress ticks, collapse into one query.
|
||||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
@@ -21,13 +22,12 @@ struct GlobalRepoListStore(Entity<RepoListStore>);
|
|||||||
impl Global for GlobalRepoListStore {}
|
impl Global for GlobalRepoListStore {}
|
||||||
|
|
||||||
/// NIP-34 activity event counts per repository, ranking the explore list by popularity.
|
/// 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)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub struct RepoActivityCounts {
|
pub struct RepoActivityCounts {
|
||||||
/// Root `30611` issue events addressed to the repository.
|
/// Root `30611` issue events addressed to the repository.
|
||||||
pub issues: u32,
|
pub issues: u32,
|
||||||
/// Root `3063` pull request events addressed to the repository.
|
/// Root `3063` pull request events addressed to the repository.
|
||||||
|
///
|
||||||
/// PR updates are not new PRs and do not count.
|
/// PR updates are not new PRs and do not count.
|
||||||
pub pull_requests: u32,
|
pub pull_requests: u32,
|
||||||
/// `1617` patch events addressed to the repository.
|
/// `1617` patch events addressed to the repository.
|
||||||
@@ -42,9 +42,6 @@ impl RepoActivityCounts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Store listing repository announcements, global discovery or per-author.
|
/// 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 {
|
pub struct RepoListStore {
|
||||||
/// Shared so views can clone the list per frame without a deep copy.
|
/// Shared so views can clone the list per frame without a deep copy.
|
||||||
pub announcements: Arc<Vec<Announcement>>,
|
pub announcements: Arc<Vec<Announcement>>,
|
||||||
@@ -52,6 +49,7 @@ pub struct RepoListStore {
|
|||||||
/// Covers announcements, state updates, patches, PRs, issues and statuses.
|
/// Covers announcements, state updates, patches, PRs, issues and statuses.
|
||||||
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
||||||
/// Issues, pull requests and commits per repository.
|
/// Issues, pull requests and commits per repository.
|
||||||
|
///
|
||||||
/// Used for the Popular ranking of the explore list.
|
/// Used for the Popular ranking of the explore list.
|
||||||
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
||||||
author: Option<PublicKey>,
|
author: Option<PublicKey>,
|
||||||
@@ -65,7 +63,6 @@ pub struct RepoListStore {
|
|||||||
|
|
||||||
impl RepoListStore {
|
impl RepoListStore {
|
||||||
/// Retrieve the global explore store.
|
/// Retrieve the global explore store.
|
||||||
/// It lists all announcements and is created at startup by [`crate::init`].
|
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
cx.global::<GlobalRepoListStore>().0.clone()
|
cx.global::<GlobalRepoListStore>().0.clone()
|
||||||
}
|
}
|
||||||
@@ -96,12 +93,15 @@ impl RepoListStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
BackendEvent::Published(event) => {
|
BackendEvent::Published(event) => {
|
||||||
let announcement = event.kind == Kind::GitRepoAnnouncement
|
let kind_match = event.kind == Kind::GitRepoAnnouncement;
|
||||||
&& this.author.is_none_or(|a| a == event.pubkey);
|
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.
|
// Locally published deletions are already in the local database.
|
||||||
// Refresh so they take effect immediately, like relay deletions.
|
// Refresh so they take effect immediately, like relay deletions.
|
||||||
let deletion =
|
let deletion =
|
||||||
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
||||||
|
|
||||||
announcement || deletion
|
announcement || deletion
|
||||||
}
|
}
|
||||||
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
|
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
|
||||||
@@ -156,9 +156,9 @@ impl RepoListStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One-shot initial load.
|
/// One-shot initial load.
|
||||||
|
///
|
||||||
/// Query the local database immediately, no debounce.
|
/// Query the local database immediately, no debounce.
|
||||||
/// Stored announcements appear as soon as the app opens.
|
/// 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>) {
|
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||||
debug_assert!(!self.debouncing);
|
debug_assert!(!self.debouncing);
|
||||||
if self.refreshing {
|
if self.refreshing {
|
||||||
@@ -169,11 +169,6 @@ impl RepoListStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Re-query the local database.
|
/// 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>) {
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.refreshing {
|
if self.refreshing {
|
||||||
self.refresh_dirty = true;
|
self.refresh_dirty = true;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use gpui_component::menu::PopupMenuItem;
|
|||||||
use gpui_component::{ActiveTheme, StyledExt, h_flex};
|
use gpui_component::{ActiveTheme, StyledExt, h_flex};
|
||||||
|
|
||||||
/// A muted command row with a copy button.
|
/// 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
|
pub fn copy_row<E>(copy_id: E, command: &SharedString, cx: &App) -> Div
|
||||||
where
|
where
|
||||||
E: Into<ElementId>,
|
E: Into<ElementId>,
|
||||||
@@ -35,10 +34,6 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One row of a copy menu, with a small title above the compact label.
|
/// 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(
|
pub fn menu_copy_row(
|
||||||
id: &'static str,
|
id: &'static str,
|
||||||
title: &'static str,
|
title: &'static str,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/// `[head chars]...[tail chars]` middle truncation.
|
/// `[head chars]...[tail chars]` middle truncation.
|
||||||
|
///
|
||||||
/// Values too short for the ellipsis to save space are left alone.
|
/// Values too short for the ellipsis to save space are left alone.
|
||||||
pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
|
pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
|
||||||
let len = value.chars().count();
|
let len = value.chars().count();
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use signed_state::ProfileStore;
|
|||||||
use signed_ui::{UserAvatar, middle_truncate};
|
use signed_ui::{UserAvatar, middle_truncate};
|
||||||
|
|
||||||
/// Open the About dialog showing every field of the announcement event.
|
/// 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) {
|
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
|
||||||
window.open_dialog(cx, move |dialog, _window, cx| {
|
window.open_dialog(cx, move |dialog, _window, cx| {
|
||||||
let announcement = announcement.clone();
|
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.
|
/// 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 {
|
fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||||
let mut rows: Vec<AnyElement> = Vec::new();
|
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.
|
/// One row per maintainer with avatar and display name.
|
||||||
/// The display name falls back to a shortened npub.
|
/// The display name falls back to a shortened npub.
|
||||||
|
///
|
||||||
/// A copy button copies the full pubkey.
|
/// A copy button copies the full pubkey.
|
||||||
fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
|
||||||
let profile_store = ProfileStore::global(cx);
|
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.
|
/// 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.
|
/// 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 {
|
fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const TREE_WIDTH: f32 = 240.;
|
|||||||
/// Files larger than this are not previewed.
|
/// Files larger than this are not previewed.
|
||||||
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
|
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
|
||||||
/// Preview cache caps, a file count and a text byte count.
|
/// Preview cache caps, a file count and a text byte count.
|
||||||
|
///
|
||||||
/// The oldest previews are evicted beyond the caps.
|
/// The oldest previews are evicted beyond the caps.
|
||||||
pub(super) const MAX_PREVIEWED_FILES: usize = 32;
|
pub(super) const MAX_PREVIEWED_FILES: usize = 32;
|
||||||
pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
|
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`].
|
/// 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 {
|
pub(super) struct MarkdownView {
|
||||||
/// Source path, `None` means the repository README.
|
/// Source path, `None` means the repository README.
|
||||||
pub(super) path: Option<SharedString>,
|
pub(super) path: Option<SharedString>,
|
||||||
@@ -44,9 +42,6 @@ pub(super) struct MarkdownView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A code file loaded into a persistent [`InputState`].
|
/// 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 {
|
pub(super) struct CodeView {
|
||||||
/// Source path, relative to the worktree root.
|
/// Source path, relative to the worktree root.
|
||||||
pub(super) path: SharedString,
|
pub(super) path: SharedString,
|
||||||
@@ -216,8 +211,6 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load `text` into the persistent markdown TextView state.
|
/// 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(
|
pub(super) fn set_markdown(
|
||||||
&mut self,
|
&mut self,
|
||||||
path: Option<SharedString>,
|
path: Option<SharedString>,
|
||||||
@@ -230,15 +223,18 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The persistent markdown TextView for `path`, where `None` is the README.
|
/// The persistent markdown TextView for `path`, where `None` is the README.
|
||||||
|
///
|
||||||
/// Shows a spinner while the document is being loaded or parsed.
|
/// Shows a spinner while the document is being loaded or parsed.
|
||||||
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
|
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
|
||||||
let Some(md) = &self.md else {
|
let Some(md) = &self.md else {
|
||||||
return preview_spinner();
|
return preview_spinner();
|
||||||
};
|
};
|
||||||
|
|
||||||
let ready = match path {
|
let ready = match path {
|
||||||
Some(path) => md.path.as_deref() == Some(path),
|
Some(path) => md.path.as_deref() == Some(path),
|
||||||
None => md.path.is_none(),
|
None => md.path.is_none(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if !ready {
|
if !ready {
|
||||||
return preview_spinner();
|
return preview_spinner();
|
||||||
}
|
}
|
||||||
@@ -252,8 +248,8 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load `text` into the persistent code editor state for `path`.
|
/// Load `text` into the persistent code editor state for `path`.
|
||||||
|
///
|
||||||
/// Code editor mode makes the Input render it read-only and highlighted.
|
/// 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(
|
pub(super) fn set_code(
|
||||||
&mut self,
|
&mut self,
|
||||||
path: SharedString,
|
path: SharedString,
|
||||||
|
|||||||
@@ -70,8 +70,6 @@ pub(super) fn commit_row(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RepoDetailView {
|
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 {
|
pub(super) fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let Some(list) = self.all_commits.as_ref() else {
|
let Some(list) = self.all_commits.as_ref() else {
|
||||||
return if self.loading_all_commits {
|
return if self.loading_all_commits {
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ use super::helpers::{
|
|||||||
const TREE_WIDTH: f32 = 260.;
|
const TREE_WIDTH: f32 = 260.;
|
||||||
|
|
||||||
/// Tree and per-file diff body, shared by the commit diff and compare views.
|
/// 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 {
|
pub struct DiffPane {
|
||||||
/// Loaded diff, `None` until [`Self::set_diff`] is called.
|
/// Loaded diff, `None` until [`Self::set_diff`] is called.
|
||||||
diff: Option<CommitDiff>,
|
diff: Option<CommitDiff>,
|
||||||
@@ -39,7 +37,6 @@ pub struct DiffPane {
|
|||||||
/// Path of the file whose diff is shown in the detail column.
|
/// Path of the file whose diff is shown in the detail column.
|
||||||
selected_file: Option<SharedString>,
|
selected_file: Option<SharedString>,
|
||||||
/// Rows of the selected file's diff, hunk headers and lines.
|
/// Rows of the selected file's diff, hunk headers and lines.
|
||||||
/// Backing the virtual list in the detail column.
|
|
||||||
rows: Vec<DiffRow>,
|
rows: Vec<DiffRow>,
|
||||||
/// Per-row heights of [`Self::rows`].
|
/// Per-row heights of [`Self::rows`].
|
||||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
@@ -90,6 +87,7 @@ impl DiffPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Forget the diff, e.g. when the compared branches changed.
|
/// Forget the diff, e.g. when the compared branches changed.
|
||||||
|
///
|
||||||
/// Clears the tree, the selection and the diff rows.
|
/// Clears the tree, the selection and the diff rows.
|
||||||
pub fn clear(&mut self, cx: &mut Context<Self>) {
|
pub fn clear(&mut self, cx: &mut Context<Self>) {
|
||||||
self.diff = None;
|
self.diff = None;
|
||||||
@@ -187,8 +185,6 @@ impl DiffPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The diff of one file, with a header showing status and stats.
|
/// 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 {
|
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
|
||||||
let status_label = match file.status {
|
let status_label = match file.status {
|
||||||
DiffStatus::Added => "A",
|
DiffStatus::Added => "A",
|
||||||
@@ -316,7 +312,6 @@ impl Render for DiffPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Detail panel showing the diff of one commit.
|
/// Detail panel showing the diff of one commit.
|
||||||
/// A metadata header plus the shared [`DiffPane`] body.
|
|
||||||
pub struct CommitDiffView {
|
pub struct CommitDiffView {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
/// Local clone the commit lives in.
|
/// Local clone the commit lives in.
|
||||||
@@ -324,8 +319,6 @@ pub struct CommitDiffView {
|
|||||||
/// Display name of the repository the commit belongs to.
|
/// Display name of the repository the commit belongs to.
|
||||||
repo_name: SharedString,
|
repo_name: SharedString,
|
||||||
/// The commit being shown in the header and tab title.
|
/// 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,
|
commit: FileCommit,
|
||||||
/// The diff is being computed on a background task.
|
/// The diff is being computed on a background task.
|
||||||
loading: bool,
|
loading: bool,
|
||||||
@@ -369,8 +362,7 @@ impl CommitDiffView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the commit diff and the full commit metadata on a background task.
|
/// Load the commit diff and the full commit metadata.
|
||||||
/// Then populate the tree.
|
|
||||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.loading = true;
|
self.loading = true;
|
||||||
self.error = None;
|
self.error = None;
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ use signed_core::Announcement;
|
|||||||
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff};
|
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff};
|
||||||
use signed_ui::{menu_copy_row, middle_truncate};
|
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 {
|
pub(super) struct TreeItemSeed {
|
||||||
/// Path of the node, relative to the worktree root.
|
/// Path of the node, relative to the worktree root.
|
||||||
id: String,
|
id: String,
|
||||||
@@ -22,10 +19,6 @@ pub(super) struct TreeItemSeed {
|
|||||||
children: Vec<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> {
|
pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
|
||||||
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
|
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
|
||||||
let mut item = TreeItem::new(seed.id, seed.label);
|
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.
|
/// 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> {
|
pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||||
// Node indices by full path, so parents resolve in constant time while inserting.
|
// Node indices by full path, so parents resolve in constant time while inserting.
|
||||||
let mut index: HashMap<String, usize> = HashMap::new();
|
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.
|
/// 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> {
|
pub(super) fn code_language(path: &str) -> Option<&'static str> {
|
||||||
let name = Path::new(path)
|
let name = Path::new(path)
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -192,6 +179,7 @@ impl ShareTargets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The share dropdown menu, one row per target.
|
/// The share dropdown menu, one row per target.
|
||||||
|
///
|
||||||
/// Each shows a compact label, the copy button and row click copy the full value.
|
/// Each shows a compact label, the copy button and row click copy the full value.
|
||||||
pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu {
|
pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu {
|
||||||
menu.min_w(px(340.))
|
menu.min_w(px(340.))
|
||||||
@@ -223,8 +211,6 @@ impl ShareTargets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Shorten an naddr link to `<url>/naddr1...[last tail chars]`.
|
/// 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 {
|
fn truncate_naddr_link(url: &str, tail: usize) -> String {
|
||||||
let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else {
|
let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else {
|
||||||
return url.to_string();
|
return url.to_string();
|
||||||
@@ -241,6 +227,7 @@ pub(super) const GUTTER_WIDTH: f32 = 44.;
|
|||||||
pub(super) const DIFF_ROW_HEIGHT: f32 = 20.;
|
pub(super) const DIFF_ROW_HEIGHT: f32 = 20.;
|
||||||
|
|
||||||
/// One row of a virtual diff list, a hunk header or a line of a hunk.
|
/// 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.
|
/// Shared by the commit diff and pull request diff viewers.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub(super) enum DiffRow {
|
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.
|
/// One diff line, old and new line numbers in the gutters.
|
||||||
|
///
|
||||||
/// The content is tinted by kind, addition, deletion or context.
|
/// The content is tinted by kind, addition, deletion or context.
|
||||||
pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||||
let bg = match line.kind {
|
let bg = match line.kind {
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ pub struct InitRepoState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Open the Init dialog for the local repository at `local_path`.
|
/// 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(
|
pub fn open(
|
||||||
local_path: PathBuf,
|
local_path: PathBuf,
|
||||||
view: WeakEntity<RepoDetailView>,
|
view: WeakEntity<RepoDetailView>,
|
||||||
@@ -39,23 +36,25 @@ pub fn open(
|
|||||||
.file_name()
|
.file_name()
|
||||||
.map(|name| name.to_string_lossy().into_owned())
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
.unwrap_or_default();
|
.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 name_input = cx.new(|cx| InputState::new(window, cx).default_value(default_name));
|
||||||
let desc_input = cx.new(|cx| {
|
let desc_input = cx.new(|cx| {
|
||||||
TextareaState::new(window, cx)
|
TextareaState::new(window, cx)
|
||||||
.auto_grow(3, 5)
|
.auto_grow(3, 5)
|
||||||
.placeholder("Short description")
|
.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);
|
load_user_grasp_servers(grasp_state.clone(), window, cx);
|
||||||
|
|
||||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||||
@@ -153,6 +152,7 @@ pub fn open(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run the init flow.
|
/// Run the init flow.
|
||||||
|
///
|
||||||
/// Closes the dialog and switches the repository into NIP-34 mode on success.
|
/// Closes the dialog and switches the repository into NIP-34 mode on success.
|
||||||
fn init_repository(
|
fn init_repository(
|
||||||
local_path: PathBuf,
|
local_path: PathBuf,
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ use utils::relative_time;
|
|||||||
use super::issue_detail::IssueDetailView;
|
use super::issue_detail::IssueDetailView;
|
||||||
|
|
||||||
/// Height of one issue row in the virtual list.
|
/// 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.;
|
const ISSUE_ROW_HEIGHT: f32 = 73.;
|
||||||
|
|
||||||
/// Status filter of the issues list, chosen via the header's filter buttons.
|
/// 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`].
|
/// Issues whose resolved status is [`RepoStatus::Open`].
|
||||||
Open,
|
Open,
|
||||||
/// Issues whose resolved status is [`RepoStatus::Closed`].
|
/// Issues whose resolved status is [`RepoStatus::Closed`].
|
||||||
/// [`RepoStatus::Applied`] counts too, both are done states.
|
|
||||||
Closed,
|
Closed,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,9 +64,6 @@ pub struct IssuesView {
|
|||||||
/// The filtered issue count [`Self::item_sizes`] was built for.
|
/// The filtered issue count [`Self::item_sizes`] was built for.
|
||||||
issue_len: usize,
|
issue_len: usize,
|
||||||
/// Indices into the store's `issues` matching [`Self::filter`].
|
/// 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>,
|
visible_issues: Vec<usize>,
|
||||||
/// Header counts `(total, open, closed)`, rebuilt with [`Self::visible_issues`].
|
/// Header counts `(total, open, closed)`, rebuilt with [`Self::visible_issues`].
|
||||||
counts: (usize, usize, usize),
|
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 {
|
fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let issue = &self.store.read(cx).issues[issue_ix];
|
let issue = &self.store.read(cx).issues[issue_ix];
|
||||||
let title = activity_subject(issue);
|
let title = activity_subject(issue);
|
||||||
@@ -186,7 +178,6 @@ impl IssuesView {
|
|||||||
|
|
||||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
// Counts of the last list rebuild.
|
// 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;
|
let (total, open, closed) = self.counts;
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -245,7 +236,6 @@ impl IssuesView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Open the new issue dialog, a title and a content input.
|
/// 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) {
|
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 subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title"));
|
||||||
let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue..."));
|
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;
|
let filter = self.filter;
|
||||||
|
|
||||||
// Rows and counts are rebuilt only when the store refreshed or filter changed.
|
// 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();
|
let version = self.store.read(cx).version();
|
||||||
|
|
||||||
if self.cache_key != Some((version, filter)) {
|
if self.cache_key != Some((version, filter)) {
|
||||||
let store = self.store.read(cx);
|
let store = self.store.read(cx);
|
||||||
let mut counts = (0usize, 0usize, 0usize);
|
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 owner = announcement.owner;
|
||||||
let user = nip05
|
let user = nip05
|
||||||
.map(str::to_owned)
|
.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}");
|
let mut url = format!("nostr://{user}");
|
||||||
if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) {
|
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::{
|
use gpui_component::combobox::{
|
||||||
Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext,
|
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::menu::{DropdownMenu, PopupMenu, PopupMenuItem};
|
||||||
use gpui_component::scroll::Scrollbar;
|
use gpui_component::scroll::Scrollbar;
|
||||||
use gpui_component::searchable_list::SearchableVec;
|
use gpui_component::searchable_list::SearchableVec;
|
||||||
@@ -36,14 +36,6 @@ use super::commits::{COMMIT_ROW_HEIGHT, commit_row};
|
|||||||
use super::diff::{CommitDiffView, DiffPane};
|
use super::diff::{CommitDiffView, DiffPane};
|
||||||
|
|
||||||
/// The new pull request panel of a repository.
|
/// 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 {
|
pub struct NewPullRequestView {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
/// Dock area the panel lives in, commit diffs are opened there.
|
/// 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.
|
/// Display name of the repository, for the panel title.
|
||||||
repo_name: SharedString,
|
repo_name: SharedString,
|
||||||
/// The user's local checkout.
|
/// 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>,
|
repo_path: Option<PathBuf>,
|
||||||
/// Branches of the checkout, backing both selectors in checkout mode.
|
/// Branches of the checkout, backing both selectors in checkout mode.
|
||||||
branches: Vec<SharedString>,
|
branches: Vec<SharedString>,
|
||||||
/// Fork-backed compare state.
|
/// 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>,
|
fork: Option<ForkCompare>,
|
||||||
/// Selected base branch, the PR target, stored as a short name.
|
/// Selected base branch, the PR target, stored as a short name.
|
||||||
base: SharedString,
|
base: SharedString,
|
||||||
@@ -96,8 +84,6 @@ pub struct NewPullRequestView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A fork-backed compare.
|
/// 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 {
|
struct ForkCompare {
|
||||||
/// Fork announcement the compare branch is imported from.
|
/// Fork announcement the compare branch is imported from.
|
||||||
announcement: Announcement,
|
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.
|
/// 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>(
|
fn fork_candidates<'a>(
|
||||||
announcements: &'a [Announcement],
|
announcements: &'a [Announcement],
|
||||||
base: &RepoAddr,
|
base: &RepoAddr,
|
||||||
@@ -154,6 +136,7 @@ fn fork_candidates<'a>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The display name of an announcement.
|
/// The display name of an announcement.
|
||||||
|
///
|
||||||
/// Its human-readable name, falling back to the repository id.
|
/// Its human-readable name, falling back to the repository id.
|
||||||
fn fork_display_name(announcement: &Announcement) -> SharedString {
|
fn fork_display_name(announcement: &Announcement) -> SharedString {
|
||||||
announcement
|
announcement
|
||||||
@@ -182,6 +165,7 @@ fn truncate_label(label: &str) -> SharedString {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The compare-source menu entry of one local checkout folder.
|
/// The compare-source menu entry of one local checkout folder.
|
||||||
|
///
|
||||||
/// Applies the folder directly, no picker.
|
/// Applies the folder directly, no picker.
|
||||||
fn checkout_source_item(
|
fn checkout_source_item(
|
||||||
view: WeakEntity<NewPullRequestView>,
|
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.
|
/// The compare-source menu entry of one announced fork.
|
||||||
|
///
|
||||||
/// Imports its branches into the target's mirror and switches the panel to fork mode.
|
/// Imports its branches into the target's mirror and switches the panel to fork mode.
|
||||||
fn fork_source_item(
|
fn fork_source_item(
|
||||||
view: WeakEntity<NewPullRequestView>,
|
view: WeakEntity<NewPullRequestView>,
|
||||||
@@ -310,7 +295,7 @@ impl NewPullRequestView {
|
|||||||
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
|
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
|
||||||
let description = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe..."));
|
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(
|
ComboboxState::new(
|
||||||
SearchableVec::new(Vec::<SharedString>::new()),
|
SearchableVec::new(Vec::<SharedString>::new()),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
@@ -320,7 +305,7 @@ impl NewPullRequestView {
|
|||||||
.searchable(true)
|
.searchable(true)
|
||||||
});
|
});
|
||||||
|
|
||||||
let compare_select: Entity<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| {
|
let compare_select = cx.new(|cx| {
|
||||||
ComboboxState::new(
|
ComboboxState::new(
|
||||||
SearchableVec::new(Vec::<SharedString>::new()),
|
SearchableVec::new(Vec::<SharedString>::new()),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
@@ -331,10 +316,6 @@ impl NewPullRequestView {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let subscriptions = vec![
|
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| {
|
cx.subscribe_in(&base_select, window, |this, _state, event, window, cx| {
|
||||||
if let ComboboxEvent::Change(values) = event
|
if let ComboboxEvent::Change(values) = event
|
||||||
&& let Some(name) = values.first()
|
&& let Some(name) = values.first()
|
||||||
@@ -405,6 +386,7 @@ impl NewPullRequestView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The path git ops run against.
|
/// The path git ops run against.
|
||||||
|
///
|
||||||
/// The target's mirror in fork mode, the user's checkout otherwise.
|
/// The target's mirror in fork mode, the user's checkout otherwise.
|
||||||
fn work_path(&self) -> Option<PathBuf> {
|
fn work_path(&self) -> Option<PathBuf> {
|
||||||
match &self.fork {
|
match &self.fork {
|
||||||
@@ -415,6 +397,7 @@ impl NewPullRequestView {
|
|||||||
|
|
||||||
/// The full ref the selected base branch resolves to.
|
/// The full ref the selected base branch resolves to.
|
||||||
/// The mirror's remote-tracking ref in fork mode.
|
/// The mirror's remote-tracking ref in fork mode.
|
||||||
|
///
|
||||||
/// The plain branch name in checkout mode, git resolves it through `refs/heads`.
|
/// The plain branch name in checkout mode, git resolves it through `refs/heads`.
|
||||||
fn base_ref(&self) -> String {
|
fn base_ref(&self) -> String {
|
||||||
match &self.fork {
|
match &self.fork {
|
||||||
@@ -425,6 +408,7 @@ impl NewPullRequestView {
|
|||||||
|
|
||||||
/// The full ref the selected compare branch resolves to.
|
/// The full ref the selected compare branch resolves to.
|
||||||
/// The imported `refs/fork/<namespace>` ref in fork mode.
|
/// The imported `refs/fork/<namespace>` ref in fork mode.
|
||||||
|
///
|
||||||
/// The plain branch name in checkout mode.
|
/// The plain branch name in checkout mode.
|
||||||
fn compare_ref(&self) -> String {
|
fn compare_ref(&self) -> String {
|
||||||
match &self.fork {
|
match &self.fork {
|
||||||
@@ -434,9 +418,6 @@ impl NewPullRequestView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Prompt for a local checkout.
|
/// 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>) {
|
fn choose_checkout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||||
files: false,
|
files: false,
|
||||||
@@ -452,6 +433,7 @@ impl NewPullRequestView {
|
|||||||
Ok(Ok(Some(mut paths))) => paths.pop(),
|
Ok(Ok(Some(mut paths))) => paths.pop(),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(path) = picked else {
|
let Some(path) = picked else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -466,6 +448,7 @@ impl NewPullRequestView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Apply `path` as the local checkout, no picker.
|
/// Apply `path` as the local checkout, no picker.
|
||||||
|
///
|
||||||
/// Branches and current branch are read off the UI thread, then applied.
|
/// 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>) {
|
fn apply_folder_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let path = path.to_string_lossy().to_string();
|
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.
|
/// 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(
|
fn apply_checkout(
|
||||||
&mut self,
|
&mut self,
|
||||||
path: String,
|
path: String,
|
||||||
@@ -506,6 +486,7 @@ impl NewPullRequestView {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
self.fork = None;
|
self.fork = None;
|
||||||
|
|
||||||
let Some((branches, current)) = info else {
|
let Some((branches, current)) = info else {
|
||||||
self.error = Some("The chosen folder is not a git repository".into());
|
self.error = Some("The chosen folder is not a git repository".into());
|
||||||
self.repo_path = None;
|
self.repo_path = None;
|
||||||
@@ -516,6 +497,7 @@ impl NewPullRequestView {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if branches.is_empty() {
|
if branches.is_empty() {
|
||||||
self.error = Some("The repository has no branches yet".into());
|
self.error = Some("The repository has no branches yet".into());
|
||||||
self.repo_path = None;
|
self.repo_path = None;
|
||||||
@@ -528,12 +510,14 @@ impl NewPullRequestView {
|
|||||||
// Falling back to `main`, then the first branch.
|
// Falling back to `main`, then the first branch.
|
||||||
// The checkout's current branch is the compare side default.
|
// The checkout's current branch is the compare side default.
|
||||||
let announced = self.store.read(cx).head.clone();
|
let announced = self.store.read(cx).head.clone();
|
||||||
|
|
||||||
let base = announced
|
let base = announced
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|branch| branches.contains(branch))
|
.filter(|branch| branches.contains(branch))
|
||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| branches.iter().find(|branch| *branch == "main").cloned())
|
.or_else(|| branches.iter().find(|branch| *branch == "main").cloned())
|
||||||
.unwrap_or_else(|| branches[0].clone());
|
.unwrap_or_else(|| branches[0].clone());
|
||||||
|
|
||||||
let compare = current
|
let compare = current
|
||||||
.filter(|branch| branches.contains(branch))
|
.filter(|branch| branches.contains(branch))
|
||||||
.unwrap_or_else(|| base.clone());
|
.unwrap_or_else(|| base.clone());
|
||||||
@@ -545,19 +529,23 @@ impl NewPullRequestView {
|
|||||||
// Remember this folder as a checkout of the target repository.
|
// Remember this folder as a checkout of the target repository.
|
||||||
// The next panel pre-fills it.
|
// The next panel pre-fills it.
|
||||||
let addr = self.store.read(cx).addr().clone();
|
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);
|
store.record(PathBuf::from(&path), addr, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
let branches = self.branches.clone();
|
let branches = self.branches.clone();
|
||||||
let base = SharedString::from(base.clone());
|
let base = SharedString::from(base.clone());
|
||||||
let compare = SharedString::from(compare.clone());
|
let compare = SharedString::from(compare.clone());
|
||||||
|
|
||||||
self.base = base.clone();
|
self.base = base.clone();
|
||||||
self.compare = compare.clone();
|
self.compare = compare.clone();
|
||||||
|
|
||||||
self.base_select.update(cx, |state, cx| {
|
self.base_select.update(cx, |state, cx| {
|
||||||
state.set_items(SearchableVec::from(branches.clone()), window, cx);
|
state.set_items(SearchableVec::from(branches.clone()), window, cx);
|
||||||
state.set_selected_values(&[base], window, cx);
|
state.set_selected_values(&[base], window, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.compare_select.update(cx, |state, cx| {
|
self.compare_select.update(cx, |state, cx| {
|
||||||
state.set_items(SearchableVec::from(branches), window, cx);
|
state.set_items(SearchableVec::from(branches), window, cx);
|
||||||
state.set_selected_values(&[compare], 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.
|
/// The base repository of the panel, its address and announced EUC.
|
||||||
|
///
|
||||||
/// Used to find fork candidates.
|
/// Used to find fork candidates.
|
||||||
fn base_repo(&self, cx: &App) -> (RepoAddr, Option<String>) {
|
fn base_repo(&self, cx: &App) -> (RepoAddr, Option<String>) {
|
||||||
let store = self.store.read(cx);
|
let store = self.store.read(cx);
|
||||||
@@ -575,6 +564,7 @@ impl NewPullRequestView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Announced forks of the target repository a compare can use, own first.
|
/// Announced forks of the target repository a compare can use, own first.
|
||||||
|
///
|
||||||
/// Re-read whenever the picker opens.
|
/// Re-read whenever the picker opens.
|
||||||
fn fork_candidates(&self, cx: &App) -> Vec<Announcement> {
|
fn fork_candidates(&self, cx: &App) -> Vec<Announcement> {
|
||||||
let (base, euc) = self.base_repo(cx);
|
let (base, euc) = self.base_repo(cx);
|
||||||
@@ -587,11 +577,6 @@ impl NewPullRequestView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Compare against an announced fork.
|
/// 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(
|
fn choose_fork(
|
||||||
&mut self,
|
&mut self,
|
||||||
announcement: Announcement,
|
announcement: Announcement,
|
||||||
@@ -728,6 +713,7 @@ impl NewPullRequestView {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
self.loading = false;
|
self.loading = false;
|
||||||
|
|
||||||
let (base_branches, compare_branches) = match result {
|
let (base_branches, compare_branches) = match result {
|
||||||
Ok(branches) => branches,
|
Ok(branches) => branches,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -738,11 +724,13 @@ impl NewPullRequestView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if compare_branches.is_empty() {
|
if compare_branches.is_empty() {
|
||||||
self.error = Some("The fork has no branches to compare".into());
|
self.error = Some("The fork has no branches to compare".into());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if base_branches.is_empty() {
|
if base_branches.is_empty() {
|
||||||
self.error =
|
self.error =
|
||||||
Some("Could not list the target repository's branches; try again later".into());
|
Some("Could not list the target repository's branches; try again later".into());
|
||||||
@@ -752,6 +740,7 @@ impl NewPullRequestView {
|
|||||||
|
|
||||||
let base_branches: Vec<SharedString> =
|
let base_branches: Vec<SharedString> =
|
||||||
base_branches.into_iter().map(SharedString::from).collect();
|
base_branches.into_iter().map(SharedString::from).collect();
|
||||||
|
|
||||||
let compare_branches: Vec<SharedString> = compare_branches
|
let compare_branches: Vec<SharedString> = compare_branches
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(SharedString::from)
|
.map(SharedString::from)
|
||||||
@@ -764,8 +753,10 @@ impl NewPullRequestView {
|
|||||||
let announced = self.store.read(cx).head.clone();
|
let announced = self.store.read(cx).head.clone();
|
||||||
let contains =
|
let contains =
|
||||||
|name: &str, list: &[SharedString]| list.iter().any(|branch| branch.as_ref() == name);
|
|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_base = keep_base.filter(|name| contains(name, &base_branches));
|
||||||
let keep_compare = keep_compare.filter(|name| contains(name, &compare_branches));
|
let keep_compare = keep_compare.filter(|name| contains(name, &compare_branches));
|
||||||
|
|
||||||
let base = keep_base
|
let base = keep_base
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
announced
|
announced
|
||||||
@@ -780,6 +771,7 @@ impl NewPullRequestView {
|
|||||||
.cloned()
|
.cloned()
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| base_branches[0].clone());
|
.unwrap_or_else(|| base_branches[0].clone());
|
||||||
|
|
||||||
let compare = keep_compare
|
let compare = keep_compare
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
compare_branches
|
compare_branches
|
||||||
@@ -794,13 +786,16 @@ impl NewPullRequestView {
|
|||||||
namespace,
|
namespace,
|
||||||
mirror_path,
|
mirror_path,
|
||||||
});
|
});
|
||||||
|
|
||||||
self.error = None;
|
self.error = None;
|
||||||
self.base = base.clone();
|
self.base = base.clone();
|
||||||
self.compare = compare.clone();
|
self.compare = compare.clone();
|
||||||
|
|
||||||
self.base_select.update(cx, |state, cx| {
|
self.base_select.update(cx, |state, cx| {
|
||||||
state.set_items(SearchableVec::from(base_branches), window, cx);
|
state.set_items(SearchableVec::from(base_branches), window, cx);
|
||||||
state.set_selected_values(&[base], window, cx);
|
state.set_selected_values(&[base], window, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.compare_select.update(cx, |state, cx| {
|
self.compare_select.update(cx, |state, cx| {
|
||||||
state.set_items(SearchableVec::from(compare_branches), window, cx);
|
state.set_items(SearchableVec::from(compare_branches), window, cx);
|
||||||
state.set_selected_values(&[compare], 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.
|
/// 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>) {
|
fn reload_compare(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let Some(repo_path) = self.work_path() else {
|
let Some(repo_path) = self.work_path() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let base = self.base_ref();
|
let base = self.base_ref();
|
||||||
let compare = self.compare_ref();
|
let compare = self.compare_ref();
|
||||||
|
|
||||||
// Short names for the error copy, the full refs go to git.
|
// Short names for the error copy, the full refs go to git.
|
||||||
let base_name = self.base.to_string();
|
let base_name = self.base.to_string();
|
||||||
let compare_name = self.compare.to_string();
|
let compare_name = self.compare.to_string();
|
||||||
@@ -826,6 +820,7 @@ impl NewPullRequestView {
|
|||||||
self.loading = true;
|
self.loading = true;
|
||||||
self.error = None;
|
self.error = None;
|
||||||
self.compare_generation += 1;
|
self.compare_generation += 1;
|
||||||
|
|
||||||
let generation = self.compare_generation;
|
let generation = self.compare_generation;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
@@ -871,11 +866,11 @@ impl NewPullRequestView {
|
|||||||
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
this.update_in(cx, |this, _window, cx| {
|
||||||
// A stale result, branches changed mid-flight, must not clobber a newer compare.
|
// A stale result, branches changed mid-flight, must not clobber a newer compare.
|
||||||
// The newer task clears the flag.
|
|
||||||
if generation != this.compare_generation {
|
if generation != this.compare_generation {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok((merge_base, commits, diff)) => {
|
Ok((merge_base, commits, diff)) => {
|
||||||
this.merge_base = Some(merge_base);
|
this.merge_base = Some(merge_base);
|
||||||
@@ -891,18 +886,17 @@ impl NewPullRequestView {
|
|||||||
this.error = Some(error.to_string().into());
|
this.error = Some(error.to_string().into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish the pull request.
|
/// 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>) {
|
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if self.submitting || self.loading {
|
if self.submitting || self.loading {
|
||||||
return;
|
return;
|
||||||
@@ -969,6 +963,7 @@ impl NewPullRequestView {
|
|||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.submitting = false;
|
this.submitting = false;
|
||||||
|
|
||||||
store.update(cx, |store, cx| {
|
store.update(cx, |store, cx| {
|
||||||
store.open_pull_request(
|
store.open_pull_request(
|
||||||
(!subject.is_empty()).then_some(subject),
|
(!subject.is_empty()).then_some(subject),
|
||||||
@@ -981,6 +976,7 @@ impl NewPullRequestView {
|
|||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close the panel once the publish is underway.
|
// Close the panel once the publish is underway.
|
||||||
cx.defer_in(window, {
|
cx.defer_in(window, {
|
||||||
let dock_area = dock_area.clone();
|
let dock_area = dock_area.clone();
|
||||||
@@ -993,6 +989,7 @@ impl NewPullRequestView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.notify();
|
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 {
|
fn render_compare_bar(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let has_source = self.has_source();
|
let has_source = self.has_source();
|
||||||
let can_submit = has_source
|
let can_submit = has_source
|
||||||
@@ -1174,22 +1169,18 @@ impl NewPullRequestView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build the compare-source menu.
|
/// 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(
|
fn source_menu(
|
||||||
&self,
|
&self,
|
||||||
cx: &Context<Self>,
|
cx: &Context<Self>,
|
||||||
) -> impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static {
|
) -> impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static {
|
||||||
let view = cx.entity().downgrade();
|
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 addr = self.store.read(cx).addr().clone();
|
||||||
let associated = CheckoutsStore::global(cx).read(cx).associations_of(&addr);
|
let associated = CheckoutsStore::global(cx).read(cx).associations_of(&addr);
|
||||||
|
|
||||||
let active_path = (self.fork.is_none())
|
let active_path = (self.fork.is_none())
|
||||||
.then(|| self.repo_path.clone())
|
.then(|| self.repo_path.clone())
|
||||||
.flatten();
|
.flatten();
|
||||||
|
|
||||||
let candidates = self.fork_candidates(cx);
|
let candidates = self.fork_candidates(cx);
|
||||||
let user = Backend::global(cx).read(cx).current_user();
|
let user = Backend::global(cx).read(cx).current_user();
|
||||||
let active_fork = self.fork.as_ref().map(|fork| fork.announcement.addr());
|
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 {
|
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let Some(commits) = self.commits.as_ref() else {
|
let Some(commits) = self.commits.as_ref() else {
|
||||||
return placeholder("No commits", cx);
|
return placeholder("No commits", cx);
|
||||||
@@ -1417,8 +1406,6 @@ fn count_badge(count: usize, cx: &App) -> impl IntoElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The trigger of a branch selector.
|
/// 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(
|
fn render_ref_trigger(
|
||||||
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
|
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
|
||||||
icon: CustomIconName,
|
icon: CustomIconName,
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
|||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||||
ScrollStrategy, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, relative,
|
ScrollStrategy, SharedString, Size, Task, WeakEntity, Window, div, px, relative, size,
|
||||||
size,
|
|
||||||
};
|
};
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::clipboard::Clipboard;
|
use gpui_component::clipboard::Clipboard;
|
||||||
@@ -42,8 +41,7 @@ use super::helpers::{
|
|||||||
const TREE_WIDTH: f32 = 260.;
|
const TREE_WIDTH: f32 = 260.;
|
||||||
|
|
||||||
/// Height of one commit row in the commits tab's virtual list.
|
/// Height of one commit row in the commits tab's virtual list.
|
||||||
/// A single text line plus the 1px bottom border.
|
const ROW_HEIGHT: f32 = 37.;
|
||||||
const PR_COMMIT_ROW_HEIGHT: f32 = 37.;
|
|
||||||
|
|
||||||
/// Detail panel of a single pull request.
|
/// Detail panel of a single pull request.
|
||||||
pub struct PullRequestDetailView {
|
pub struct PullRequestDetailView {
|
||||||
@@ -60,8 +58,6 @@ pub struct PullRequestDetailView {
|
|||||||
/// Display name of the repository, for panels opened from here.
|
/// Display name of the repository, for panels opened from here.
|
||||||
repo_name: SharedString,
|
repo_name: SharedString,
|
||||||
/// Local clone the PR's git changes come from.
|
/// 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>,
|
worktree: Option<PathBuf>,
|
||||||
/// Root PR's content, shown as plain text.
|
/// Root PR's content, shown as plain text.
|
||||||
description: SharedString,
|
description: SharedString,
|
||||||
@@ -91,14 +87,9 @@ pub struct PullRequestDetailView {
|
|||||||
/// Virtual list state of the commits tab.
|
/// Virtual list state of the commits tab.
|
||||||
commit_scroll_handle: VirtualListScrollHandle,
|
commit_scroll_handle: VirtualListScrollHandle,
|
||||||
/// Comment bodies as shared strings, keyed by comment event ID.
|
/// 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>,
|
contents: HashMap<EventId, SharedString>,
|
||||||
/// In-flight tasks, finished tasks are pruned on every push.
|
/// 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>>>,
|
tasks: Vec<Task<Result<(), anyhow::Error>>>,
|
||||||
/// Subscriptions keeping the view live as the store refreshes.
|
|
||||||
_subscriptions: Vec<Subscription>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PullRequestDetailView {
|
impl PullRequestDetailView {
|
||||||
@@ -109,26 +100,12 @@ impl PullRequestDetailView {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let repo_name = store.read(cx).name();
|
||||||
let tree_state = cx.new(|cx| TreeState::new(cx));
|
let tree_state = cx.new(|cx| TreeState::new(cx));
|
||||||
|
|
||||||
let comment_input =
|
let comment_input =
|
||||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
|
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.
|
// Defer loading until the window is ready, like the commit diff view.
|
||||||
cx.defer_in(window, |this, window, cx| {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
this.load(window, cx);
|
this.load(window, cx);
|
||||||
@@ -158,16 +135,10 @@ impl PullRequestDetailView {
|
|||||||
commit_scroll_handle: VirtualListScrollHandle::new(),
|
commit_scroll_handle: VirtualListScrollHandle::new(),
|
||||||
contents: HashMap::new(),
|
contents: HashMap::new(),
|
||||||
tasks: Vec::new(),
|
tasks: Vec::new(),
|
||||||
_subscriptions: subscriptions,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot the PR events from the store.
|
/// 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>) {
|
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.loading = true;
|
self.loading = true;
|
||||||
self.error = None;
|
self.error = None;
|
||||||
@@ -177,6 +148,7 @@ impl PullRequestDetailView {
|
|||||||
|
|
||||||
let (description, patch, current_commit, merge_base, clone_urls, addr, has_patch_link) = {
|
let (description, patch, current_commit, merge_base, clone_urls, addr, has_patch_link) = {
|
||||||
let store = self.store.read(cx);
|
let store = self.store.read(cx);
|
||||||
|
|
||||||
let Some(root) = store
|
let Some(root) = store
|
||||||
.pull_requests
|
.pull_requests
|
||||||
.iter()
|
.iter()
|
||||||
@@ -187,19 +159,24 @@ impl PullRequestDetailView {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let update = latest_update(store.pull_requests.iter(), root);
|
let update = latest_update(store.pull_requests.iter(), root);
|
||||||
|
|
||||||
let tip = update
|
let tip = update
|
||||||
.and_then(current_commit_of)
|
.and_then(current_commit_of)
|
||||||
.or_else(|| current_commit_of(root));
|
.or_else(|| current_commit_of(root));
|
||||||
|
|
||||||
let base = update
|
let base = update
|
||||||
.and_then(merge_base_of)
|
.and_then(merge_base_of)
|
||||||
.or_else(|| merge_base_of(root));
|
.or_else(|| merge_base_of(root));
|
||||||
|
|
||||||
let clone_urls = clone_urls_of(root).or_else(|| {
|
let clone_urls = clone_urls_of(root).or_else(|| {
|
||||||
store
|
store
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| a.clone.iter().map(ToString::to_string).collect())
|
.map(|a| a.clone.iter().map(ToString::to_string).collect())
|
||||||
});
|
});
|
||||||
|
|
||||||
(
|
(
|
||||||
root.content.clone(),
|
root.content.clone(),
|
||||||
pull_request_patch(root, store.patches.iter()),
|
pull_request_patch(root, store.patches.iter()),
|
||||||
@@ -210,16 +187,17 @@ impl PullRequestDetailView {
|
|||||||
root.tags.event_ids().next().is_some(),
|
root.tags.event_ids().next().is_some(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
self.description = description.into();
|
self.description = description.into();
|
||||||
|
|
||||||
let task = cx.spawn_in(window, async move |this, cx| {
|
let task = cx.spawn_in(window, async move |this, cx| {
|
||||||
// Parse the nostr patch set first.
|
|
||||||
let nostr_diff = cx
|
let nostr_diff = cx
|
||||||
.background_spawn({
|
.background_spawn({
|
||||||
let patch = patch.clone();
|
let patch = patch.clone();
|
||||||
async move { patch_diffs(&patch) }
|
async move { patch_diffs(&patch) }
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let nostr_commits = cx
|
let nostr_commits = cx
|
||||||
.background_spawn({
|
.background_spawn({
|
||||||
let patch = patch.clone();
|
let patch = patch.clone();
|
||||||
@@ -233,6 +211,7 @@ impl PullRequestDetailView {
|
|||||||
Ok(diff) => has_patch_link || !diff.files.is_empty(),
|
Ok(diff) => has_patch_link || !diff.files.is_empty(),
|
||||||
Err(_) => true,
|
Err(_) => true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let git = if use_nostr {
|
let git = if use_nostr {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -241,19 +220,22 @@ impl PullRequestDetailView {
|
|||||||
let clone_urls = clone_urls.clone();
|
let clone_urls = clone_urls.clone();
|
||||||
let base = merge_base.clone();
|
let base = merge_base.clone();
|
||||||
let tip = current_commit.clone();
|
let tip = current_commit.clone();
|
||||||
|
|
||||||
Some(
|
Some(
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
||||||
|
|
||||||
let workdir = repo
|
let workdir = repo
|
||||||
.workdir()
|
.workdir()
|
||||||
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
||||||
.to_path_buf();
|
.to_path_buf();
|
||||||
|
|
||||||
let tip =
|
let tip =
|
||||||
tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
|
tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
|
||||||
|
|
||||||
let base = match base {
|
let base = match base {
|
||||||
Some(base) => base,
|
Some(base) => base,
|
||||||
// No `merge-base` tag.
|
// No `merge-base` tag. Use the merge base of the tip and the default branch.
|
||||||
// Use the merge base of the tip and the default branch.
|
|
||||||
None => {
|
None => {
|
||||||
let head = repo
|
let head = repo
|
||||||
.head_id()
|
.head_id()
|
||||||
@@ -262,9 +244,11 @@ impl PullRequestDetailView {
|
|||||||
repo.merge_base(tip_id, head)?.to_string()
|
repo.merge_base(tip_id, head)?.to_string()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
|
let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
|
||||||
let commits =
|
let commits =
|
||||||
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
|
signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?;
|
||||||
|
|
||||||
Ok::<_, anyhow::Error>((diff, commits, workdir))
|
Ok::<_, anyhow::Error>((diff, commits, workdir))
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
@@ -281,9 +265,9 @@ impl PullRequestDetailView {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.worktree = worktree;
|
this.worktree = worktree;
|
||||||
this.current_commit = current_commit.map(SharedString::from);
|
this.current_commit = current_commit.map(SharedString::from);
|
||||||
this.commit_item_sizes =
|
this.commit_item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]);
|
||||||
Rc::new(vec![size(px(0.), px(PR_COMMIT_ROW_HEIGHT)); commits.len()]);
|
|
||||||
this.commits = commits;
|
this.commits = commits;
|
||||||
|
|
||||||
match diff {
|
match diff {
|
||||||
Ok(diff) => {
|
Ok(diff) => {
|
||||||
let mut paths: Vec<PathBuf> = diff
|
let mut paths: Vec<PathBuf> = diff
|
||||||
@@ -312,6 +296,7 @@ impl PullRequestDetailView {
|
|||||||
this.error = Some(error.to_string().into());
|
this.error = Some(error.to_string().into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.notify();
|
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 {
|
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let tree_state = self.tree_state.clone();
|
let tree_state = self.tree_state.clone();
|
||||||
let view = cx.entity().downgrade();
|
let view = cx.entity().downgrade();
|
||||||
@@ -416,7 +400,6 @@ impl PullRequestDetailView {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Right column, header of the selected file plus its diff.
|
|
||||||
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
if self.loading {
|
if self.loading {
|
||||||
return v_flex()
|
return v_flex()
|
||||||
@@ -426,12 +409,15 @@ impl PullRequestDetailView {
|
|||||||
.child(Spinner::new().small())
|
.child(Spinner::new().small())
|
||||||
.into_any_element();
|
.into_any_element();
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(error) = self.error.clone() {
|
if let Some(error) = self.error.clone() {
|
||||||
return placeholder(&error, cx);
|
return placeholder(&error, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(diff) = self.diff.as_ref() else {
|
let Some(diff) = self.diff.as_ref() else {
|
||||||
return placeholder("Failed to load diff", cx);
|
return placeholder("Failed to load diff", cx);
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(path) = self.selected_file.clone() else {
|
let Some(path) = self.selected_file.clone() else {
|
||||||
return if diff.files.is_empty() {
|
return if diff.files.is_empty() {
|
||||||
placeholder("No files changed in this pull request", cx)
|
placeholder("No files changed in this pull request", cx)
|
||||||
@@ -439,15 +425,13 @@ impl PullRequestDetailView {
|
|||||||
placeholder("Select a file", cx)
|
placeholder("Select a file", cx)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else {
|
let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else {
|
||||||
return placeholder("File not found", cx);
|
return placeholder("File not found", cx);
|
||||||
};
|
};
|
||||||
self.render_file_diff(file, cx.entity(), 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 {
|
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
|
||||||
let status_label = match file.status {
|
let status_label = match file.status {
|
||||||
signed_git::DiffStatus::Added => "A",
|
signed_git::DiffStatus::Added => "A",
|
||||||
@@ -456,6 +440,7 @@ impl PullRequestDetailView {
|
|||||||
signed_git::DiffStatus::Renamed => "R",
|
signed_git::DiffStatus::Renamed => "R",
|
||||||
signed_git::DiffStatus::Copied => "C",
|
signed_git::DiffStatus::Copied => "C",
|
||||||
};
|
};
|
||||||
|
|
||||||
let status_color = match file.status {
|
let status_color = match file.status {
|
||||||
signed_git::DiffStatus::Added => cx.theme().success,
|
signed_git::DiffStatus::Added => cx.theme().success,
|
||||||
signed_git::DiffStatus::Modified => cx.theme().info,
|
signed_git::DiffStatus::Modified => cx.theme().info,
|
||||||
@@ -464,6 +449,7 @@ impl PullRequestDetailView {
|
|||||||
cx.theme().muted_foreground
|
cx.theme().muted_foreground
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let title = match &file.old_path {
|
let title = match &file.old_path {
|
||||||
Some(old) => format!("{old} → {}", file.path),
|
Some(old) => format!("{old} → {}", file.path),
|
||||||
None => file.path.clone(),
|
None => file.path.clone(),
|
||||||
@@ -476,6 +462,7 @@ impl PullRequestDetailView {
|
|||||||
} else {
|
} else {
|
||||||
let sizes = self.item_sizes.clone();
|
let sizes = self.item_sizes.clone();
|
||||||
let scroll_handle = self.scroll_handle.clone();
|
let scroll_handle = self.scroll_handle.clone();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.relative()
|
.relative()
|
||||||
@@ -564,7 +551,6 @@ impl PullRequestDetailView {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Underline tab bar with the Discussion, Files and Commits tabs.
|
|
||||||
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let active = self.active_tab;
|
let active = self.active_tab;
|
||||||
let files_count = self.diff.as_ref().map(|diff| diff.files.len());
|
let files_count = self.diff.as_ref().map(|diff| diff.files.len());
|
||||||
@@ -610,8 +596,6 @@ impl PullRequestDetailView {
|
|||||||
.into_any_element()
|
.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 {
|
fn render_discussion(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
if self.loading {
|
if self.loading {
|
||||||
return v_flex()
|
return v_flex()
|
||||||
@@ -776,8 +760,6 @@ impl PullRequestDetailView {
|
|||||||
.into_any_element()
|
.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 {
|
fn render_files_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
h_flex()
|
h_flex()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
@@ -790,6 +772,7 @@ impl PullRequestDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Full-height Commits tab.
|
/// Full-height Commits tab.
|
||||||
|
///
|
||||||
/// Every commit of the patch series, or a status message while loading or empty.
|
/// Every commit of the patch series, or a status message while loading or empty.
|
||||||
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
if self.loading {
|
if self.loading {
|
||||||
@@ -839,6 +822,7 @@ impl PullRequestDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the commits tab, id, summary, author and time.
|
/// One row of the commits tab, id, summary, author and time.
|
||||||
|
///
|
||||||
/// Clicking a row opens the commit's diff in the bottom dock.
|
/// Clicking a row opens the commit's diff in the bottom dock.
|
||||||
fn render_commit_row(
|
fn render_commit_row(
|
||||||
&self,
|
&self,
|
||||||
@@ -852,7 +836,7 @@ impl PullRequestDetailView {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.id(ix)
|
.id(ix)
|
||||||
.px_4()
|
.px_4()
|
||||||
.h(px(PR_COMMIT_ROW_HEIGHT))
|
.h(px(ROW_HEIGHT))
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.items_center()
|
.items_center()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
@@ -893,7 +877,6 @@ impl PullRequestDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One comment card, same design as the issue panel.
|
/// 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 {
|
fn render_comments(&mut self, id: &EventId, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let store = self.store.read(cx);
|
let store = self.store.read(cx);
|
||||||
let comments: Vec<&Event> = store.comments_of(id).collect();
|
let comments: Vec<&Event> = store.comments_of(id).collect();
|
||||||
@@ -1104,8 +1087,6 @@ impl PullRequestDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Open the update pull request dialog.
|
/// 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(
|
fn open_update_pull_request_dialog(
|
||||||
store: Entity<RepoStore>,
|
store: Entity<RepoStore>,
|
||||||
root: Event,
|
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 `merge-base` tag of a PR event, as hex.
|
||||||
|
///
|
||||||
/// The most recent common ancestor with the target branch.
|
/// The most recent common ancestor with the target branch.
|
||||||
fn merge_base_of(event: &Event) -> Option<String> {
|
fn merge_base_of(event: &Event) -> Option<String> {
|
||||||
event
|
event
|
||||||
@@ -1200,6 +1182,7 @@ fn merge_base_of(event: &Event) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The `clone` tag of a PR event.
|
/// The `clone` tag of a PR event.
|
||||||
|
///
|
||||||
/// URLs where the proposed branch can be fetched, or `None` if the PR has none.
|
/// URLs where the proposed branch can be fetched, or `None` if the PR has none.
|
||||||
fn clone_urls_of(event: &Event) -> Option<Vec<String>> {
|
fn clone_urls_of(event: &Event) -> Option<Vec<String>> {
|
||||||
event
|
event
|
||||||
@@ -1223,9 +1206,6 @@ fn branch_name_of(event: &Event) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The latest PR update, kind 1619, revising `root`.
|
/// 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> {
|
fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &Event) -> Option<&'a Event> {
|
||||||
let root_hex = root.id.to_hex();
|
let root_hex = root.id.to_hex();
|
||||||
events
|
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.
|
/// One-line commit metadata for the commits list.
|
||||||
|
///
|
||||||
/// Author and relative time, whichever is available.
|
/// Author and relative time, whichever is available.
|
||||||
fn commit_meta(commit: &FileCommit) -> String {
|
fn commit_meta(commit: &FileCommit) -> String {
|
||||||
let author = commit.author.trim();
|
let author = commit.author.trim();
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ use super::pull_request_detail::PullRequestDetailView;
|
|||||||
use super::send_patch::open_send_patch_panel;
|
use super::send_patch::open_send_patch_panel;
|
||||||
|
|
||||||
/// Height of one pull request row in the virtual list.
|
/// Height of one pull request row in the virtual list.
|
||||||
/// Same layout as an issue row.
|
const ROW_HEIGHT: f32 = 73.;
|
||||||
const PR_ROW_HEIGHT: f32 = 73.;
|
|
||||||
|
|
||||||
/// Status filter of the pull request list, chosen via the header's filter buttons.
|
/// Status filter of the pull request list, chosen via the header's filter buttons.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -70,17 +69,10 @@ pub struct PullRequestsView {
|
|||||||
/// Per-row heights of the virtual list.
|
/// Per-row heights of the virtual list.
|
||||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
/// The filtered pull request count [`Self::item_sizes`] was built for.
|
/// The filtered pull request count [`Self::item_sizes`] was built for.
|
||||||
/// Rebuilt on change.
|
|
||||||
pr_len: usize,
|
pr_len: usize,
|
||||||
/// Indices into the store's `pull_requests` matching [`Self::filter`].
|
/// 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>,
|
visible_prs: Vec<usize>,
|
||||||
/// Header counts `(total, open, closed, draft, merged)`.
|
/// 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),
|
counts: (usize, usize, usize, usize, usize),
|
||||||
/// Store version and filter the cached rows/counts were built from.
|
/// Store version and filter the cached rows/counts were built from.
|
||||||
cache_key: Option<(u64, PullRequestFilter)>,
|
cache_key: Option<(u64, PullRequestFilter)>,
|
||||||
@@ -139,6 +131,7 @@ impl PullRequestsView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Render one row of the pull request list.
|
/// Render one row of the pull request list.
|
||||||
|
///
|
||||||
/// `ix` is the row index, `pr_ix` the index in the store's `pull_requests`.
|
/// `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 {
|
fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let pr = &self.store.read(cx).pull_requests[pr_ix];
|
let pr = &self.store.read(cx).pull_requests[pr_ix];
|
||||||
@@ -205,7 +198,6 @@ impl PullRequestsView {
|
|||||||
|
|
||||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
// Counts of the last list rebuild.
|
// 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;
|
let (total, open, closed, draft, merged) = self.counts;
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -341,8 +333,8 @@ impl Render for PullRequestsView {
|
|||||||
let filter = self.filter;
|
let filter = self.filter;
|
||||||
|
|
||||||
// Rows and counts are rebuilt only when the store refreshed or filter changed.
|
// 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();
|
let version = self.store.read(cx).version();
|
||||||
|
|
||||||
if self.cache_key != Some((version, filter)) {
|
if self.cache_key != Some((version, filter)) {
|
||||||
let store = self.store.read(cx);
|
let store = self.store.read(cx);
|
||||||
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
|
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
|
||||||
@@ -351,24 +343,24 @@ impl Render for PullRequestsView {
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(ix, pr)| {
|
.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 {
|
if pr.kind != Kind::GitPullRequest {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let status = store.status_of(pr);
|
let status = store.status_of(pr);
|
||||||
counts.0 += 1;
|
counts.0 += 1;
|
||||||
|
|
||||||
match status {
|
match status {
|
||||||
RepoStatus::Open => counts.1 += 1,
|
RepoStatus::Open => counts.1 += 1,
|
||||||
RepoStatus::Closed => counts.2 += 1,
|
RepoStatus::Closed => counts.2 += 1,
|
||||||
RepoStatus::Draft => counts.3 += 1,
|
RepoStatus::Draft => counts.3 += 1,
|
||||||
RepoStatus::Applied => counts.4 += 1,
|
RepoStatus::Applied => counts.4 += 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
filter.matches(status).then_some(ix)
|
filter.matches(status).then_some(ix)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
self.counts = counts;
|
self.counts = counts;
|
||||||
self.cache_key = Some((version, filter));
|
self.cache_key = Some((version, filter));
|
||||||
}
|
}
|
||||||
@@ -379,7 +371,7 @@ impl Render for PullRequestsView {
|
|||||||
// Rebuild it whenever the filtered pull request count changes.
|
// Rebuild it whenever the filtered pull request count changes.
|
||||||
if count != self.pr_len {
|
if count != self.pr_len {
|
||||||
self.pr_len = count;
|
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();
|
let sizes = self.item_sizes.clone();
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
|||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
|
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_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::scroll::ScrollableElement;
|
||||||
use gpui_component::spinner::Spinner;
|
use gpui_component::spinner::Spinner;
|
||||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
||||||
@@ -29,7 +29,6 @@ pub struct SendPatchView {
|
|||||||
submitting: bool,
|
submitting: bool,
|
||||||
/// Error of the last submit attempt, it keeps the panel open.
|
/// Error of the last submit attempt, it keeps the panel open.
|
||||||
error: Option<SharedString>,
|
error: Option<SharedString>,
|
||||||
_subscriptions: Vec<Subscription>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SendPatchView {
|
impl SendPatchView {
|
||||||
@@ -41,22 +40,14 @@ impl SendPatchView {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
let repo_name = store.read(cx).name();
|
let repo_name = store.read(cx).name();
|
||||||
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
|
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
|
||||||
|
|
||||||
let description = cx
|
let description = cx
|
||||||
.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)"));
|
.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)"));
|
||||||
|
|
||||||
let patch = cx.new(|cx| {
|
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")
|
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 {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
dock_area,
|
dock_area,
|
||||||
@@ -67,25 +58,23 @@ impl SendPatchView {
|
|||||||
patch,
|
patch,
|
||||||
submitting: false,
|
submitting: false,
|
||||||
error: None,
|
error: None,
|
||||||
_subscriptions: subscriptions,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish the pull request from the pasted patch.
|
/// 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>) {
|
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if self.submitting {
|
if self.submitting {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let subject = self.subject.read(cx).value().to_string();
|
let subject = self.subject.read(cx).value().to_string();
|
||||||
let description = self.description.read(cx).value().to_string();
|
let description = self.description.read(cx).value().to_string();
|
||||||
let patch = self.patch.read(cx).value().to_string();
|
let patch = self.patch.read(cx).value().to_string();
|
||||||
|
|
||||||
if patch.is_empty() {
|
if patch.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let store = self.store.clone();
|
let store = self.store.clone();
|
||||||
let dock_area = self.dock_area.clone();
|
let dock_area = self.dock_area.clone();
|
||||||
let entity = cx.entity().clone();
|
let entity = cx.entity().clone();
|
||||||
@@ -95,7 +84,6 @@ impl SendPatchView {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
// Errors the store detects before publishing.
|
// Errors the store detects before publishing.
|
||||||
// Returned synchronously through `last_error`.
|
|
||||||
let sync_error = store.update(cx, |store, cx| {
|
let sync_error = store.update(cx, |store, cx| {
|
||||||
store.open_pull_request(
|
store.open_pull_request(
|
||||||
(!subject.is_empty()).then_some(subject),
|
(!subject.is_empty()).then_some(subject),
|
||||||
@@ -129,6 +117,7 @@ impl SendPatchView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ enum RepoFilter {
|
|||||||
|
|
||||||
impl RepoFilter {
|
impl RepoFilter {
|
||||||
/// Indices into the store's `announcements` this filter includes, in display order.
|
/// Indices into the store's `announcements` this filter includes, in display order.
|
||||||
|
///
|
||||||
/// Narrowed to repositories whose name or id contains `query`.
|
/// Narrowed to repositories whose name or id contains `query`.
|
||||||
/// An empty query matches everything.
|
|
||||||
fn visible(self, store: &RepoListStore, query: &str) -> Vec<usize> {
|
fn visible(self, store: &RepoListStore, query: &str) -> Vec<usize> {
|
||||||
let announcements = &store.announcements;
|
let announcements = &store.announcements;
|
||||||
let mut indices: Vec<usize> = (0..announcements.len()).collect();
|
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.
|
/// Number of rows [`Self::item_sizes`] was built for, the filtered repo count.
|
||||||
repo_len: usize,
|
repo_len: usize,
|
||||||
/// Indices matching [`Self::filter`] into the store's `announcements`.
|
/// Indices matching [`Self::filter`] into the store's `announcements`.
|
||||||
/// The virtual list renders this slice in display order.
|
|
||||||
visible: Vec<usize>,
|
visible: Vec<usize>,
|
||||||
/// Search box filtering repositories by name.
|
/// Search box filtering repositories by name.
|
||||||
search: Entity<InputState>,
|
search: Entity<InputState>,
|
||||||
@@ -150,6 +149,7 @@ impl RepoListView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the store.
|
/// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the store.
|
||||||
|
///
|
||||||
/// Uses the store contents, [`Self::filter`] and the search query.
|
/// Uses the store contents, [`Self::filter`] and the search query.
|
||||||
fn rebuild_rows(&mut self, cx: &mut Context<Self>) {
|
fn rebuild_rows(&mut self, cx: &mut Context<Self>) {
|
||||||
let filter = self.filter;
|
let filter = self.filter;
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ pub struct CreateRepoState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Open the Create Repository dialog.
|
/// 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) {
|
pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
||||||
let settings = SettingsStore::global(cx);
|
let settings = SettingsStore::global(cx);
|
||||||
let default_folder = settings
|
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.
|
/// 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) {
|
fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||||
let handle = window.window_handle();
|
let handle = window.window_handle();
|
||||||
let folder_input = folder_input.clone();
|
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.
|
/// Run the create-repository flow.
|
||||||
|
///
|
||||||
/// Opens the new working copy and the repository panel on success.
|
/// Opens the new working copy and the repository panel on success.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn create_repository(
|
fn create_repository(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub struct GraspServersState {
|
|||||||
|
|
||||||
impl GraspServersState {
|
impl GraspServersState {
|
||||||
/// Defaults used until the user's grasp list loads, which replaces them when non-empty.
|
/// 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.
|
/// Persisted settings supply the defaults, an empty list falls back to the built-ins.
|
||||||
pub fn new_default(settings: &GraspServersSettings) -> Self {
|
pub fn new_default(settings: &GraspServersSettings) -> Self {
|
||||||
let urls: Vec<String> = if settings.default_servers.is_empty() {
|
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.
|
/// 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(
|
pub fn grasp_servers_field(
|
||||||
state: &Entity<GraspServersState>,
|
state: &Entity<GraspServersState>,
|
||||||
relay_input: &Entity<InputState>,
|
relay_input: &Entity<InputState>,
|
||||||
@@ -223,6 +222,7 @@ fn add_relay(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load the user's grasp list of kind `10317` from the local database.
|
/// Load the user's grasp list of kind `10317` from the local database.
|
||||||
|
///
|
||||||
/// It replaces the defaults when it lists any servers.
|
/// It replaces the defaults when it lists any servers.
|
||||||
pub fn load_user_grasp_servers(
|
pub fn load_user_grasp_servers(
|
||||||
state: Entity<GraspServersState>,
|
state: Entity<GraspServersState>,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use gpui::{App, Window, px};
|
|||||||
use gpui_component::WindowExt;
|
use gpui_component::WindowExt;
|
||||||
|
|
||||||
/// Open the Import Identity dialog.
|
/// Open the Import Identity dialog.
|
||||||
/// Currently a placeholder, the dialog only shows a title.
|
|
||||||
pub fn open(window: &mut Window, cx: &mut App) {
|
pub fn open(window: &mut Window, cx: &mut App) {
|
||||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||||
dialog.title("Import identity").width(px(400.))
|
dialog.title("Import identity").width(px(400.))
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ pub struct OnboardingState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Open the Onboarding dialog for creating a new identity.
|
/// 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(
|
pub fn open(
|
||||||
name_input: Entity<InputState>,
|
name_input: Entity<InputState>,
|
||||||
pass_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.
|
/// 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) {
|
pub fn open(window: &mut Window, cx: &mut App) {
|
||||||
let pass_input = cx.new(|cx| {
|
let pass_input = cx.new(|cx| {
|
||||||
InputState::new(window, cx)
|
InputState::new(window, cx)
|
||||||
@@ -98,8 +96,6 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Submit the passphrase to the backend.
|
/// 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(
|
fn unlock(
|
||||||
pass_input: &Entity<InputState>,
|
pass_input: &Entity<InputState>,
|
||||||
state: &Entity<PassphraseState>,
|
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.
|
/// Stateful controls of the settings dialog, created once when it opens.
|
||||||
/// Their values survive re-renders of the dialog content.
|
|
||||||
struct SettingsControls {
|
struct SettingsControls {
|
||||||
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
appearance: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
light_theme: Entity<SelectState<Vec<SelectOption>>>,
|
light_theme: Entity<SelectState<Vec<SelectOption>>>,
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ impl Workspace {
|
|||||||
let mut subscriptions = vec![];
|
let mut subscriptions = vec![];
|
||||||
|
|
||||||
// A bottom or right dock whose last panel was dragged away is removed entirely.
|
// 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();
|
let dock_for_pruning = dock.clone();
|
||||||
subscriptions.push(cx.subscribe_in(
|
subscriptions.push(cx.subscribe_in(
|
||||||
&dock,
|
&dock,
|
||||||
@@ -78,7 +76,6 @@ impl Workspace {
|
|||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
|
||||||
// Ask for the passphrase when the stored identity is NIP-49 encrypted.
|
// 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 =
|
let passphrase_subscription =
|
||||||
window.subscribe(&backend, cx, |_backend, event, window, cx| {
|
window.subscribe(&backend, cx, |_backend, event, window, cx| {
|
||||||
if matches!(event, BackendEvent::PassphraseRequired) {
|
if matches!(event, BackendEvent::PassphraseRequired) {
|
||||||
@@ -87,7 +84,6 @@ impl Workspace {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// The event may have fired before this window existed.
|
// 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.
|
// Fall back to the backend state in that case.
|
||||||
if backend.read(cx).passphrase_required() {
|
if backend.read(cx).passphrase_required() {
|
||||||
passphrase_dialog::open(window, cx);
|
passphrase_dialog::open(window, cx);
|
||||||
|
|||||||
Reference in New Issue
Block a user