Show the branch a patch is opened against

Resolve the merge target from `target` and the project payload, and name
it in the byline next to the patch ID.

Co-Authored-By: Brandon Oxendine <brandon.oxendine@gmail.com>
This commit is contained in:
Rūdolfs Ošiņš
2026-09-03 12:03:16 +02:00
co-authored by Brandon Oxendine
parent 1a160e55c1
commit 8e78634a23
3 changed files with 88 additions and 10 deletions
+18 -3
View File
@@ -1,9 +1,24 @@
import type { Author, BaseUrl } from "@http-client";
import type { Author, BaseUrl, Repo } from "@http-client";
import md5 from "md5";
import bs58 from "bs58";
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;
}
export function unqualifyBranch(refname: string): string {
return refname.startsWith(REFS_HEADS)
? refname.slice(REFS_HEADS.length)
: refname;
}
export async function toClipboard(text: string): Promise<void> {
await navigator.clipboard.writeText(text);
}
@@ -328,8 +343,8 @@ export function getBranchesFromRefs(
): 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;
if (name.startsWith(REFS_HEADS)) {
branches[name.slice(REFS_HEADS.length)] = oid;
}
}
return branches;
+65 -3
View File
@@ -4,11 +4,14 @@
Comment,
Review,
Merge,
Patch,
Repo,
Revision,
Diff,
} from "@http-client";
import * as utils from "@app/lib/utils";
interface Thread {
root: Comment;
replies: Comment[];
@@ -41,15 +44,32 @@
string,
{ latest: boolean; review: Review }
>;
// 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);
}
// Nothing validates a patch's target when it is opened, so it can name a
// branch that is not a canonical reference. The source view cannot resolve
// 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) ||
repo.refs?.refs[`${utils.REFS_HEADS}${branch}`] !== undefined
);
}
</script>
<script lang="ts">
import type { BaseUrl, Patch } from "@http-client";
import type { BaseUrl } from "@http-client";
import type { PatchView } from "./router";
import type { Route } from "@app/lib/router";
import type { ComponentProps } from "svelte";
import * as utils from "@app/lib/utils";
import capitalize from "lodash/capitalize";
import uniqBy from "lodash/uniqBy";
@@ -232,6 +252,9 @@
})),
].sort((a, b) => a.timestamp - b.timestamp),
]);
$: targetBranch = resolveTargetBranch(patch, repo);
$: targetBranchCaption = `This patch merges into ${targetBranch}`;
$: targetBranchBrowsable = isBrowsableBranch(targetBranch, repo);
$: firstRevision = timelineTuple[0][0];
$: latestRevision = patch.revisions[patch.revisions.length - 1];
</script>
@@ -286,6 +309,19 @@
color: var(--color-text-tertiary);
font: var(--txt-body-m-regular);
}
.code-chip {
display: inline-flex;
}
.code-chip > :global(*) {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font: var(--txt-code-regular);
color: var(--color-text-tertiary);
background-color: var(--color-surface-alpha-subtle);
border-radius: var(--border-radius-sm);
padding: 0 0.25rem;
}
.revision-description {
display: flex;
flex-direction: column;
@@ -379,7 +415,33 @@
nodeId={patch.author.id}
alias={patch.author.alias} />
opened
<Id id={patch.id} />
<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>
<span title={utils.absoluteTimestamp(patch.revisions[0].timestamp)}>
{utils.formatTimestamp(patch.revisions[0].timestamp)}
</span>
+5 -4
View File
@@ -32,6 +32,7 @@ import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
import { cached } from "@app/lib/cache";
import { handleError, unreachableError } from "@app/views/repos/error";
import {
REFS_HEADS,
getBranchesFromRefs,
getTagsFromRefs,
unreachable,
@@ -43,7 +44,7 @@ export const ISSUES_PER_PAGE = 10;
export const RELEASES_PER_PAGE = 30;
export function peerHasBranches(peer: PeerRefs): boolean {
return Object.keys(peer.refs).some(name => name.startsWith("refs/heads/"));
return Object.keys(peer.refs).some(name => name.startsWith(REFS_HEADS));
}
function canonicalOids(
@@ -70,7 +71,7 @@ export function remoteToPeerRefs(remote: Remote): PeerRefs {
const refs: Record<string, string> = {};
for (const [name, oid] of Object.entries(remote.heads)) {
refs[`refs/heads/${name}`] = oid;
refs[`${REFS_HEADS}${name}`] = oid;
}
return {
@@ -979,8 +980,8 @@ function canonicalBranchMap(repo: Repo): Record<string, string> {
}
for (const [refName, oid] of canonicalOids(repo.refs)) {
const shortName = refName.startsWith("refs/heads/")
? refName.slice("refs/heads/".length)
const shortName = refName.startsWith(REFS_HEADS)
? refName.slice(REFS_HEADS.length)
: refName.startsWith("refs/tags/")
? refName.slice("refs/tags/".length)
: refName;