mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-15 11:20:36 +08:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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> };
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user