diff --git a/crates/radicle-tauri/src/commands/repo.rs b/crates/radicle-tauri/src/commands/repo.rs index dbb9a2e2f..74871b951 100644 --- a/crates/radicle-tauri/src/commands/repo.rs +++ b/crates/radicle-tauri/src/commands/repo.rs @@ -1,5 +1,6 @@ use radicle::git; use radicle::identity::RepoId; +use radicle::node::NodeId; use radicle_types as types; use radicle_types::error::Error; use radicle_types::traits::repo::{Repo, Show}; @@ -7,58 +8,72 @@ use radicle_types::traits::repo::{Repo, Show}; use crate::AppState; #[tauri::command] -pub fn list_repos( - ctx: tauri::State, +pub async fn list_repos( + ctx: tauri::State<'_, AppState>, show: Show, ) -> Result, Error> { ctx.list_repos(show) } #[tauri::command] -pub fn list_repos_summary( - ctx: tauri::State, +pub async fn list_repos_summary( + ctx: tauri::State<'_, AppState>, ) -> Result, Error> { ctx.list_repos_summary() } #[tauri::command] -pub fn repo_count(ctx: tauri::State) -> Result { +pub async fn repo_count(ctx: tauri::State<'_, AppState>) -> Result { ctx.repo_count() } #[tauri::command] -pub fn repo_by_id( - ctx: tauri::State, +pub async fn list_repo_refs( + ctx: tauri::State<'_, AppState>, + rid: RepoId, +) -> Result { + ctx.list_repo_refs(rid) +} + +#[tauri::command] +pub async fn repo_by_id( + ctx: tauri::State<'_, AppState>, rid: RepoId, ) -> Result { ctx.repo_by_id(rid) } #[tauri::command] -pub fn repo_readme( - ctx: tauri::State, +pub async fn repo_readme( + ctx: tauri::State<'_, AppState>, rid: RepoId, sha: Option, + peer: Option, + revision: Option, ) -> Result, Error> { - ctx.repo_readme(rid, sha) + ctx.repo_readme(rid, sha, peer, revision) } #[tauri::command] -pub fn repo_tree( - ctx: tauri::State, +pub async fn repo_tree( + ctx: tauri::State<'_, AppState>, rid: RepoId, path: std::path::PathBuf, + sha: Option, + peer: Option, + revision: Option, ) -> Result { - ctx.repo_tree(rid, path) + ctx.repo_tree(rid, path, sha, peer, revision) } #[tauri::command] -pub fn repo_blob( - ctx: tauri::State, +pub async fn repo_blob( + ctx: tauri::State<'_, AppState>, rid: RepoId, path: std::path::PathBuf, + sha: Option, ) -> Result { - ctx.repo_blob(rid, path) + ctx.repo_blob(rid, path, sha) } #[tauri::command] @@ -97,15 +112,17 @@ pub async fn list_repo_commits( ctx: tauri::State<'_, AppState>, rid: RepoId, head: Option, + peer: Option, + revision: Option, skip: Option, take: Option, ) -> Result>, Error> { - ctx.list_repo_commits(rid, head, skip, take) + ctx.list_repo_commits(rid, head, peer, revision, skip, take) } #[tauri::command] -pub fn repo_commit_count( - ctx: tauri::State, +pub async fn repo_commit_count( + ctx: tauri::State<'_, AppState>, rid: RepoId, head: git::Oid, ) -> Result { @@ -113,25 +130,27 @@ pub fn repo_commit_count( } #[tauri::command] -pub fn repo_commit( - ctx: tauri::State, +pub async fn repo_commit( + ctx: tauri::State<'_, AppState>, rid: RepoId, - sha: git::Oid, + sha: Option, + peer: Option, + revision: Option, ) -> Result { - ctx.repo_commit(rid, sha) + ctx.repo_commit(rid, sha, peer, revision) } #[tauri::command] -pub fn seed(ctx: tauri::State, rid: RepoId) -> Result<(), Error> { +pub fn seed(ctx: tauri::State<'_, AppState>, rid: RepoId) -> Result<(), Error> { ctx.seed(rid) } #[tauri::command] -pub fn unseed(ctx: tauri::State, rid: RepoId) -> Result<(), Error> { +pub fn unseed(ctx: tauri::State<'_, AppState>, rid: RepoId) -> Result<(), Error> { ctx.unseed(rid) } #[tauri::command] -pub fn seeded_not_replicated(ctx: tauri::State) -> Result, Error> { +pub async fn seeded_not_replicated(ctx: tauri::State<'_, AppState>) -> Result, Error> { ctx.seeded_not_replicated() } diff --git a/crates/radicle-tauri/src/lib.rs b/crates/radicle-tauri/src/lib.rs index d4672bb1f..abb6ceb38 100644 --- a/crates/radicle-tauri/src/lib.rs +++ b/crates/radicle-tauri/src/lib.rs @@ -57,6 +57,7 @@ pub fn run() { repo::diff_stats, repo::list_commits, repo::list_repo_commits, + repo::list_repo_refs, repo::list_repos, repo::list_repos_summary, repo::repo_by_id, diff --git a/crates/radicle-types/bindings/repo/Canonical.ts b/crates/radicle-types/bindings/repo/Canonical.ts new file mode 100644 index 000000000..846c173f1 --- /dev/null +++ b/crates/radicle-types/bindings/repo/Canonical.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Tag } from "./Tag"; + +export type Canonical = { + branches: { [key in string]: string }; + tags: { [key in string]: Tag }; +}; diff --git a/crates/radicle-types/bindings/repo/Remote.ts b/crates/radicle-types/bindings/repo/Remote.ts new file mode 100644 index 000000000..6cc67379b --- /dev/null +++ b/crates/radicle-types/bindings/repo/Remote.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Tag } from "./Tag"; + +export type Remote = { + id: string; + alias?: string; + delegate: boolean; + branches: { [key in string]: string }; + tags: { [key in string]: Tag }; +}; diff --git a/crates/radicle-types/bindings/repo/RepoRefs.ts b/crates/radicle-types/bindings/repo/RepoRefs.ts new file mode 100644 index 000000000..c0b7b4929 --- /dev/null +++ b/crates/radicle-types/bindings/repo/RepoRefs.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Canonical } from "./Canonical"; +import type { Remote } from "./Remote"; + +export type RepoRefs = { canonical: Canonical; remotes: Array }; diff --git a/crates/radicle-types/bindings/repo/Tag.ts b/crates/radicle-types/bindings/repo/Tag.ts new file mode 100644 index 000000000..53b9faae8 --- /dev/null +++ b/crates/radicle-types/bindings/repo/Tag.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Tagger } from "./Tagger"; + +export type Tag = { + oid: string; + /** + * Tagger time for annotated tags, otherwise the commit time of the + * tagged commit. Seconds since epoch. + */ + timestamp: number; + /** + * Tagger of an annotated tag. Absent for lightweight tags. + */ + tagger?: Tagger; + /** + * Message of an annotated tag. Absent for lightweight tags. + */ + message?: string; +}; diff --git a/crates/radicle-types/bindings/repo/Tagger.ts b/crates/radicle-types/bindings/repo/Tagger.ts new file mode 100644 index 000000000..40e7c774e --- /dev/null +++ b/crates/radicle-types/bindings/repo/Tagger.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Tagger = { + name: string; + email: string; + /** + * Tagger time. Seconds since epoch. + */ + timestamp: number; +}; diff --git a/crates/radicle-types/src/error.rs b/crates/radicle-types/src/error.rs index cd4b0a251..009610c2d 100644 --- a/crates/radicle-types/src/error.rs +++ b/crates/radicle-types/src/error.rs @@ -136,6 +136,14 @@ pub enum Error { #[error(transparent)] Storage(#[from] radicle::storage::Error), + /// Storage refs error. + #[error(transparent)] + StorageRefs(#[from] radicle::storage::refs::Error), + + /// Revision not found. + #[error("revision not found: {0}")] + RevisionNotFound(String), + /// Surf error. #[error(transparent)] Surf(#[from] radicle_surf::Error), diff --git a/crates/radicle-types/src/repo.rs b/crates/radicle-types/src/repo.rs index 551404ebe..3e56c2a4d 100644 --- a/crates/radicle-types/src/repo.rs +++ b/crates/radicle-types/src/repo.rs @@ -1,10 +1,11 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use radicle_surf as surf; use serde::{Deserialize, Serialize}; use ts_rs::TS; -use radicle::{git, identity, issue, patch}; +use radicle::node::Alias; +use radicle::{git, identity, issue, node, patch}; use crate::cobs::Author; use crate::error; @@ -169,6 +170,74 @@ pub struct Commit { pub parents: Vec, } +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "repo/")] +pub struct Tagger { + pub name: String, + pub email: String, + /// Tagger time. Seconds since epoch. + #[ts(type = "number")] + pub timestamp: i64, +} + +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "repo/")] +pub struct Tag { + #[ts(as = "String")] + pub oid: git::Oid, + /// Tagger time for annotated tags, otherwise the commit time of the + /// tagged commit. Seconds since epoch. + #[ts(type = "number")] + pub timestamp: i64, + /// Tagger of an annotated tag. Absent for lightweight tags. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub tagger: Option, + /// Message of an annotated tag. Absent for lightweight tags. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub message: Option, +} + +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "repo/")] +pub struct Remote { + #[ts(as = "String")] + pub id: node::NodeId, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(as = "Option", optional)] + pub alias: Option, + pub delegate: bool, + #[ts(as = "BTreeMap")] + pub branches: BTreeMap, + pub tags: BTreeMap, +} + +#[derive(Default, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "repo/")] +pub struct Canonical { + #[ts(as = "BTreeMap")] + pub branches: BTreeMap, + pub tags: BTreeMap, +} + +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "repo/")] +pub struct RepoRefs { + pub canonical: Canonical, + pub remotes: Vec, +} + impl From for Commit { fn from(value: surf::Commit) -> Self { Self { diff --git a/crates/radicle-types/src/traits/repo.rs b/crates/radicle-types/src/traits/repo.rs index 946d08e24..353607fc2 100644 --- a/crates/radicle-types/src/traits/repo.rs +++ b/crates/radicle-types/src/traits/repo.rs @@ -1,10 +1,12 @@ +use std::collections::BTreeMap; + use base64::Engine; -use radicle::git::Oid; use radicle_surf as surf; use serde::{Deserialize, Serialize}; use radicle::identity::{Doc, DocAt, doc}; use radicle::issue::cache::Issues as _; +use radicle::node::AliasStore; use radicle::node::routing::Store; use radicle::patch::cache::Patches as _; use radicle::storage; @@ -42,6 +44,175 @@ fn is_contributor(refs: &storage::SignedRefsInfo) -> bool { ) } +/// Resolve a `(peer, revision)` pair to a commit OID. A named revision is +/// looked up under the peer's namespaced `refs/heads` and `refs/tags` when a +/// peer is given, otherwise under the canonical top-level refs. With no +/// revision, a peer resolves to its head of the project's default branch, and +/// no peer resolves to the canonical head. Raw commit OIDs are passed through +/// the handlers' `sha`/`head` argument instead of this function. +fn resolve_revision( + repo: &storage::git::Repository, + peer: Option, + revision: Option, +) -> Result { + let lookup = |refname: String| -> Option { + let r = repo.backend.find_reference(&refname).ok()?; + let commit = r.peel_to_commit().ok()?; + Some(commit.id().into()) + }; + + match peer { + Some(peer) => { + let name = match revision { + Some(rev) => rev, + None => { + let DocAt { doc, .. } = repo.identity_doc()?; + doc.project() + .map_err(|e| Error::RevisionNotFound(e.to_string()))? + .default_branch() + .to_string() + } + }; + ["refs/heads", "refs/tags"] + .iter() + .find_map(|prefix| lookup(format!("refs/namespaces/{peer}/{prefix}/{name}"))) + .ok_or_else(|| Error::RevisionNotFound(format!("{name} under peer {peer}"))) + } + None => match revision { + Some(name) => ["refs/heads", "refs/tags"] + .iter() + .find_map(|prefix| lookup(format!("{prefix}/{name}"))) + .ok_or(Error::RevisionNotFound(name)), + None => { + let (_, head) = repo.head()?; + Ok(head) + } + }, + } +} + +/// Collect canonical branches and tags as declared by the repository's +/// identity document. The canonical-refs rules (the `xyz.radicle.crefs` +/// payload, or a synthesized default covering the project's default branch) +/// define which ref patterns are canonical; each pattern is globbed against +/// the storage repo's resolved top-level refs (per-peer refs live under +/// `refs/namespaces/`). Only refs under `refs/heads` and `refs/tags` are +/// kept, and refs that cannot be peeled to a commit are skipped. +fn canonical_refs(repo: &storage::git::Repository) -> Result { + let mut canonical = repo::Canonical::default(); + + let DocAt { doc, .. } = repo.identity_doc()?; + let crefs = doc + .canonical_refs() + .map_err(storage::RepositoryError::from)?; + let rules = git::canonical::rules::RawRules::from(crefs.rules().clone()); + + for (pattern, _) in rules.iter() { + for r in repo.backend.references_glob(pattern.as_str())? { + let r = r?; + let Some(name) = r.name() else { continue }; + let Some(oid) = r.target() else { continue }; + + if let Some(short) = name.strip_prefix("refs/tags/") { + let Some(tag) = resolve_tag(repo, oid) else { + continue; + }; + canonical.tags.insert(short.to_owned(), tag); + } else if let Some(short) = name.strip_prefix("refs/heads/") { + let Ok(commit) = repo + .backend + .find_object(oid, None) + .and_then(|obj| obj.peel_to_commit()) + else { + continue; + }; + canonical + .branches + .insert(short.to_owned(), commit.id().into()); + } + } + } + + Ok(canonical) +} + +/// Resolve a ref OID to a [`repo::Tag`]. For annotated tags uses tagger time; +/// for lightweight tags uses the target commit's time. Returns `None` if the +/// OID cannot be peeled to a commit. +fn resolve_tag(repo: &storage::git::Repository, oid: git::raw::Oid) -> Option { + if let Ok(tag) = repo.backend.find_tag(oid) { + let target_oid = tag.target_id(); + let commit = repo.backend.find_commit(target_oid).ok()?; + let tagger = tag.tagger().map(|t| repo::Tagger { + name: t.name().unwrap_or_default().to_owned(), + email: t.email().unwrap_or_default().to_owned(), + timestamp: t.when().seconds(), + }); + let timestamp = tagger + .as_ref() + .map(|t| t.timestamp) + .unwrap_or_else(|| commit.time().seconds()); + return Some(repo::Tag { + oid: commit.id().into(), + timestamp, + tagger, + message: tag.message().map(str::to_owned), + }); + } + let commit = repo.backend.find_commit(oid).ok()?; + Some(repo::Tag { + oid: commit.id().into(), + timestamp: commit.time().seconds(), + tagger: None, + message: None, + }) +} + +/// Partition a remote's refs into short-name branch and tag maps. Refs that +/// cannot be peeled to a commit, are not qualified, or are not under +/// `refs/heads` or `refs/tags` are skipped. +fn partition_refs( + refs: &storage::refs::Refs, + repo: &storage::git::Repository, +) -> (BTreeMap, BTreeMap) { + let mut branches = BTreeMap::new(); + let mut tags = BTreeMap::new(); + + for (refname, oid) in refs.iter() { + let Some(qualified) = refname.qualified() else { + continue; + }; + + let (_, category, first, rest) = qualified.non_empty_components(); + let name = std::iter::once(first) + .chain(rest) + .collect::() + .to_string(); + + match category.as_str() { + "heads" => { + let Ok(commit) = repo + .backend + .find_object((*oid).into(), None) + .and_then(|obj| obj.peel_to_commit()) + else { + continue; + }; + branches.insert(name, commit.id().into()); + } + "tags" => { + let Some(tag) = resolve_tag(repo, (*oid).into()) else { + continue; + }; + tags.insert(name, tag); + } + _ => {} + } + } + + (branches, tags) +} + pub trait Repo: Profile { fn list_repos(&self, show: Show) -> Result, Error> { let profile = self.profile(); @@ -152,8 +323,11 @@ pub trait Repo: Profile { &self, rid: identity::RepoId, sha: Option, + peer: Option, + revision: Option, ) -> Result, Error> { let profile = self.profile(); + let storage_repo = profile.storage.repository(rid)?; let repo = radicle_surf::Repository::open(storage::git::paths::repository( &profile.storage, &rid, @@ -169,7 +343,10 @@ pub trait Repo: Profile { "Readme.md", ]; - let oid = sha.map_or_else(|| repo.head().map(|oid| Oid::from(*oid)), Ok)?; + let oid = match sha { + Some(sha) => sha, + None => resolve_revision(&storage_repo, peer, revision)?, + }; for path in paths .iter() @@ -203,14 +380,21 @@ pub trait Repo: Profile { &self, rid: identity::RepoId, path: std::path::PathBuf, + sha: Option, + peer: Option, + revision: Option, ) -> Result { let profile = self.profile(); + let storage_repo = profile.storage.repository(rid)?; let repo = radicle_surf::Repository::open(radicle::storage::git::paths::repository( &profile.storage, &rid, ))?; - let head = repo.head()?; - let tree = repo.tree(head, &path)?; + let oid = match sha { + Some(sha) => sha, + None => resolve_revision(&storage_repo, peer, revision)?, + }; + let tree = repo.tree(crate::oid::into_surf(oid), &path)?; Ok(source::tree::Tree::from_surf(tree, &path)) } @@ -218,15 +402,41 @@ pub trait Repo: Profile { &self, rid: identity::RepoId, path: std::path::PathBuf, + sha: Option, ) -> Result { let profile = self.profile(); let repo = radicle_surf::Repository::open(radicle::storage::git::paths::repository( &profile.storage, &rid, ))?; - let head = repo.head()?; + let oid = sha.map_or_else(|| repo.head(), |sha| Ok(crate::oid::into_surf(sha)))?; - repo.blob(head, &path).map(Into::into).map_err(Error::from) + repo.blob(oid, &path).map(Into::into).map_err(Error::from) + } + + fn list_repo_refs(&self, rid: identity::RepoId) -> Result { + let profile = self.profile(); + let repo = profile.storage.repository(rid)?; + let DocAt { doc, .. } = repo.identity_doc()?; + let delegates = doc.delegates(); + let aliases = profile.aliases(); + + let mut remotes = Vec::new(); + for entry in repo.remotes()? { + let (id, remote) = entry?; + let (branches, tags) = partition_refs(&remote.refs, &repo); + remotes.push(repo::Remote { + id, + alias: aliases.alias(&id), + delegate: delegates.contains(&id.into()), + branches, + tags, + }); + } + + let canonical = canonical_refs(&repo).unwrap_or_default(); + + Ok(repo::RepoRefs { canonical, remotes }) } fn repo_by_id(&self, rid: identity::RepoId) -> Result { @@ -413,17 +623,21 @@ pub trait Repo: Profile { &self, rid: identity::RepoId, head: Option, + peer: Option, + revision: Option, skip: Option, take: Option, ) -> Result>, Error> { let profile = self.profile(); - let repo = profile.storage.repository(rid)?; + let storage_repo = profile.storage.repository(rid)?; - let repo = surf::Repository::open(repo.path())?; - let head = match head { - Some(head) => crate::oid::into_surf(head), - None => repo.head()?, + let oid = match head { + Some(head) => head, + None => resolve_revision(&storage_repo, peer, revision)?, }; + + let repo = surf::Repository::open(storage_repo.path())?; + let head = crate::oid::into_surf(oid); let commits = repo.history(head)?; let cursor = skip.unwrap_or(0); @@ -470,12 +684,23 @@ pub trait Repo: Profile { Ok(count) } - fn repo_commit(&self, rid: identity::RepoId, sha: git::Oid) -> Result { + fn repo_commit( + &self, + rid: identity::RepoId, + sha: Option, + peer: Option, + revision: Option, + ) -> Result { let profile = self.profile(); - let repo = profile.storage.repository(rid)?; + let storage_repo = profile.storage.repository(rid)?; - let repo = surf::Repository::open(repo.path())?; - let commit = repo.commit(crate::oid::into_surf(sha))?; + let oid = match sha { + Some(sha) => sha, + None => resolve_revision(&storage_repo, peer, revision)?, + }; + + let repo = surf::Repository::open(storage_repo.path())?; + let commit = repo.commit(crate::oid::into_surf(oid))?; Ok(commit.into()) } diff --git a/crates/test-http-api/src/api.rs b/crates/test-http-api/src/api.rs index 7fa380118..15f6367ec 100644 --- a/crates/test-http-api/src/api.rs +++ b/crates/test-http-api/src/api.rs @@ -11,6 +11,7 @@ use hyper::header::CONTENT_TYPE; use serde::{Deserialize, Serialize}; use tower_http::cors::{self, CorsLayer}; +use radicle::node::NodeId; use radicle::{git, identity}; use radicle_types as types; use radicle_types::cobs::CobOptions; @@ -69,6 +70,7 @@ pub fn router(ctx: Context) -> Router { post(seeded_not_replicated_handler), ) .route("/repo_by_id", post(repo_handler)) + .route("/list_repo_refs", post(list_repo_refs_handler)) .route("/version", post(version_handler)) .route("/diff_stats", post(diff_stats_handler)) .route( @@ -181,6 +183,15 @@ async fn repo_handler( Ok::<_, Error>(Json(info)) } +async fn list_repo_refs_handler( + State(ctx): State, + Json(RepoBody { rid }): Json, +) -> impl IntoResponse { + let refs = ctx.list_repo_refs(rid)?; + + Ok::<_, Error>(Json(refs)) +} + async fn version_handler() -> impl IntoResponse { let version = Version { version: String::from("0.6.1"), @@ -215,13 +226,24 @@ struct DiffBody { #[derive(Serialize, Deserialize)] struct ReadmeBody { pub rid: identity::RepoId, + #[serde(default)] + pub sha: Option, + #[serde(default)] + pub peer: Option, + #[serde(default)] + pub revision: Option, } async fn readme_handler( State(ctx): State, - Json(ReadmeBody { rid }): Json, + Json(ReadmeBody { + rid, + sha, + peer, + revision, + }): Json, ) -> impl IntoResponse { - let readme = ctx.repo_readme(rid, None)?; + let readme = ctx.repo_readme(rid, sha, peer, revision)?; Ok::<_, Error>(Json(readme)) } @@ -230,13 +252,25 @@ async fn readme_handler( struct TreeBody { pub rid: identity::RepoId, pub path: PathBuf, + #[serde(default)] + pub sha: Option, + #[serde(default)] + pub peer: Option, + #[serde(default)] + pub revision: Option, } async fn tree_handler( State(ctx): State, - Json(TreeBody { rid, path }): Json, + Json(TreeBody { + rid, + path, + sha, + peer, + revision, + }): Json, ) -> impl IntoResponse { - let info = ctx.repo_tree(rid, path)?; + let info = ctx.repo_tree(rid, path, sha, peer, revision)?; Ok::<_, Error>(Json(info)) } @@ -245,13 +279,15 @@ async fn tree_handler( struct BlobBody { pub rid: identity::RepoId, pub path: PathBuf, + #[serde(default)] + pub sha: Option, } async fn blob_handler( State(ctx): State, - Json(BlobBody { rid, path }): Json, + Json(BlobBody { rid, path, sha }): Json, ) -> impl IntoResponse { - let info = ctx.repo_blob(rid, path)?; + let info = ctx.repo_blob(rid, path, sha)?; Ok::<_, Error>(Json(info)) } @@ -291,6 +327,10 @@ async fn commit_diff_handler( struct ListRepoCommitsBody { pub rid: identity::RepoId, pub head: Option, + #[serde(default)] + pub peer: Option, + #[serde(default)] + pub revision: Option, pub skip: Option, pub take: Option, } @@ -300,11 +340,13 @@ async fn list_repo_commits_handler( Json(ListRepoCommitsBody { rid, head, + peer, + revision, skip, take, }): Json, ) -> impl IntoResponse { - let commits = ctx.list_repo_commits(rid, head, skip, take)?; + let commits = ctx.list_repo_commits(rid, head, peer, revision, skip, take)?; Ok::<_, Error>(Json(commits)) } @@ -327,14 +369,24 @@ async fn repo_commit_count_handler( #[derive(Serialize, Deserialize)] struct RepoCommitBody { pub rid: identity::RepoId, - pub sha: git::Oid, + #[serde(default)] + pub sha: Option, + #[serde(default)] + pub peer: Option, + #[serde(default)] + pub revision: Option, } async fn repo_commit_handler( State(ctx): State, - Json(RepoCommitBody { rid, sha }): Json, + Json(RepoCommitBody { + rid, + sha, + peer, + revision, + }): Json, ) -> impl IntoResponse { - let commit = ctx.repo_commit(rid, sha)?; + let commit = ctx.repo_commit(rid, sha, peer, revision)?; Ok::<_, Error>(Json(commit)) } diff --git a/public/typography.css b/public/typography.css index 1637b832a..6fbb61acf 100644 --- a/public/typography.css +++ b/public/typography.css @@ -75,11 +75,13 @@ } [data-codefont="system"] { + --txt-code-small: 400 0.75rem/1rem monospace; --txt-code-regular: 400 0.875rem/1.25rem monospace; --txt-code-semibold: 600 0.875rem/1.25rem monospace; } [data-codefont="jetbrains"] { + --txt-code-small: 400 0.75rem/1rem "JetBrains Mono"; --txt-code-regular: 400 0.875rem/1.25rem "JetBrains Mono"; --txt-code-semibold: 600 0.875rem/1.25rem "JetBrains Mono"; } @@ -160,6 +162,10 @@ html { font: var(--txt-body-l-semibold); } +.txt-code-small { + font: var(--txt-code-small); +} + .txt-code-regular { font: var(--txt-code-regular); } diff --git a/src/components/Button.svelte b/src/components/Button.svelte index 5ce162754..3213c52ef 100644 --- a/src/components/Button.svelte +++ b/src/components/Button.svelte @@ -13,7 +13,7 @@ flatLeft?: boolean; flatRight?: boolean; title?: string; - styleHeight?: "2rem" | "2.5rem"; + styleHeight?: "1.75rem" | "2rem" | "2.5rem"; styleWidth?: string; styleJustifyContent?: string; stylePadding?: string; @@ -119,12 +119,16 @@ } .button.outline { - border: 1px solid var(--color-fill); + border: 1px solid var(--color-border-subtle); + } + + .button.outline:hover:not(.disabled) { + border-color: var(--color-border-mid); } .button.outline.active:not(.disabled), .button.outline:active:not(.disabled) { - border-color: var(--color-fill-active); + border-color: var(--color-border-mid); } .button.outline.disabled { diff --git a/src/components/FileBlock.svelte b/src/components/FileBlock.svelte index d152599ce..f0430fe20 100644 --- a/src/components/FileBlock.svelte +++ b/src/components/FileBlock.svelte @@ -14,6 +14,7 @@ rightHeader?: Snippet; sticky?: boolean; border?: boolean; + headerBackground?: string; } /* eslint-disable prefer-const */ @@ -25,6 +26,7 @@ sticky = true, expandable = true, border = true, + headerBackground = "var(--color-surface-canvas)", }: Props = $props(); /* eslint-enable prefer-const */ @@ -40,7 +42,7 @@ z-index: 2; font: var(--txt-body-m-regular); position: relative; - background-color: var(--color-surface-canvas); + background-color: var(--header-background); border-top-left-radius: var(--border-radius-md); border-top-right-radius: var(--border-radius-md); } @@ -59,6 +61,8 @@ gap: 0.5rem; margin-right: 1rem; align-items: center; + flex: 1; + min-width: 0; } .container { @@ -76,6 +80,7 @@ class:sticky class:collapsed={!expanded} bind:this={header} + style:--header-background={headerBackground} style:border={border ? "1px solid var(--color-border-subtle)" : undefined} style:border-bottom={border ? "undefined" diff --git a/src/components/FuzzySearch.svelte b/src/components/FuzzySearch.svelte index 0122cee05..674ca56ec 100644 --- a/src/components/FuzzySearch.svelte +++ b/src/components/FuzzySearch.svelte @@ -13,6 +13,7 @@ value: string; onFocus?: () => void; onSubmit?: () => void; + styleHeight?: "1.75rem" | "2rem" | "2.5rem"; } /* eslint-disable prefer-const */ @@ -24,6 +25,7 @@ value = $bindable(), onFocus, onSubmit, + styleHeight = "2rem", }: Props = $props(); /* eslint-enable prefer-const */ @@ -44,6 +46,7 @@ show = false; }} {placeholder} + {styleHeight} keyShortcuts="ctrl+f" bind:value> {#snippet left()} @@ -58,6 +61,7 @@
+ {/snippet} + + {#snippet popover()} + + {/snippet} + diff --git a/src/components/PeerSelector/TagIcon.svelte b/src/components/PeerSelector/TagIcon.svelte new file mode 100644 index 000000000..3f3c7156b --- /dev/null +++ b/src/components/PeerSelector/TagIcon.svelte @@ -0,0 +1,78 @@ + + + + +{#if annotated} + + {#snippet toggle()} + + {/snippet} + {#snippet popover()} +
+ {#if tag.tagger} +
`}> + avatar + {tag.tagger.name} tagged + + {formatTimestamp(tag.tagger.timestamp * 1000)} + +
+ {/if} + {#if tag.message} +
{tag.message}
+ {/if} +
+ {/snippet} +
+{:else} + +{/if} diff --git a/src/components/PreviewSwitch.svelte b/src/components/PreviewSwitch.svelte index 9fc65ed67..79171160e 100644 --- a/src/components/PreviewSwitch.svelte +++ b/src/components/PreviewSwitch.svelte @@ -1,5 +1,4 @@ + + + +
+ + + {#if extra} + {@render extra()} + {/if} + +
+ + + {#if peer !== undefined || revision !== undefined} + + {/if} +
+ +
diff --git a/src/components/TextInput.svelte b/src/components/TextInput.svelte index ec36dba02..5346fb0c4 100644 --- a/src/components/TextInput.svelte +++ b/src/components/TextInput.svelte @@ -20,6 +20,7 @@ type?: string; valid?: boolean; value?: string; + styleHeight?: string; } /* eslint-disable prefer-const */ @@ -39,6 +40,7 @@ type = "text", valid = true, value = $bindable(undefined), + styleHeight = "2rem", }: Props = $props(); /* eslint-enable prefer-const */ @@ -83,9 +85,9 @@ outline: none; text-overflow: ellipsis; width: 100%; - height: 100%; margin: 0; - height: 30px; /* + 2px border = 2rem */ + /* The 1px border on each side of the wrapper adds 2px to the total. */ + height: calc(var(--input-height) - 2px); border: 0; } input::placeholder { @@ -104,7 +106,8 @@ style:gap="0.5rem" style:align-items="center" style:background-color="var(--color-surface-base)" - style:width="100%"> + style:width="100%" + style:--input-height={styleHeight}> {@render left?.()} ; } - const { repo, commits }: Props = $props(); + const { repo, peer, revision, oid, commit, commits }: Props = $props(); - const project = $derived(repo.payloads["xyz.radicle.project"]!); - let commitCount: number | undefined = $state(); - - $effect(() => { - const rid = repo.rid; - commitCount = undefined; - void cachedRepoCommitCount(rid, project.meta.head) - .then(count => { - if (repo.rid === rid) { - commitCount = count; - } - }) - .catch(error => { - console.error("Failed to load commit count", error); - }); + const baseRoute = $derived({ + resource: "repo.commits" as const, + rid: repo.rid, + peer, + revision, }); type CommitGroup = { @@ -88,7 +82,7 @@ const page = await loader.run(async () => { return await invoke>("list_repo_commits", { rid: repo.rid, - head: project.meta.head, + head: oid, skip: all ? 0 : items.length, take: all ? undefined : COMMITS_PAGE_SIZE, }); @@ -187,35 +181,6 @@ height: 100%; min-height: 0; } - .topbar-title { - font: var(--txt-body-m-semibold); - color: var(--color-text-secondary); - display: flex; - align-items: center; - gap: 0.25rem; - } - .branch-group { - display: flex; - align-items: center; - gap: 0.25rem; - margin-left: 1.5rem; - } - .branch { - color: var(--color-text-secondary); - font: var(--txt-body-m-regular); - } - .canonical-badge { - display: inline-flex; - align-items: center; - height: 1.25rem; - padding: 0 0.375rem; - border-radius: var(--border-radius-sm); - background-color: var(--color-surface-strong); - color: var(--color-text-secondary); - font: var(--txt-body-s-regular); - text-transform: lowercase; - flex-shrink: 0; - } .content { display: flex; flex-direction: column; @@ -250,24 +215,22 @@ } - +
- - - Commits - - - {project.data.defaultBranch} - canonical - - {#if commitCount !== undefined} - {commitCount} - {/if} - -
+ + + {#snippet extra()} 0} placeholder={`Fuzzy filter commits ${modifierKey()} + f`} + styleHeight="1.75rem" icon={loading ? "clock" : "filter"} onFocus={async () => { try { @@ -290,63 +253,65 @@ }} bind:show={showSearch} bind:value={searchInput} /> -
-
-
- {#each groupedCommits as group (group.key)} -
-
{group.label}
-
- {#each group.commits as commit (commit.id)} -
{ - void router.push({ - resource: "repo.commit", - rid: repo.rid, - commit: commit.id, - }); - }} - onkeydown={e => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); + {/snippet} + + +
+ {#each groupedCommits as group (group.key)} +
+
{group.label}
+
+ {#each group.commits as commit (commit.id)} +
{ void router.push({ resource: "repo.commit", rid: repo.rid, commit: commit.id, }); - } - }}> - -
- {/each} -
-
- {/each} + }} + onkeydown={e => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + void router.push({ + resource: "repo.commit", + rid: repo.rid, + commit: commit.id, + }); + } + }}> + +
+ {/each} +
+
+ {/each} - {#if filteredCommits.length === 0} -
+ {#if filteredCommits.length === 0}
- {#if items.length > 0} - No matching commits - {:else} - No commits - {/if} + class="global-flex" + style:flex="1" + style:justify-content="center" + style:align-items="center"> +
+ {#if items.length > 0} + No matching commits + {:else} + No commits + {/if} +
-
- {/if} + {/if} - -
+ +
+
diff --git a/src/views/repo/RepoHome.svelte b/src/views/repo/RepoHome.svelte index 28516a2aa..de02dc975 100644 --- a/src/views/repo/RepoHome.svelte +++ b/src/views/repo/RepoHome.svelte @@ -1,4 +1,5 @@