mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-10-18 19:10:45 +08:00
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:
co-authored by
Rūdolfs Ošiņš
parent
7c29fae1a7
commit
54835363a9
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¤t_commit)
|
||||
.filter(|revision| revision.is_accepted())
|
||||
.map(|revision| revision.id);
|
||||
|
||||
Ok(types::Identity {
|
||||
rid,
|
||||
current,
|
||||
doc: types::Doc::new(¤t_doc, &aliases),
|
||||
revisions,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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") {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user