mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-16 03:40:37 +08:00
Move repo facts outside the project payload
Repos need not carry a `xyz.radicle.project` payload; the default branch can come from `xyz.radicle.crefs` alone. Such repos got no counts and no default branch, and failed schema validation in explorer, which broke the parse of a whole listing. httpd: - Add top-level `cobs` with issue, patch and bucketed release counts - Add top-level `defaultBranch`, qualified like the crefs payload - Omit a count the node cannot determine instead of reporting zero - Drop `head`: the tip is `refs[defaultBranch]` - Keep counts in the project payload for older clients until a major release - Keep the project payload when a COB read fails - Keep `meta.releases` a single count: delegate + other - Log and skip canonical ref rules and refs that fail to read, so one bad ref doesn't drop the default branch tip explorer: - Model `cobs` and `defaultBranch`, adapting older nodes to one shape - Resolve the default branch from the crefs HEAD on older nodes - Collect the shims for older httpd in http-client/lib/compat.ts, each naming the httpd release that the client can drop it with - Add `defaultBranchTip`, replacing `head`, with a fallback to the legacy `meta.head` - Make the project payload and its legacy meta optional - Fall back to the RID for the repo name, and show a placeholder for a missing name in repo cards and the header - Hide a counter instead of reading a missing count as zero - Fall back to a full-page check for "More" without counts - Add a `repo.sourceUnavailable` route for repos without a default branch or tip - Browse explicit revisions and offer the branch selector without a default branch - List these repos in repo grids and on the landing page - Say the node is older or built without releases - Scope release pages in the client, so views never call the shims - Read OG worker repos through the same compat shims - Share the repo name and default branch helpers with the OG worker CI: - Add check:open-graph, a wrangler deploy dry run - Run it in GitHub CI, Buildkite and check:ci - Install explorer's dependencies first: the worker resolves compat.ts imports, such as zod, from there Tests: - Test that a build without releases omits the count - Test that one failing rule keeps the others - Test the repo shape across httpd versions and the release shims - Test `hasMorePages` and `repoName` - Test repos without a project payload end to end
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
import Release from "@app/views/repos/Release.svelte";
|
||||
import Releases from "@app/views/repos/Releases.svelte";
|
||||
import Source from "@app/views/repos/Source.svelte";
|
||||
import SourceUnavailable from "@app/views/repos/SourceUnavailable.svelte";
|
||||
import Users from "@app/views/users/View.svelte";
|
||||
|
||||
import Error from "@app/views/error/View.svelte";
|
||||
@@ -90,6 +91,8 @@
|
||||
<Users {...$activeRouteStore.params} />
|
||||
{:else if $activeRouteStore.resource === "repo.source"}
|
||||
<Source {...$activeRouteStore.params} />
|
||||
{:else if $activeRouteStore.resource === "repo.sourceUnavailable"}
|
||||
<SourceUnavailable {...$activeRouteStore.params} />
|
||||
{:else if $activeRouteStore.resource === "repo.history"}
|
||||
<History {...$activeRouteStore.params} />
|
||||
{:else if $activeRouteStore.resource === "repo.commit"}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import {
|
||||
absoluteTimestamp,
|
||||
defaultBranchTip,
|
||||
formatTimestamp,
|
||||
formatRepositoryId,
|
||||
repoSegment,
|
||||
@@ -28,6 +29,7 @@
|
||||
$: project = repoInfo.repo.payloads["xyz.radicle.project"];
|
||||
$: baseUrl = repoInfo.baseUrl;
|
||||
$: isPrivate = repo.visibility.type === "private";
|
||||
$: tip = defaultBranchTip(repo);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -126,6 +128,14 @@
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.name-placeholder {
|
||||
align-self: center;
|
||||
height: 1rem;
|
||||
width: 40%;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-mid);
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -162,11 +172,15 @@
|
||||
<div class="headline-and-badges txt-overflow">
|
||||
<div class="icon">
|
||||
<RepoAvatar
|
||||
rid={repoInfo.repo.rid}
|
||||
name={project.data.name}
|
||||
rid={repo.rid}
|
||||
name={project?.data.name ?? ""}
|
||||
styleWidth="1.5rem" />
|
||||
</div>
|
||||
<div class="txt-body-l-semibold" use:twemoji>{project.data.name}</div>
|
||||
{#if project}
|
||||
<div class="txt-body-l-semibold" use:twemoji>{project.data.name}</div>
|
||||
{:else}
|
||||
<div class="name-placeholder" title="This repository has no name"></div>
|
||||
{/if}
|
||||
<div class="badges" style:margin-left="auto">
|
||||
{#if isPrivate}
|
||||
<div
|
||||
@@ -182,7 +196,7 @@
|
||||
<div class="content-and-activity">
|
||||
<div class="content">
|
||||
<div class="txt-body-m-regular txt-overflow" use:twemoji>
|
||||
{project.data.description}
|
||||
{project?.data.description ?? ""}
|
||||
</div>
|
||||
<span style:text-overflow="ellipsis">
|
||||
<Id id={repo.rid} title={repo.rid}>
|
||||
@@ -207,20 +221,26 @@
|
||||
class="stats-row txt-code-regular"
|
||||
style:color="var(--color-text-tertiary)">
|
||||
<Icon name="seed" />
|
||||
{repoInfo.repo.seeding} ·
|
||||
<Icon name="issue" />
|
||||
{project.meta.issues.open} ·
|
||||
<Icon name="patch" />
|
||||
{project.meta.patches.open}
|
||||
{#await api.repo.getCommitBySha(repo.rid, project.meta.head) then { commit }}
|
||||
<span
|
||||
class="txt-body-m-regular"
|
||||
style:margin-left="auto"
|
||||
style:overflow="hidden"
|
||||
style:text-overflow="ellipsis"
|
||||
title={absoluteTimestamp(commit.committer.time)}>
|
||||
Updated {formatTimestamp(commit.committer.time)}
|
||||
</span>
|
||||
{repo.seeding}
|
||||
{#if repo.cobs?.issues && repo.cobs.patches}
|
||||
·
|
||||
<Icon name="issue" />
|
||||
{repo.cobs.issues.open} ·
|
||||
<Icon name="patch" />
|
||||
{repo.cobs.patches.open}
|
||||
{/if}
|
||||
{#await tip && api.repo.getCommitBySha(repo.rid, tip) then result}
|
||||
{#if result}
|
||||
{@const commit = result.commit}
|
||||
<span
|
||||
class="txt-body-m-regular"
|
||||
style:margin-left="auto"
|
||||
style:overflow="hidden"
|
||||
style:text-overflow="ellipsis"
|
||||
title={absoluteTimestamp(commit.committer.time)}>
|
||||
Updated {formatTimestamp(commit.committer.time)}
|
||||
</span>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,18 +24,16 @@ export async function fetchRepoInfos(
|
||||
repos = await api.repo.getAll(query);
|
||||
}
|
||||
|
||||
return repos
|
||||
.filter(r => Boolean(r.payloads["xyz.radicle.project"]))
|
||||
.map(repo => ({
|
||||
repo,
|
||||
baseUrl,
|
||||
activity: loadRepoActivity(repo.rid, baseUrl, activitySignal).catch(e => {
|
||||
if (import.meta.env.DEV && (e as Error)?.name !== "AbortError") {
|
||||
console.warn("loadRepoActivity failed for", repo.rid, e);
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
}));
|
||||
return repos.map(repo => ({
|
||||
repo,
|
||||
baseUrl,
|
||||
activity: loadRepoActivity(repo.rid, baseUrl, activitySignal).catch(e => {
|
||||
if (import.meta.env.DEV && (e as Error)?.name !== "AbortError") {
|
||||
console.warn("loadRepoActivity failed for", repo.rid, e);
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function sortRepoInfosByActivity(
|
||||
|
||||
@@ -46,8 +46,9 @@ export function artifactView(
|
||||
trusted: visible(byScope.trusted).length,
|
||||
untrusted: visible(byScope.untrusted).length,
|
||||
},
|
||||
// Others is one click away whenever it holds something.
|
||||
showFilters: byScope.untrusted.length > 0,
|
||||
// Others is one click away whenever it holds something, and Delegates is
|
||||
// one click back from it.
|
||||
showFilters: scope === "untrusted" || byScope.untrusted.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -299,7 +299,8 @@ function setTitle(loadedRoute: LoadedRoute) {
|
||||
loadedRoute.resource === "repo.patches" ||
|
||||
loadedRoute.resource === "repo.patch" ||
|
||||
loadedRoute.resource === "repo.releases" ||
|
||||
loadedRoute.resource === "repo.release"
|
||||
loadedRoute.resource === "repo.release" ||
|
||||
loadedRoute.resource === "repo.sourceUnavailable"
|
||||
) {
|
||||
title.push(...repoTitle(loadedRoute));
|
||||
} else if (loadedRoute.resource === "nodes") {
|
||||
|
||||
+26
-17
@@ -6,25 +6,14 @@ import twemojiModule from "twemoji";
|
||||
|
||||
export const REFS_HEADS = "refs/heads/";
|
||||
|
||||
// Reads the repository's default branch. Heartwood no longer requires the
|
||||
// project payload, so this is the single place to change once the API stops
|
||||
// carrying the default branch there.
|
||||
export function defaultBranch(repo: Repo): string {
|
||||
return repo.payloads["xyz.radicle.project"].data.defaultBranch;
|
||||
// The commit the default branch resolves to.
|
||||
export function defaultBranchTip(repo: Repo): string | undefined {
|
||||
return repo.defaultBranch ? repo.refs?.refs[repo.defaultBranch] : undefined;
|
||||
}
|
||||
|
||||
// Counts the releases a user can browse: the two unredacted buckets, or the
|
||||
// single count older nodes report in place of the buckets. Undefined means the
|
||||
// node doesn't report releases at all, which is how the release API is
|
||||
// detected.
|
||||
export function visibleReleaseCount(repo: Repo): number | undefined {
|
||||
const releases = repo.payloads["xyz.radicle.project"].meta.releases;
|
||||
if (releases === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof releases === "number"
|
||||
? releases
|
||||
: releases.delegate + releases.other;
|
||||
// The default branch in the unqualified form that routes use.
|
||||
export function defaultBranchName(repo: Repo): string | undefined {
|
||||
return repo.defaultBranch ? unqualifyBranch(repo.defaultBranch) : undefined;
|
||||
}
|
||||
|
||||
export function unqualifyBranch(refname: string): string {
|
||||
@@ -105,6 +94,26 @@ export function truncateId(pubkey: string): string {
|
||||
return `${pubkey.substring(0, 6)}…${pubkey.slice(-6)}`;
|
||||
}
|
||||
|
||||
// Whether a paged list may have more items. With a total, compare against it;
|
||||
// without one, offer more while every loaded page came back full.
|
||||
export function hasMorePages(
|
||||
loaded: number,
|
||||
pages: number,
|
||||
perPage: number,
|
||||
total: number | undefined,
|
||||
): boolean {
|
||||
return total === undefined ? loaded === pages * perPage : loaded < total;
|
||||
}
|
||||
|
||||
// A repo only has a name if it carries a project payload. Fall back to the RID,
|
||||
// which every repo has.
|
||||
export function repoName(repo: Repo): string {
|
||||
return (
|
||||
repo.payloads["xyz.radicle.project"]?.data.name ??
|
||||
formatRepositoryId(repo.rid)
|
||||
);
|
||||
}
|
||||
|
||||
// The URL path segment to use for a repo: its configured alias when the node
|
||||
// advertises one, otherwise the canonical RID. Keeps alias-based URLs sticky
|
||||
// across in-app navigation, since the backend resolves either form.
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
import { renderRepoAvatar } from "@radicle-gui/ui/lib/avatar";
|
||||
|
||||
interface CarouselRepo {
|
||||
name: string;
|
||||
name: string | undefined;
|
||||
description: string;
|
||||
rid: string;
|
||||
seeding: number;
|
||||
issues: number;
|
||||
patches: number;
|
||||
issues: number | undefined;
|
||||
patches: number | undefined;
|
||||
route: Route;
|
||||
avatar: string;
|
||||
}
|
||||
@@ -66,21 +66,19 @@
|
||||
if (token !== loadToken) {
|
||||
return;
|
||||
}
|
||||
repos = list
|
||||
.filter(r => r.payloads["xyz.radicle.project"])
|
||||
.map(r => {
|
||||
const project = r.payloads["xyz.radicle.project"];
|
||||
return {
|
||||
name: project.data.name,
|
||||
description: project.data.description,
|
||||
rid: r.rid,
|
||||
seeding: r.seeding,
|
||||
issues: project.meta.issues.open,
|
||||
patches: project.meta.patches.open,
|
||||
route: { resource: "repo.source", repo: r.rid, node: seed },
|
||||
avatar: renderRepoAvatar(project.data.name),
|
||||
};
|
||||
});
|
||||
repos = list.map(r => {
|
||||
const project = r.payloads["xyz.radicle.project"];
|
||||
return {
|
||||
name: project?.data.name,
|
||||
description: project?.data.description ?? "",
|
||||
rid: r.rid,
|
||||
seeding: r.seeding,
|
||||
issues: r.cobs?.issues?.open,
|
||||
patches: r.cobs?.patches?.open,
|
||||
route: { resource: "repo.source", repo: r.rid, node: seed },
|
||||
avatar: renderRepoAvatar(project?.data.name ?? r.rid),
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (token === loadToken && import.meta.env.DEV) {
|
||||
console.warn(
|
||||
@@ -361,6 +359,13 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.name-placeholder {
|
||||
height: 1.125rem;
|
||||
width: 40%;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-mid);
|
||||
}
|
||||
|
||||
.id-link {
|
||||
font: var(--txt-code-regular);
|
||||
color: var(--color-accent-blue-500);
|
||||
@@ -623,21 +628,35 @@
|
||||
class="repo-card">
|
||||
<div class="repo-image">
|
||||
{#if repo.avatar}
|
||||
<img src={repo.avatar} alt={repo.name} class="repo-avatar" />
|
||||
<img
|
||||
src={repo.avatar}
|
||||
alt={repo.name ?? repo.rid}
|
||||
class="repo-avatar" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="repo-body">
|
||||
<div class="repo-info">
|
||||
<h3 class="txt-bold-18">{repo.name}</h3>
|
||||
{#if repo.name}
|
||||
<h3 class="txt-bold-18">{repo.name}</h3>
|
||||
{:else}
|
||||
<div
|
||||
class="name-placeholder"
|
||||
title="This repository has no name">
|
||||
</div>
|
||||
{/if}
|
||||
<p class="description txt-medium-14">{repo.description}</p>
|
||||
<span class="id-link">{shortenRid(repo.rid)}</span>
|
||||
</div>
|
||||
|
||||
<div class="meta-row">
|
||||
<div class="stats txt-code-regular">
|
||||
<span class="stat">{repo.issues} issues</span>
|
||||
<span class="stat">{repo.patches} patches</span>
|
||||
{#if repo.issues !== undefined}
|
||||
<span class="stat">{repo.issues} issues</span>
|
||||
{/if}
|
||||
{#if repo.patches !== undefined}
|
||||
<span class="stat">{repo.patches} patches</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="time txt-medium-14">{repo.seeding} seeds</span>
|
||||
</div>
|
||||
|
||||
@@ -39,8 +39,7 @@
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<Repo> => r.status === "fulfilled",
|
||||
)
|
||||
.map(r => r.value)
|
||||
.filter(r => Boolean(r.payloads["xyz.radicle.project"]));
|
||||
.map(r => r.value);
|
||||
|
||||
return repos.map(repo => ({
|
||||
repo,
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
export let revisionReactions: Comment["reactions"];
|
||||
export let revisionAuthor: Author;
|
||||
export let revisionDescription: string;
|
||||
export let targetBranch: string;
|
||||
export let targetBranch: string | undefined = undefined;
|
||||
export let timelines: Timeline[];
|
||||
export let previousRevBase: string | undefined = undefined;
|
||||
export let previousRevId: string | undefined = undefined;
|
||||
@@ -532,11 +532,13 @@
|
||||
|
||||
merged revision
|
||||
<Id id={element.inner.revision} />
|
||||
into
|
||||
<span class="target-branch">
|
||||
<Icon name="branch" />
|
||||
{targetBranch}
|
||||
</span>
|
||||
{#if targetBranch}
|
||||
into
|
||||
<span class="target-branch">
|
||||
<Icon name="branch" />
|
||||
{targetBranch}
|
||||
</span>
|
||||
{/if}
|
||||
at commit
|
||||
<Id id={element.inner.commit} />
|
||||
<span
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import config from "@app/lib/config";
|
||||
import debounce from "lodash/debounce";
|
||||
import { routeToPath } from "@app/lib/router";
|
||||
import { toClipboard, visibleReleaseCount } from "@app/lib/utils";
|
||||
import { toClipboard } from "@app/lib/utils";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
@@ -21,8 +21,8 @@
|
||||
export let repo: Repo;
|
||||
export let repoId: string;
|
||||
|
||||
// Undefined on older nodes that don't report release counts.
|
||||
$: releaseCount = visibleReleaseCount(repo);
|
||||
// Undefined on nodes that cannot serve releases.
|
||||
$: releaseCount = repo.releaseCount;
|
||||
|
||||
let shareIcon: "link" | "checkmark" = "link";
|
||||
|
||||
@@ -133,12 +133,14 @@
|
||||
<Icon name="issue" />
|
||||
<div class="title-counter">
|
||||
Issues
|
||||
<span
|
||||
class="counter"
|
||||
class:selected={activeTab === "issues"}
|
||||
class:hover={hover && activeTab !== "issues"}>
|
||||
{repo.payloads["xyz.radicle.project"].meta.issues.open}
|
||||
</span>
|
||||
{#if repo.cobs?.issues}
|
||||
<span
|
||||
class="counter"
|
||||
class:selected={activeTab === "issues"}
|
||||
class:hover={hover && activeTab !== "issues"}>
|
||||
{repo.cobs.issues.open}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -153,18 +155,19 @@
|
||||
<Icon name="patch" />
|
||||
<div class="title-counter">
|
||||
Patches
|
||||
<span
|
||||
class="counter"
|
||||
class:hover={hover && activeTab !== "patches"}
|
||||
class:selected={activeTab === "patches"}>
|
||||
{repo.payloads["xyz.radicle.project"].meta.patches.open}
|
||||
</span>
|
||||
{#if repo.cobs?.patches}
|
||||
<span
|
||||
class="counter"
|
||||
class:hover={hover && activeTab !== "patches"}
|
||||
class:selected={activeTab === "patches"}>
|
||||
{repo.cobs.patches.open}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<!-- Only nodes that report release counts support the release API; hide the
|
||||
tab on older nodes where the field is absent. -->
|
||||
<!-- Only nodes that report a release count support the release API. -->
|
||||
{#if releaseCount !== undefined}
|
||||
<Link
|
||||
route={{
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
import config from "@app/lib/config";
|
||||
import { HttpdClient } from "@http-client";
|
||||
import {
|
||||
defaultBranchName,
|
||||
baseUrlToString,
|
||||
formatQualifiedRefname,
|
||||
repoName,
|
||||
safeDecodeURIComponent,
|
||||
} from "@app/lib/utils";
|
||||
import { groupCommits } from "@app/lib/commit";
|
||||
@@ -47,7 +49,7 @@
|
||||
$: currentRefname = formatQualifiedRefname(
|
||||
revision
|
||||
? safeDecodeURIComponent(revision)
|
||||
: repo.payloads["xyz.radicle.project"].data.defaultBranch,
|
||||
: (defaultBranchName(repo) ?? ""),
|
||||
peer,
|
||||
);
|
||||
|
||||
@@ -128,7 +130,7 @@
|
||||
{currentRefname}
|
||||
id={repo.rid}
|
||||
alias={repo.alias}
|
||||
name={repo.payloads["xyz.radicle.project"].data.name} />
|
||||
name={repoName(repo)} />
|
||||
</svelte:fragment>
|
||||
<RepoNameHeader {repo} {repoId} {baseUrl} {seedingPolicy} slot="header" />
|
||||
|
||||
|
||||
@@ -216,9 +216,7 @@
|
||||
<Markdown
|
||||
breaks
|
||||
content={issue.discussion[0].body}
|
||||
rawPath={rawPath(
|
||||
repo.payloads["xyz.radicle.project"].meta.head,
|
||||
)} />
|
||||
rawPath={rawPath(utils.defaultBranchTip(repo))} />
|
||||
{:else}
|
||||
<span style:color="var(--color-text-tertiary)">No description</span>
|
||||
{/if}
|
||||
@@ -237,9 +235,7 @@
|
||||
<ThreadComponent
|
||||
{baseUrl}
|
||||
{thread}
|
||||
rawPath={rawPath(
|
||||
repo.payloads["xyz.radicle.project"].meta.head,
|
||||
)} />
|
||||
rawPath={rawPath(utils.defaultBranchTip(repo))} />
|
||||
{#if i < threads.length - 1}
|
||||
<div class="connector"></div>
|
||||
{/if}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import { HttpdClient } from "@http-client";
|
||||
import { ISSUES_PER_PAGE } from "./router";
|
||||
import { baseUrlToString } from "@app/lib/utils";
|
||||
import { baseUrlToString, hasMorePages } from "@app/lib/utils";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import ErrorMessage from "@app/components/ErrorMessage.svelte";
|
||||
@@ -57,7 +57,12 @@
|
||||
$: showMoreButton =
|
||||
!loading &&
|
||||
!error &&
|
||||
allIssues.length < repo.payloads["xyz.radicle.project"].meta.issues[status];
|
||||
hasMorePages(
|
||||
allIssues.length,
|
||||
page + 1,
|
||||
ISSUES_PER_PAGE,
|
||||
repo.cobs?.issues?.[status],
|
||||
);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -128,9 +133,11 @@
|
||||
<Icon name="issue" />
|
||||
<div class="title-counter">
|
||||
Open
|
||||
<span class="counter" class:selected={status === "open"}>
|
||||
{repo.payloads["xyz.radicle.project"].meta.issues.open}
|
||||
</span>
|
||||
{#if repo.cobs?.issues}
|
||||
<span class="counter" class:selected={status === "open"}>
|
||||
{repo.cobs.issues.open}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -145,9 +152,11 @@
|
||||
<Icon name="issue-closed" />
|
||||
<div class="title-counter">
|
||||
Closed
|
||||
<span class="counter" class:selected={status === "closed"}>
|
||||
{repo.payloads["xyz.radicle.project"].meta.issues.closed}
|
||||
</span>
|
||||
{#if repo.cobs?.issues}
|
||||
<span class="counter" class:selected={status === "closed"}>
|
||||
{repo.cobs.issues.closed}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -165,7 +174,7 @@
|
||||
{error} />
|
||||
{/if}
|
||||
|
||||
{#if repo.payloads["xyz.radicle.project"].meta.issues[status] === 0}
|
||||
{#if repo.cobs?.issues?.[status] === 0}
|
||||
<div class="placeholder">
|
||||
<Placeholder iconName="issue" caption={`No ${status} issues`} />
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { onMount } from "svelte";
|
||||
|
||||
import { activeRouteStore } from "@app/lib/router";
|
||||
import { repoName } from "@app/lib/utils";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import Header from "@app/components/Header.svelte";
|
||||
@@ -26,6 +27,7 @@
|
||||
export let nodeAvatarUrl: string | undefined;
|
||||
|
||||
$: route = $activeRouteStore;
|
||||
$: project = repo.payloads["xyz.radicle.project"];
|
||||
$: isRepoHome = route.resource === "repo.source" && route.params.path === "/";
|
||||
|
||||
let trail: HTMLElement | undefined = undefined;
|
||||
@@ -217,10 +219,10 @@
|
||||
}}>
|
||||
<div class="breadcrumb repo-crumb">
|
||||
<RepoAvatar
|
||||
name={repo.payloads["xyz.radicle.project"].data.name}
|
||||
name={project?.data.name ?? ""}
|
||||
rid={repo.rid}
|
||||
styleWidth="1rem" />
|
||||
{repo.payloads["xyz.radicle.project"].data.name}
|
||||
{repoName(repo)}
|
||||
</div>
|
||||
</Link>
|
||||
</span>
|
||||
@@ -270,7 +272,9 @@
|
||||
|
||||
<div style:width="100%">
|
||||
<Link
|
||||
title={`${repo.payloads["xyz.radicle.project"].meta.issues.open} Issues`}
|
||||
title={repo.cobs?.issues
|
||||
? `${repo.cobs.issues.open} Issues`
|
||||
: "Issues"}
|
||||
route={{
|
||||
resource: "repo.issues",
|
||||
repo: repoId,
|
||||
@@ -286,7 +290,9 @@
|
||||
|
||||
<div style:width="100%">
|
||||
<Link
|
||||
title={`${repo.payloads["xyz.radicle.project"].meta.patches.open} Patches`}
|
||||
title={repo.cobs?.patches
|
||||
? `${repo.cobs.patches.open} Patches`
|
||||
: "Patches"}
|
||||
route={{
|
||||
resource: "repo.patches",
|
||||
repo: repoId,
|
||||
@@ -300,7 +306,7 @@
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{#if repo.payloads["xyz.radicle.project"].meta.releases !== undefined}
|
||||
{#if repo.releaseCount !== undefined}
|
||||
<div style:width="100%">
|
||||
<Link
|
||||
title="Releases"
|
||||
|
||||
@@ -47,10 +47,12 @@
|
||||
|
||||
// The branch a patch is opened against, unqualified. The `delegates` target
|
||||
// leaves the repository default implied.
|
||||
function resolveTargetBranch(patch: Patch, repo: Repo): string {
|
||||
return patch.target === "delegates"
|
||||
? utils.defaultBranch(repo)
|
||||
: utils.unqualifyBranch(patch.target.branch);
|
||||
function resolveTargetBranch(patch: Patch, repo: Repo): string | undefined {
|
||||
if (patch.target !== "delegates") {
|
||||
return utils.unqualifyBranch(patch.target.branch);
|
||||
}
|
||||
|
||||
return utils.defaultBranchName(repo);
|
||||
}
|
||||
|
||||
// Nothing validates a patch's target when it is opened, so it can name a
|
||||
@@ -58,7 +60,7 @@
|
||||
// such a branch, so it is shown without a link rather than one that errors.
|
||||
function isBrowsableBranch(branch: string, repo: Repo): boolean {
|
||||
return (
|
||||
branch === utils.defaultBranch(repo) ||
|
||||
`${utils.REFS_HEADS}${branch}` === repo.defaultBranch ||
|
||||
repo.refs?.refs[`${utils.REFS_HEADS}${branch}`] !== undefined
|
||||
);
|
||||
}
|
||||
@@ -254,7 +256,8 @@
|
||||
]);
|
||||
$: targetBranch = resolveTargetBranch(patch, repo);
|
||||
$: targetBranchCaption = `This patch merges into ${targetBranch}`;
|
||||
$: targetBranchBrowsable = isBrowsableBranch(targetBranch, repo);
|
||||
$: targetBranchBrowsable =
|
||||
targetBranch !== undefined && isBrowsableBranch(targetBranch, repo);
|
||||
$: firstRevision = timelineTuple[0][0];
|
||||
$: latestRevision = patch.revisions[patch.revisions.length - 1];
|
||||
</script>
|
||||
@@ -418,30 +421,34 @@
|
||||
<span class="code-chip">
|
||||
<Id id={patch.id} />
|
||||
</span>
|
||||
<span class="global-flex-item" title={targetBranchCaption}>
|
||||
<Icon name="arrow-right" />
|
||||
</span>
|
||||
<span class="code-chip">
|
||||
{#if targetBranchBrowsable}
|
||||
<Link
|
||||
styleHoverState
|
||||
title={targetBranchCaption}
|
||||
route={{
|
||||
resource: "repo.source",
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
revision: targetBranch,
|
||||
}}>
|
||||
<Icon name="branch" />
|
||||
<span class="txt-overflow">{targetBranch}</span>
|
||||
</Link>
|
||||
{:else}
|
||||
<span title={targetBranchCaption}>
|
||||
<Icon name="branch" />
|
||||
<span class="txt-overflow">{targetBranch}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<!-- A `delegates` target implies the repository default, which a
|
||||
repo without a default branch cannot name. -->
|
||||
{#if targetBranch}
|
||||
<span class="global-flex-item" title={targetBranchCaption}>
|
||||
<Icon name="arrow-right" />
|
||||
</span>
|
||||
<span class="code-chip">
|
||||
{#if targetBranchBrowsable}
|
||||
<Link
|
||||
styleHoverState
|
||||
title={targetBranchCaption}
|
||||
route={{
|
||||
resource: "repo.source",
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
revision: targetBranch,
|
||||
}}>
|
||||
<Icon name="branch" />
|
||||
<span class="txt-overflow">{targetBranch}</span>
|
||||
</Link>
|
||||
{:else}
|
||||
<span title={targetBranchCaption}>
|
||||
<Icon name="branch" />
|
||||
<span class="txt-overflow">{targetBranch}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<span title={utils.absoluteTimestamp(patch.revisions[0].timestamp)}>
|
||||
{utils.formatTimestamp(patch.revisions[0].timestamp)}
|
||||
</span>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { HttpdClient } from "@http-client";
|
||||
|
||||
import { PATCHES_PER_PAGE } from "./router";
|
||||
import { baseUrlToString } from "@app/lib/utils";
|
||||
import { baseUrlToString, hasMorePages } from "@app/lib/utils";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import ErrorMessage from "@app/components/ErrorMessage.svelte";
|
||||
@@ -58,8 +58,12 @@
|
||||
$: showMoreButton =
|
||||
!loading &&
|
||||
!error &&
|
||||
allPatches.length <
|
||||
repo.payloads["xyz.radicle.project"].meta.patches[status];
|
||||
hasMorePages(
|
||||
allPatches.length,
|
||||
page + 1,
|
||||
PATCHES_PER_PAGE,
|
||||
repo.cobs?.patches?.[status],
|
||||
);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -133,9 +137,11 @@
|
||||
<Icon name="patch" />
|
||||
<div class="title-counter" class:active={status === "open"}>
|
||||
Open
|
||||
<span class="counter" class:selected={status === "open"}>
|
||||
{repo.payloads["xyz.radicle.project"].meta.patches.open}
|
||||
</span>
|
||||
{#if repo.cobs?.patches}
|
||||
<span class="counter" class:selected={status === "open"}>
|
||||
{repo.cobs.patches.open}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -150,9 +156,11 @@
|
||||
<Icon name="patch-draft" />
|
||||
<div class="title-counter" class:active={status === "draft"}>
|
||||
Draft
|
||||
<span class="counter" class:selected={status === "draft"}>
|
||||
{repo.payloads["xyz.radicle.project"].meta.patches.draft}
|
||||
</span>
|
||||
{#if repo.cobs?.patches}
|
||||
<span class="counter" class:selected={status === "draft"}>
|
||||
{repo.cobs.patches.draft}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -167,9 +175,11 @@
|
||||
<Icon name="patch-archived" />
|
||||
<div class="title-counter" class:active={status === "archived"}>
|
||||
Archived
|
||||
<span class="counter" class:selected={status === "archived"}>
|
||||
{repo.payloads["xyz.radicle.project"].meta.patches.archived}
|
||||
</span>
|
||||
{#if repo.cobs?.patches}
|
||||
<span class="counter" class:selected={status === "archived"}>
|
||||
{repo.cobs.patches.archived}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -184,9 +194,11 @@
|
||||
<Icon name="patch-merged" />
|
||||
<div class="title-counter" class:active={status === "merged"}>
|
||||
Merged
|
||||
<span class="counter" class:selected={status === "merged"}>
|
||||
{repo.payloads["xyz.radicle.project"].meta.patches.merged}
|
||||
</span>
|
||||
{#if repo.cobs?.patches}
|
||||
<span class="counter" class:selected={status === "merged"}>
|
||||
{repo.cobs.patches.merged}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -204,7 +216,7 @@
|
||||
{error} />
|
||||
{/if}
|
||||
|
||||
{#if repo.payloads["xyz.radicle.project"].meta.patches[status] === 0}
|
||||
{#if repo.cobs?.patches?.[status] === 0}
|
||||
<div class="placeholder">
|
||||
<Placeholder iconName="patch" caption={`No ${status} patches`} />
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { BaseUrl, Release, ReleaseScope, Repo } from "@http-client";
|
||||
|
||||
import { HttpdClient } from "@http-client";
|
||||
import { RELEASES_PER_PAGE, releasesQuery, scopeReleases } from "./router";
|
||||
import { releasesQuery } from "./router";
|
||||
import { baseUrlToString } from "@app/lib/utils";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
@@ -42,9 +42,8 @@
|
||||
|
||||
$: delegateIds = new Set(repo.delegates.map(d => d.id));
|
||||
|
||||
// Older nodes report a single count, so their segments go without counters.
|
||||
$: releasesMeta = repo.payloads["xyz.radicle.project"].meta.releases;
|
||||
$: counts = typeof releasesMeta === "object" ? releasesMeta : undefined;
|
||||
// Without counts per scope, the segments go without counters.
|
||||
$: counts = repo.cobs?.releases;
|
||||
|
||||
const api = new HttpdClient(baseUrl);
|
||||
|
||||
@@ -54,12 +53,13 @@
|
||||
// doesn't leave a gap behind on retry.
|
||||
const next = page + 1;
|
||||
try {
|
||||
const response = await api.repo.getAllReleases(
|
||||
const response = await api.repo.getReleasesPage(
|
||||
repo.rid,
|
||||
repo.delegates,
|
||||
releasesQuery(scope, next),
|
||||
);
|
||||
allReleases = [...allReleases, ...scopeReleases(response, repo, scope)];
|
||||
morePages = response.length === RELEASES_PER_PAGE;
|
||||
allReleases = [...allReleases, ...response.releases];
|
||||
morePages = response.morePages;
|
||||
page = next;
|
||||
} catch (e) {
|
||||
error = e;
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
import { HttpdClient } from "@http-client";
|
||||
|
||||
import {
|
||||
defaultBranchName,
|
||||
formatQualifiedRefname,
|
||||
repoName,
|
||||
safeDecodeURIComponent,
|
||||
} from "@app/lib/utils";
|
||||
|
||||
@@ -57,7 +59,7 @@
|
||||
$: currentRefname = formatQualifiedRefname(
|
||||
revision
|
||||
? safeDecodeURIComponent(revision)
|
||||
: repo.payloads["xyz.radicle.project"].data.defaultBranch,
|
||||
: (defaultBranchName(repo) ?? ""),
|
||||
peer,
|
||||
);
|
||||
|
||||
@@ -146,7 +148,7 @@
|
||||
{currentRefname}
|
||||
id={repo.rid}
|
||||
alias={repo.alias}
|
||||
name={repo.payloads["xyz.radicle.project"].data.name} />
|
||||
name={repoName(repo)} />
|
||||
</svelte:fragment>
|
||||
<RepoNameHeader {repo} {repoId} {baseUrl} {seedingPolicy} slot="header" />
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// with many peers. Going through `cached` shares a single request across
|
||||
// every Header instance for the same repo, including instances that mount
|
||||
// while the request is still in flight.
|
||||
const getPeers = cached(
|
||||
export const getPeers = cached(
|
||||
async (baseUrl: BaseUrl, rid: string) => {
|
||||
const remotes = await new HttpdClient(baseUrl).repo.getAllRemotes(rid);
|
||||
return remotes.map(remoteToPeerRefs).filter(peerHasBranches);
|
||||
@@ -26,6 +26,8 @@
|
||||
import type { Repo, Tree } from "@http-client";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
import { defaultBranchName as getDefaultBranchName } from "@app/lib/utils";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import CommitButton from "../components/CommitButton.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
@@ -96,20 +98,19 @@
|
||||
let selectedBranch: string | undefined;
|
||||
let commitButtonVariant: ComponentProps<CommitButton>["variant"] | undefined;
|
||||
|
||||
// Route revisions are unqualified, so compare against the short form.
|
||||
$: defaultBranchName = getDefaultBranchName(repo);
|
||||
|
||||
// Revision may be a commit ID, a branch name or `undefined` which means the
|
||||
// default branch. We assign `selectedBranch` accordingly.
|
||||
$: if (revision === lastCommit.id) {
|
||||
selectedBranch = undefined;
|
||||
} else {
|
||||
selectedBranch =
|
||||
revision || repo.payloads["xyz.radicle.project"].data.defaultBranch;
|
||||
selectedBranch = revision || defaultBranchName;
|
||||
}
|
||||
|
||||
$: lastCommit = tree.lastCommit;
|
||||
$: onCanonical = Boolean(
|
||||
!peer &&
|
||||
selectedBranch === repo.payloads["xyz.radicle.project"].data.defaultBranch,
|
||||
);
|
||||
$: onCanonical = Boolean(!peer && selectedBranch === defaultBranchName);
|
||||
$: if (onCanonical) {
|
||||
commitButtonVariant = "right";
|
||||
} else if (!selectedBranch) {
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
formatCommit,
|
||||
formatNodeId,
|
||||
formatTimestamp,
|
||||
defaultBranchName as getDefaultBranchName,
|
||||
defaultBranchTip,
|
||||
getBranchesFromRefs,
|
||||
getTagsFromRefs,
|
||||
gravatarURL,
|
||||
@@ -53,6 +55,8 @@
|
||||
type: "branch" | "tag";
|
||||
};
|
||||
|
||||
$: defaultBranchName = getDefaultBranchName(repo);
|
||||
$: tip = defaultBranchTip(repo);
|
||||
$: peerList = peers ?? [];
|
||||
$: canonicalBranchesMap = getBranchesFromRefs(repo.refs?.refs ?? {});
|
||||
$: canonicalTagsInfo = Object.fromEntries(
|
||||
@@ -63,18 +67,20 @@
|
||||
);
|
||||
|
||||
$: branchElements = [
|
||||
{
|
||||
peer: undefined,
|
||||
revision: repo.payloads["xyz.radicle.project"].data.defaultBranch,
|
||||
head: repo.payloads["xyz.radicle.project"].meta.head,
|
||||
type: "branch",
|
||||
},
|
||||
// Only listed separately when it resolves; otherwise the canonical refs
|
||||
// below already carry every branch there is.
|
||||
...(defaultBranchName && tip
|
||||
? [
|
||||
{
|
||||
peer: undefined,
|
||||
revision: defaultBranchName,
|
||||
head: tip,
|
||||
type: "branch",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...Object.entries(canonicalBranchesMap)
|
||||
.filter(
|
||||
([branchName]) =>
|
||||
branchName !==
|
||||
repo.payloads["xyz.radicle.project"].data.defaultBranch,
|
||||
)
|
||||
.filter(([branchName]) => branchName !== defaultBranchName)
|
||||
.map(([name, head]) => ({
|
||||
peer: undefined,
|
||||
revision: name,
|
||||
@@ -131,8 +137,7 @@
|
||||
info => info.tagger?.timestamp ?? 0,
|
||||
).flatMap(group => group.tags);
|
||||
$: canonicalBranches = Object.entries(canonicalBranchesMap).filter(
|
||||
([branchName]) =>
|
||||
branchName !== repo.payloads["xyz.radicle.project"].data.defaultBranch,
|
||||
([branchName]) => branchName !== defaultBranchName,
|
||||
);
|
||||
$: hasTags =
|
||||
Object.keys(canonicalTagsInfo).length > 0 ||
|
||||
@@ -381,6 +386,9 @@
|
||||
Canonical
|
||||
</Badge>
|
||||
{/if}
|
||||
{:else if !displayPeer}
|
||||
<Icon name="branch" />
|
||||
<span class="txt-overflow">Select branch</span>
|
||||
{/if}
|
||||
<Icon name={expanded ? "chevron-up" : "chevron-down"} />
|
||||
</Button>
|
||||
@@ -532,26 +540,30 @@
|
||||
{/if}
|
||||
{/each}
|
||||
{:else if selectedTab === "branches"}
|
||||
<Link
|
||||
style={subgridStyle}
|
||||
route={{ ...baseRoute, revision: undefined }}
|
||||
on:afterNavigate={() => {
|
||||
searchInput = "";
|
||||
toggle();
|
||||
}}>
|
||||
<DropdownListItem selected={onCanonical} style={subgridStyle}>
|
||||
<div class="global-flex-item">
|
||||
<Icon name="branch" />
|
||||
{repo.payloads["xyz.radicle.project"].data.defaultBranch}
|
||||
<Badge title="Canonical branch" variant="foreground-emphasized">
|
||||
Canonical
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="txt-id">
|
||||
{formatCommit(repo.payloads["xyz.radicle.project"].meta.head)}
|
||||
</div>
|
||||
</DropdownListItem>
|
||||
</Link>
|
||||
{#if defaultBranchName && tip}
|
||||
<Link
|
||||
style={subgridStyle}
|
||||
route={{ ...baseRoute, revision: undefined }}
|
||||
on:afterNavigate={() => {
|
||||
searchInput = "";
|
||||
toggle();
|
||||
}}>
|
||||
<DropdownListItem selected={onCanonical} style={subgridStyle}>
|
||||
<div class="global-flex-item">
|
||||
<Icon name="branch" />
|
||||
{defaultBranchName}
|
||||
<Badge
|
||||
title="Canonical branch"
|
||||
variant="foreground-emphasized">
|
||||
Canonical
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="txt-id">
|
||||
{formatCommit(tip)}
|
||||
</div>
|
||||
</DropdownListItem>
|
||||
</Link>
|
||||
{/if}
|
||||
{#each canonicalBranches as [branchName, branchHead]}
|
||||
<Link
|
||||
style={subgridStyle}
|
||||
|
||||
@@ -60,6 +60,13 @@
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.name-placeholder {
|
||||
display: block;
|
||||
width: 12rem;
|
||||
height: 1.5rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-mid);
|
||||
}
|
||||
.repo-name:hover {
|
||||
color: inherit;
|
||||
}
|
||||
@@ -108,7 +115,10 @@
|
||||
|
||||
<div class="header-layout">
|
||||
<div class="avatar">
|
||||
<RepoAvatar name={project.data.name} rid={repo.rid} styleWidth="10rem" />
|
||||
<RepoAvatar
|
||||
name={project?.data.name ?? ""}
|
||||
rid={repo.rid}
|
||||
styleWidth="10rem" />
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div class="title">
|
||||
@@ -119,9 +129,12 @@
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
}}>
|
||||
<span class="repo-name">
|
||||
{project.data.name}
|
||||
</span>
|
||||
{#if project}
|
||||
<span class="repo-name">{project.data.name}</span>
|
||||
{:else}
|
||||
<span class="name-placeholder" title="This repository has no name">
|
||||
</span>
|
||||
{/if}
|
||||
</Link>
|
||||
</span>
|
||||
{#if repo.visibility.type === "private"}
|
||||
@@ -136,19 +149,23 @@
|
||||
{formatRepositoryId(repo.rid)}
|
||||
</Id>
|
||||
</div>
|
||||
{#if project}
|
||||
<div
|
||||
class="description global-hide-on-mobile-down"
|
||||
title={project.data.description}
|
||||
use:twemoji>
|
||||
{@html render(project.data.description)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if project}
|
||||
<div
|
||||
class="description global-hide-on-mobile-down"
|
||||
class="description mobile-description global-hide-on-small-desktop-up"
|
||||
title={project.data.description}
|
||||
use:twemoji>
|
||||
{@html render(project.data.description)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="description mobile-description global-hide-on-small-desktop-up"
|
||||
title={project.data.description}
|
||||
use:twemoji>
|
||||
{@html render(project.data.description)}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="info">
|
||||
<ContextRepo
|
||||
{baseUrl}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import type { BaseUrl, PeerRefs, Repo, SeedingPolicy } from "@http-client";
|
||||
|
||||
import { getPeers } from "./Source/Header.svelte";
|
||||
import { defaultBranchName } from "@app/lib/utils";
|
||||
|
||||
import Layout from "./Layout.svelte";
|
||||
import PeerBranchSelector from "./Source/PeerBranchSelector.svelte";
|
||||
import Placeholder from "@app/components/Placeholder.svelte";
|
||||
import RepoNameHeader from "./Source/RepoNameHeader.svelte";
|
||||
|
||||
export let baseUrl: BaseUrl;
|
||||
export let repo: Repo;
|
||||
export let repoId: string;
|
||||
export let seedingPolicy: SeedingPolicy;
|
||||
export let peer: string | undefined = undefined;
|
||||
export let nodeId: string;
|
||||
export let nodeAvatarUrl: string | undefined;
|
||||
|
||||
let peers: PeerRefs[] | undefined = undefined;
|
||||
|
||||
function fetchPeers(node: BaseUrl, rid: string) {
|
||||
peers = undefined;
|
||||
void getPeers(node, rid)
|
||||
.then(result => {
|
||||
peers = result;
|
||||
})
|
||||
.catch(error => console.error("Failed to load repo remotes", error));
|
||||
}
|
||||
|
||||
$: fetchPeers(baseUrl, repo.rid);
|
||||
$: branch = defaultBranchName(repo);
|
||||
$: reason = !branch
|
||||
? "This repository has no default branch."
|
||||
: peer
|
||||
? `This peer has not pushed the ${branch} branch.`
|
||||
: `The default branch ${branch} has no canonical commit.`;
|
||||
$: caption = `${reason} Select a branch or tag to browse.`;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.subheader {
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.placeholder {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4rem 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- A repository with no default branch, a default branch without a canonical
|
||||
commit, or a peer who hasn't pushed it has nothing to open at. Keep the shell
|
||||
so the issue and patch tabs stay reachable, and offer the peer branch
|
||||
selector. -->
|
||||
<Layout {baseUrl} {repo} {repoId} {nodeId} {nodeAvatarUrl} activeTab="source">
|
||||
<RepoNameHeader {repo} {repoId} {baseUrl} {seedingPolicy} slot="header" />
|
||||
|
||||
<div class="subheader" slot="subheader">
|
||||
<PeerBranchSelector
|
||||
baseRoute={{ resource: "repo.source", repo: repoId, node: baseUrl }}
|
||||
onCanonical={false}
|
||||
{peer}
|
||||
selectedBranch={undefined}
|
||||
{peers}
|
||||
{repo} />
|
||||
</div>
|
||||
|
||||
<div class="placeholder">
|
||||
<Placeholder iconName="branch" {caption} />
|
||||
</div>
|
||||
</Layout>
|
||||
+110
-123
@@ -36,8 +36,11 @@ import { cached } from "@app/lib/cache";
|
||||
import { handleError, unreachableError } from "@app/views/repos/error";
|
||||
import {
|
||||
REFS_HEADS,
|
||||
defaultBranchName,
|
||||
defaultBranchTip,
|
||||
getBranchesFromRefs,
|
||||
getTagsFromRefs,
|
||||
repoName,
|
||||
unreachable,
|
||||
} from "@app/lib/utils";
|
||||
import { nodePath } from "@app/views/nodes/router";
|
||||
@@ -217,6 +220,18 @@ export type RepoLoadedRoute =
|
||||
nodeAvatarUrl: string | undefined;
|
||||
};
|
||||
}
|
||||
| {
|
||||
resource: "repo.sourceUnavailable";
|
||||
params: {
|
||||
baseUrl: BaseUrl;
|
||||
seedingPolicy: SeedingPolicy;
|
||||
repo: Repo;
|
||||
repoId: string;
|
||||
peer: string | undefined;
|
||||
nodeId: string;
|
||||
nodeAvatarUrl: string | undefined;
|
||||
};
|
||||
}
|
||||
| {
|
||||
resource: "repo.commit";
|
||||
params: {
|
||||
@@ -354,24 +369,17 @@ export const cachedGetDiffStats = cached(
|
||||
);
|
||||
|
||||
function parseRevisionToOid(
|
||||
revision: string | undefined,
|
||||
defaultBranch: string,
|
||||
revision: string,
|
||||
branches: Record<string, string>,
|
||||
): string {
|
||||
if (revision) {
|
||||
if (isOid(revision)) {
|
||||
return revision;
|
||||
} else {
|
||||
const oid = branches[revision];
|
||||
if (oid) {
|
||||
return oid;
|
||||
} else {
|
||||
throw new Error(`Revision ${revision} not found`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return branches[defaultBranch];
|
||||
if (isOid(revision)) {
|
||||
return revision;
|
||||
}
|
||||
const oid = branches[revision];
|
||||
if (oid) {
|
||||
return oid;
|
||||
}
|
||||
throw new Error(`Revision ${revision} not found`);
|
||||
}
|
||||
|
||||
export async function loadRepoRoute(
|
||||
@@ -492,44 +500,46 @@ async function loadIssuesView(
|
||||
};
|
||||
}
|
||||
|
||||
// Older nodes don't report a release count and lack the release API. Show a
|
||||
// friendly page pointing to another node instead of a raw 404.
|
||||
// Neither the project nor the canonical refs payload names a branch, or the
|
||||
// named branch has no tip, so there is no revision to browse. The repo still has COBs, so render its shell rather
|
||||
// than a full-page not-found that would take the issue and patch tabs with it.
|
||||
function sourceUnavailable(
|
||||
route: { node: BaseUrl; repo: string; peer?: string },
|
||||
repo: Repo,
|
||||
seedingPolicy: SeedingPolicy,
|
||||
node: { id: string; avatarUrl?: string | undefined },
|
||||
): RepoLoadedRoute {
|
||||
return {
|
||||
resource: "repo.sourceUnavailable",
|
||||
params: {
|
||||
baseUrl: route.node,
|
||||
repoId: route.repo,
|
||||
repo,
|
||||
seedingPolicy,
|
||||
peer: route.peer,
|
||||
nodeId: node.id,
|
||||
nodeAvatarUrl: node.avatarUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A missing release count means the node cannot serve releases: it predates
|
||||
// the release API or was built without release support.
|
||||
function releasesNotSupported(node: BaseUrl): NotFoundRoute {
|
||||
return {
|
||||
resource: "notFound",
|
||||
params: {
|
||||
title: "Releases are not available on this node",
|
||||
description:
|
||||
"This node is running an older version that doesn't support browsing releases yet.\nSelect a different node to continue.",
|
||||
"This node is running an older version or was built without release support.\nSelect a different node to continue.",
|
||||
baseUrl: node,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The query for a page of releases in one author scope. Older nodes ignore
|
||||
// `scope`, so ask them for every author too when the scope is untrusted.
|
||||
// The query for a page of releases in one author scope.
|
||||
export function releasesQuery(scope: ReleaseScope, page: number) {
|
||||
return {
|
||||
scope,
|
||||
allAuthors: scope === "untrusted" ? true : undefined,
|
||||
page,
|
||||
perPage: RELEASES_PER_PAGE,
|
||||
};
|
||||
}
|
||||
|
||||
// Keep only the releases in the author scope. A newer node has done this
|
||||
// already; an older one answers the untrusted scope with every author. Its pages
|
||||
// then come back short, so page on the length of the response, not of this.
|
||||
export function scopeReleases(
|
||||
releases: Release[],
|
||||
repo: Repo,
|
||||
scope: ReleaseScope,
|
||||
): Release[] {
|
||||
if (scope === "trusted") {
|
||||
return releases;
|
||||
}
|
||||
const delegateIds = new Set(repo.delegates.map(d => d.id));
|
||||
return releases.filter(r => !delegateIds.has(r.creator.id));
|
||||
return { scope, page, perPage: RELEASES_PER_PAGE };
|
||||
}
|
||||
|
||||
async function loadReleasesView(
|
||||
@@ -540,42 +550,48 @@ async function loadReleasesView(
|
||||
// shows only when asked for.
|
||||
const scope = route.scope ?? "trusted";
|
||||
|
||||
// Fetch releases alongside the repo so the common path stays parallel; the
|
||||
// repo's `meta.releases` then tells us whether a failure is an unsupported
|
||||
// node or a genuine error.
|
||||
const [repo, releasesResult, node] = await Promise.all([
|
||||
api.repo.getByRid(route.repo),
|
||||
api.repo.getAllReleases(route.repo, releasesQuery(scope, 0)).then(
|
||||
releases => ({ releases }),
|
||||
(error: unknown) => ({ error }),
|
||||
),
|
||||
// Fetch the page alongside the repo. The repo then tells whether a failed
|
||||
// fetch is a node without releases or a real error.
|
||||
const repoPromise = api.repo.getByRid(route.repo);
|
||||
const [repo, pageResult, showFilters, node] = await Promise.all([
|
||||
repoPromise,
|
||||
api.repo
|
||||
.getReleasesPage(
|
||||
route.repo,
|
||||
repoPromise.then(repo => repo.delegates),
|
||||
releasesQuery(scope, 0),
|
||||
)
|
||||
.then(
|
||||
page => ({ page }),
|
||||
(error: unknown) => ({ error }),
|
||||
),
|
||||
// Others is one click away whenever it holds releases, and Delegates is
|
||||
// one click back from it.
|
||||
scope === "untrusted" ||
|
||||
repoPromise.then(
|
||||
repo =>
|
||||
repo.releaseCount !== undefined &&
|
||||
api.repo.hasOtherReleases(repo, RELEASES_PER_PAGE),
|
||||
),
|
||||
api.getNode(),
|
||||
]);
|
||||
|
||||
// Older nodes report a single count here, newer ones the buckets, and
|
||||
// either way its absence means no release API.
|
||||
const releasesMeta = repo.payloads["xyz.radicle.project"].meta.releases;
|
||||
if (releasesMeta === undefined) {
|
||||
// A missing release count means the node cannot serve releases.
|
||||
if (repo.releaseCount === undefined) {
|
||||
return releasesNotSupported(route.node);
|
||||
}
|
||||
if ("error" in releasesResult) {
|
||||
throw releasesResult.error;
|
||||
if ("error" in pageResult) {
|
||||
throw pageResult.error;
|
||||
}
|
||||
const page = releasesResult.releases;
|
||||
|
||||
// Others is one click away whenever it holds releases.
|
||||
const showFilters =
|
||||
typeof releasesMeta !== "number"
|
||||
? releasesMeta.other > 0
|
||||
: scope === "untrusted" || (await legacyHasOthers(api, route.repo, repo));
|
||||
const { page } = pageResult;
|
||||
|
||||
return {
|
||||
resource: "repo.releases",
|
||||
params: {
|
||||
baseUrl: route.node,
|
||||
repoId: route.repo,
|
||||
releases: scopeReleases(page, repo, scope),
|
||||
lastPageFull: page.length === RELEASES_PER_PAGE,
|
||||
releases: page.releases,
|
||||
lastPageFull: page.morePages,
|
||||
scope,
|
||||
showFilters,
|
||||
repo,
|
||||
@@ -585,25 +601,6 @@ async function loadReleasesView(
|
||||
};
|
||||
}
|
||||
|
||||
// Older nodes report a single release count without the delegate and
|
||||
// non-delegate buckets, so look for others in the first page of every author.
|
||||
// A full page means others may come later, and only the filter depends on
|
||||
// this, so a full page or a failed fetch keeps the filter. Remove this once
|
||||
// nodes without the buckets are no longer supported.
|
||||
async function legacyHasOthers(
|
||||
api: HttpdClient,
|
||||
repoId: string,
|
||||
repo: Repo,
|
||||
): Promise<boolean> {
|
||||
const everyAuthor = await api.repo
|
||||
.getAllReleases(repoId, releasesQuery("untrusted", 0))
|
||||
.catch(() => undefined);
|
||||
if (everyAuthor === undefined || everyAuthor.length === RELEASES_PER_PAGE) {
|
||||
return true;
|
||||
}
|
||||
return scopeReleases(everyAuthor, repo, "untrusted").length > 0;
|
||||
}
|
||||
|
||||
async function loadReleaseView(
|
||||
route: RepoReleaseRoute,
|
||||
): Promise<RepoLoadedRoute | NotFoundRoute> {
|
||||
@@ -618,7 +615,7 @@ async function loadReleaseView(
|
||||
api.getNode(),
|
||||
]);
|
||||
|
||||
if (repo.payloads["xyz.radicle.project"].meta.releases === undefined) {
|
||||
if (repo.releaseCount === undefined) {
|
||||
return releasesNotSupported(route.node);
|
||||
}
|
||||
if ("error" in releaseResult) {
|
||||
@@ -679,13 +676,6 @@ async function loadTreeView(
|
||||
nodePromise,
|
||||
]);
|
||||
|
||||
if (!repo["payloads"]["xyz.radicle.project"]) {
|
||||
throw new Error(
|
||||
`Repository ${repo.rid} does not have a xyz.radicle.project payload.`,
|
||||
);
|
||||
}
|
||||
|
||||
const project = repo["payloads"]["xyz.radicle.project"];
|
||||
let branchMap = canonicalBranchMap(repo);
|
||||
|
||||
if (route.peer) {
|
||||
@@ -708,11 +698,13 @@ async function loadTreeView(
|
||||
route.path = path;
|
||||
}
|
||||
|
||||
const commit = parseRevisionToOid(
|
||||
route.revision,
|
||||
project.data.defaultBranch,
|
||||
branchMap,
|
||||
);
|
||||
const revision = route.revision || defaultBranchName(repo);
|
||||
// Without a delegate quorum the default branch has no canonical tip.
|
||||
if (!revision || (!route.revision && !branchMap[revision])) {
|
||||
return sourceUnavailable(route, repo, seedingPolicy, node);
|
||||
}
|
||||
|
||||
const commit = parseRevisionToOid(revision, branchMap);
|
||||
const path = route.path || "/";
|
||||
const [tree, blobResult] = await Promise.all([
|
||||
api.repo.getTree(route.repo, commit),
|
||||
@@ -827,17 +819,13 @@ async function loadHistoryView(
|
||||
? await getPeerBranchMap(api, route.repo, route.peer)
|
||||
: canonicalBranchMap(repo);
|
||||
|
||||
if (!repo["payloads"]["xyz.radicle.project"]) {
|
||||
throw new Error(
|
||||
`Repository ${repo.rid} does not have a xyz.radicle.project payload.`,
|
||||
);
|
||||
const revision = route.revision || defaultBranchName(repo);
|
||||
// Without a delegate quorum the default branch has no canonical tip.
|
||||
if (!revision || (!route.revision && !branchMap[revision])) {
|
||||
return sourceUnavailable(route, repo, seedingPolicy, node);
|
||||
}
|
||||
|
||||
const project = repo["payloads"]["xyz.radicle.project"];
|
||||
const commitId =
|
||||
route.revision && isOid(route.revision)
|
||||
? route.revision
|
||||
: branchMap[route.revision || project.data.defaultBranch];
|
||||
const commitId = isOid(revision) ? revision : branchMap[revision];
|
||||
|
||||
if (!commitId) {
|
||||
throw new Error(
|
||||
@@ -1032,11 +1020,12 @@ async function loadPatchView(
|
||||
function canonicalBranchMap(repo: Repo): Record<string, string> {
|
||||
const branchMap: Record<string, string> = {};
|
||||
|
||||
const project = repo.payloads["xyz.radicle.project"];
|
||||
if (project) {
|
||||
branchMap[project.data.defaultBranch] = project.meta.head;
|
||||
branchMap[encodeURIComponent(project.data.defaultBranch)] =
|
||||
project.meta.head;
|
||||
// `defaultBranch` is qualified but this map is keyed by short names.
|
||||
const name = defaultBranchName(repo);
|
||||
const tip = defaultBranchTip(repo);
|
||||
if (name && tip) {
|
||||
branchMap[name] = tip;
|
||||
branchMap[encodeURIComponent(name)] = tip;
|
||||
}
|
||||
|
||||
for (const [refName, oid] of canonicalOids(repo.refs)) {
|
||||
@@ -1362,35 +1351,31 @@ function patchRouteToPath(route: RepoPatchRoute): string {
|
||||
export function repoTitle(loadedRoute: RepoLoadedRoute) {
|
||||
const title: string[] = [];
|
||||
|
||||
if (!loadedRoute.params.repo["payloads"]["xyz.radicle.project"]) {
|
||||
throw new Error(
|
||||
`Repository ${loadedRoute.params.repo.rid} does not have a xyz.radicle.project payload.`,
|
||||
);
|
||||
}
|
||||
const project = loadedRoute.params.repo["payloads"]["xyz.radicle.project"];
|
||||
const repo = loadedRoute.params.repo;
|
||||
const project = repo["payloads"]["xyz.radicle.project"];
|
||||
|
||||
if (loadedRoute.resource === "repo.source") {
|
||||
title.push(project.data.name);
|
||||
if (project.data.description.length > 0) {
|
||||
title.push(repoName(repo));
|
||||
if (project && project.data.description.length > 0) {
|
||||
title.push(project.data.description);
|
||||
}
|
||||
} else if (loadedRoute.resource === "repo.commit") {
|
||||
title.push(loadedRoute.params.commit.commit.summary);
|
||||
title.push("Commit");
|
||||
} else if (loadedRoute.resource === "repo.history") {
|
||||
title.push(project.data.name);
|
||||
title.push(repoName(repo));
|
||||
title.push("Commits");
|
||||
} else if (loadedRoute.resource === "repo.issue") {
|
||||
title.push(loadedRoute.params.issue.title);
|
||||
title.push("Issue");
|
||||
} else if (loadedRoute.resource === "repo.issues") {
|
||||
title.push(project.data.name);
|
||||
title.push(repoName(repo));
|
||||
title.push("Issues");
|
||||
} else if (loadedRoute.resource === "repo.patch") {
|
||||
title.push(loadedRoute.params.patch.title);
|
||||
title.push("Patch");
|
||||
} else if (loadedRoute.resource === "repo.patches") {
|
||||
title.push(project.data.name);
|
||||
title.push(repoName(repo));
|
||||
title.push("Patches");
|
||||
} else if (loadedRoute.resource === "repo.release") {
|
||||
title.push(
|
||||
@@ -1400,8 +1385,10 @@ export function repoTitle(loadedRoute: RepoLoadedRoute) {
|
||||
);
|
||||
title.push("Release");
|
||||
} else if (loadedRoute.resource === "repo.releases") {
|
||||
title.push(project.data.name);
|
||||
title.push(repoName(repo));
|
||||
title.push("Releases");
|
||||
} else if (loadedRoute.resource === "repo.sourceUnavailable") {
|
||||
title.push(repoName(repo));
|
||||
} else {
|
||||
return unreachable(loadedRoute);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user