mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
Show canonical branches and tags in peer selector
This commit is contained in:
@@ -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
@@ -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>;
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user