From 54835363a96dbb39e79b6b9c25b643a56c63afdd Mon Sep 17 00:00:00 2001 From: Brandon Oxendine Date: Thu, 3 Sep 2026 12:30:00 +0200 Subject: [PATCH] Add a repository identity document view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a read-only page for a repository's identity document. It shows the current document together with its full revision history. The data and wording follow heartwood and the rad id / rad inspect CLI. Co-Authored-By: Rūdolfs Ošiņš --- crates/radicle-tauri/src/commands.rs | 1 + crates/radicle-tauri/src/commands/identity.rs | 15 + crates/radicle-tauri/src/lib.rs | 3 +- .../bindings/identity/Allowed.ts | 10 + .../bindings/identity/CanonicalRefRule.ts | 15 + .../radicle-types/bindings/identity/Change.ts | 19 + crates/radicle-types/bindings/identity/Doc.ts | 42 ++ .../bindings/identity/Identity.ts | 24 + .../bindings/identity/PayloadOperation.ts | 3 + .../bindings/identity/Project.ts | 7 + .../bindings/identity/RejectedReason.ts | 6 + .../bindings/identity/Revision.ts | 46 ++ .../radicle-types/bindings/identity/State.ts | 7 + crates/radicle-types/src/identity.rs | 446 +++++++++++++++++ crates/radicle-types/src/lib.rs | 3 + crates/radicle-types/src/repo.rs | 11 + crates/radicle-types/src/traits.rs | 1 + crates/radicle-types/src/traits/identity.rs | 106 ++++ crates/radicle-types/src/traits/repo.rs | 10 +- crates/test-http-api/src/api.rs | 12 + src/App.svelte | 3 + src/components/Id.svelte | 3 +- src/components/IdentityChanges.svelte | 137 ++++++ src/components/IdentityDocument.svelte | 248 ++++++++++ src/components/IdentityJsonButton.svelte | 26 + src/components/IdentityStateBadge.svelte | 72 +++ src/components/NodeId.svelte | 39 +- src/components/PierreFile.svelte | 81 +++ src/components/RepoHeader.svelte | 118 ++--- src/components/SidebarRepoList.svelte | 8 + src/lib/router.ts | 1 + src/lib/router/definitions.ts | 4 + src/modals/RawIdentityDocument.svelte | 115 +++++ src/views/repo/Identity.svelte | 465 ++++++++++++++++++ src/views/repo/router.ts | 40 ++ 35 files changed, 2069 insertions(+), 78 deletions(-) create mode 100644 crates/radicle-tauri/src/commands/identity.rs create mode 100644 crates/radicle-types/bindings/identity/Allowed.ts create mode 100644 crates/radicle-types/bindings/identity/CanonicalRefRule.ts create mode 100644 crates/radicle-types/bindings/identity/Change.ts create mode 100644 crates/radicle-types/bindings/identity/Doc.ts create mode 100644 crates/radicle-types/bindings/identity/Identity.ts create mode 100644 crates/radicle-types/bindings/identity/PayloadOperation.ts create mode 100644 crates/radicle-types/bindings/identity/Project.ts create mode 100644 crates/radicle-types/bindings/identity/RejectedReason.ts create mode 100644 crates/radicle-types/bindings/identity/Revision.ts create mode 100644 crates/radicle-types/bindings/identity/State.ts create mode 100644 crates/radicle-types/src/identity.rs create mode 100644 crates/radicle-types/src/traits/identity.rs create mode 100644 src/components/IdentityChanges.svelte create mode 100644 src/components/IdentityDocument.svelte create mode 100644 src/components/IdentityJsonButton.svelte create mode 100644 src/components/IdentityStateBadge.svelte create mode 100644 src/components/PierreFile.svelte create mode 100644 src/modals/RawIdentityDocument.svelte create mode 100644 src/views/repo/Identity.svelte diff --git a/crates/radicle-tauri/src/commands.rs b/crates/radicle-tauri/src/commands.rs index d9c072905..ff016c7c3 100644 --- a/crates/radicle-tauri/src/commands.rs +++ b/crates/radicle-tauri/src/commands.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod cob; pub mod diff; +pub mod identity; pub mod inbox; pub mod profile; pub mod repo; diff --git a/crates/radicle-tauri/src/commands/identity.rs b/crates/radicle-tauri/src/commands/identity.rs new file mode 100644 index 000000000..4c76aac66 --- /dev/null +++ b/crates/radicle-tauri/src/commands/identity.rs @@ -0,0 +1,15 @@ +use radicle::identity::RepoId; +use radicle_types as types; +use radicle_types::error::Error; +use radicle_types::traits::identity::Identity; + +use crate::AppState; +use crate::commands::blocking; + +#[tauri::command] +pub async fn identity_by_repo( + ctx: tauri::State<'_, AppState>, + rid: RepoId, +) -> Result { + blocking(ctx, move |ctx| ctx.identity_by_repo(rid)).await +} diff --git a/crates/radicle-tauri/src/lib.rs b/crates/radicle-tauri/src/lib.rs index d30fad4a4..659ef51e3 100644 --- a/crates/radicle-tauri/src/lib.rs +++ b/crates/radicle-tauri/src/lib.rs @@ -3,7 +3,7 @@ mod commands; use radicle_types::AppState; use tauri_plugin_log::{Target, TargetKind}; -use commands::{auth, cob, diff, inbox, profile, repo, startup, thread}; +use commands::{auth, cob, diff, identity, inbox, profile, repo, startup, thread}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -69,6 +69,7 @@ pub fn run() { diff::get_diff_text, diff::save_diff_to_disk, repo::get_commit_diff, + identity::identity_by_repo, inbox::clear_notifications, inbox::notification_count, inbox::list_notifications, diff --git a/crates/radicle-types/bindings/identity/Allowed.ts b/crates/radicle-types/bindings/identity/Allowed.ts new file mode 100644 index 000000000..24ee380f9 --- /dev/null +++ b/crates/radicle-types/bindings/identity/Allowed.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Author } from "../cob/Author"; + +/** + * Whose refs count towards a canonical ref. + */ +export type Allowed = { "type": "delegates" } | { + "type": "set"; + dids: Array; +}; diff --git a/crates/radicle-types/bindings/identity/CanonicalRefRule.ts b/crates/radicle-types/bindings/identity/CanonicalRefRule.ts new file mode 100644 index 000000000..66bf4be20 --- /dev/null +++ b/crates/radicle-types/bindings/identity/CanonicalRefRule.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Allowed } from "./Allowed"; + +/** + * One `refs/...` pattern and the rule that decides its canonical value. + */ +export type CanonicalRefRule = { + pattern: string; + allow: Allowed; + /** + * DIDs from `allow` that must agree on a commit or tag, in their own + * namespace, for it to become canonical. + */ + threshold: number; +}; diff --git a/crates/radicle-types/bindings/identity/Change.ts b/crates/radicle-types/bindings/identity/Change.ts new file mode 100644 index 000000000..81f29bf7c --- /dev/null +++ b/crates/radicle-types/bindings/identity/Change.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Author } from "../cob/Author"; +import type { Visibility } from "../repo/Visibility"; +import type { PayloadOperation } from "./PayloadOperation"; + +/** + * A single field-level difference between a revision and its parent. + */ +export type Change = + | { "type": "delegateAdded"; delegate: Author } + | { "type": "delegateRemoved"; delegate: Author } + | { "type": "thresholdChanged"; from: number; to: number } + | { "type": "visibilityChanged"; from: Visibility; to: Visibility } + | { "type": "allowAdded"; peer: Author } + | { "type": "allowRemoved"; peer: Author } + | { "type": "nameChanged"; from: string; to: string } + | { "type": "descriptionChanged" } + | { "type": "defaultBranchChanged"; from: string; to: string } + | { "type": "payloadChanged"; payload: string; operation: PayloadOperation }; diff --git a/crates/radicle-types/bindings/identity/Doc.ts b/crates/radicle-types/bindings/identity/Doc.ts new file mode 100644 index 000000000..bbaa58dcf --- /dev/null +++ b/crates/radicle-types/bindings/identity/Doc.ts @@ -0,0 +1,42 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Author } from "../cob/Author"; +import type { Visibility } from "../repo/Visibility"; +import type { CanonicalRefRule } from "./CanonicalRefRule"; +import type { Project } from "./Project"; + +export type Doc = { + version: number; + delegates: Array; + /** + * Delegates that must agree on a commit, by having it on their own copy + * of the default branch, for it to become canonical. This is *not* what + * governs changes to this document — see `majority`. + */ + threshold: number; + /** + * Delegate signatures needed to adopt a new revision of this document: + * a simple majority of the delegate set. + */ + majority: number; + visibility: Visibility; + project: Project | null; + /** + * The canonical-refs rules heartwood applies: those of the + * `xyz.radicle.crefs` payload plus the default-branch rule it synthesizes + * from the project payload when there is none. + */ + canonicalRefs: Array; + /** + * Why heartwood rejects the document's canonical-refs rules, if it does. + * `canonical_refs` then holds the payload's rules as written. + */ + canonicalRefsError?: string; + /** + * All payload ids, including ones the UI doesn't render. + */ + payloadIds: Array; + /** + * The document pretty-printed as `rad inspect --identity` prints it. + */ + raw: string; +}; diff --git a/crates/radicle-types/bindings/identity/Identity.ts b/crates/radicle-types/bindings/identity/Identity.ts new file mode 100644 index 000000000..d83eb317d --- /dev/null +++ b/crates/radicle-types/bindings/identity/Identity.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Doc } from "./Doc"; +import type { Revision } from "./Revision"; + +/** + * The identity of a repository: the document as it stands now, plus every + * revision that was ever proposed to it. + */ +export type Identity = { + rid: string; + /** + * The revision whose commit `refs/rad/id` points at. Unset if the + * identity COB has no accepted revision for that commit. + */ + current?: string; + /** + * The current document, read from `refs/rad/id`. + */ + doc: Doc; + /** + * Every revision, newest first. + */ + revisions: Array; +}; diff --git a/crates/radicle-types/bindings/identity/PayloadOperation.ts b/crates/radicle-types/bindings/identity/PayloadOperation.ts new file mode 100644 index 000000000..d1b17bb62 --- /dev/null +++ b/crates/radicle-types/bindings/identity/PayloadOperation.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PayloadOperation = "added" | "removed" | "updated"; diff --git a/crates/radicle-types/bindings/identity/Project.ts b/crates/radicle-types/bindings/identity/Project.ts new file mode 100644 index 000000000..81cc1bbba --- /dev/null +++ b/crates/radicle-types/bindings/identity/Project.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Project = { + name: string; + description: string; + defaultBranch: string; +}; diff --git a/crates/radicle-types/bindings/identity/RejectedReason.ts b/crates/radicle-types/bindings/identity/RejectedReason.ts new file mode 100644 index 000000000..16bd9a880 --- /dev/null +++ b/crates/radicle-types/bindings/identity/RejectedReason.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RejectedReason = { "type": "vote" } | { "type": "parent" } | { + "type": "sibling"; + revision: string; +}; diff --git a/crates/radicle-types/bindings/identity/Revision.ts b/crates/radicle-types/bindings/identity/Revision.ts new file mode 100644 index 000000000..f099e7a58 --- /dev/null +++ b/crates/radicle-types/bindings/identity/Revision.ts @@ -0,0 +1,46 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Author } from "../cob/Author"; +import type { Change } from "./Change"; +import type { State } from "./State"; + +/** + * A proposed change to the identity document. + */ +export type Revision = { + id: string; + blob: string; + title: string; + description: string; + state: State; + author: Author; + timestamp: number; + parent?: string; + accepted: Array; + rejected: Array; + /** + * Delegates that have not given a verdict. Together with `accepted` and + * `rejected` these are the delegates of the parent document, the only + * ones whose verdicts count. Empty for the root revision. + */ + pending: Array; + /** + * Signatures needed to adopt the revision: a majority of the parent + * document's delegates. Unset for the root revision, which is accepted + * on creation. + */ + majority?: number; + /** + * The document as it would stand if this revision were adopted, + * pretty-printed like `Doc::raw`. + */ + raw: string; + /** + * What this revision changes relative to its parent, in domain terms + * rather than as a text diff. + */ + changes: Array; + /** + * The same change as a unified diff of the pretty-printed documents. + */ + diff: string; +}; diff --git a/crates/radicle-types/bindings/identity/State.ts b/crates/radicle-types/bindings/identity/State.ts new file mode 100644 index 000000000..44f0c1db7 --- /dev/null +++ b/crates/radicle-types/bindings/identity/State.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RejectedReason } from "./RejectedReason"; + +export type State = { "status": "active" } | { "status": "accepted" } | { + "status": "rejected"; + reason: RejectedReason; +}; diff --git a/crates/radicle-types/src/identity.rs b/crates/radicle-types/src/identity.rs new file mode 100644 index 000000000..18ecf67ea --- /dev/null +++ b/crates/radicle-types/src/identity.rs @@ -0,0 +1,446 @@ +use std::collections::BTreeMap; + +use serde::Serialize; +use ts_rs::TS; + +use radicle::cob::identity; +use radicle::git::canonical::rules::{self, RawRules}; +use radicle::identity::doc::{self, GetRawCanonicalRefs}; +use radicle::node::AliasStore; + +use crate::cobs::Author; +use crate::repo::Visibility; + +/// The identity of a repository: the document as it stands now, plus every +/// revision that was ever proposed to it. +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "identity/")] +pub struct Identity { + #[ts(as = "String")] + pub rid: radicle::identity::RepoId, + /// The revision whose commit `refs/rad/id` points at. Unset if the + /// identity COB has no accepted revision for that commit. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(as = "Option", optional)] + pub current: Option, + /// The current document, read from `refs/rad/id`. + pub doc: Doc, + /// Every revision, newest first. + pub revisions: Vec, +} + +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "identity/")] +pub struct Doc { + pub version: u32, + pub delegates: Vec, + /// Delegates that must agree on a commit, by having it on their own copy + /// of the default branch, for it to become canonical. This is *not* what + /// governs changes to this document — see `majority`. + pub threshold: usize, + /// Delegate signatures needed to adopt a new revision of this document: + /// a simple majority of the delegate set. + pub majority: usize, + pub visibility: Visibility, + pub project: Option, + /// The canonical-refs rules heartwood applies: those of the + /// `xyz.radicle.crefs` payload plus the default-branch rule it synthesizes + /// from the project payload when there is none. + pub canonical_refs: Vec, + /// Why heartwood rejects the document's canonical-refs rules, if it does. + /// `canonical_refs` then holds the payload's rules as written. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub canonical_refs_error: Option, + /// All payload ids, including ones the UI doesn't render. + pub payload_ids: Vec, + /// The document pretty-printed as `rad inspect --identity` prints it. + pub raw: String, +} + +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "identity/")] +pub struct Project { + pub name: String, + pub description: String, + pub default_branch: String, +} + +/// One `refs/...` pattern and the rule that decides its canonical value. +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "identity/")] +pub struct CanonicalRefRule { + pub pattern: String, + pub allow: Allowed, + /// DIDs from `allow` that must agree on a commit or tag, in their own + /// namespace, for it to become canonical. + pub threshold: usize, +} + +/// Whose refs count towards a canonical ref. +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase", tag = "type")] +#[ts(export)] +#[ts(export_to = "identity/")] +pub enum Allowed { + Delegates, + Set { dids: Vec }, +} + +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase", tag = "status")] +#[ts(export)] +#[ts(export_to = "identity/")] +pub enum State { + Active, + Accepted, + Rejected { reason: RejectedReason }, +} + +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase", tag = "type")] +#[ts(export)] +#[ts(export_to = "identity/")] +pub enum RejectedReason { + /// Enough delegates rejected it that it can no longer reach a majority. + Vote, + Parent, + Sibling { + #[ts(as = "String")] + revision: radicle::git::Oid, + }, +} + +impl State { + /// Redacted revisions have no counterpart: heartwood's + /// `Identity::revisions()` skips them, as does `rad id list`. + pub fn new(state: identity::State) -> Option { + match state { + identity::State::Active => Some(State::Active), + identity::State::Accepted => Some(State::Accepted), + identity::State::Rejected(by) => Some(State::Rejected { + reason: match by { + identity::RejectedBy::Vote => RejectedReason::Vote, + identity::RejectedBy::Parent => RejectedReason::Parent, + identity::RejectedBy::Sibling(id) => RejectedReason::Sibling { revision: id }, + }, + }), + identity::State::Redacted(_) => None, + } + } +} + +/// A proposed change to the identity document. +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "identity/")] +pub struct Revision { + #[ts(as = "String")] + pub id: radicle::git::Oid, + #[ts(as = "String")] + pub blob: radicle::git::Oid, + pub title: String, + pub description: String, + pub state: State, + pub author: Author, + #[ts(type = "number")] + pub timestamp: u64, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(as = "Option", optional)] + pub parent: Option, + pub accepted: Vec, + pub rejected: Vec, + /// Delegates that have not given a verdict. Together with `accepted` and + /// `rejected` these are the delegates of the parent document, the only + /// ones whose verdicts count. Empty for the root revision. + pub pending: Vec, + /// Signatures needed to adopt the revision: a majority of the parent + /// document's delegates. Unset for the root revision, which is accepted + /// on creation. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub majority: Option, + /// The document as it would stand if this revision were adopted, + /// pretty-printed like `Doc::raw`. + pub raw: String, + /// What this revision changes relative to its parent, in domain terms + /// rather than as a text diff. + pub changes: Vec, + /// The same change as a unified diff of the pretty-printed documents. + pub diff: String, +} + +/// A single field-level difference between a revision and its parent. +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase", tag = "type")] +#[ts(export)] +#[ts(export_to = "identity/")] +pub enum Change { + DelegateAdded { + delegate: Author, + }, + DelegateRemoved { + delegate: Author, + }, + ThresholdChanged { + from: usize, + to: usize, + }, + VisibilityChanged { + from: Visibility, + to: Visibility, + }, + /// A DID was added to a private repository's allow list. + AllowAdded { + peer: Author, + }, + /// A DID was removed from a private repository's allow list. + AllowRemoved { + peer: Author, + }, + NameChanged { + from: String, + to: String, + }, + DescriptionChanged, + DefaultBranchChanged { + from: String, + to: String, + }, + /// A payload this app does not model changed, was added or was removed. + /// The raw document is the place to read the detail. + PayloadChanged { + payload: String, + operation: PayloadOperation, + }, +} + +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "identity/")] +pub enum PayloadOperation { + Added, + Removed, + Updated, +} + +impl Doc { + pub fn new(doc: &doc::Doc, aliases: &impl AliasStore) -> Self { + let (canonical_refs, canonical_refs_error) = canonical_ref_rules(doc, aliases); + let payload_ids = doc + .payload() + .keys() + .map(|id| id.to_string()) + .collect::>(); + + let project = doc.project().ok().map(|project| Project { + name: project.name().to_string(), + description: project.description().to_string(), + default_branch: project.default_branch().to_string(), + }); + + Self { + version: doc.version().number().get(), + delegates: doc + .delegates() + .iter() + .map(|did| Author::new(did, aliases)) + .collect(), + threshold: doc.threshold(), + majority: doc.majority(), + visibility: Visibility::new(doc.visibility(), aliases), + project, + canonical_refs, + canonical_refs_error, + payload_ids, + raw: raw_doc(doc), + } + } +} + +/// Empty rather than failing the whole view if the document cannot be +/// encoded. +pub fn raw_doc(doc: &doc::Doc) -> String { + serde_json::to_string_pretty(doc).unwrap_or_default() +} + +/// A unified diff of a pretty-printed document against its parent's with +/// full context, as `rad id show` prints it. The root revision is diffed +/// against nothing. +pub fn doc_diff(before: Option<&str>, after: &str) -> String { + let before = before.unwrap_or_default(); + let mut opts = git2::DiffOptions::new(); + opts.context_lines(u32::MAX); + + git2::Patch::from_buffers( + before.as_bytes(), + Some(*doc::PATH), + after.as_bytes(), + Some(*doc::PATH), + Some(&mut opts), + ) + .and_then(|mut patch| patch.to_buf()) + .ok() + .and_then(|buf| buf.as_str().ok().map(str::to_owned)) + .unwrap_or_default() +} + +/// The canonical-refs rules heartwood applies, including the default-branch +/// rule it synthesizes. If it rejects them, the payload's rules are returned +/// as written along with the reason. +fn canonical_ref_rules( + doc: &doc::Doc, + aliases: &impl AliasStore, +) -> (Vec, Option) { + let (rules, error) = match doc.canonical_refs() { + Ok(crefs) => (RawRules::from(crefs.rules().clone()), None), + Err(err) => ( + doc.raw_canonical_refs() + .ok() + .flatten() + .map(|crefs| crefs.raw_rules().clone()) + .unwrap_or_default(), + Some(err.to_string()), + ), + }; + + let rules = rules + .iter() + .map(|(pattern, rule)| CanonicalRefRule { + pattern: pattern.to_string(), + allow: match rule.allowed() { + rules::Allowed::Delegates => Allowed::Delegates, + rules::Allowed::Set(dids) => Allowed::Set { + dids: dids.iter().map(|did| Author::new(did, aliases)).collect(), + }, + }, + threshold: *rule.threshold(), + }) + .collect(); + + (rules, error) +} + +/// Diff two documents field by field, so the UI can say "added delegate X" +/// instead of showing a JSON patch. +pub fn changes(from: Option<&doc::Doc>, to: &doc::Doc, aliases: &impl AliasStore) -> Vec { + let Some(from) = from else { + return Vec::new(); + }; + let mut changes = Vec::new(); + + for did in to.delegates().iter() { + if !from.delegates().contains(did) { + changes.push(Change::DelegateAdded { + delegate: Author::new(did, aliases), + }); + } + } + for did in from.delegates().iter() { + if !to.delegates().contains(did) { + changes.push(Change::DelegateRemoved { + delegate: Author::new(did, aliases), + }); + } + } + + if from.threshold() != to.threshold() { + changes.push(Change::ThresholdChanged { + from: from.threshold(), + to: to.threshold(), + }); + } + + if from.visibility().is_public() != to.visibility().is_public() { + changes.push(Change::VisibilityChanged { + from: Visibility::new(from.visibility(), aliases), + to: Visibility::new(to.visibility(), aliases), + }); + } + let allow = |visibility: &radicle::identity::Visibility| match visibility { + radicle::identity::Visibility::Private { allow } => allow.clone(), + radicle::identity::Visibility::Public => Default::default(), + }; + let (allow_before, allow_after) = (allow(from.visibility()), allow(to.visibility())); + for did in allow_after.difference(&allow_before) { + changes.push(Change::AllowAdded { + peer: Author::new(did, aliases), + }); + } + for did in allow_before.difference(&allow_after) { + changes.push(Change::AllowRemoved { + peer: Author::new(did, aliases), + }); + } + + if let (Ok(before), Ok(after)) = (from.project(), to.project()) { + if before.name() != after.name() { + changes.push(Change::NameChanged { + from: before.name().to_string(), + to: after.name().to_string(), + }); + } + if before.description() != after.description() { + changes.push(Change::DescriptionChanged); + } + if before.default_branch() != after.default_branch() { + changes.push(Change::DefaultBranchChanged { + from: before.default_branch().to_string(), + to: after.default_branch().to_string(), + }); + } + } + + changes.extend(payload_changes(from, to)); + + changes +} + +/// Payloads `changes` does not compare field by field. The project payload is +/// left out only when both documents have one. +fn payload_changes(from: &doc::Doc, to: &doc::Doc) -> Vec { + let project = doc::PayloadId::project(); + let compared = from.project().is_ok() && to.project().is_ok(); + let before: BTreeMap<_, _> = from + .payload() + .iter() + .filter(|(id, _)| !compared || **id != project) + .collect(); + let after: BTreeMap<_, _> = to + .payload() + .iter() + .filter(|(id, _)| !compared || **id != project) + .collect(); + + let mut changes = Vec::new(); + for (id, payload) in &after { + let operation = match before.get(id) { + None => PayloadOperation::Added, + Some(previous) if previous != payload => PayloadOperation::Updated, + Some(_) => continue, + }; + changes.push(Change::PayloadChanged { + payload: id.to_string(), + operation, + }); + } + for id in before.keys() { + if !after.contains_key(id) { + changes.push(Change::PayloadChanged { + payload: id.to_string(), + operation: PayloadOperation::Removed, + }); + } + } + changes +} diff --git a/crates/radicle-types/src/lib.rs b/crates/radicle-types/src/lib.rs index 7c65d16f7..21801a7be 100644 --- a/crates/radicle-types/src/lib.rs +++ b/crates/radicle-types/src/lib.rs @@ -1,5 +1,6 @@ use traits::Profile; use traits::cobs::Cobs; +use traits::identity::Identity; use traits::issue::{Issues, IssuesMut}; use traits::job::Jobs; use traits::patch::{Patches, PatchesMut}; @@ -12,6 +13,7 @@ pub mod config; pub mod diff; pub mod domain; pub mod error; +pub mod identity; pub mod outbound; pub mod repo; pub mod source; @@ -26,6 +28,7 @@ pub struct AppState { impl Repo for AppState {} impl Thread for AppState {} impl Cobs for AppState {} +impl Identity for AppState {} impl Issues for AppState {} impl IssuesMut for AppState {} impl Jobs for AppState {} diff --git a/crates/radicle-types/src/repo.rs b/crates/radicle-types/src/repo.rs index 2426ee6a7..37040a586 100644 --- a/crates/radicle-types/src/repo.rs +++ b/crates/radicle-types/src/repo.rs @@ -86,6 +86,17 @@ pub enum Visibility { }, } +impl Visibility { + pub fn new(visibility: &identity::Visibility, aliases: &impl node::AliasStore) -> Self { + match visibility { + identity::Visibility::Public => Self::Public, + identity::Visibility::Private { allow } => Self::Private { + allow: allow.iter().map(|did| Author::new(did, aliases)).collect(), + }, + } + } +} + impl From for identity::Visibility { fn from(value: Visibility) -> Self { match value { diff --git a/crates/radicle-types/src/traits.rs b/crates/radicle-types/src/traits.rs index a2d04aa2b..d74ce5a63 100644 --- a/crates/radicle-types/src/traits.rs +++ b/crates/radicle-types/src/traits.rs @@ -3,6 +3,7 @@ use radicle::node::{AliasStore, NodeId}; use crate::config::Config; pub mod cobs; +pub mod identity; pub mod issue; pub mod job; pub mod patch; diff --git a/crates/radicle-types/src/traits/identity.rs b/crates/radicle-types/src/traits/identity.rs new file mode 100644 index 000000000..12790a2dd --- /dev/null +++ b/crates/radicle-types/src/traits/identity.rs @@ -0,0 +1,106 @@ +use radicle::cob::identity; +use radicle::identity::DocAt; +use radicle::storage::{ReadRepository, ReadStorage}; + +use crate::cobs::Author; +use crate::error::Error; +use crate::identity as types; +use crate::traits::Profile; + +pub trait Identity: Profile { + /// The identity document and its revision history, returned whole since + /// identity histories are short. + fn identity_by_repo(&self, rid: radicle::identity::RepoId) -> Result { + let profile = self.profile(); + let aliases = profile.aliases(); + let repo = profile.storage.repository(rid)?; + let identity = identity::Identity::load(&repo)?; + + // `refs/rad/id` points at the current revision's commit, as + // `rad inspect --history` relies on. + let DocAt { + doc: current_doc, + commit: current_commit, + .. + } = repo.identity_doc()?; + + let raws = identity + .revisions() + .map(|revision| (revision.id, types::raw_doc(&revision.doc))) + .collect::>(); + + // Newest first. + let revisions = identity + .revisions() + .rev() + .filter_map(|revision| { + let parent = revision + .parent + .and_then(|id| identity.revision(&id)) + .map(|parent| &parent.doc); + let state = types::State::new(revision.state)?; + let raw = raws.get(&revision.id)?; + + // Verdicts are cast by the delegates of the parent document and + // listed sorted, as `rad id show` does. The root revision has + // no parent: it is accepted on creation and never voted on. + let mut accepted = revision.accepted().collect::>(); + accepted.sort(); + let mut rejected = revision.rejected().collect::>(); + rejected.sort(); + let mut pending = parent + .map(|parent| { + parent + .delegates() + .iter() + .filter(|did| !accepted.contains(did) && !rejected.contains(did)) + .cloned() + .collect::>() + }) + .unwrap_or_default(); + pending.sort(); + let authors = |dids: Vec| { + dids.iter() + .map(|did| Author::new(did, &aliases)) + .collect::>() + }; + + Some(types::Revision { + id: revision.id, + blob: revision.blob, + title: revision.title.to_string(), + description: revision.description.clone(), + state, + author: Author::new(&revision.author.id, &aliases), + timestamp: revision.timestamp.as_millis(), + parent: revision.parent, + accepted: authors(accepted), + rejected: authors(rejected), + pending: authors(pending), + majority: parent.map(|parent| parent.majority()), + changes: types::changes(parent, &revision.doc, &aliases), + diff: types::doc_diff( + revision + .parent + .and_then(|id| raws.get(&id)) + .map(String::as_str), + raw, + ), + raw: raw.clone(), + }) + }) + .collect::>(); + + let current = identity + .revision(¤t_commit) + .filter(|revision| revision.is_accepted()) + .map(|revision| revision.id); + + Ok(types::Identity { + rid, + current, + doc: types::Doc::new(¤t_doc, &aliases), + revisions, + }) + } +} diff --git a/crates/radicle-types/src/traits/repo.rs b/crates/radicle-types/src/traits/repo.rs index 9067934fb..7d36ba918 100644 --- a/crates/radicle-types/src/traits/repo.rs +++ b/crates/radicle-types/src/traits/repo.rs @@ -691,15 +691,7 @@ pub trait Repo: Profile { payloads: repo::SupportedPayloads { project }, delegates, threshold: doc.threshold(), - visibility: match doc.visibility().clone() { - identity::Visibility::Public => repo::Visibility::Public, - identity::Visibility::Private { allow } => repo::Visibility::Private { - allow: allow - .iter() - .map(|did| cobs::Author::new(did, &aliases)) - .collect(), - }, - }, + visibility: repo::Visibility::new(doc.visibility(), &aliases), rid: repo.id, seeding, seeded, diff --git a/crates/test-http-api/src/api.rs b/crates/test-http-api/src/api.rs index 91bdbc673..cf0cf43ad 100644 --- a/crates/test-http-api/src/api.rs +++ b/crates/test-http-api/src/api.rs @@ -29,6 +29,7 @@ use radicle_types::error::Error; use radicle_types::outbound::sqlite::Sqlite; use radicle_types::traits::Profile; use radicle_types::traits::cobs::Cobs; +use radicle_types::traits::identity::Identity; use radicle_types::traits::issue::{Issues, IssuesMut}; use radicle_types::traits::job::Jobs; use radicle_types::traits::patch::{Patches, PatchesMut}; @@ -44,6 +45,7 @@ pub struct Context { impl Repo for Context {} impl Cobs for Context {} +impl Identity for Context {} impl Thread for Context {} impl Issues for Context {} impl IssuesMut for Context {} @@ -86,6 +88,7 @@ pub fn router(ctx: Context) -> Router { .route("/clean", post(clean_handler)) .route("/repo_by_id", post(repo_handler)) .route("/list_repo_refs", post(list_repo_refs_handler)) + .route("/identity_by_repo", post(identity_handler)) .route("/version", post(version_handler)) .route("/git_info", post(git_info_handler)) .route("/diff_stats", post(diff_stats_handler)) @@ -234,6 +237,15 @@ async fn repo_handler( Ok::<_, Error>(Json(info)) } +async fn identity_handler( + State(ctx): State, + Json(RepoBody { rid }): Json, +) -> impl IntoResponse { + let identity = ctx.identity_by_repo(rid)?; + + Ok::<_, Error>(Json(identity)) +} + async fn list_repo_refs_handler( State(ctx): State, Json(RepoBody { rid }): Json, diff --git a/src/App.svelte b/src/App.svelte index 5a9118fce..657430ae8 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -40,6 +40,7 @@ import Auth from "@app/views/auth/Auth.svelte"; import CreateIdentity from "@app/views/auth/CreateIdentity.svelte"; import InboxView from "@app/views/Inbox.svelte"; + import Identity from "@app/views/repo/Identity.svelte"; import Issue from "@app/views/repo/Issue.svelte"; import Issues from "@app/views/repo/Issues.svelte"; import Patch from "@app/views/repo/Patch.svelte"; @@ -207,6 +208,8 @@ {:else if $activeRouteStore.resource === "repo.commit"} + {:else if $activeRouteStore.resource === "repo.identity"} + {:else if $activeRouteStore.resource === "repo.issue"} {:else if $activeRouteStore.resource === "repo.issues"} diff --git a/src/components/Id.svelte b/src/components/Id.svelte index abf98aa18..cc27d67aa 100644 --- a/src/components/Id.svelte +++ b/src/components/Id.svelte @@ -89,7 +89,8 @@ flex-direction: row; gap: 0.5rem; justify-content: center; - z-index: 20; + /* Portaled to the body, so it has to clear the modal layer as well. */ + z-index: 400; background: var(--color-surface-subtle); color: var(--color-text-primary); border: 1px solid var(--color-border-subtle); diff --git a/src/components/IdentityChanges.svelte b/src/components/IdentityChanges.svelte new file mode 100644 index 000000000..13a7034f5 --- /dev/null +++ b/src/components/IdentityChanges.svelte @@ -0,0 +1,137 @@ + + + + +
+ {#each changes as change} +
+ {#if change.type === "delegateAdded"} + + Added delegate + + {:else if change.type === "delegateRemoved"} + + Removed delegate + + {:else if change.type === "thresholdChanged"} + + Threshold + {change.from} + + {change.to} + {:else if change.type === "allowAdded"} + + Allowed to view + + {:else if change.type === "allowRemoved"} + + No longer allowed to view + + {:else if change.type === "visibilityChanged"} + + Visibility + {change.from.type} + + {change.to.type} + {:else if change.type === "nameChanged"} + + Name + {change.from} + + {change.to} + {:else if change.type === "descriptionChanged"} + + Description changed + {:else if change.type === "defaultBranchChanged"} + + Default branch + {change.from} + + {change.to} + {:else if change.type === "payloadChanged"} + + + + + Payload {change.operation} + + {change.payload} + {:else} + {unreachable(change)} + {/if} +
+ {:else} +
+ {#if root} + Establishes the repository's first identity document. + {:else} + No changes to the fields shown here. See the raw document for detail. + {/if} +
+ {/each} +
diff --git a/src/components/IdentityDocument.svelte b/src/components/IdentityDocument.svelte new file mode 100644 index 000000000..7b1a25b97 --- /dev/null +++ b/src/components/IdentityDocument.svelte @@ -0,0 +1,248 @@ + + + + +
+
+
+

Delegates

+ + {doc.majority} of {doc.delegates.length} must sign to change this document + +
+
+ {#each doc.delegates as delegate (delegate.did)} +
+ +
+ {/each} +
+
+ +
+

Document

+
+ {#if doc.project} + Default branch + {doc.project.defaultBranch} + {/if} + Visibility + + + + {#if doc.visibility.type === "private" && doc.visibility.allow} + Also visible to + + {#each doc.visibility.allow as peer (peer.did)} + + {/each} + + {/if} + Default branch threshold + + {doc.threshold} of {doc.delegates.length} + + — delegates whose {doc.project?.defaultBranch ?? "default"} branch must + contain the same commit for it to become canonical + + + Repository ID + + + + Document version + {doc.version} +
+
+ +
+
+ +
+

Canonical refs

+ {#if doc.canonicalRefsError} +
+ + + Heartwood rejects these rules: {doc.canonicalRefsError} + +
+ {/if} + {#if doc.canonicalRefs.length > 0} + {#each doc.canonicalRefs as rule (rule.pattern)} +
+
{rule.pattern}
+
+ {#if rule.allow.type === "delegates"} + + Canonical once {rule.threshold} of {doc.delegates.length} + {pluralize("delegate", doc.delegates.length)} + {rule.threshold === 1 ? "agrees" : "agree"} + + {:else if rule.allow.dids.length === 1} + Canonical as published by + + {:else} + + Canonical once {rule.threshold} of these + {rule.allow.dids.length} peers + {rule.threshold === 1 ? "agrees" : "agree"}: + + {#each rule.allow.dids as peer (peer.did)} + + {/each} + {/if} +
+
+ {/each} + {:else} +
No canonical-refs rules.
+ {/if} +
+ + {#if otherPayloads.length > 0} +
+

Other payloads

+ {#each otherPayloads as payload (payload)} +
+
{payload}
+
+ Not rendered by this app. See the raw document. +
+
+ {/each} +
+ {/if} +
diff --git a/src/components/IdentityJsonButton.svelte b/src/components/IdentityJsonButton.svelte new file mode 100644 index 000000000..426ab59b4 --- /dev/null +++ b/src/components/IdentityJsonButton.svelte @@ -0,0 +1,26 @@ + + + diff --git a/src/components/IdentityStateBadge.svelte b/src/components/IdentityStateBadge.svelte new file mode 100644 index 000000000..93e5f34f6 --- /dev/null +++ b/src/components/IdentityStateBadge.svelte @@ -0,0 +1,72 @@ + + + + + + + + + {labels[state.status]} + diff --git a/src/components/NodeId.svelte b/src/components/NodeId.svelte index 86b671254..07ccf5de6 100644 --- a/src/components/NodeId.svelte +++ b/src/components/NodeId.svelte @@ -22,6 +22,10 @@ alias?: string; inline?: boolean; styleFont?: string; + // Hides the alias; the card still shows it. + avatarOnly?: boolean; + avatarSize?: string; + oncardtoggle?: (expanded: boolean) => void; } const { @@ -29,9 +33,17 @@ alias, inline = false, styleFont = undefined, + avatarOnly = false, + avatarSize = "1rem", + oncardtoggle, }: Props = $props(); let cardExpanded = $state(false); + + $effect(() => { + oncardtoggle?.(cardExpanded); + }); + let openTimer: ReturnType | undefined; let closeTimer: ReturnType | undefined; let copyIcon: "copy" | "checkmark" = $state("copy"); @@ -118,8 +130,6 @@ border-radius: var(--border-radius-sm); } .avatar-container { - width: 1rem; - height: 1rem; overflow: hidden; flex-shrink: 0; } @@ -294,17 +304,22 @@ onmouseleave={scheduleClose} onfocusin={openCard} onfocusout={scheduleClose}> -
- +
+
- {#if effectiveAlias} - - {effectiveAlias} - - {:else} - - {truncateId(publicKey)} - + {#if !avatarOnly} + {#if effectiveAlias} + + {effectiveAlias} + + {:else} + + {truncateId(publicKey)} + + {/if} {/if}
{/snippet} diff --git a/src/components/PierreFile.svelte b/src/components/PierreFile.svelte new file mode 100644 index 000000000..dce41d951 --- /dev/null +++ b/src/components/PierreFile.svelte @@ -0,0 +1,81 @@ + + + + +
+
diff --git a/src/components/RepoHeader.svelte b/src/components/RepoHeader.svelte index 300f4ba7c..9784d2a69 100644 --- a/src/components/RepoHeader.svelte +++ b/src/components/RepoHeader.svelte @@ -2,13 +2,12 @@ import type { Config } from "@bindings/config/Config"; import type { RepoInfo } from "@bindings/repo/RepoInfo"; - import { explorerUrl, truncateDid } from "@app/lib/utils"; + import * as router from "@app/lib/router"; + import { authorForNodeId } from "@app/lib/utils"; import CheckoutRepoButton from "@app/components/CheckoutRepoButton.svelte"; - import HoverPopover from "@app/components/HoverPopover.svelte"; - import Icon from "@app/components/Icon.svelte"; + import NodeId from "@app/components/NodeId.svelte"; import ShareButton from "@app/components/ShareButton.svelte"; - import UserAvatar from "@app/components/UserAvatar.svelte"; import VisibilityBadge from "@app/components/VisibilityBadge.svelte"; interface Props { @@ -19,6 +18,12 @@ const { repo, config }: Props = $props(); const project = $derived(repo.payloads["xyz.radicle.project"]!); + + // The cards are portaled out of the stack, so moving onto one ends the + // stack's hover. Keep it fanned out while any card is still open. + let hovered = $state(false); + const cardsOpen: Record = $state({}); + const fanned = $derived(hovered || Object.values(cardsOpen).some(Boolean));
@@ -106,39 +124,29 @@ seeds={repo.seeding} />
- Delegates - {repo.threshold}/{repo.delegates.length} -
- {#each repo.delegates as delegate} - - {#snippet toggle()} -
- -
- {/snippet} - {#snippet popover()} - - {#if delegate.alias} - - {delegate.alias} - - {:else} - - {truncateDid(delegate.did)} - - {/if} - - - - - {/snippet} -
+ + +
(hovered = true)} + onmouseleave={() => (hovered = false)}> + {#each repo.delegates as delegate, index (delegate.did)} +
+ (cardsOpen[delegate.did] = expanded)} /> +
{/each}
diff --git a/src/components/SidebarRepoList.svelte b/src/components/SidebarRepoList.svelte index 3dea8f655..b42e9f0bd 100644 --- a/src/components/SidebarRepoList.svelte +++ b/src/components/SidebarRepoList.svelte @@ -1200,6 +1200,14 @@ y={contextMenu.y} target={contextMenu.target} onclose={closeContextMenu}> + + + +
+
+ +
+ {#if revision && diff !== undefined} + + {:else} + + {/if} +
+
+ diff --git a/src/views/repo/Identity.svelte b/src/views/repo/Identity.svelte new file mode 100644 index 000000000..9fc684ad8 --- /dev/null +++ b/src/views/repo/Identity.svelte @@ -0,0 +1,465 @@ + + + + + +
+
+ +
+ + +
+

Identity document

+
+ {#if current} + Current revision + + + created {formatTimestamp(current.timestamp)} + + {:else} + No accepted revision matches refs/rad/id + {/if} +
+ + + +
+

History

+ + {identity.revisions.length} + {pluralize("revision", identity.revisions.length)} + +
+ +
1}> + {#each identity.revisions as rev (rev.id)} + {@const open = expandedId === rev.id} +
+ + {#if open} + {@const voters = + rev.accepted.length + + rev.rejected.length + + rev.pending.length} + {@const caption = stateCaption(rev.state)} +
+ {#if caption} +
{caption}
+ {/if} + + {#if rev.description} +
+
Description
+
+ +
+
+ {/if} + +
+
Changes
+ +
+ +
+
+
Signatures
+ {#if rev.majority !== undefined} + + {rev.accepted.length} of {voters} + {pluralize("delegate", voters)} signed, {rev.majority} + needed + + {/if} +
+
+ {#if rev.parent} + The delegates of the parent revision, whose votes decide + this one. + {:else} + The first revision is accepted when it is created, with + its author's signature. + {/if} +
+
+ {#each rev.accepted as delegate (delegate.did)} +
+ + + + + signed +
+ {/each} + {#each rev.rejected as delegate (delegate.did)} +
+ + + + + rejected +
+ {/each} + {#each rev.pending as delegate (delegate.did)} +
+ + + + + no verdict +
+ {/each} +
+
+ +
+ + + + {formatTimestamp(rev.timestamp)} + + +
+
+ {/if} +
+ {/each} +
+
+
+
+
diff --git a/src/views/repo/router.ts b/src/views/repo/router.ts index ea42a031a..8e2a09d66 100644 --- a/src/views/repo/router.ts +++ b/src/views/repo/router.ts @@ -9,6 +9,7 @@ import type { Revision } from "@bindings/cob/patch/Revision"; import type { Thread } from "@bindings/cob/thread/Thread"; import type { Config } from "@bindings/config/Config"; import type { Diff } from "@bindings/diff/Diff"; +import type { Identity } from "@bindings/identity/Identity"; import type { Commit } from "@bindings/repo/Commit"; import type { Readme } from "@bindings/repo/Readme"; import type { RepoInfo } from "@bindings/repo/RepoInfo"; @@ -50,6 +51,20 @@ export interface RepoCommitRoute { commit: string; } +export interface RepoIdentityRoute { + resource: "repo.identity"; + rid: string; +} + +export interface LoadedRepoIdentityRoute { + resource: "repo.identity"; + params: { + repo: RepoInfo; + identity: Identity; + sidebarData: SidebarData; + }; +} + export interface RepoIssueRoute { resource: "repo.issue"; rid: string; @@ -172,6 +187,7 @@ export type RepoRoute = | RepoHomeRoute | RepoCommitsRoute | RepoCommitRoute + | RepoIdentityRoute | RepoIssueRoute | RepoIssuesRoute | RepoPatchRoute @@ -180,6 +196,7 @@ export type LoadedRepoRoute = | LoadedRepoHomeRoute | LoadedRepoCommitsRoute | LoadedRepoCommitRoute + | LoadedRepoIdentityRoute | LoadedRepoIssueRoute | LoadedRepoIssuesRoute | LoadedRepoPatchRoute @@ -406,6 +423,25 @@ export async function loadRepoCommit( }; } +export async function loadIdentity( + route: RepoIdentityRoute, +): Promise { + const [sidebarData, repo, identity] = await Promise.all([ + loadSidebarData(), + invoke("repo_by_id", { + rid: route.rid, + }), + invoke("identity_by_repo", { + rid: route.rid, + }), + ]); + + return { + resource: "repo.identity", + params: { sidebarData, repo, identity }, + }; +} + export async function loadIssue( route: RepoIssueRoute, ): Promise { @@ -486,6 +522,8 @@ export function repoRouteToPath(route: RepoRoute): string { return segments.join("/"); } else if (route.resource === "repo.commit") { return [...pathSegments, "commits", route.commit].join("/"); + } else if (route.resource === "repo.identity") { + return [...pathSegments, "identity"].join("/"); } else if (route.resource === "repo.issue") { let url = [...pathSegments, "issues", route.issue].join("/"); searchParams.set("status", route.status); @@ -561,6 +599,8 @@ export function repoUrlToRoute( peer, revision: segments.join("/"), }; + } else if (resource === "identity") { + return { resource: "repo.identity", rid }; } else if (resource === "issues") { const idOrAction = segments.shift(); if (idOrAction) {