Files
radicle-explorer/explorer/src/components/RepoCard.svelte
T
Daniel Norman 0dc1e557a5 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
2026-10-29 18:21:09 +01:00

249 lines
5.7 KiB
Svelte

<script lang="ts">
import type { RepoInfo } from "./RepoCard";
import type { BaseUrl } from "@http-client";
import { HttpdClient } from "@http-client";
import {
absoluteTimestamp,
defaultBranchTip,
formatTimestamp,
formatRepositoryId,
repoSegment,
twemoji,
} from "@app/lib/utils";
import ActivityDiagram from "@app/components/ActivityDiagram.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import Link from "@app/components/Link.svelte";
import RepoAvatar from "@radicle-gui/ui/components/RepoAvatar.svelte";
export let compact = false;
export let repoInfo: RepoInfo;
export let baseUrl: BaseUrl;
const api = new HttpdClient(baseUrl);
$: repo = repoInfo.repo;
$: project = repoInfo.repo.payloads["xyz.radicle.project"];
$: baseUrl = repoInfo.baseUrl;
$: isPrivate = repo.visibility.type === "private";
$: tip = defaultBranchTip(repo);
</script>
<style>
.repo-card {
container-type: inline-size;
height: 10rem;
background-color: var(--color-surface-canvas);
padding: 1rem;
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid var(--color-border-subtle);
margin-top: -1px;
margin-left: -1px;
}
.repo-card.compact {
height: 8rem;
}
.repo-card:hover {
background-color: var(--color-surface-subtle);
}
.content-and-activity {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.content {
flex: 1;
min-width: 0;
}
.activity {
flex: 1;
max-width: 9rem;
align-self: flex-end;
}
.activity-shimmer {
width: 100%;
min-width: 130px;
aspect-ratio: 493 / 116;
background: linear-gradient(
90deg,
var(--color-surface-base) 0%,
var(--color-surface-subtle) 50%,
var(--color-surface-base) 100%
);
background-size: 200% 100%;
animation:
shimmer 1.8s ease-in-out infinite,
shimmer-fade-in 200ms ease-out 500ms both;
}
@keyframes shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@keyframes shimmer-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.headline-and-badges {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.badges {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.badge {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
overflow: hidden;
position: relative;
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;
gap: 0.25rem;
height: 1.5rem;
align-items: center;
white-space: nowrap;
}
.icon {
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-brand-bg);
color: var(--color-text-on-brand);
}
@container (max-width: 22rem) {
.activity {
display: none;
}
}
</style>
<Link
route={{
resource: "repo.source",
repo: repoSegment(repo),
node: baseUrl,
}}>
<div class="repo-card" class:compact>
<div class="headline-and-badges txt-overflow">
<div class="icon">
<RepoAvatar
rid={repo.rid}
name={project?.data.name ?? ""}
styleWidth="1.5rem" />
</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
title="Private"
class="badge"
style="background-color: var(--color-feedback-warning-bg); color: var(--color-feedback-warning-text)">
<Icon name="lock" />
</div>
{/if}
<slot name="delegate" />
</div>
</div>
<div class="content-and-activity">
<div class="content">
<div class="txt-body-m-regular txt-overflow" use:twemoji>
{project?.data.description ?? ""}
</div>
<span style:text-overflow="ellipsis">
<Id id={repo.rid} title={repo.rid}>
{formatRepositoryId(repo.rid)}
</Id>
</span>
</div>
<div class="activity">
{#await repoInfo.activity}
<div class="activity-shimmer"></div>
{:then activity}
<ActivityDiagram
id={repo.rid}
viewBoxHeight={100}
styleColor="var(--color-text-brand)"
{activity} />
{/await}
</div>
</div>
<div>
<div
class="stats-row txt-code-regular"
style:color="var(--color-text-tertiary)">
<Icon name="seed" />
{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>
</div>
</Link>