Show canonical branches and tags in peer selector

This commit is contained in:
Rūdolfs Ošiņš
2026-04-22 09:45:00 +02:00
parent 42bc27c1f8
commit ecbea99f56
14 changed files with 631 additions and 83 deletions
+4
View File
@@ -3,9 +3,11 @@ import type {
Blob,
DiffResponse,
Job,
PeerRefs,
Remote,
Repo,
RepoListQuery,
TagInfo,
Tree,
TreeStats,
} from "./lib/repo.js";
@@ -77,6 +79,7 @@ export type {
Merge,
Patch,
PatchState,
PeerRefs,
Reaction,
Remote,
Repo,
@@ -84,6 +87,7 @@ export type {
Review,
Revision,
SeedingPolicy,
TagInfo,
Tree,
TreeStats,
Verdict,
+30 -6
View File
@@ -1,4 +1,3 @@
import type { ZodSchema } from "zod";
import type { Fetcher, RequestOptions } from "./fetcher.js";
import type { Commit, Commits } from "./repo/commit.js";
import type { Issue } from "./repo/issue.js";
@@ -15,6 +14,7 @@ import {
string,
union,
z,
ZodSchema,
} from "zod";
import {
@@ -28,6 +28,25 @@ import { issueSchema, issuesSchema } from "./repo/issue.js";
import { patchSchema, patchesSchema } from "./repo/patch.js";
import { authorSchema } from "./shared.js";
export type PeerRefs = {
id: string;
alias?: string;
delegate: boolean;
refs: Record<string, string>;
};
const tagInfoSchema = object({
commit: string(),
tagger: object({
name: string(),
email: string(),
timestamp: number(),
}).optional(),
message: string().optional(),
});
export type TagInfo = z.infer<typeof tagInfoSchema>;
const repoSchema = object({
rid: string(),
payloads: object({
@@ -59,6 +78,10 @@ const repoSchema = object({
object({ type: literal("private"), allow: optional(array(string())) }),
]),
seeding: number(),
refs: object({
tags: record(string(), tagInfoSchema),
refs: record(string(), string()),
}).optional(),
});
const reposSchema = array(repoSchema);
@@ -106,16 +129,17 @@ const treeSchema = object({
path: string(),
});
export type Remote = z.infer<typeof remoteSchema>;
export const remoteSchema = object({
const remoteSchema = object({
id: string(),
alias: string().optional(),
heads: record(string(), string()),
delegate: boolean(),
heads: record(string(), string()),
refs: record(string(), string()).optional(),
});
const remotesSchema = array(remoteSchema) satisfies ZodSchema<Remote[]>;
export type Remote = z.infer<typeof remoteSchema>;
const remotesSchema = array(remoteSchema);
export type DiffResponse = z.infer<typeof diffResponseSchema>;
-4
View File
@@ -52,10 +52,6 @@ describe("repo", () => {
await api.repo.getTree(sourceBrowsingRid, aliceMainHead, "src");
});
test("#getAllRemotes(rid)", async () => {
await api.repo.getAllRemotes(sourceBrowsingRid);
});
test("#getRemoteByPeer(rid, peer)", async () => {
await api.repo.getRemoteByPeer(sourceBrowsingRid, aliceRemote.substring(8));
});
+6
View File
@@ -73,11 +73,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";
}
@@ -158,6 +160,10 @@ html {
font: var(--txt-body-l-semibold);
}
.txt-code-small {
font: var(--txt-code-small);
}
.txt-code-regular {
font: var(--txt-code-regular);
}
+9 -2
View File
@@ -11,6 +11,8 @@
export let placeholder: string | undefined = undefined;
export let value: string | undefined = undefined;
export let size: "small" | "regular" = "regular";
export let autofocus: boolean = false;
export let autoselect: boolean = false;
export let disabled: boolean = false;
@@ -76,9 +78,14 @@
position: relative;
flex: 1;
align-items: center;
height: var(--button-regular-height);
background: var(--color-surface-base);
}
.wrapper.small {
height: var(--button-small-height);
}
.wrapper.regular {
height: var(--button-regular-height);
}
input {
background: var(--color-surface-base);
font-family: inherit;
@@ -139,7 +146,7 @@
}
</style>
<div class="wrapper">
<div class="wrapper {size}">
<input
class:invalid={!valid && value}
style:padding-right={rightContainerWidth
+24
View File
@@ -266,3 +266,27 @@ export function formatQualifiedRefname(
): string {
return peer ? `refs/namespaces/${peer}/refs/heads/${refname}` : refname;
}
export function getBranchesFromRefs(
refs: Record<string, string>,
): Record<string, string> {
const branches: Record<string, string> = {};
for (const [name, oid] of Object.entries(refs)) {
if (name.startsWith("refs/heads/")) {
branches[name.slice("refs/heads/".length)] = oid;
}
}
return branches;
}
export function getTagsFromRefs(
refs: Record<string, string>,
): Record<string, string> {
const tags: Record<string, string> = {};
for (const [name, oid] of Object.entries(refs)) {
if (name.startsWith("refs/tags/")) {
tags[name.slice("refs/tags/".length)] = oid;
}
}
return tags;
}
+2 -2
View File
@@ -2,8 +2,8 @@
import type {
BaseUrl,
CommitHeader,
PeerRefs,
Repo,
Remote,
SeedingPolicy,
Tree,
} from "@http-client";
@@ -31,7 +31,7 @@
export let commit: string;
export let commitHeaders: CommitHeader[];
export let peer: string | undefined;
export let peers: Remote[];
export let peers: PeerRefs[];
export let repo: Repo;
export let revision: string | undefined;
export let tree: Tree;
+2 -2
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import type {
BaseUrl,
PeerRefs,
Repo,
Remote,
SeedingPolicy,
Tree,
} from "@http-client";
@@ -28,7 +28,7 @@
export let commit: string;
export let path: string;
export let peer: string | undefined;
export let peers: Remote[];
export let peers: PeerRefs[];
export let repo: Repo;
export let rawPath: (commit?: string) => string;
export let revision: string | undefined;
+2 -2
View File
@@ -5,7 +5,7 @@
<script lang="ts">
import type { RepoRoute } from "../router";
import type { BaseUrl, Repo, Remote, Tree } from "@http-client";
import type { BaseUrl, PeerRefs, Repo, Tree } from "@http-client";
import type { ComponentProps } from "svelte";
import { HttpdClient } from "@http-client";
@@ -23,7 +23,7 @@
export let historyLinkActive: boolean;
export let node: BaseUrl;
export let peer: string | undefined;
export let peers: Remote[];
export let peers: PeerRefs[];
export let repo: Repo;
export let baseRoute: Extract<
RepoRoute,
+382 -31
View File
@@ -1,14 +1,23 @@
<script lang="ts">
import type { RepoRoute } from "@app/views/repos/router";
import type { Repo, Remote } from "@http-client";
import type { Repo, PeerRefs } from "@http-client";
import fuzzysort from "fuzzysort";
import orderBy from "lodash/orderBy";
import { formatCommit, formatNodeId } from "@app/lib/utils";
import {
absoluteTimestamp,
formatCommit,
formatNodeId,
formatTimestamp,
getBranchesFromRefs,
getTagsFromRefs,
gravatarURL,
} from "@app/lib/utils";
import Badge from "@app/components/Badge.svelte";
import Button from "@app/components/Button.svelte";
import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
import HoverPopover from "@app/components/HoverPopover.svelte";
import Icon from "@app/components/Icon.svelte";
import Link from "@app/components/Link.svelte";
import Peer from "./PeerBranchSelector/Peer.svelte";
@@ -22,7 +31,7 @@
>;
export let onCanonical: boolean;
export let peer: string | undefined;
export let peers: Remote[];
export let peers: PeerRefs[];
export let repo: Repo;
export let selectedBranch: string | undefined;
@@ -33,22 +42,72 @@
"</span>",
];
let searchInput = "";
let selectedTab: "branches" | "tags" = "branches";
const searchElements = [
type SearchElement = {
peer?: { id: string; alias?: string; delegate: boolean };
revision: string;
head: string;
type: "branch" | "tag";
};
$: canonicalBranchesMap = getBranchesFromRefs(repo.refs?.refs ?? {});
$: canonicalTagsInfo = Object.fromEntries(
Object.entries(repo.refs?.tags ?? {}).map(([name, info]) => [
name.slice("refs/tags/".length),
info,
]),
);
$: branchElements = [
{
peer: undefined,
revision: repo.payloads["xyz.radicle.project"].data.defaultBranch,
head: repo.payloads["xyz.radicle.project"].meta.head,
type: "branch",
},
...peers.flatMap(peer =>
Object.entries(peer.heads).map(([name, head]) => ({
...Object.entries(canonicalBranchesMap)
.filter(
([branchName]) =>
branchName !==
repo.payloads["xyz.radicle.project"].data.defaultBranch,
)
.map(([name, head]) => ({
peer: undefined,
revision: name,
head,
type: "branch",
})),
...peers.flatMap(peer => {
const peerBranches = getBranchesFromRefs(peer.refs);
return Object.entries(peerBranches).map(([name, head]) => ({
peer: { id: peer.id, alias: peer.alias, delegate: peer.delegate },
revision: name,
head,
})),
),
];
type: "branch",
}));
}),
] as SearchElement[];
$: tagElements = [
...Object.entries(canonicalTagsInfo).map(([name, info]) => ({
peer: undefined,
revision: name,
head: info.commit,
type: "tag",
})),
...peers.flatMap(peer => {
const peerTags = getTagsFromRefs(peer.refs);
return Object.entries(peerTags).map(([name, head]) => ({
peer: { id: peer.id, alias: peer.alias, delegate: peer.delegate },
revision: name,
head,
type: "tag",
}));
}),
] as SearchElement[];
$: searchElements = selectedTab === "branches" ? branchElements : tagElements;
$: selectedPeer = peers.find(p => p.id === peer);
$: searchResults = fuzzysort.go(searchInput, searchElements, {
keys: ["peer.alias", "revision"],
@@ -58,6 +117,81 @@
(r.obj.peer === undefined ? 10 : 1) *
(r.obj.peer?.alias ? 2 : 1),
});
$: canonicalTags = Object.entries(canonicalTagsInfo).sort(
([nameA, infoA], [nameB, infoB]) => {
const tsA = infoA.tagger?.timestamp ?? 0;
const tsB = infoB.tagger?.timestamp ?? 0;
if (tsA !== tsB) return tsB - tsA;
return nameB.localeCompare(nameA);
},
);
$: canonicalBranches = Object.entries(canonicalBranchesMap).filter(
([branchName]) =>
branchName !== repo.payloads["xyz.radicle.project"].data.defaultBranch,
);
$: hasTags =
Object.keys(canonicalTagsInfo).length > 0 ||
peers.some(p => Object.keys(getTagsFromRefs(p.refs)).length > 0);
$: selectedTag = (() => {
if (!selectedBranch) return undefined;
if (peer) {
const p = peers.find(x => x.id === peer);
if (!p) return undefined;
const peerTags = getTagsFromRefs(p.refs);
for (const [tagName, oid] of Object.entries(peerTags)) {
if (
oid === selectedBranch ||
encodeURIComponent(tagName) === selectedBranch
) {
return { name: tagName, peer: p };
}
}
return undefined;
}
for (const [tagName, info] of Object.entries(canonicalTagsInfo)) {
if (
info.commit === selectedBranch ||
encodeURIComponent(tagName) === selectedBranch
) {
return { name: tagName, peer: undefined };
}
}
return undefined;
})();
$: selectedTagName = selectedTag?.name;
$: selectedTagPeer = selectedTag?.peer;
let lastSelectedBranch: string | undefined;
$: {
if (selectedBranch !== lastSelectedBranch) {
if (selectedTagName) {
selectedTab = "tags";
} else if (!selectedBranch) {
selectedTab = "branches";
}
lastSelectedBranch = selectedBranch;
}
}
$: if (!hasTags && selectedTab === "tags") {
selectedTab = "branches";
}
$: isSelectedBranchCanonical = (() => {
if (onCanonical) return true;
if (!selectedBranch || peer) return false;
const branchNames = Object.keys(canonicalBranchesMap);
return (
branchNames.includes(selectedBranch) ||
branchNames.includes(decodeURIComponent(selectedBranch))
);
})();
</script>
<style>
@@ -68,6 +202,7 @@
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.25rem;
background-color: var(--color-background-default);
}
.subgrid-item {
display: grid;
@@ -100,6 +235,44 @@
height: 1rem;
font: var(--txt-body-m-regular);
}
.tabs {
display: flex;
align-items: center;
gap: 0.5rem;
}
.sticky-header {
position: sticky;
top: -0.25rem;
margin: -0.25rem -0.25rem 0;
padding: 0.25rem;
background-color: var(--color-surface-canvas);
z-index: 1;
}
.tag-details {
display: flex;
flex-direction: column;
width: 32rem;
grid-column: span 2;
color: var(--color-text-secondary);
min-width: 0;
font: var(--txt-body-m-regular);
}
.tag-tagger {
display: flex;
align-items: center;
gap: 0.375rem;
margin-bottom: 1rem;
}
.tag-avatar {
width: 1rem;
height: 1rem;
}
.tag-message {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: var(--txt-code-small);
}
@media (max-width: 719.98px) {
.dropdown {
width: 100%;
@@ -120,16 +293,17 @@
styleBorderRadius="var(--border-radius-sm) 0 0 var(--border-radius-sm)"
styleWidth="100%"
on:click={toggle}
title="Change branch"
title={hasTags ? "Change branch or tag" : "Change branch"}
disabled={!peers}>
{#if selectedPeer}
{@const displayPeer = selectedPeer || selectedTagPeer}
{#if displayPeer}
<div class="global-flex-item">
<div class="node-id">
<UserAvatar nodeId={selectedPeer.id} styleWidth="1rem" />
{selectedPeer.alias || formatNodeId(selectedPeer.id)}
<UserAvatar nodeId={displayPeer.id} styleWidth="1rem" />
{displayPeer.alias || formatNodeId(displayPeer.id)}
</div>
{#if selectedPeer.delegate}
{#if displayPeer.delegate}
<Badge size="tiny" variant="delegate">
<Icon name="badge" />
<span class="global-hide-on-small-desktop-down">Delegate</span>
@@ -137,15 +311,25 @@
{/if}
</div>
{/if}
{#if selectedPeer && selectedBranch}
{#if displayPeer && (selectedBranch || selectedTagName)}
<span>/</span>
{/if}
{#if selectedBranch}
{#if selectedTagName}
<Icon name="label" />
<span class="txt-overflow">
{selectedTagName}
</span>
{#if Object.keys(canonicalTagsInfo).includes(selectedTagName)}
<Badge title="Canonical tag" variant="foreground-emphasized">
Canonical
</Badge>
{/if}
{:else if selectedBranch}
<Icon name="branch" />
<span class="txt-overflow">
{selectedBranch}
</span>
{#if onCanonical}
{#if isSelectedBranchCanonical}
<Badge title="Canonical branch" variant="foreground-emphasized">
Canonical
</Badge>
@@ -155,34 +339,88 @@
</Button>
<div slot="popover" class="dropdown" let:toggle>
<TextInput
showKeyHint={false}
placeholder="Search"
bind:value={searchInput} />
<div class="sticky-header">
{#if hasTags}
<div class="tabs">
<Button
variant={selectedTab === "branches" ? "selected" : "background"}
on:click={() => {
selectedTab = "branches";
searchInput = "";
}}>
<Icon name="branch" />
Branches
</Button>
<Button
variant={selectedTab === "tags" ? "selected" : "background"}
on:click={() => {
selectedTab = "tags";
searchInput = "";
}}>
<Icon name="label" />
Tags
</Button>
<div class="global-hide-on-mobile-down" style:flex="1">
<TextInput
size="small"
showKeyHint={false}
placeholder={selectedTab === "branches"
? "Filter branches"
: "Filter tags"}
bind:value={searchInput} />
</div>
</div>
{:else}
<div style="margin-bottom: 0.5rem;">
<TextInput
showKeyHint={false}
placeholder="Filter branches"
bind:value={searchInput} />
</div>
{/if}
{#if hasTags}
<div
class="global-hide-on-small-desktop-up"
style="margin-bottom: 0.5rem;">
<TextInput
showKeyHint={false}
placeholder={selectedTab === "branches"
? "Filter branches"
: "Filter tags"}
bind:value={searchInput} />
</div>
{/if}
</div>
<div class="dropdown-grid">
<div class="dropdown-header">Branch</div>
<div class="dropdown-header">
{selectedTab === "branches" ? "Branch" : "Tag"}
</div>
<div class="dropdown-header" style="padding-left: 0;">Head</div>
{#if searchInput}
{#each searchResults as result}
{@const { revision, peer, head } = result.obj}
{@const { revision, peer, head, type } = result.obj}
<Link
style={subgridStyle}
route={{
...baseRoute,
peer: peer?.id,
revision: peer ? revision : undefined,
peer: type === "branch" ? peer?.id : undefined,
revision:
type === "tag" ? encodeURIComponent(revision) : revision,
}}
on:afterNavigate={() => {
searchInput = "";
toggle();
}}>
<DropdownListItem
selected={selectedPeer?.id === peer?.id &&
selectedBranch === revision}
style={subgridStyle}>
selected={type === "tag"
? selectedTagName === revision ||
selectedBranch === encodeURIComponent(revision)
: selectedPeer?.id === peer?.id &&
selectedBranch === revision}
style={`${subgridStyle} gap: inherit;`}>
<div class="global-flex-item">
<Icon name="branch" />
<Icon name={type === "tag" ? "label" : "branch"} />
<span class="txt-overflow">
{#if peer?.id}
<span class="global-flex-item">
@@ -216,7 +454,9 @@
<div class="global-flex-item">
{revision}
<Badge
title="Canonical branch"
title={type === "tag"
? "Canonical tag"
: "Canonical branch"}
variant="foreground-emphasized">
Canonical
</Badge>
@@ -237,7 +477,7 @@
No entries found
</div>
{/each}
{:else}
{:else if selectedTab === "branches"}
<Link
style={subgridStyle}
route={{ ...baseRoute, revision: undefined }}
@@ -258,12 +498,123 @@
</div>
</DropdownListItem>
</Link>
{#each canonicalBranches as [branchName, branchHead]}
<Link
style={subgridStyle}
route={{
...baseRoute,
peer: undefined,
revision: encodeURIComponent(branchName),
}}
on:afterNavigate={() => {
searchInput = "";
toggle();
}}>
<DropdownListItem
selected={!peer &&
(selectedBranch === branchName ||
selectedBranch === encodeURIComponent(branchName))}
style={subgridStyle}>
<div class="global-flex-item">
<Icon name="branch" />
<span class="txt-overflow">{branchName}</span>
<Badge
title="Canonical branch"
variant="foreground-emphasized">
Canonical
</Badge>
</div>
<div class="txt-id">
{formatCommit(branchHead)}
</div>
</DropdownListItem>
</Link>
{/each}
{#each orderBy(peers, ["delegate", o => o.alias?.toLowerCase()], ["desc", "asc"]) as peer}
<Peer
{baseRoute}
revision={selectedBranch}
peer={{ remote: peer, selected: selectedPeer?.id === peer.id }} />
{/each}
{:else if selectedTab === "tags"}
{#if canonicalTags.length > 0}
{#each canonicalTags as [tagName, info]}
{@const annotated = info.tagger || info.message}
<Link
style={subgridStyle}
route={{
...baseRoute,
peer: undefined,
revision: encodeURIComponent(tagName),
}}
on:afterNavigate={() => {
searchInput = "";
toggle();
}}>
<DropdownListItem
selected={!peer &&
(selectedBranch === tagName ||
selectedBranch === encodeURIComponent(tagName) ||
selectedTagName === tagName)}
style={subgridStyle}>
<div class="global-flex-item">
{#if annotated}
<HoverPopover>
<svelte:fragment slot="toggle">
<Icon name="label" />
</svelte:fragment>
<div slot="popover" class="tag-details">
{#if info.tagger}
<div
class="tag-tagger"
title={`${info.tagger.name} <${info.tagger.email}>`}>
<img
class="tag-avatar"
alt="avatar"
src={gravatarURL(info.tagger.email)} />
{info.tagger.name}
tagged
<span
title={absoluteTimestamp(
info.tagger.timestamp,
)}>
{formatTimestamp(info.tagger.timestamp)}
</span>
</div>
{/if}
{#if info.message}
<pre class="tag-message">{info.message}</pre>
{/if}
</div>
</HoverPopover>
{:else}
<Icon name="label" />
{/if}
<span class="txt-overflow">{tagName}</span>
<Badge
title="Canonical tag"
variant="foreground-emphasized">
Canonical
</Badge>
</div>
<div class="txt-id">
{formatCommit(info.commit)}
</div>
</DropdownListItem>
</Link>
{/each}
{/if}
{#each orderBy(peers, ["delegate", o => o.alias?.toLowerCase()], ["desc", "asc"]).filter(p => Object.keys(getTagsFromRefs(p.refs)).length > 0) as peer}
<Peer
{baseRoute}
revision={selectedBranch}
type="tags"
{selectedTagName}
peer={{
remote: peer,
selected: selectedTagPeer?.id === peer.id,
}} />
{/each}
{/if}
</div>
</div>
@@ -1,9 +1,13 @@
<script lang="ts">
import type { RepoRoute } from "@app/views/repos/router";
import type { Remote } from "@http-client";
import type { PeerRefs } from "@http-client";
import { closeFocused } from "@app/components/Popover.svelte";
import { formatCommit } from "@app/lib/utils";
import {
formatCommit,
getBranchesFromRefs,
getTagsFromRefs,
} from "@app/lib/utils";
import { replace } from "@app/lib/router";
import Badge from "@app/components/Badge.svelte";
@@ -17,12 +21,23 @@
RepoRoute,
{ resource: "repo.source" } | { resource: "repo.history" }
>;
export let peer: { remote: Remote; selected: boolean };
export let peer: { remote: PeerRefs; selected: boolean };
export let revision: string | undefined = undefined;
export let type: "branches" | "tags" = "branches";
export let selectedTagName: string | undefined = undefined;
const subgridStyle =
"display: grid; grid-template-columns: subgrid; grid-column: span 2;";
let expanded = false;
$: refs =
type === "branches"
? getBranchesFromRefs(peer.remote.refs)
: getTagsFromRefs(peer.remote.refs);
$: if (peer.selected) {
expanded = true;
}
</script>
<style>
@@ -51,26 +66,29 @@
</div>
</div>
{#if expanded}
{#each Object.entries(peer.remote.heads) as [name, head]}
{#each Object.entries(refs) as [name, head]}
<Link
style={subgridStyle}
route={{
...baseRoute,
peer: peer.remote.id,
revision: name,
revision: type === "tags" ? encodeURIComponent(name) : name,
}}
on:afterNavigate={() => closeFocused()}>
<DropdownListItem
selected={peer.selected && revision === name}
selected={type === "tags"
? peer.selected &&
(selectedTagName === name || revision === encodeURIComponent(name))
: peer.selected && revision === name}
on:click={() =>
replace({
...baseRoute,
peer: peer.remote.id,
revision: name,
revision: type === "tags" ? encodeURIComponent(name) : name,
})}
style={`${subgridStyle} padding-left: 2.3rem;`}>
<div class="global-flex-item">
<Icon name="branch" />
<Icon name={type === "branches" ? "branch" : "label"} />
<span class="txt-overflow">
{name}
</span>
+134 -22
View File
@@ -16,8 +16,9 @@ import type {
Node,
Patch,
PatchState,
Repo,
PeerRefs,
Remote,
Repo,
Revision,
SeedingPolicy,
Tree,
@@ -29,12 +30,55 @@ import { HttpdClient } from "@http-client";
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
import { cached } from "@app/lib/cache";
import { handleError, unreachableError } from "@app/views/repos/error";
import { unreachable } from "@app/lib/utils";
import {
getBranchesFromRefs,
getTagsFromRefs,
unreachable,
} from "@app/lib/utils";
import { nodePath } from "@app/views/nodes/router";
export const PATCHES_PER_PAGE = 10;
export const ISSUES_PER_PAGE = 10;
function peerHasBranches(peer: PeerRefs): boolean {
return Object.keys(peer.refs).some(name => name.startsWith("refs/heads/"));
}
function canonicalOids(
refs: Repo["refs"] | undefined,
): Array<[string, string]> {
return [
...Object.entries(refs?.refs ?? {}),
...Object.entries(refs?.tags ?? {}).map(
([name, info]): [string, string] => [name, info.commit],
),
];
}
function remoteToPeerRefs(remote: Remote): PeerRefs {
if (remote.refs) {
return {
id: remote.id,
alias: remote.alias,
delegate: remote.delegate,
refs: remote.refs,
};
}
const refs: Record<string, string> = {};
for (const [name, oid] of Object.entries(remote.heads)) {
refs[`refs/heads/${name}`] = oid;
}
return {
id: remote.id,
alias: remote.alias,
delegate: remote.delegate,
refs,
};
}
export type RepoRoute =
| RepoTreeRoute
| RepoHistoryRoute
@@ -116,7 +160,7 @@ export type RepoLoadedRoute =
seedingPolicy: SeedingPolicy;
commit: string;
repo: Repo;
peers: Remote[];
peers: PeerRefs[];
peer: string | undefined;
revision: string | undefined;
tree: Tree;
@@ -133,7 +177,7 @@ export type RepoLoadedRoute =
seedingPolicy: SeedingPolicy;
commit: string;
repo: Repo;
peers: Remote[];
peers: PeerRefs[];
peer: string | undefined;
revision: string | undefined;
tree: Tree;
@@ -373,7 +417,6 @@ async function loadTreeView(
let repoPromise: Promise<Repo>;
let seedingPolicyPromise: Promise<SeedingPolicy>;
let peersPromise: Promise<Remote[]>;
let nodePromise: Promise<Partial<Node>>;
if (
(previousLoaded.resource === "repo.source" ||
@@ -382,25 +425,25 @@ async function loadTreeView(
previousLoaded.params.peer === route.peer
) {
repoPromise = Promise.resolve(previousLoaded.params.repo);
peersPromise = Promise.resolve(previousLoaded.params.peers);
seedingPolicyPromise = Promise.resolve(previousLoaded.params.seedingPolicy);
nodePromise = Promise.resolve({
avatarUrl: previousLoaded.params.nodeAvatarUrl,
});
} else {
repoPromise = api.repo.getByRid(route.repo);
peersPromise = api.repo.getAllRemotes(route.repo);
seedingPolicyPromise = api.getPolicyByRid(route.repo);
nodePromise = api.getNode();
}
const [repo, peers, seedingPolicy, node] = await Promise.all([
const [repo, seedingPolicy, node] = await Promise.all([
repoPromise,
peersPromise,
seedingPolicyPromise,
nodePromise,
]);
const remotes = await api.repo.getAllRemotes(route.repo);
const peers: PeerRefs[] = remotes.map(remoteToPeerRefs);
if (!repo["payloads"]["xyz.radicle.project"]) {
throw new Error(
`Repository ${repo.rid} does not have a xyz.radicle.project payload.`,
@@ -411,6 +454,25 @@ async function loadTreeView(
let branchMap: Record<string, string> = {
[project.data.defaultBranch]: project.meta.head,
};
for (const [refName, oid] of canonicalOids(repo.refs)) {
const shortName = refName.startsWith("refs/heads/")
? refName.slice("refs/heads/".length)
: refName.startsWith("refs/tags/")
? refName.slice("refs/tags/".length)
: refName;
branchMap[shortName] = oid;
branchMap[encodeURIComponent(shortName)] = oid;
}
for (const peer of peers) {
const tags = getTagsFromRefs(peer.refs);
for (const [tagName, oid] of Object.entries(tags)) {
branchMap[tagName] = oid;
branchMap[encodeURIComponent(tagName)] = oid;
}
}
if (route.peer) {
const peer = peers.find(peer => peer.id === route.peer);
if (!peer) {
@@ -419,7 +481,11 @@ async function loadTreeView(
params: { title: `Peer ${route.peer} could not be found` },
};
} else {
branchMap = peer.heads;
branchMap = { ...getBranchesFromRefs(peer.refs) };
for (const [tagName, oid] of Object.entries(getTagsFromRefs(peer.refs))) {
branchMap[tagName] = oid;
branchMap[encodeURIComponent(tagName)] = oid;
}
}
}
@@ -446,7 +512,7 @@ async function loadTreeView(
seedingPolicy,
commit,
repo,
peers: peers.filter(remote => Object.keys(remote.heads).length > 0),
peers: peers.filter(peerHasBranches),
peer: route.peer,
rawPath,
revision: route.revision,
@@ -515,7 +581,6 @@ async function loadHistoryView(
let repoPromise: Promise<Repo>;
let seedingPolicyPromise: Promise<SeedingPolicy>;
let peersPromise: Promise<Remote[]>;
let nodePromise: Promise<Partial<Node>>;
if (
(previousLoaded.resource === "repo.source" ||
@@ -524,26 +589,33 @@ async function loadHistoryView(
previousLoaded.params.peer === route.peer
) {
repoPromise = Promise.resolve(previousLoaded.params.repo);
peersPromise = Promise.resolve(previousLoaded.params.peers);
seedingPolicyPromise = Promise.resolve(previousLoaded.params.seedingPolicy);
nodePromise = Promise.resolve({
avatarUrl: previousLoaded.params.nodeAvatarUrl,
});
} else {
repoPromise = api.repo.getByRid(route.repo);
peersPromise = api.repo.getAllRemotes(route.repo);
seedingPolicyPromise = api.getPolicyByRid(route.repo);
nodePromise = api.getNode();
}
const [repo, peers, seedingPolicy, branchMap, node] = await Promise.all([
const [repo, seedingPolicy, node] = await Promise.all([
repoPromise,
peersPromise,
seedingPolicyPromise,
getPeerBranches(api, route.repo, route.peer),
nodePromise,
]);
const remotes = await api.repo.getAllRemotes(route.repo);
const peers: PeerRefs[] = remotes.map(remoteToPeerRefs);
const branchMap = await getPeerBranches(
api,
route.repo,
route.peer,
repo,
peers,
);
if (!repo["payloads"]["xyz.radicle.project"]) {
throw new Error(
`Repository ${repo.rid} does not have a xyz.radicle.project payload.`,
@@ -556,8 +628,6 @@ async function loadHistoryView(
commitId = route.revision;
} else if (branchMap) {
commitId = branchMap[route.revision || project.data.defaultBranch];
} else if (!route.revision) {
commitId = project.meta.head;
}
if (!commitId) {
@@ -595,7 +665,7 @@ async function loadHistoryView(
seedingPolicy,
commit: commitId,
repo,
peers: peers.filter(remote => Object.keys(remote.heads).length > 0),
peers: peers.filter(peerHasBranches),
peer: route.peer,
revision: route.revision,
tree,
@@ -733,9 +803,51 @@ async function loadPatchView(
};
}
async function getPeerBranches(api: HttpdClient, repo: string, peer?: string) {
async function getPeerBranches(
api: HttpdClient,
repoId: string,
peer?: string,
repo?: Repo,
loadedPeers?: PeerRefs[],
) {
if (peer) {
return (await api.repo.getRemoteByPeer(repo, peer)).heads;
const remote = await api.repo.getRemoteByPeer(repoId, peer);
const refs = remoteToPeerRefs(remote).refs;
const map: Record<string, string> = { ...getBranchesFromRefs(refs) };
for (const [tagName, oid] of Object.entries(getTagsFromRefs(refs))) {
map[tagName] = oid;
map[encodeURIComponent(tagName)] = oid;
}
return map;
} else if (repo) {
const branchMap: Record<string, string> = {};
const peers = loadedPeers ?? [];
const project = repo.payloads["xyz.radicle.project"];
if (project) {
branchMap[project.data.defaultBranch] = project.meta.head;
branchMap[encodeURIComponent(project.data.defaultBranch)] =
project.meta.head;
}
for (const [refName, oid] of canonicalOids(repo.refs)) {
const shortName = refName.startsWith("refs/heads/")
? refName.slice("refs/heads/".length)
: refName.startsWith("refs/tags/")
? refName.slice("refs/tags/".length)
: refName;
branchMap[shortName] = oid;
branchMap[encodeURIComponent(shortName)] = oid;
}
for (const p of peers) {
const tags = getTagsFromRefs(p.refs);
for (const [tagName, oid] of Object.entries(tags)) {
branchMap[tagName] = oid;
branchMap[encodeURIComponent(tagName)] = oid;
}
}
return branchMap;
} else {
return undefined;
}
+1 -2
View File
@@ -349,8 +349,7 @@ test("peer and branch switching", async ({ page }) => {
await changeBranch("alice", "feature/branch", page);
await page.locator('[title="Change branch"]:visible').first().click();
await page
.getByRole("button", { name: "feature/branch" })
.first()
.getByRole("button", { name: "feature/branch 1aded56" })
.click();
await expect(
+9 -2
View File
@@ -6,8 +6,15 @@ import * as Path from "node:path";
export async function changeBranch(peer: string, branch: string, page: Page) {
await page.locator('[title="Change branch"]:visible').first().click();
const peerLocator = page.getByLabel("peer-item").filter({ hasText: peer });
await peerLocator.getByTitle("Expand peer").click();
await page.getByRole("button", { name: branch }).click();
const branchButton = page.getByRole("button", { name: branch });
const isVisible = await branchButton.isVisible().catch(() => false);
if (!isVisible) {
await peerLocator.getByTitle("Expand peer").click();
}
await branchButton.click();
}
// Create a repo using the rad CLI.