Add a repository identity document view

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ņš <rudolfs@osins.org>
This commit is contained in:
Brandon Oxendine
2026-09-24 17:04:54 +02:00
committed by Rūdolfs Ošiņš
co-authored by Rūdolfs Ošiņš
parent 7c29fae1a7
commit 54835363a9
35 changed files with 2069 additions and 78 deletions
+1
View File
@@ -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;
@@ -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<types::identity::Identity, Error> {
blocking(ctx, move |ctx| ctx.identity_by_repo(rid)).await
}
+2 -1
View File
@@ -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,
@@ -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<Author>;
};
@@ -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;
};
@@ -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 };
@@ -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<Author>;
/**
* 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<CanonicalRefRule>;
/**
* 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<string>;
/**
* The document pretty-printed as `rad inspect --identity` prints it.
*/
raw: string;
};
@@ -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<Revision>;
};
@@ -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";
@@ -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;
};
@@ -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;
};
@@ -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<Author>;
rejected: Array<Author>;
/**
* 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<Author>;
/**
* 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<Change>;
/**
* The same change as a unified diff of the pretty-printed documents.
*/
diff: string;
};
@@ -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;
};
+446
View File
@@ -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<String>", optional)]
pub current: Option<radicle::git::Oid>,
/// The current document, read from `refs/rad/id`.
pub doc: Doc,
/// Every revision, newest first.
pub revisions: Vec<Revision>,
}
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[ts(export_to = "identity/")]
pub struct Doc {
pub version: u32,
pub delegates: Vec<Author>,
/// 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<Project>,
/// 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<CanonicalRefRule>,
/// 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<String>,
/// All payload ids, including ones the UI doesn't render.
pub payload_ids: Vec<String>,
/// 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<Author> },
}
#[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<Self> {
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<String>", optional)]
pub parent: Option<radicle::git::Oid>,
pub accepted: Vec<Author>,
pub rejected: Vec<Author>,
/// 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<Author>,
/// 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<usize>,
/// 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<Change>,
/// 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::<Vec<_>>();
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<CanonicalRefRule>, Option<String>) {
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<Change> {
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<Change> {
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
}
+3
View File
@@ -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 {}
+11
View File
@@ -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<Visibility> for identity::Visibility {
fn from(value: Visibility) -> Self {
match value {
+1
View File
@@ -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;
+106
View File
@@ -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<types::Identity, Error> {
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::<std::collections::HashMap<_, _>>();
// 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::<Vec<_>>();
accepted.sort();
let mut rejected = revision.rejected().collect::<Vec<_>>();
rejected.sort();
let mut pending = parent
.map(|parent| {
parent
.delegates()
.iter()
.filter(|did| !accepted.contains(did) && !rejected.contains(did))
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
pending.sort();
let authors = |dids: Vec<radicle::identity::Did>| {
dids.iter()
.map(|did| Author::new(did, &aliases))
.collect::<Vec<_>>()
};
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::<Vec<_>>();
let current = identity
.revision(&current_commit)
.filter(|revision| revision.is_accepted())
.map(|revision| revision.id);
Ok(types::Identity {
rid,
current,
doc: types::Doc::new(&current_doc, &aliases),
revisions,
})
}
}
+1 -9
View File
@@ -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,
+12
View File
@@ -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<Context>,
Json(RepoBody { rid }): Json<RepoBody>,
) -> impl IntoResponse {
let identity = ctx.identity_by_repo(rid)?;
Ok::<_, Error>(Json(identity))
}
async fn list_repo_refs_handler(
State(ctx): State<Context>,
Json(RepoBody { rid }): Json<RepoBody>,
+3
View File
@@ -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 @@
<RepoCommits {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.commit"}
<RepoCommit {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.identity"}
<Identity {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.issue"}
<Issue {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.issues"}
+2 -1
View File
@@ -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);
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
import type { Change } from "@bindings/identity/Change";
import { authorForNodeId, unreachable } from "@app/lib/utils";
import Icon from "@app/components/Icon.svelte";
import NodeId from "@app/components/NodeId.svelte";
interface Props {
changes: Change[];
// An empty change list means something different for the root.
root?: boolean;
}
const { changes, root = false }: Props = $props();
</script>
<style>
.changes {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.change {
display: flex;
align-items: center;
gap: 0.5rem;
font: var(--txt-body-m-regular);
color: var(--color-text-primary);
}
.icon {
display: inline-flex;
flex-shrink: 0;
}
.added {
color: var(--color-feedback-success-text);
}
.removed {
color: var(--color-feedback-error-text);
}
.neutral {
color: var(--color-text-secondary);
}
.label {
color: var(--color-text-secondary);
}
.from {
color: var(--color-text-secondary);
text-decoration: line-through;
}
.value {
font: var(--txt-code-regular);
color: var(--color-text-primary);
}
.empty {
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
}
</style>
<div class="changes">
{#each changes as change}
<div class="change">
{#if change.type === "delegateAdded"}
<span class="icon added"><Icon name="plus" /></span>
<span class="label">Added delegate</span>
<NodeId {...authorForNodeId(change.delegate)} />
{:else if change.type === "delegateRemoved"}
<span class="icon removed"><Icon name="minus" /></span>
<span class="label">Removed delegate</span>
<NodeId {...authorForNodeId(change.delegate)} />
{:else if change.type === "thresholdChanged"}
<span class="icon neutral"><Icon name="badge" /></span>
<span class="label">Threshold</span>
<span class="from value">{change.from}</span>
<Icon name="arrow-right" />
<span class="value">{change.to}</span>
{:else if change.type === "allowAdded"}
<span class="icon added"><Icon name="plus" /></span>
<span class="label">Allowed to view</span>
<NodeId {...authorForNodeId(change.peer)} />
{:else if change.type === "allowRemoved"}
<span class="icon removed"><Icon name="minus" /></span>
<span class="label">No longer allowed to view</span>
<NodeId {...authorForNodeId(change.peer)} />
{:else if change.type === "visibilityChanged"}
<span class="icon neutral"><Icon name="eye" /></span>
<span class="label">Visibility</span>
<span class="from value">{change.from.type}</span>
<Icon name="arrow-right" />
<span class="value">{change.to.type}</span>
{:else if change.type === "nameChanged"}
<span class="icon neutral"><Icon name="repository" /></span>
<span class="label">Name</span>
<span class="from value">{change.from}</span>
<Icon name="arrow-right" />
<span class="value">{change.to}</span>
{:else if change.type === "descriptionChanged"}
<span class="icon neutral"><Icon name="document" /></span>
<span class="label">Description changed</span>
{:else if change.type === "defaultBranchChanged"}
<span class="icon neutral"><Icon name="branch" /></span>
<span class="label">Default branch</span>
<span class="from value">{change.from}</span>
<Icon name="arrow-right" />
<span class="value">{change.to}</span>
{:else if change.type === "payloadChanged"}
<span
class="icon"
class:added={change.operation === "added"}
class:removed={change.operation === "removed"}
class:neutral={change.operation === "updated"}>
<Icon
name={change.operation === "added"
? "plus"
: change.operation === "removed"
? "minus"
: "edit"} />
</span>
<span class="label">
Payload {change.operation}
</span>
<span class="value">{change.payload}</span>
{:else}
{unreachable(change)}
{/if}
</div>
{:else}
<div class="empty">
{#if root}
Establishes the repository's first identity document.
{:else}
No changes to the fields shown here. See the raw document for detail.
{/if}
</div>
{/each}
</div>
+248
View File
@@ -0,0 +1,248 @@
<script lang="ts">
import type { Doc } from "@bindings/identity/Doc";
import type { Revision } from "@bindings/identity/Revision";
import { authorForNodeId, pluralize } from "@app/lib/utils";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import IdentityJsonButton from "@app/components/IdentityJsonButton.svelte";
import NodeId from "@app/components/NodeId.svelte";
import VisibilityBadge from "@app/components/VisibilityBadge.svelte";
interface Props {
doc: Doc;
rid: string;
revision?: Revision;
}
const { doc, rid, revision }: Props = $props();
// Payloads rendered below; the rest are listed by name.
const knownPayloads = ["xyz.radicle.project", "xyz.radicle.crefs"];
const otherPayloads = $derived(
doc.payloadIds.filter(id => !knownPayloads.includes(id)),
);
</script>
<style>
.details {
min-width: 0;
}
.section {
padding: 0.875rem 0;
}
.section + .section {
border-top: 1px solid var(--color-border-subtle);
}
.section-title {
font: var(--txt-body-m-medium);
color: var(--color-text-primary);
margin: 0 0 0.625rem;
}
.delegates {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
min-width: 0;
}
.delegate-chip {
display: inline-flex;
align-items: center;
height: 2rem;
padding: 0 0.5rem;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-sm);
background-color: var(--color-surface-canvas);
font: var(--txt-body-m-regular);
}
.delegates-head {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.625rem;
}
.delegates-head .section-title {
margin: 0;
}
.quorum {
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
white-space: nowrap;
}
.fields {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
column-gap: 2rem;
row-gap: 0.5rem;
align-items: baseline;
font: var(--txt-body-m-regular);
}
.field-label {
color: var(--color-text-secondary);
}
.field-value {
min-width: 0;
color: var(--color-text-primary);
overflow-wrap: anywhere;
justify-self: start;
}
.section-action {
margin-top: 0.875rem;
}
.mono {
font: var(--txt-code-regular);
}
.hint {
color: var(--color-text-secondary);
}
.empty {
color: var(--color-text-secondary);
}
.rules-error {
display: flex;
align-items: flex-start;
gap: 0.375rem;
margin-bottom: 0.625rem;
font: var(--txt-body-m-regular);
color: var(--color-feedback-warning-text);
}
.inline-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.rule + .rule {
margin-top: 0.625rem;
}
.rule-pattern {
font: var(--txt-code-regular);
color: var(--color-text-primary);
overflow-wrap: anywhere;
}
.rule-detail {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
margin-top: 0.125rem;
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
}
</style>
<div class="details">
<div class="section">
<div class="delegates-head">
<h2 class="section-title">Delegates</h2>
<span class="quorum">
{doc.majority} of {doc.delegates.length} must sign to change this document
</span>
</div>
<div class="delegates">
{#each doc.delegates as delegate (delegate.did)}
<div class="delegate-chip">
<NodeId {...authorForNodeId(delegate)} />
</div>
{/each}
</div>
</div>
<div class="section">
<h2 class="section-title">Document</h2>
<div class="fields">
{#if doc.project}
<span class="field-label">Default branch</span>
<span class="field-value mono">{doc.project.defaultBranch}</span>
{/if}
<span class="field-label">Visibility</span>
<span class="field-value">
<VisibilityBadge type={doc.visibility.type} />
</span>
{#if doc.visibility.type === "private" && doc.visibility.allow}
<span class="field-label">Also visible to</span>
<span class="field-value inline-list">
{#each doc.visibility.allow as peer (peer.did)}
<NodeId {...authorForNodeId(peer)} />
{/each}
</span>
{/if}
<span class="field-label">Default branch threshold</span>
<span class="field-value">
{doc.threshold} of {doc.delegates.length}
<span class="hint">
— delegates whose {doc.project?.defaultBranch ?? "default"} branch must
contain the same commit for it to become canonical
</span>
</span>
<span class="field-label">Repository ID</span>
<span class="field-value mono">
<Id id={rid} clipboard={rid} label="repository ID" shorten={false} />
</span>
<span class="field-label">Document version</span>
<span class="field-value">{doc.version}</span>
</div>
<div class="section-action">
<IdentityJsonButton raw={doc.raw} {revision} />
</div>
</div>
<div class="section">
<h2 class="section-title">Canonical refs</h2>
{#if doc.canonicalRefsError}
<div class="rules-error">
<Icon name="warning" />
<span>
Heartwood rejects these rules: {doc.canonicalRefsError}
</span>
</div>
{/if}
{#if doc.canonicalRefs.length > 0}
{#each doc.canonicalRefs as rule (rule.pattern)}
<div class="rule">
<div class="rule-pattern">{rule.pattern}</div>
<div class="rule-detail">
{#if rule.allow.type === "delegates"}
<span>
Canonical once {rule.threshold} of {doc.delegates.length}
{pluralize("delegate", doc.delegates.length)}
{rule.threshold === 1 ? "agrees" : "agree"}
</span>
{:else if rule.allow.dids.length === 1}
<span>Canonical as published by</span>
<NodeId {...authorForNodeId(rule.allow.dids[0])} />
{:else}
<span>
Canonical once {rule.threshold} of these
{rule.allow.dids.length} peers
{rule.threshold === 1 ? "agrees" : "agree"}:
</span>
{#each rule.allow.dids as peer (peer.did)}
<NodeId {...authorForNodeId(peer)} />
{/each}
{/if}
</div>
</div>
{/each}
{:else}
<div class="empty">No canonical-refs rules.</div>
{/if}
</div>
{#if otherPayloads.length > 0}
<div class="section">
<h2 class="section-title">Other payloads</h2>
{#each otherPayloads as payload (payload)}
<div class="rule">
<div class="rule-pattern">{payload}</div>
<div class="rule-detail">
Not rendered by this app. See the raw document.
</div>
</div>
{/each}
</div>
{/if}
</div>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import type { Revision } from "@bindings/identity/Revision";
import type { ComponentProps } from "svelte";
import { show } from "@app/lib/modal";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
import RawIdentityDocumentModal from "@app/modals/RawIdentityDocument.svelte";
interface Props {
raw: string;
revision?: Revision;
styleHeight?: ComponentProps<typeof Button>["styleHeight"];
}
const { raw, revision, styleHeight = "2rem" }: Props = $props();
</script>
<Button
{styleHeight}
variant="outline"
onclick={() =>
show({ component: RawIdentityDocumentModal, props: { raw, revision } })}>
<Icon name="code" />View JSON
</Button>
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts" module>
import type { State } from "@bindings/identity/State";
import { formatOid } from "@app/lib/utils";
const icons = {
active: "hourglass",
accepted: "checkmark",
rejected: "close",
} as const;
const labels = {
active: "Active",
accepted: "Accepted",
rejected: "Rejected",
};
export function stateIcon(state: State): (typeof icons)[State["status"]] {
return icons[state.status];
}
export function stateCaption(state: State): string | undefined {
if (state.status === "rejected") {
if (state.reason.type === "vote") return "Rejected by delegate votes";
if (state.reason.type === "parent")
return "Rejected because its parent revision was rejected";
return `Rejected because sibling ${formatOid(state.reason.revision)} was accepted`;
}
return undefined;
}
</script>
<script lang="ts">
import Icon from "@app/components/Icon.svelte";
interface Props {
state: State;
}
const { state }: Props = $props();
</script>
<style>
.badge {
gap: 0.375rem;
padding-right: 0.625rem;
font: var(--txt-body-s-regular);
white-space: nowrap;
}
.accepted {
background-color: var(--color-feedback-success-bg);
color: var(--color-feedback-success-text);
}
.active {
background-color: var(--color-feedback-warning-bg);
color: var(--color-feedback-warning-text);
}
.rejected {
background-color: var(--color-feedback-error-bg);
color: var(--color-feedback-error-text);
}
</style>
<span
class="global-chip badge"
class:accepted={state.status === "accepted"}
class:active={state.status === "active"}
class:rejected={state.status === "rejected"}
title={stateCaption(state)}>
<Icon name={stateIcon(state)} />
{labels[state.status]}
</span>
+27 -12
View File
@@ -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<typeof setTimeout> | undefined;
let closeTimer: ReturnType<typeof setTimeout> | 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}>
<div class="avatar-container">
<UserAvatar nodeId={publicKey} styleWidth="1rem" />
<div
class="avatar-container"
style:width={avatarSize}
style:height={avatarSize}>
<UserAvatar nodeId={publicKey} styleWidth={avatarSize} />
</div>
{#if effectiveAlias}
<span class="txt-overflow alias">
{effectiveAlias}
</span>
{:else}
<span class="no-alias">
{truncateId(publicKey)}
</span>
{#if !avatarOnly}
{#if effectiveAlias}
<span class="txt-overflow alias">
{effectiveAlias}
</span>
{:else}
<span class="no-alias">
{truncateId(publicKey)}
</span>
{/if}
{/if}
</div>
{/snippet}
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import { File } from "@pierre/diffs";
import { untrack } from "svelte";
import { fontSettings } from "@app/lib/appearance.svelte";
import {
codeLineHeight,
getWorkerPool,
gutterUnsafeCSS,
surfaceUnsafeCSS,
themes,
} from "@app/lib/pierreView";
import { theme } from "@app/components/ThemeSwitch.svelte";
interface Props {
contents: string;
path: string;
// Keys the shared worker pool's highlight cache, so it must be unique to
// this exact text.
cacheKey: string;
}
const { contents, path, cacheKey }: Props = $props();
let container = $state<HTMLElement>();
let view = $state.raw<File | undefined>(undefined);
const lineHeightPx = $derived(codeLineHeight(fontSettings.size));
function options() {
return {
theme: themes,
themeType: $theme,
disableFileHeader: true,
unsafeCSS: gutterUnsafeCSS + surfaceUnsafeCSS,
};
}
$effect(() => {
const el = container;
if (!el) {
return;
}
const file = { name: path, contents, cacheKey };
return untrack(() => {
const instance = new File(options(), getWorkerPool());
view = instance;
instance.render({ file, containerWrapper: el });
return () => {
view = undefined;
instance.cleanUp();
};
});
});
$effect(() => {
void $theme;
void lineHeightPx;
const instance = view;
if (!instance) {
return;
}
untrack(() => {
instance.setOptions(options());
instance.rerender();
});
});
</script>
<style>
.file {
min-width: 0;
}
</style>
<div
bind:this={container}
class="file global-pierre-surface"
style:--diffs-line-height="{lineHeightPx}px">
</div>
+63 -55
View File
@@ -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<string, boolean> = $state({});
const fanned = $derived(hovered || Object.values(cardsOpen).some(Boolean));
</script>
<style>
@@ -52,9 +57,25 @@
.meta-item {
display: flex;
align-items: center;
gap: 0.375rem;
gap: 0.25rem;
font: var(--txt-body-m-regular);
}
.delegates-button {
display: inline-flex;
align-items: center;
gap: 0.375rem;
height: 1.75rem;
padding: 0 0.5rem;
border: 0;
border-radius: var(--border-radius-sm);
background: none;
font: inherit;
cursor: pointer;
}
.delegates-button:hover,
.delegates-button:focus-visible {
background-color: var(--color-surface-subtle);
}
.meta-label {
color: var(--color-text-secondary);
}
@@ -64,13 +85,21 @@
.avatars {
display: flex;
align-items: center;
gap: 0.25rem;
}
.avatar-wrap {
width: 1.25rem;
height: 1.25rem;
overflow: hidden;
flex-shrink: 0;
/* Positioned so each avatar paints together with its ring; otherwise every
ring is painted before any image and the one below covers it. */
.avatar {
position: relative;
display: flex;
border-radius: 2px;
box-shadow: 0 0 0 2px var(--color-surface-canvas);
transition: margin-left 150ms ease;
}
.avatar + .avatar {
margin-left: -0.625rem;
}
.avatars.fanned .avatar + .avatar {
margin-left: 0.25rem;
}
.actions {
display: flex;
@@ -78,17 +107,6 @@
gap: 0.5rem;
flex-shrink: 0;
}
a {
color: inherit;
display: inline-flex;
align-items: center;
gap: 0.25rem;
text-decoration: none;
color: var(--color-text-secondary);
}
a:hover {
color: var(--color-text-primary);
}
</style>
<div class="header">
@@ -106,39 +124,29 @@
seeds={repo.seeding} />
<div class="meta-item">
<span class="meta-label">Delegates</span>
<span class="meta-value">{repo.threshold}/{repo.delegates.length}</span>
<div class="avatars">
{#each repo.delegates as delegate}
<HoverPopover placement="bottom-start" stylePadding="0.25rem 0.5rem">
{#snippet toggle()}
<div class="avatar-wrap">
<UserAvatar nodeId={delegate.did} styleWidth="1.25rem" />
</div>
{/snippet}
{#snippet popover()}
<a
class="global-flex txt-body-m-regular"
style:white-space="nowrap"
style:text-decoration="none"
style:width="100%"
href={explorerUrl(`users/${delegate.did}`, config)}
target="_blank">
{#if delegate.alias}
<span class="txt-overflow alias">
{delegate.alias}
</span>
{:else}
<span class="no-alias">
{truncateDid(delegate.did)}
</span>
{/if}
<span style:margin-left="auto">
<Icon name="open-external" />
</span>
</a>
{/snippet}
</HoverPopover>
<button
type="button"
class="delegates-button"
title={`A commit becomes canonical once ${repo.threshold} of ${repo.delegates.length} delegates have the same commit on their ${project.data.defaultBranch} branch`}
onclick={() =>
router.push({ resource: "repo.identity", rid: repo.rid })}>
<span class="meta-label">Delegates</span>
<span class="meta-value">{repo.threshold}/{repo.delegates.length}</span>
</button>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="avatars"
class:fanned
onmouseenter={() => (hovered = true)}
onmouseleave={() => (hovered = false)}>
{#each repo.delegates as delegate, index (delegate.did)}
<div class="avatar" style:z-index={repo.delegates.length - index}>
<NodeId
{...authorForNodeId(delegate)}
avatarOnly
avatarSize="1.25rem"
oncardtoggle={expanded => (cardsOpen[delegate.did] = expanded)} />
</div>
{/each}
</div>
</div>
+8
View File
@@ -1200,6 +1200,14 @@
y={contextMenu.y}
target={contextMenu.target}
onclose={closeContextMenu}>
<button
class="menu-item"
role="menuitem"
onclick={() => router.push({ resource: "repo.identity", rid: repo.rid })}>
<Icon name="document" />
View identity document
</button>
<div class="menu-separator"></div>
<button
class="menu-item"
role="menuitem"
+1
View File
@@ -191,6 +191,7 @@ export function routeToPath(route: Route): string {
route.resource === "repo.home" ||
route.resource === "repo.commits" ||
route.resource === "repo.commit" ||
route.resource === "repo.identity" ||
route.resource === "repo.issue" ||
route.resource === "repo.issues" ||
route.resource === "repo.patch" ||
+4
View File
@@ -4,6 +4,7 @@ import type { Config } from "@bindings/config/Config";
import type { RepoSummary } from "@bindings/repo/RepoSummary";
import {
loadIdentity,
loadIssue,
loadIssues,
loadPatch,
@@ -59,6 +60,7 @@ export function isLoadedRepoRoute(
route.resource === "repo.home" ||
route.resource === "repo.commits" ||
route.resource === "repo.commit" ||
route.resource === "repo.identity" ||
route.resource === "repo.issue" ||
route.resource === "repo.issues" ||
route.resource === "repo.patch" ||
@@ -109,6 +111,8 @@ export async function loadRoute(
return loadRepoCommits(route);
} else if (route.resource === "repo.commit") {
return loadRepoCommit(route);
} else if (route.resource === "repo.identity") {
return loadIdentity(route);
} else if (route.resource === "repo.issue") {
return loadIssue(route);
} else if (route.resource === "repo.issues") {
+115
View File
@@ -0,0 +1,115 @@
<script lang="ts">
import type { Revision } from "@bindings/identity/Revision";
import debounce from "lodash/debounce";
import { writeToClipboard } from "@app/lib/invoke";
import { hide } from "@app/lib/modal";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import PierreFile from "@app/components/PierreFile.svelte";
import PierreSnippet from "@app/components/PierreSnippet.svelte";
import ScrollArea from "@app/components/ScrollArea.svelte";
import SegmentedSwitch from "@app/components/SegmentedSwitch.svelte";
interface Props {
raw: string;
// When set, its ID is shown and its diff is offered.
revision?: Revision;
}
const { raw, revision }: Props = $props();
let view = $state<"document" | "changes">("document");
const diff = $derived(view === "changes" ? revision?.diff : undefined);
let copyIcon: "copy" | "checkmark" = $state("copy");
const restoreIcon = debounce(() => (copyIcon = "copy"), 1000);
async function copy() {
await writeToClipboard(diff ?? raw);
copyIcon = "checkmark";
restoreIcon();
}
</script>
<style>
.modal {
width: min(46rem, 92vw);
height: min(38rem, 85vh);
display: flex;
flex-direction: column;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-lg);
background-color: var(--color-surface-canvas);
overflow: hidden;
}
.header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 0.75rem 0 1.5rem;
height: 3.25rem;
flex-shrink: 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.title {
font: var(--txt-heading-s);
color: var(--color-text-primary);
}
.actions {
display: flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
}
.code {
padding: 0.75rem 0;
}
</style>
<div class="modal">
<div class="header">
<span class="title">Identity document</span>
{#if revision}
<Id id={revision.id} clipboard={revision.id} label="revision ID" />
{/if}
<div class="actions">
{#if revision}
<SegmentedSwitch
options={[
{ value: "document", label: "Document" },
{ value: "changes", label: "Changes" },
]}
value={view}
onchange={value => (view = value)} />
{/if}
<Button variant="outline" styleHeight="2rem" onclick={copy}>
<Icon name={copyIcon} />
{diff !== undefined ? "Copy diff" : "Copy JSON"}
</Button>
<Button variant="naked" onclick={hide}>
<span style:color="var(--color-text-tertiary)">
<Icon name="close" />
</span>
</Button>
</div>
</div>
<ScrollArea style="flex: 1; min-height: 0;">
<div class="code">
{#if revision && diff !== undefined}
<PierreSnippet
patch={diff}
path="radicle.json"
cacheKey={`identity-diff:${revision.id}`} />
{:else}
<PierreFile
contents={raw}
path="radicle.json"
cacheKey={`identity-doc:${revision?.blob ?? raw}`} />
{/if}
</div>
</ScrollArea>
</div>
+465
View File
@@ -0,0 +1,465 @@
<script lang="ts">
import type { Identity } from "@bindings/identity/Identity";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import { slide } from "svelte/transition";
import type { SidebarData } from "@app/lib/router/definitions";
import {
absoluteTimestamp,
authorForNodeId,
formatTimestamp,
pluralize,
} from "@app/lib/utils";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import IdentityChanges from "@app/components/IdentityChanges.svelte";
import IdentityDocument from "@app/components/IdentityDocument.svelte";
import IdentityJsonButton from "@app/components/IdentityJsonButton.svelte";
import IdentityStateBadge, {
stateCaption,
stateIcon,
} from "@app/components/IdentityStateBadge.svelte";
import Markdown from "@app/components/Markdown.svelte";
import NodeId from "@app/components/NodeId.svelte";
import RepoHeader from "@app/components/RepoHeader.svelte";
import ScrollArea from "@app/components/ScrollArea.svelte";
import Layout from "./Layout.svelte";
interface Props {
repo: RepoInfo;
identity: Identity;
sidebarData: SidebarData;
}
const { repo, identity, sidebarData }: Props = $props();
const current = $derived(
identity.revisions.find(r => r.id === identity.current),
);
let expandedId: string | undefined = $state();
function toggle(id: string) {
expandedId = expandedId === id ? undefined : id;
}
</script>
<style>
.page {
display: flex;
flex-direction: column;
height: 100%;
}
.content {
max-width: 80rem;
margin: 0 auto;
padding: 1.5rem 6rem;
min-width: 0;
}
.title {
font: var(--txt-heading-m);
color: var(--color-text-primary);
margin: 0 0 1rem;
min-width: 0;
}
.meta-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.5rem;
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
}
/* RepoHeader has no bottom border of its own. */
.repo-context {
border-bottom: 1px solid var(--color-border-subtle);
flex-shrink: 0;
}
.section-head {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 0 0.625rem;
border-top: 1px solid var(--color-border-subtle);
}
.section-title {
font: var(--txt-body-m-medium);
color: var(--color-text-primary);
margin: 0;
}
.section-note {
margin-left: auto;
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
}
.timeline {
position: relative;
padding-bottom: 1rem;
}
/* Markers paint the canvas over the rail, so it reads as connecting them. */
.timeline.has-runs::before {
content: "";
position: absolute;
top: 1.25rem;
bottom: 2rem;
left: 1rem;
width: 1px;
background-color: var(--color-border-subtle);
pointer-events: none;
z-index: -1;
}
.timeline-item {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
min-height: 2.25rem;
padding: 0.375rem 0.5rem;
border: 0;
background: none;
text-align: left;
color: inherit;
cursor: pointer;
font: var(--txt-body-m-regular);
}
.timeline-item .icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
flex-shrink: 0;
padding: 0.25rem 0;
background-color: var(--color-surface-canvas);
color: var(--color-text-tertiary);
}
.timeline-item .icon[data-state="accepted"] {
color: var(--color-feedback-success-text);
}
.timeline-item .icon[data-state="rejected"] {
color: var(--color-feedback-error-text);
}
.timeline-item:hover {
background-color: var(--color-surface-subtle);
border-radius: var(--border-radius-md);
}
.entry.open {
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-md);
background-color: var(--color-surface-canvas);
margin: 0.5rem 0;
overflow: hidden;
}
.entry.open .timeline-item {
border-radius: 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.timeline-item:hover .icon {
background-color: var(--color-surface-subtle);
}
.icon-stack {
display: grid;
width: 1rem;
place-items: center;
}
.icon-default,
.icon-hover {
grid-area: 1 / 1;
transition:
opacity 150ms ease,
transform 150ms ease;
}
.icon-hover {
opacity: 0;
transform: rotate(-90deg);
}
.timeline-item:hover .icon-default,
.timeline-item:focus-visible .icon-default {
opacity: 0;
transform: rotate(90deg);
}
.timeline-item:hover .icon-hover,
.timeline-item:focus-visible .icon-hover {
opacity: 1;
transform: none;
}
.item-body {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex: 1 1 0;
}
.item-title {
color: var(--color-text-primary);
min-width: 0;
}
.item-meta {
display: flex;
align-items: center;
gap: 0.625rem;
flex-shrink: 0;
}
.timestamp {
color: var(--color-text-quaternary);
}
.current-marker {
flex-shrink: 0;
white-space: nowrap;
padding: 0.125rem 0.375rem;
border-radius: var(--border-radius-sm);
background-color: var(--color-surface-brand-subtle);
color: var(--color-text-brand);
}
/* Indented to line up with the row's text, clear of the rail marker:
0.5rem row padding + 1rem marker + 0.625rem gap. */
.detail {
padding: 0.875rem 1rem 1rem 2.125rem;
}
.detail-section + .detail-section {
margin-top: 1rem;
}
.detail-title {
font: var(--txt-body-m-medium);
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
}
.state-caption {
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
margin-bottom: 0.75rem;
}
.signatures {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.signature {
display: flex;
align-items: center;
gap: 0.5rem;
font: var(--txt-body-m-regular);
}
.detail-head {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.detail-head .detail-title {
margin-bottom: 0;
}
.detail-note {
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
}
.voters-note {
font: var(--txt-body-m-regular);
color: var(--color-text-tertiary);
margin-bottom: 0.5rem;
}
/* Markdown sets no base font and neither does `body`, so prose would fall
back to the browser's default size instead of the app's. */
.description {
font: var(--txt-body-m-regular);
color: var(--color-text-primary);
}
.outcome {
color: var(--color-text-secondary);
}
.pending-icon {
color: var(--color-text-quaternary);
display: inline-flex;
}
.accepted-icon {
color: var(--color-feedback-success-text);
display: inline-flex;
}
.rejected-icon {
color: var(--color-feedback-error-text);
display: inline-flex;
}
.detail-foot {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1rem;
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
}
</style>
<Layout selfScroll>
<div class="page">
<div class="repo-context">
<RepoHeader {repo} config={sidebarData.config} />
</div>
<ScrollArea style="flex: 1; min-height: 0;">
<div class="content">
<h1 class="title">Identity document</h1>
<div class="meta-bar">
{#if current}
<span>Current revision</span>
<Id id={current.id} clipboard={current.id} label="revision ID" />
<span title={absoluteTimestamp(current.timestamp)}>
created {formatTimestamp(current.timestamp)}
</span>
{:else}
<span>No accepted revision matches refs/rad/id</span>
{/if}
</div>
<IdentityDocument
doc={identity.doc}
rid={identity.rid}
revision={current} />
<div class="section-head">
<h2 class="section-title">History</h2>
<span class="section-note">
{identity.revisions.length}
{pluralize("revision", identity.revisions.length)}
</span>
</div>
<div class="timeline" class:has-runs={identity.revisions.length > 1}>
{#each identity.revisions as rev (rev.id)}
{@const open = expandedId === rev.id}
<div class="entry" class:open>
<button
type="button"
class="timeline-item"
aria-expanded={open}
onclick={() => toggle(rev.id)}>
<span class="icon" data-state={rev.state.status}>
<span class="icon-stack">
<span class="icon-default">
<Icon name={stateIcon(rev.state)} />
</span>
<span class="icon-hover">
<Icon
name={open ? "collapse-vertical" : "expand-vertical"} />
</span>
</span>
</span>
<span class="item-body">
<NodeId {...authorForNodeId(rev.author)} />
<span class="item-title txt-overflow">{rev.title}</span>
</span>
<span class="item-meta">
{#if rev.id === identity.current}
<span class="current-marker txt-body-s-medium">
Current
</span>
{/if}
<span
class="timestamp"
title={absoluteTimestamp(rev.timestamp)}>
{formatTimestamp(rev.timestamp)}
</span>
</span>
</button>
{#if open}
{@const voters =
rev.accepted.length +
rev.rejected.length +
rev.pending.length}
{@const caption = stateCaption(rev.state)}
<div class="detail" transition:slide={{ duration: 180 }}>
{#if caption}
<div class="state-caption">{caption}</div>
{/if}
{#if rev.description}
<div class="detail-section">
<div class="detail-title">Description</div>
<div class="description">
<Markdown
breaks
content={rev.description}
rid={repo.rid} />
</div>
</div>
{/if}
<div class="detail-section">
<div class="detail-title">Changes</div>
<IdentityChanges changes={rev.changes} root={!rev.parent} />
</div>
<div class="detail-section">
<div class="detail-head">
<div class="detail-title">Signatures</div>
{#if rev.majority !== undefined}
<span class="detail-note">
{rev.accepted.length} of {voters}
{pluralize("delegate", voters)} signed, {rev.majority}
needed
</span>
{/if}
</div>
<div class="voters-note">
{#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}
</div>
<div class="signatures">
{#each rev.accepted as delegate (delegate.did)}
<div class="signature">
<span class="accepted-icon">
<Icon name="checkmark" />
</span>
<NodeId {...authorForNodeId(delegate)} />
<span class="outcome">signed</span>
</div>
{/each}
{#each rev.rejected as delegate (delegate.did)}
<div class="signature">
<span class="rejected-icon">
<Icon name="close" />
</span>
<NodeId {...authorForNodeId(delegate)} />
<span class="outcome">rejected</span>
</div>
{/each}
{#each rev.pending as delegate (delegate.did)}
<div class="signature">
<span class="pending-icon">
<Icon name="minus" />
</span>
<NodeId {...authorForNodeId(delegate)} />
<span class="outcome">no verdict</span>
</div>
{/each}
</div>
</div>
<div class="detail-foot">
<IdentityStateBadge state={rev.state} />
<Id id={rev.id} clipboard={rev.id} label="revision ID" />
<span title={absoluteTimestamp(rev.timestamp)}>
{formatTimestamp(rev.timestamp)}
</span>
<IdentityJsonButton
raw={rev.raw}
revision={rev}
styleHeight="1.75rem" />
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
</ScrollArea>
</div>
</Layout>
+40
View File
@@ -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<LoadedRepoIdentityRoute> {
const [sidebarData, repo, identity] = await Promise.all([
loadSidebarData(),
invoke<RepoInfo>("repo_by_id", {
rid: route.rid,
}),
invoke<Identity>("identity_by_repo", {
rid: route.rid,
}),
]);
return {
resource: "repo.identity",
params: { sidebarData, repo, identity },
};
}
export async function loadIssue(
route: RepoIssueRoute,
): Promise<LoadedRepoIssueRoute> {
@@ -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) {