Add peer selector to repo source browsing

Introduce a peer/branch/tag selector in the repository source and commits
views, letting you browse the canonical refs or any peer's namespace.
This commit is contained in:
Rūdolfs Ošiņš
2026-06-04 17:19:31 +02:00
parent 857d68a1e9
commit efea7f1ab2
28 changed files with 1905 additions and 293 deletions
+45 -26
View File
@@ -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<AppState>,
pub async fn list_repos(
ctx: tauri::State<'_, AppState>,
show: Show,
) -> Result<Vec<types::repo::RepoInfo>, Error> {
ctx.list_repos(show)
}
#[tauri::command]
pub fn list_repos_summary(
ctx: tauri::State<AppState>,
pub async fn list_repos_summary(
ctx: tauri::State<'_, AppState>,
) -> Result<Vec<types::repo::RepoSummary>, Error> {
ctx.list_repos_summary()
}
#[tauri::command]
pub fn repo_count(ctx: tauri::State<AppState>) -> Result<types::repo::RepoCount, Error> {
pub async fn repo_count(ctx: tauri::State<'_, AppState>) -> Result<types::repo::RepoCount, Error> {
ctx.repo_count()
}
#[tauri::command]
pub fn repo_by_id(
ctx: tauri::State<AppState>,
pub async fn list_repo_refs(
ctx: tauri::State<'_, AppState>,
rid: RepoId,
) -> Result<types::repo::RepoRefs, Error> {
ctx.list_repo_refs(rid)
}
#[tauri::command]
pub async fn repo_by_id(
ctx: tauri::State<'_, AppState>,
rid: RepoId,
) -> Result<types::repo::RepoInfo, Error> {
ctx.repo_by_id(rid)
}
#[tauri::command]
pub fn repo_readme(
ctx: tauri::State<AppState>,
pub async fn repo_readme(
ctx: tauri::State<'_, AppState>,
rid: RepoId,
sha: Option<git::Oid>,
peer: Option<NodeId>,
revision: Option<String>,
) -> Result<Option<types::repo::Readme>, Error> {
ctx.repo_readme(rid, sha)
ctx.repo_readme(rid, sha, peer, revision)
}
#[tauri::command]
pub fn repo_tree(
ctx: tauri::State<AppState>,
pub async fn repo_tree(
ctx: tauri::State<'_, AppState>,
rid: RepoId,
path: std::path::PathBuf,
sha: Option<git::Oid>,
peer: Option<NodeId>,
revision: Option<String>,
) -> Result<types::source::tree::Tree, Error> {
ctx.repo_tree(rid, path)
ctx.repo_tree(rid, path, sha, peer, revision)
}
#[tauri::command]
pub fn repo_blob(
ctx: tauri::State<AppState>,
pub async fn repo_blob(
ctx: tauri::State<'_, AppState>,
rid: RepoId,
path: std::path::PathBuf,
sha: Option<git::Oid>,
) -> Result<types::source::blob::Blob, Error> {
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<git::Oid>,
peer: Option<NodeId>,
revision: Option<String>,
skip: Option<usize>,
take: Option<usize>,
) -> Result<types::cobs::PaginatedQuery<Vec<types::repo::Commit>>, 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<AppState>,
pub async fn repo_commit_count(
ctx: tauri::State<'_, AppState>,
rid: RepoId,
head: git::Oid,
) -> Result<usize, Error> {
@@ -113,25 +130,27 @@ pub fn repo_commit_count(
}
#[tauri::command]
pub fn repo_commit(
ctx: tauri::State<AppState>,
pub async fn repo_commit(
ctx: tauri::State<'_, AppState>,
rid: RepoId,
sha: git::Oid,
sha: Option<git::Oid>,
peer: Option<NodeId>,
revision: Option<String>,
) -> Result<types::repo::Commit, Error> {
ctx.repo_commit(rid, sha)
ctx.repo_commit(rid, sha, peer, revision)
}
#[tauri::command]
pub fn seed(ctx: tauri::State<AppState>, 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<AppState>, 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<AppState>) -> Result<Vec<RepoId>, Error> {
pub async fn seeded_not_replicated(ctx: tauri::State<'_, AppState>) -> Result<Vec<RepoId>, Error> {
ctx.seeded_not_replicated()
}
+1
View File
@@ -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,
@@ -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 };
};
@@ -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 };
};
@@ -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<Remote> };
+19
View File
@@ -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;
};
@@ -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;
};
+8
View File
@@ -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),
+71 -2
View File
@@ -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<git::Oid>,
}
#[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<Tagger>,
/// Message of an annotated tag. Absent for lightweight tags.
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub message: Option<String>,
}
#[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<String>", optional)]
pub alias: Option<Alias>,
pub delegate: bool,
#[ts(as = "BTreeMap<String, String>")]
pub branches: BTreeMap<String, git::Oid>,
pub tags: BTreeMap<String, Tag>,
}
#[derive(Default, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[ts(export_to = "repo/")]
pub struct Canonical {
#[ts(as = "BTreeMap<String, String>")]
pub branches: BTreeMap<String, git::Oid>,
pub tags: BTreeMap<String, Tag>,
}
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[ts(export_to = "repo/")]
pub struct RepoRefs {
pub canonical: Canonical,
pub remotes: Vec<Remote>,
}
impl From<surf::Commit> for Commit {
fn from(value: surf::Commit) -> Self {
Self {
+240 -15
View File
@@ -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<node::NodeId>,
revision: Option<String>,
) -> Result<git::Oid, Error> {
let lookup = |refname: String| -> Option<git::Oid> {
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<repo::Canonical, Error> {
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<repo::Tag> {
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<String, git::Oid>, BTreeMap<String, repo::Tag>) {
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::<git::fmt::RefString>()
.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<Vec<repo::RepoInfo>, Error> {
let profile = self.profile();
@@ -152,8 +323,11 @@ pub trait Repo: Profile {
&self,
rid: identity::RepoId,
sha: Option<git::Oid>,
peer: Option<node::NodeId>,
revision: Option<String>,
) -> Result<Option<repo::Readme>, 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<git::Oid>,
peer: Option<node::NodeId>,
revision: Option<String>,
) -> Result<source::tree::Tree, Error> {
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<git::Oid>,
) -> Result<source::blob::Blob, Error> {
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<repo::RepoRefs, Error> {
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<repo::RepoInfo, Error> {
@@ -413,17 +623,21 @@ pub trait Repo: Profile {
&self,
rid: identity::RepoId,
head: Option<git::Oid>,
peer: Option<node::NodeId>,
revision: Option<String>,
skip: Option<usize>,
take: Option<usize>,
) -> Result<crate::cobs::PaginatedQuery<Vec<repo::Commit>>, 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<repo::Commit, Error> {
fn repo_commit(
&self,
rid: identity::RepoId,
sha: Option<git::Oid>,
peer: Option<node::NodeId>,
revision: Option<String>,
) -> Result<repo::Commit, 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 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())
}
+62 -10
View File
@@ -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<Context>,
Json(RepoBody { rid }): Json<RepoBody>,
) -> 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<git::Oid>,
#[serde(default)]
pub peer: Option<NodeId>,
#[serde(default)]
pub revision: Option<String>,
}
async fn readme_handler(
State(ctx): State<Context>,
Json(ReadmeBody { rid }): Json<ReadmeBody>,
Json(ReadmeBody {
rid,
sha,
peer,
revision,
}): Json<ReadmeBody>,
) -> 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<git::Oid>,
#[serde(default)]
pub peer: Option<NodeId>,
#[serde(default)]
pub revision: Option<String>,
}
async fn tree_handler(
State(ctx): State<Context>,
Json(TreeBody { rid, path }): Json<TreeBody>,
Json(TreeBody {
rid,
path,
sha,
peer,
revision,
}): Json<TreeBody>,
) -> 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<git::Oid>,
}
async fn blob_handler(
State(ctx): State<Context>,
Json(BlobBody { rid, path }): Json<BlobBody>,
Json(BlobBody { rid, path, sha }): Json<BlobBody>,
) -> 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<git::Oid>,
#[serde(default)]
pub peer: Option<NodeId>,
#[serde(default)]
pub revision: Option<String>,
pub skip: Option<usize>,
pub take: Option<usize>,
}
@@ -300,11 +340,13 @@ async fn list_repo_commits_handler(
Json(ListRepoCommitsBody {
rid,
head,
peer,
revision,
skip,
take,
}): Json<ListRepoCommitsBody>,
) -> 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<git::Oid>,
#[serde(default)]
pub peer: Option<NodeId>,
#[serde(default)]
pub revision: Option<String>,
}
async fn repo_commit_handler(
State(ctx): State<Context>,
Json(RepoCommitBody { rid, sha }): Json<RepoCommitBody>,
Json(RepoCommitBody {
rid,
sha,
peer,
revision,
}): Json<RepoCommitBody>,
) -> impl IntoResponse {
let commit = ctx.repo_commit(rid, sha)?;
let commit = ctx.repo_commit(rid, sha, peer, revision)?;
Ok::<_, Error>(Json(commit))
}
+6
View File
@@ -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);
}
+7 -3
View File
@@ -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 {
+6 -1
View File
@@ -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"
+4
View File
@@ -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 */
</script>
@@ -44,6 +46,7 @@
show = false;
}}
{placeholder}
{styleHeight}
keyShortcuts="ctrl+f"
bind:value>
{#snippet left()}
@@ -58,6 +61,7 @@
<div style:display="flex" style:padding="0 1px">
<Button
variant="naked"
{styleHeight}
keyShortcuts="ctrl+f"
onclick={() => (show = true)}>
<Icon name="filter" />
+45 -16
View File
@@ -33,6 +33,11 @@
let anchorEl: HTMLDivElement | undefined = $state();
let floatingEl: HTMLDivElement | undefined = $state();
// Whether the pointer is currently over the anchor or popover.
let hovering = false;
// Whether a text-selection drag started inside the popover is ongoing.
let selecting = false;
const show = debounce(() => {
visible = true;
}, 100);
@@ -41,6 +46,39 @@
visible = false;
}, 200);
function enter() {
hovering = true;
hide.cancel();
show();
}
function leave() {
hovering = false;
show.cancel();
// A selection drag may move the pointer out of the popover; keep it open
// until the drag ends (see endSelection).
if (!selecting) {
hide();
}
}
// A text-selection drag can move the pointer outside the popover and release
// anywhere, so we follow it for the duration of the drag with a listener that
// removes itself on release, rather than closing on pointer-leave.
function endSelection() {
selecting = false;
document.removeEventListener("pointerup", endSelection);
if (!hovering) {
hide();
}
}
function startSelection() {
selecting = true;
hide.cancel();
document.addEventListener("pointerup", endSelection);
}
$effect(() => {
if (floatingEl && anchorEl) {
const cleanup = autoUpdate(anchorEl, floatingEl, () => {
@@ -82,14 +120,8 @@
<div
class="container"
bind:this={anchorEl}
onmouseenter={() => {
hide.cancel();
show();
}}
onmouseleave={() => {
show.cancel();
hide();
}}>
onmouseenter={enter}
onmouseleave={leave}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="button"
@@ -102,19 +134,16 @@
{#if visible}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
use:portal
bind:this={floatingEl}
class="popover"
style:padding={stylePadding}
onmouseenter={() => {
hide.cancel();
show();
}}
onmouseleave={() => {
show.cancel();
hide();
}}>
onmouseenter={enter}
onmouseleave={leave}
onpointerdown={startSelection}
onclick={e => e.stopPropagation()}>
{@render popover()}
</div>
{/if}
+3 -1
View File
@@ -17,9 +17,10 @@
interface Props {
commit: string;
rid: string;
styleHeight?: "1.75rem" | "2rem" | "2.5rem";
}
const { commit, rid }: Props = $props();
const { commit, rid, styleHeight = "2rem" }: Props = $props();
type Status = Run["status"];
@@ -333,6 +334,7 @@
{#snippet toggle(onclick)}
<Button
variant="naked"
{styleHeight}
onclick={e => {
e.stopPropagation();
onclick();
+179
View File
@@ -0,0 +1,179 @@
<script lang="ts">
import type { SourceBaseRoute } from "@app/views/repo/router";
import type { Remote } from "@bindings/repo/Remote";
import type { Tag } from "@bindings/repo/Tag";
import * as router from "@app/lib/router";
import { truncateId } from "@app/lib/utils";
import Icon from "@app/components/Icon.svelte";
import TagIcon from "@app/components/PeerSelector/TagIcon.svelte";
import UserAvatar from "@app/components/UserAvatar.svelte";
interface Props {
baseRoute: SourceBaseRoute;
remote: Remote;
selected: boolean;
revision?: string;
tab: "branches" | "tags";
onSelect: () => void;
}
const { baseRoute, remote, selected, revision, tab, onSelect }: Props =
$props();
// Initial expansion follows whether this peer is currently selected; the
// user can collapse/expand it freely after that without being overridden.
// svelte-ignore state_referenced_locally
let expanded = $state(selected);
const refEntries = $derived.by<{ name: string; oid: string; tag?: Tag }[]>(
() => {
if (tab === "branches") {
return Object.entries(remote.branches)
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, oid]) => ({ name, oid }));
}
return Object.entries(remote.tags)
.sort(([nameA, a], [nameB, b]) => {
if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
return nameB.localeCompare(nameA);
})
.map(([name, tag]) => ({ name, oid: tag.oid, tag }));
},
);
function routeFor(name: string): string {
return router.routeToPath({
...baseRoute,
peer: remote.id,
revision: name,
});
}
function shortOid(oid: string): string {
return oid.slice(0, 7);
}
</script>
<style>
.peer-header {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border-radius: var(--border-radius-sm);
cursor: pointer;
font: var(--txt-body-m-regular);
color: var(--color-text-primary);
}
.peer-header:hover {
background-color: var(--color-surface-subtle);
}
.peer-header.selected {
background-color: var(--color-surface-subtle);
}
.peer-identity {
display: flex;
align-items: center;
gap: 0.375rem;
min-width: 0;
flex: 1;
}
.avatar {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.no-alias {
color: var(--color-text-secondary);
}
.delegate-badge {
color: var(--color-text-brand);
display: inline-flex;
align-items: center;
}
.ref-row {
display: flex;
align-items: center;
gap: 0.375rem;
/* Align icon with peer name: 0.75rem (header padding) + 1rem (avatar
* width) + 0.375rem (peer-identity gap). */
padding: 0.375rem 0.75rem 0.375rem 2.125rem;
border-radius: var(--border-radius-sm);
cursor: pointer;
color: var(--color-text-primary);
font: var(--txt-body-m-regular);
text-decoration: none;
}
.ref-row:hover {
background-color: var(--color-surface-subtle);
}
.ref-row.selected {
background-color: var(--color-surface-subtle);
}
.ref-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.empty {
padding: 0.5rem 0.75rem;
color: var(--color-text-tertiary);
font: var(--txt-body-s-regular);
}
</style>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="button"
tabindex="0"
class="peer-header"
class:selected={selected && !expanded}
onclick={() => (expanded = !expanded)}>
<div class="peer-identity">
<div class="avatar">
<UserAvatar nodeId={remote.id} styleWidth="1rem" />
</div>
{#if remote.alias}
<span class="name">{remote.alias}</span>
{:else}
<span class="name no-alias">{truncateId(remote.id)}</span>
{/if}
{#if remote.delegate}
<span class="delegate-badge" title="Delegate">
<Icon name="badge" />
</span>
{/if}
</div>
<Icon name={expanded ? "chevron-up" : "chevron-down"} />
</div>
{#if expanded}
{#if refEntries.length > 0}
{#each refEntries as { name, oid, tag } (name)}
<a
class="ref-row"
class:selected={selected && revision === name}
href={routeFor(name)}
onclick={onSelect}>
{#if tag}
<TagIcon {tag} />
{:else}
<Icon name="branch" />
{/if}
<span class="ref-name">{name}</span>
<span class="txt-id">{shortOid(oid)}</span>
</a>
{/each}
{:else}
<div class="empty">No {tab}</div>
{/if}
{/if}
@@ -0,0 +1,614 @@
<script lang="ts">
import type { SourceBaseRoute } from "@app/views/repo/router";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { RepoRefs } from "@bindings/repo/RepoRefs";
import fuzzysort from "fuzzysort";
import orderBy from "lodash/orderBy";
import * as router from "@app/lib/router";
import { truncateId } from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
import PeerEntry from "@app/components/PeerSelector/Peer.svelte";
import TagIcon from "@app/components/PeerSelector/TagIcon.svelte";
import Popover, { closeFocused } from "@app/components/Popover.svelte";
import ScrollArea from "@app/components/ScrollArea.svelte";
import TextInput from "@app/components/TextInput.svelte";
import UserAvatar from "@app/components/UserAvatar.svelte";
interface Props {
baseRoute: SourceBaseRoute;
repo: RepoInfo;
refs?: RepoRefs;
peer?: string;
revision?: string;
}
const {
baseRoute,
repo,
refs,
peer = undefined,
revision = undefined,
}: Props = $props();
const project = $derived(repo.payloads["xyz.radicle.project"]!);
const defaultBranch = $derived(project.data.defaultBranch);
const canonicalBranchNames = $derived(
Object.keys(refs?.canonical.branches ?? {}).sort(),
);
const canonicalTagsSorted = $derived(
Object.entries(refs?.canonical.tags ?? {}).sort(
([nameA, a], [nameB, b]) => {
if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
return nameB.localeCompare(nameA);
},
),
);
const selectedPeer = $derived(refs?.remotes.find(r => r.id === peer));
// Until refs load, fall back to the bare NID from the route so the trigger
// can render the selected peer immediately.
const displayPeer = $derived(
selectedPeer ??
(peer !== undefined
? { id: peer, alias: undefined, delegate: false }
: undefined),
);
const onCanonicalDefault = $derived(
peer === undefined &&
(revision === undefined || revision === defaultBranch),
);
const selectedRefType = $derived.by<"branch" | "tag" | undefined>(() => {
if (revision === undefined) return "branch";
if (selectedPeer) {
if (revision in selectedPeer.branches) return "branch";
if (revision in selectedPeer.tags) return "tag";
} else {
if (revision in (refs?.canonical.branches ?? {})) return "branch";
if (revision in (refs?.canonical.tags ?? {})) return "tag";
if (revision === defaultBranch) return "branch";
}
return undefined;
});
// Delegates first; within each (delegate / non-delegate) group, peers with
// an alias come before those without, alphabetically. No-alias peers fall
// to the bottom and sort by NID.
function sortRemotes<
T extends { id: string; alias?: string; delegate: boolean },
>(list: T[]): T[] {
return orderBy(
list,
[
r => !r.delegate,
r => r.alias === undefined,
r => (r.alias ?? "").toLowerCase(),
r => r.id,
],
["asc", "asc", "asc", "asc"],
);
}
const peersWithBranches = $derived(
sortRemotes(
(refs?.remotes ?? []).filter(r => Object.keys(r.branches).length > 0),
),
);
const peersWithTags = $derived(
sortRemotes(
(refs?.remotes ?? []).filter(r => Object.keys(r.tags).length > 0),
),
);
const hasTags = $derived(
canonicalTagsSorted.length > 0 || peersWithTags.length > 0,
);
// Initial tab follows the currently selected ref type; the user can switch
// freely after that.
// svelte-ignore state_referenced_locally
let activeTab: "branches" | "tags" = $state(
selectedRefType === "tag" ? "tags" : "branches",
);
$effect(() => {
if (!hasTags && activeTab === "tags") {
activeTab = "branches";
}
});
const peersForActiveTab = $derived(
activeTab === "branches" ? peersWithBranches : peersWithTags,
);
let searchInput = $state("");
let debouncedSearch = $state("");
$effect(() => {
const value = searchInput;
const timer = setTimeout(() => {
debouncedSearch = value;
}, 100);
return () => clearTimeout(timer);
});
type SearchPeer = { id: string; alias?: string; delegate: boolean };
type SearchElement = {
peer?: SearchPeer;
revision: string;
oid: string;
type: "branch" | "tag";
};
const branchElements = $derived<SearchElement[]>([
...canonicalBranchNames.map(name => ({
revision: name,
oid: refs?.canonical.branches[name] ?? "",
type: "branch" as const,
})),
...peersWithBranches.flatMap(remote =>
Object.entries(remote.branches).map(([name, oid]) => ({
peer: { id: remote.id, alias: remote.alias, delegate: remote.delegate },
revision: name,
oid,
type: "branch" as const,
})),
),
]);
const tagElements = $derived<SearchElement[]>([
...canonicalTagsSorted.map(([name, tag]) => ({
revision: name,
oid: tag.oid,
type: "tag" as const,
})),
...peersWithTags.flatMap(remote =>
Object.entries(remote.tags).map(([name, tag]) => ({
peer: { id: remote.id, alias: remote.alias, delegate: remote.delegate },
revision: name,
oid: tag.oid,
type: "tag" as const,
})),
),
]);
const searchElements = $derived(
activeTab === "branches" ? branchElements : tagElements,
);
const searchResults = $derived(
fuzzysort.go(debouncedSearch, searchElements, {
keys: ["peer.alias", "revision"],
threshold: 0.5,
}),
);
function isElementSelected(el: SearchElement): boolean {
if (el.peer === undefined) {
return peer === undefined && revision === el.revision;
}
return peer === el.peer.id && revision === el.revision;
}
function routeFor(el: SearchElement): string {
return router.routeToPath({
...baseRoute,
peer: el.peer?.id,
revision: el.revision,
});
}
function canonicalRouteFor(revision: string | undefined): string {
return router.routeToPath({
...baseRoute,
peer: undefined,
revision,
});
}
function displayLabel(): string {
if (revision !== undefined) return revision;
return defaultBranch;
}
function shortOid(oid: string): string {
return oid.slice(0, 7);
}
</script>
<style>
.dropdown {
width: 34rem;
background-color: var(--color-surface-canvas);
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-sm);
padding: 0.25rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem 0.25rem;
font: var(--txt-body-s-regular);
color: var(--color-text-tertiary);
}
.canonical-row {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border-radius: var(--border-radius-sm);
cursor: pointer;
color: var(--color-text-primary);
font: var(--txt-body-m-regular);
text-decoration: none;
}
.canonical-row:hover {
background-color: var(--color-surface-subtle);
}
.canonical-row.selected {
background-color: var(--color-surface-subtle);
}
.canonical-badge {
padding: 0 0.375rem;
font: var(--txt-body-s-regular);
background-color: var(--color-surface-brand-primary);
color: var(--color-text-on-brand);
border-radius: var(--border-radius-sm);
height: 1.25rem;
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.trigger-content .canonical-badge {
margin-left: auto;
}
.trigger-content {
display: flex;
align-items: center;
gap: 0.375rem;
max-width: 100%;
min-width: 0;
}
.trigger-ref {
display: flex;
align-items: center;
gap: 0.25rem;
min-width: 0;
}
.trigger-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 14rem;
}
.trigger-peer {
display: inline-flex;
align-items: center;
gap: 0.25rem;
min-width: 0;
}
.trigger-peer-avatar {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.trigger-peer-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 8rem;
}
.trigger-peer-name.no-alias {
color: var(--color-text-secondary);
}
.trigger-delegate {
color: var(--color-text-brand);
display: inline-flex;
align-items: center;
}
.trigger-separator {
color: var(--color-text-tertiary);
}
.header-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem;
margin-bottom: 0.25rem;
}
.tabs {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.tab {
background: transparent;
border: none;
padding: 0.375rem 0.75rem;
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
cursor: pointer;
border-radius: var(--border-radius-sm);
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.tab.active {
background-color: var(--color-surface-subtle);
color: var(--color-text-primary);
}
.tab:hover:not(.active) {
background-color: var(--color-surface-subtle);
}
.search {
flex: 1;
min-width: 0;
}
.result-row {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border-radius: var(--border-radius-sm);
cursor: pointer;
color: var(--color-text-primary);
font: var(--txt-body-m-regular);
text-decoration: none;
min-width: 0;
}
.result-row:hover {
background-color: var(--color-surface-subtle);
}
.result-row.selected {
background-color: var(--color-surface-subtle);
}
.result-peer {
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.result-peer-avatar {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.result-peer-name {
color: var(--color-text-secondary);
}
.result-peer-name.no-alias {
color: var(--color-text-tertiary);
}
.result-delegate {
color: var(--color-text-brand);
display: inline-flex;
align-items: center;
}
.result-separator {
color: var(--color-text-tertiary);
}
.result-name {
display: flex;
align-items: center;
gap: 0.375rem;
overflow: hidden;
min-width: 0;
flex: 1;
}
.result-name-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
padding: 0.75rem;
color: var(--color-text-tertiary);
font: var(--txt-body-m-regular);
text-align: center;
}
</style>
<Popover popoverPadding="0">
{#snippet toggle(toggleFn)}
<Button
variant="ghost"
styleHeight="1.75rem"
onclick={toggleFn}
title="Change branch or tag">
<div class="trigger-content">
{#if displayPeer}
<span class="trigger-peer">
<span class="trigger-peer-avatar">
<UserAvatar nodeId={displayPeer.id} styleWidth="1rem" />
</span>
{#if displayPeer.alias}
<span class="trigger-peer-name">{displayPeer.alias}</span>
{:else}
<span class="trigger-peer-name no-alias">
{truncateId(displayPeer.id)}
</span>
{/if}
{#if displayPeer.delegate}
<span class="trigger-delegate" title="Delegate">
<Icon name="badge" />
</span>
{/if}
</span>
<span class="trigger-separator">/</span>
{/if}
<div class="trigger-ref">
<Icon name={selectedRefType === "tag" ? "label" : "branch"} />
<span class="trigger-name">{displayLabel()}</span>
</div>
{#if peer === undefined}
<span class="canonical-badge">Canonical</span>
{/if}
<Icon name="chevron-down" />
</div>
</Button>
{/snippet}
{#snippet popover()}
<div class="dropdown">
{#if refs === undefined}
<div class="empty">Loading…</div>
{:else}
<div class="header-row">
{#if hasTags}
<div class="tabs">
<button
class="tab"
class:active={activeTab === "branches"}
onclick={() => {
activeTab = "branches";
searchInput = "";
}}>
<Icon name="branch" />Branches
</button>
<button
class="tab"
class:active={activeTab === "tags"}
onclick={() => {
activeTab = "tags";
searchInput = "";
}}>
<Icon name="label" />Tags
</button>
</div>
{/if}
<div class="search">
<TextInput
placeholder={activeTab === "branches"
? "Filter branches"
: "Filter tags"}
bind:value={searchInput}>
{#snippet left()}
<div
style:color="var(--color-text-secondary)"
style:padding-left="0.5rem">
<Icon name="filter" />
</div>
{/snippet}
</TextInput>
</div>
</div>
<ScrollArea style="max-height: calc(60vh - 3rem);">
{#if debouncedSearch !== ""}
{#if searchResults.total > 0}
{#each searchResults as result (result.obj)}
{@const el = result.obj}
<a
class="result-row"
class:selected={isElementSelected(el)}
href={routeFor(el)}
onclick={() => closeFocused()}>
<div class="result-name">
<Icon name={el.type === "tag" ? "label" : "branch"} />
{#if el.peer}
<span class="result-peer">
<div class="result-peer-avatar">
<UserAvatar nodeId={el.peer.id} styleWidth="1rem" />
</div>
{#if el.peer.alias}
<span class="result-peer-name">{el.peer.alias}</span>
{:else}
<span class="result-peer-name no-alias">
{truncateId(el.peer.id)}
</span>
{/if}
{#if el.peer.delegate}
<span class="result-delegate" title="Delegate">
<Icon name="badge" />
</span>
{/if}
</span>
<span class="result-separator">/</span>
{/if}
<span class="result-name-text">{el.revision}</span>
{#if el.peer === undefined}
<span class="canonical-badge">Canonical</span>
{/if}
</div>
<span class="txt-id">{shortOid(el.oid)}</span>
</a>
{/each}
{:else}
<div class="empty">No matches</div>
{/if}
{:else if activeTab === "branches"}
<div class="section-header">
<span>Branch</span>
<span>Head</span>
</div>
{#each [defaultBranch, ...canonicalBranchNames.filter(n => n !== defaultBranch)] as name (name)}
<a
class="canonical-row"
class:selected={peer === undefined &&
(name === defaultBranch
? onCanonicalDefault
: revision === name)}
href={canonicalRouteFor(
name === defaultBranch ? undefined : name,
)}
onclick={() => closeFocused()}>
<div class="result-name">
<Icon name="branch" />
<span class="result-name-text">{name}</span>
<span class="canonical-badge">Canonical</span>
</div>
<span class="txt-id">
{shortOid(
refs?.canonical.branches[name] ?? project.meta.head,
)}
</span>
</a>
{/each}
{:else if activeTab === "tags" && canonicalTagsSorted.length > 0}
<div class="section-header">
<span>Tag</span>
<span>Head</span>
</div>
{#each canonicalTagsSorted as [name, tag] (name)}
<a
class="canonical-row"
class:selected={peer === undefined && revision === name}
href={canonicalRouteFor(name)}
onclick={() => closeFocused()}>
<div class="result-name">
<TagIcon {tag} />
<span class="result-name-text">{name}</span>
<span class="canonical-badge">Canonical</span>
</div>
<span class="txt-id">{shortOid(tag.oid)}</span>
</a>
{/each}
{/if}
{#if debouncedSearch === "" && peersForActiveTab.length > 0}
{#if activeTab === "tags" && canonicalTagsSorted.length === 0}
<div class="section-header">
<span></span>
<span>Head</span>
</div>
{/if}
{#each peersForActiveTab as remote (remote.id)}
<PeerEntry
{baseRoute}
{remote}
selected={selectedPeer?.id === remote.id}
{revision}
tab={activeTab}
onSelect={() => closeFocused()} />
{/each}
{/if}
</ScrollArea>
{/if}
</div>
{/snippet}
</Popover>
@@ -0,0 +1,78 @@
<script lang="ts">
import type { Tag } from "@bindings/repo/Tag";
import {
absoluteTimestamp,
formatTimestamp,
gravatarURL,
} from "@app/lib/utils";
import HoverPopover from "@app/components/HoverPopover.svelte";
import Icon from "@app/components/Icon.svelte";
const { tag }: { tag: Tag } = $props();
const annotated = $derived(
tag.tagger !== undefined || tag.message !== undefined,
);
</script>
<style>
.tag-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 32rem;
}
.tag-tagger {
display: flex;
align-items: center;
gap: 0.375rem;
white-space: nowrap;
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
}
.tag-avatar {
width: 1rem;
height: 1rem;
border-radius: var(--border-radius-round);
}
.tag-message {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font: var(--txt-code-small);
color: var(--color-text-primary);
}
</style>
{#if annotated}
<HoverPopover>
{#snippet toggle()}
<Icon name="label" />
{/snippet}
{#snippet popover()}
<div class="tag-details">
{#if tag.tagger}
<div
class="tag-tagger"
title={`${tag.tagger.name} <${tag.tagger.email}>`}>
<img
class="tag-avatar"
alt="avatar"
src={gravatarURL(tag.tagger.email)} />
{tag.tagger.name} tagged
<span title={absoluteTimestamp(tag.tagger.timestamp * 1000)}>
{formatTimestamp(tag.tagger.timestamp * 1000)}
</span>
</div>
{/if}
{#if tag.message}
<pre class="tag-message txt-selectable">{tag.message}</pre>
{/if}
</div>
{/snippet}
</HoverPopover>
{:else}
<Icon name="label" />
{/if}
+32 -13
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
let { preview = $bindable(true) }: { preview?: boolean } = $props();
@@ -9,27 +8,47 @@
.container {
display: flex;
align-items: center;
gap: 0.25rem;
}
.toggle {
display: flex;
align-items: center;
gap: 0.25rem;
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
padding: 0.25rem 0.5rem;
border: 0;
border-radius: var(--border-radius-sm);
background-color: transparent;
cursor: pointer;
white-space: nowrap;
}
.toggle:hover {
background-color: var(--color-surface-strong);
color: var(--color-text-primary);
}
.toggle.active {
background-color: var(--color-surface-strong);
color: var(--color-text-primary);
}
</style>
<div class="container">
<Button
variant="ghost"
flatRight
active={!preview}
<button
class="toggle"
class:active={!preview}
onclick={() => {
preview = !preview;
preview = false;
}}>
<Icon name="code" />Code
</Button>
</button>
<Button
variant="ghost"
flatLeft
active={preview}
<button
class="toggle"
class:active={preview}
onclick={() => {
preview = !preview;
preview = true;
}}>
<Icon name="eye" />Preview
</Button>
</button>
</div>
-13
View File
@@ -10,8 +10,6 @@
import CheckoutRepoButton from "@app/components/CheckoutRepoButton.svelte";
import HoverPopover from "@app/components/HoverPopover.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import JobCob from "@app/components/JobCob.svelte";
import UserAvatar from "@app/components/UserAvatar.svelte";
import VisibilityBadge from "@app/components/VisibilityBadge.svelte";
@@ -42,7 +40,6 @@
flex-direction: row;
gap: 1rem;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--color-border-subtle);
flex-shrink: 0;
}
.project {
@@ -117,16 +114,6 @@
<div class="meta">
<VisibilityBadge type={repo.visibility.type} />
<div class="meta-item">
<span class="meta-label">{project.data.defaultBranch}</span>
<Icon name="arrow-right" />
<Id
id={project.meta.head}
clipboard={project.meta.head}
placement="bottom-start" />
<JobCob rid={repo.rid} commit={project.meta.head} />
</div>
<div class="meta-item">
<span class="meta-label">Delegates</span>
<span class="meta-value">{repo.threshold}/{repo.delegates.length}</span>
+1 -40
View File
@@ -46,11 +46,7 @@
import { nodeRunning } from "@app/lib/events";
import { dynamicInterval, resetDynamicInterval } from "@app/lib/interval";
import {
cachedRepoCommitCount,
invoke,
writeToClipboard,
} from "@app/lib/invoke";
import { invoke, writeToClipboard } from "@app/lib/invoke";
import * as router from "@app/lib/router";
import { explorerUrl, formatRepositoryId } from "@app/lib/utils";
@@ -78,7 +74,6 @@
let repos: RepoSummary[] = $derived(initialRepos);
let seededNotReplicated: string[] = $derived(initialSeededNotReplicated);
let activeCommitCount = $state<number | undefined>(undefined);
let filterInputElement: HTMLInputElement | undefined = $state(undefined);
let contextMenu = $state<
@@ -100,25 +95,6 @@
contextMenu = undefined;
}
$effect(() => {
const rid = activeRepo?.rid;
const head = activeRepo?.payloads["xyz.radicle.project"]?.meta.head;
activeCommitCount = undefined;
if (!rid || !head) return;
void cachedRepoCommitCount(rid, head)
.then(count => {
if (activeRepo?.rid === rid) {
activeCommitCount = count;
}
})
.catch(error => {
console.error("Failed to load commit count", error);
});
});
$effect(() => {
if (filterOpen && filterInputElement) {
filterInputElement.focus({ preventScroll: true });
@@ -237,14 +213,6 @@
);
}
function isCommits(rid: string): boolean {
return (
($activeRoute.resource === "repo.commits" ||
$activeRoute.resource === "repo.commit") &&
activeRid() === rid
);
}
function isPatches(rid: string): boolean {
return (
($activeRoute.resource === "repo.patches" ||
@@ -668,13 +636,6 @@
</a>
{#if activeRid() === repo.rid}
{@const activeProject = activeRepo?.payloads["xyz.radicle.project"]}
{@render subItem(
router.routeToPath({ resource: "repo.commits", rid: repo.rid }),
"branch",
"Commits",
isCommits(repo.rid),
activeCommitCount,
)}
{@render subItem(
router.routeToPath({
resource: "repo.issues",
+210
View File
@@ -0,0 +1,210 @@
<script lang="ts">
import type { SourceBaseRoute } from "@app/views/repo/router";
import type { Commit } from "@bindings/repo/Commit";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { RepoRefs } from "@bindings/repo/RepoRefs";
import type { Snippet } from "svelte";
import { cachedRepoCommitCount, invoke } from "@app/lib/invoke";
import * as router from "@app/lib/router";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
import JobCob from "@app/components/JobCob.svelte";
import PeerSelector from "@app/components/PeerSelector/PeerSelector.svelte";
interface Props {
repo: RepoInfo;
peer?: string;
revision?: string;
oid: string;
commit?: Commit;
baseRoute: SourceBaseRoute;
active: "files" | "commits";
extra?: Snippet;
}
const { repo, peer, revision, oid, commit, baseRoute, active, extra }: Props =
$props();
// The commit count requires a full history walk on the backend, so it is
// fetched after render instead of blocking navigation. The key guard keeps
// navigations that only swap object identity (e.g. picking a branch in the
// same repo) from clearing already-loaded state.
let commitCount: number | undefined = $state();
let commitCountKey: string | undefined;
$effect(() => {
const requested = `${repo.rid}:${oid}`;
if (commitCountKey === requested) {
return;
}
commitCountKey = requested;
commitCount = undefined;
void cachedRepoCommitCount(repo.rid, oid)
.then(count => {
if (commitCountKey === requested) {
commitCount = count;
}
})
.catch(error => {
if (commitCountKey === requested) {
commitCountKey = undefined;
}
console.error("Failed to load commit count", error);
});
});
// Enumerating refs requires loading and verifying sigrefs for every remote,
// which takes hundreds of milliseconds on repos with many peers. The result
// only feeds the peer selector dropdown, so it loads after render and is
// kept across navigations within the same repo.
let refs: RepoRefs | undefined = $state();
let refsRid: string | undefined;
$effect(() => {
const requested = repo.rid;
if (refsRid === requested) {
return;
}
refsRid = requested;
refs = undefined;
void invoke<RepoRefs>("list_repo_refs", { rid: requested })
.then(result => {
if (refsRid === requested) {
refs = result;
}
})
.catch(error => {
if (refsRid === requested) {
refsRid = undefined;
}
console.error("Failed to load repo refs", error);
});
});
</script>
<style>
.section {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--color-border-subtle);
flex-shrink: 0;
}
.tabs {
display: flex;
align-items: center;
gap: 0.25rem;
}
.tab {
display: flex;
align-items: center;
gap: 0.25rem;
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
padding: 0.25rem 0.5rem;
border-radius: var(--border-radius-sm);
text-decoration: none;
cursor: pointer;
white-space: nowrap;
}
.tab:hover {
background-color: var(--color-surface-subtle);
color: var(--color-text-primary);
}
.tab.active {
background-color: var(--color-surface-subtle);
color: var(--color-text-primary);
}
.tab .global-counter-badge {
margin-left: 0.25rem;
}
.selector-group {
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
}
.commit-summary {
color: var(--color-text-secondary);
font: var(--txt-body-m-regular);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 10rem;
}
</style>
<div class="section">
<div class="tabs">
<a
class="tab"
class:active={active === "files"}
href={router.routeToPath({
resource: "repo.home",
rid: repo.rid,
peer,
revision,
})}>
<Icon name="document" />Files
</a>
<a
class="tab"
class:active={active === "commits"}
href={router.routeToPath({
resource: "repo.commits",
rid: repo.rid,
peer,
revision,
})}>
<Icon name="commit" />Commits
{#if commitCount !== undefined}
<span class="global-counter-badge">{commitCount}</span>
{/if}
</a>
</div>
{#if extra}
{@render extra()}
{/if}
<div class="selector-group">
<PeerSelector {baseRoute} {repo} {refs} {peer} {revision} />
<Button
variant="ghost"
styleHeight="1.75rem"
title={commit?.summary}
onclick={() => {
void router.push({
resource: "repo.commit",
rid: repo.rid,
commit: oid,
});
}}>
<span class="txt-id">{oid.slice(0, 7)}</span>
{#if commit}
<span class="commit-summary">
{commit.summary}
</span>
{/if}
</Button>
{#if peer !== undefined || revision !== undefined}
<Button
variant="outline"
styleHeight="1.75rem"
title="Reset to canonical default branch"
onclick={() => {
void router.push({
...baseRoute,
peer: undefined,
revision: undefined,
});
}}>
<Icon name="close" />
</Button>
{/if}
</div>
<JobCob rid={repo.rid} commit={oid} styleHeight="1.75rem" />
</div>
+6 -3
View File
@@ -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?.()}
<input
style:padding={left ? "0.25rem 0.75rem 0.25rem 0" : "0.25rem 0.75rem"}
+77 -112
View File
@@ -6,7 +6,6 @@
import { COMMITS_PAGE_SIZE } from "@app/views/repo/router";
import fuzzysort from "fuzzysort";
import { cachedRepoCommitCount } from "@app/lib/invoke";
import { invoke } from "@app/lib/invoke";
import * as mutexExecutor from "@app/lib/mutexExecutor";
import * as router from "@app/lib/router";
@@ -14,34 +13,29 @@
import CobCommitTeaser from "@app/components/CobCommitTeaser.svelte";
import FuzzySearch from "@app/components/FuzzySearch.svelte";
import Icon from "@app/components/Icon.svelte";
import InfiniteScrollSentinel from "@app/components/InfiniteScrollSentinel.svelte";
import Topbar from "@app/components/Topbar.svelte";
import RepoHeader from "@app/components/RepoHeader.svelte";
import ScrollArea from "@app/components/ScrollArea.svelte";
import SourceHeader from "@app/components/SourceHeader.svelte";
import Layout from "./Layout.svelte";
interface Props {
repo: RepoInfo;
peer?: string;
revision?: string;
oid: string;
commit: Commit;
commits: PaginatedQuery<Commit[]>;
}
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<PaginatedQuery<Commit[]>>("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 @@
}
</style>
<Layout>
<Layout selfScroll>
<div class="page">
<Topbar>
<span class="topbar-title">
Commits
<span class="branch-group">
<Icon name="branch" />
<span class="branch">{project.data.defaultBranch}</span>
<span class="canonical-badge">canonical</span>
</span>
{#if commitCount !== undefined}
<span class="global-counter-badge">{commitCount}</span>
{/if}
</span>
<div class="global-flex" style:margin-left="auto" style:gap="0.5rem">
<RepoHeader {repo} />
<SourceHeader
{repo}
{peer}
{revision}
{oid}
{commit}
{baseRoute}
active="commits">
{#snippet extra()}
<FuzzySearch
hasItems={items.length > 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} />
</div>
</Topbar>
<div class="content">
{#each groupedCommits as group (group.key)}
<section class="group">
<div class="group-title">{group.label}</div>
<div class="commit-list">
{#each group.commits as commit (commit.id)}
<div
class="commit-item"
role="button"
tabindex="0"
onclick={() => {
void router.push({
resource: "repo.commit",
rid: repo.rid,
commit: commit.id,
});
}}
onkeydown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
{/snippet}
</SourceHeader>
<ScrollArea style="flex: 1; min-height: 0;">
<div class="content">
{#each groupedCommits as group (group.key)}
<section class="group">
<div class="group-title">{group.label}</div>
<div class="commit-list">
{#each group.commits as commit (commit.id)}
<div
class="commit-item"
role="button"
tabindex="0"
onclick={() => {
void router.push({
resource: "repo.commit",
rid: repo.rid,
commit: commit.id,
});
}
}}>
<CobCommitTeaser {commit} disabled={false} flush hoverable />
</div>
{/each}
</div>
</section>
{/each}
}}
onkeydown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
void router.push({
resource: "repo.commit",
rid: repo.rid,
commit: commit.id,
});
}
}}>
<CobCommitTeaser {commit} disabled={false} flush hoverable />
</div>
{/each}
</div>
</section>
{/each}
{#if filteredCommits.length === 0}
<div
class="global-flex"
style:flex="1"
style:justify-content="center"
style:align-items="center">
{#if filteredCommits.length === 0}
<div
class="txt-missing txt-body-m-regular global-flex"
style:gap="0.25rem">
{#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">
<div
class="txt-missing txt-body-m-regular global-flex"
style:gap="0.25rem">
{#if items.length > 0}
No matching commits
{:else}
No commits
{/if}
</div>
</div>
</div>
{/if}
{/if}
<InfiniteScrollSentinel
onIntersect={loadMoreContent}
disabled={!more || loadingMore} />
</div>
<InfiniteScrollSentinel
onIntersect={loadMoreContent}
disabled={!more || loadingMore} />
</div>
</ScrollArea>
</div>
</Layout>
+42 -13
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Commit } from "@bindings/repo/Commit";
import type { Readme } from "@bindings/repo/Readme";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { Blob } from "@bindings/source/Blob";
@@ -19,19 +20,31 @@
import PreviewSwitch from "@app/components/PreviewSwitch.svelte";
import RepoHeader from "@app/components/RepoHeader.svelte";
import ScrollArea from "@app/components/ScrollArea.svelte";
import SourceHeader from "@app/components/SourceHeader.svelte";
import TreeComponent from "@app/components/Tree.svelte";
import Layout from "@app/views/repo/Layout.svelte";
interface Props {
tree: Tree;
repo: RepoInfo;
peer?: string;
revision?: string;
oid: string;
commit: Commit;
readme: Readme | null;
}
/* eslint-disable prefer-const */
let { tree, readme, repo }: Props = $props();
let { tree, readme, repo, peer, revision, oid, commit }: Props = $props();
/* eslint-enable prefer-const */
const baseRoute = $derived({
resource: "repo.home" as const,
rid: repo.rid,
peer,
revision,
});
let currentPath = $state("");
let codeElement: HTMLElement | undefined = $state();
let error: InvokeError | undefined = $state();
@@ -46,12 +59,16 @@
}
async function fetchTree(path: string) {
return await invoke<Tree>("repo_tree", { rid: repo.rid, path });
return await invoke<Tree>("repo_tree", { rid: repo.rid, path, sha: oid });
}
async function fetchBlob(path: string) {
try {
blob = await invoke<Blob>("repo_blob", { rid: repo.rid, path });
blob = await invoke<Blob>("repo_blob", {
rid: repo.rid,
path,
sha: oid,
});
currentPath = path;
error = undefined;
} catch (err) {
@@ -125,6 +142,14 @@
style:flex-direction="column"
style:height="100%">
<RepoHeader {repo} />
<SourceHeader
{repo}
{peer}
{revision}
{oid}
{commit}
{baseRoute}
active="files" />
<div
style:display="grid"
style:grid-template-columns="16.5rem 1fr"
@@ -163,33 +188,37 @@
</div>
</div>
{:else}
<FileBlock expandable={false} sticky={false} border={false}>
<FileBlock
expandable={false}
sticky={false}
border={false}
headerBackground="var(--color-surface-subtle)">
{#snippet leftHeader()}
<div style:margin-left="0.5rem">
<div style:margin-left="0.5rem" style:flex-shrink="0">
<Path fullPath={currentPath} />
</div>
{/snippet}
{#snippet rightHeader()}
{#if blob}
<div
style:display="flex"
style:gap="0.5rem"
style:align-items="center"
style:justify-content="center"
style:max-width="fit-content">
style:min-width="0"
style:margin-right="1rem"
style:background-color="var(--color-surface-mid)"
style:padding="2px 4px"
style:border-radius="var(--border-radius-sm)">
<Id
id={blob.commit.id}
clipboard={blob.commit.id}
placement="bottom-start" />
<span
class="commit-msg txt-overflow"
style:max-width="20rem">
<span class="commit-msg txt-overflow" style:min-width="0">
{blob.commit.message}
</span>
</div>
{/if}
{/snippet}
{#snippet rightHeader()}
{#if isMarkdownPath(currentPath)}
<PreviewSwitch bind:preview />
{/if}
+117 -25
View File
@@ -28,15 +28,20 @@ export const COMMITS_PAGE_SIZE = 300;
export interface RepoHomeRoute {
resource: "repo.home";
sha?: string;
rid: string;
peer?: string;
revision?: string;
}
export interface RepoCommitsRoute {
resource: "repo.commits";
rid: string;
peer?: string;
revision?: string;
}
export type SourceBaseRoute = RepoHomeRoute | RepoCommitsRoute;
export interface RepoCommitRoute {
resource: "repo.commit";
rid: string;
@@ -54,7 +59,10 @@ export interface LoadedRepoHomeRoute {
resource: "repo.home";
params: {
repo: RepoInfo;
sha?: string;
peer?: string;
revision?: string;
oid: string;
commit: Commit;
tree: Tree;
readme: Readme | null;
sidebarData: SidebarData;
@@ -65,6 +73,10 @@ export interface LoadedRepoCommitsRoute {
resource: "repo.commits";
params: {
repo: RepoInfo;
peer?: string;
revision?: string;
oid: string;
commit: Commit;
commits: PaginatedQuery<Commit[]>;
sidebarData: SidebarData;
};
@@ -248,40 +260,84 @@ export async function loadPatches(
};
}
export async function loadRepoHome(
route: RepoHomeRoute,
): Promise<LoadedRepoHomeRoute> {
const [sidebarData, repo, readme, tree] = await Promise.all([
interface SourceContext {
sidebarData: SidebarData;
repo: RepoInfo;
peer?: string;
revision?: string;
oid: string;
commit: Commit;
}
// Revision resolution happens in the backend: content commands accept
// (peer, revision) directly, so everything loads in parallel. The resolved
// OID is read off the commit response. Repo refs for the peer selector are
// expensive to enumerate and are fetched lazily by the SourceHeader after
// render instead of blocking navigation here.
async function loadSourceContext(route: {
rid: string;
peer?: string;
revision?: string;
}): Promise<SourceContext> {
const [sidebarData, repo, commit] = await Promise.all([
loadSidebarData(),
invoke<RepoInfo>("repo_by_id", {
rid: route.rid,
}),
invoke<Commit>("repo_commit", {
rid: route.rid,
peer: route.peer,
revision: route.revision,
}),
]);
return {
sidebarData,
repo,
peer: route.peer,
revision: route.revision,
oid: commit.id,
commit,
};
}
export async function loadRepoHome(
route: RepoHomeRoute,
): Promise<LoadedRepoHomeRoute> {
const [context, readme, tree] = await Promise.all([
loadSourceContext(route),
invoke<Readme | null>("repo_readme", {
rid: route.rid,
peer: route.peer,
revision: route.revision,
}),
invoke<Tree>("repo_tree", {
rid: route.rid,
path: "",
sha: route.sha,
peer: route.peer,
revision: route.revision,
}),
]);
return {
resource: "repo.home",
params: { sidebarData, repo, sha: route.sha, readme, tree },
params: {
...context,
readme,
tree,
},
};
}
export async function loadRepoCommits(
route: RepoCommitsRoute,
): Promise<LoadedRepoCommitsRoute> {
const [sidebarData, repo, commits] = await Promise.all([
loadSidebarData(),
invoke<RepoInfo>("repo_by_id", {
rid: route.rid,
}),
const [context, commits] = await Promise.all([
loadSourceContext(route),
invoke<PaginatedQuery<Commit[]>>("list_repo_commits", {
rid: route.rid,
peer: route.peer,
revision: route.revision,
skip: 0,
take: COMMITS_PAGE_SIZE,
}),
@@ -289,7 +345,10 @@ export async function loadRepoCommits(
return {
resource: "repo.commits",
params: { sidebarData, repo, commits },
params: {
...context,
commits,
},
};
}
@@ -381,10 +440,23 @@ export function repoRouteToPath(route: RepoRoute): string {
const searchParams = new URLSearchParams();
if (route.resource === "repo.home") {
const url = [...pathSegments, "home"].join("/");
return url;
const segments = [...pathSegments, "home"];
if (route.peer !== undefined) {
segments.push("remotes", route.peer);
}
if (route.revision !== undefined) {
segments.push(route.revision);
}
return segments.join("/");
} else if (route.resource === "repo.commits") {
return [...pathSegments, "commits"].join("/");
const segments = [...pathSegments, "commits"];
if (route.peer !== undefined) {
segments.push("remotes", route.peer);
}
if (route.revision !== undefined) {
segments.push(route.revision);
}
return segments.join("/");
} else if (route.resource === "repo.commit") {
return [...pathSegments, "commits", route.commit].join("/");
} else if (route.resource === "repo.issue") {
@@ -430,15 +502,35 @@ export function repoUrlToRoute(
if (rid) {
if (resource === "home") {
return { resource: "repo.home", rid, sha: segments.shift() };
} else if (resource === "commits") {
const sha = segments.shift();
if (sha) {
return { resource: "repo.commit", rid, commit: sha };
let peer: string | undefined;
if (segments[0] === "remotes") {
segments.shift();
peer = segments.shift();
}
return { resource: "repo.commits", rid };
const revision = segments.length > 0 ? segments.join("/") : undefined;
return { resource: "repo.home", rid, peer, revision };
} else if (resource === "commits") {
let peer: string | undefined;
if (segments[0] === "remotes") {
segments.shift();
peer = segments.shift();
}
if (segments.length === 0) {
return { resource: "repo.commits", rid, peer };
}
if (
peer === undefined &&
segments.length === 1 &&
/^[0-9a-f]{40}$/.test(segments[0])
) {
return { resource: "repo.commit", rid, commit: segments[0] };
}
return {
resource: "repo.commits",
rid,
peer,
revision: segments.join("/"),
};
} else if (resource === "issues") {
const idOrAction = segments.shift();
if (idOrAction) {