Show the branch a patch is opened against

Add it to the patch list byline and the patch page metadata, shown only
when it differs from the default branch. The merge activity item now
names the real target instead of always naming the default branch.

Co-Authored-By: Brandon Oxendine <brandon.oxendine@gmail.com>
This commit is contained in:
Rūdolfs Ošiņš
2026-09-03 17:19:42 +02:00
co-authored by Brandon Oxendine
parent 3688218060
commit e35baf750f
5 changed files with 159 additions and 20 deletions
+10 -1
View File
@@ -257,6 +257,11 @@
.merge-badge :global(*) {
color: inherit;
}
.merge-target {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.copy-link {
display: inline-flex;
align-items: center;
@@ -572,7 +577,11 @@
<div class="summary-line">
<span class="txt-body-m-medium">merged patch</span>
{#if targetBranch}
into <b>{targetBranch}</b>
into
<span class="merge-target">
<Icon name="branch" />
{targetBranch}
</span>
{/if}
</div>
<div class="meta">
+125 -13
View File
@@ -6,6 +6,7 @@
import type { Config } from "@bindings/config/Config";
import type { Stats } from "@bindings/diff/Stats";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { RepoRefs } from "@bindings/repo/RepoRefs";
import debounce from "lodash/debounce";
@@ -19,12 +20,14 @@
summaryTitle,
} from "@app/lib/reviewSummary";
import * as roles from "@app/lib/roles";
import { push } from "@app/lib/router";
import { push, routeToPath } from "@app/lib/router";
import {
authorForNodeId,
defaultBranch,
formatOid,
pluralize,
publicKeyFromDid,
unqualifyBranch,
verdictIcon,
} from "@app/lib/utils";
@@ -149,6 +152,58 @@
await loadPatch();
}
}
const targetBranch = $derived(
patch.targetBranch === undefined
? undefined
: unqualifyBranch(patch.targetBranch),
);
const targetBranchCaption = $derived(
`This patch merges into ${targetBranch}`,
);
// A patch's target is not validated when it is opened, so it can name a
// branch the source view cannot resolve. The default branch is canonical by
// definition; any other target needs the canonical ref list, which is
// expensive enough to load after render. An unresolved target renders
// unlinked — a missing link beats one that errors.
const targetIsDefaultBranch = $derived(
targetBranch !== undefined && targetBranch === defaultBranch(repo),
);
let canonicalBranches: Record<string, string> | undefined = $state();
let canonicalBranchesRid: string | undefined;
$effect(() => {
if (targetBranch === undefined || targetIsDefaultBranch) {
return;
}
const requested = repo.rid;
if (canonicalBranchesRid === requested) {
return;
}
canonicalBranchesRid = requested;
canonicalBranches = undefined;
void invoke<RepoRefs>("list_repo_refs", { rid: requested })
.then(refs => {
if (canonicalBranchesRid === requested) {
canonicalBranches = refs.canonical.branches;
}
})
.catch(error => {
if (canonicalBranchesRid === requested) {
canonicalBranchesRid = undefined;
}
console.error("Failed to load repo refs", error);
});
});
const targetBranchBrowsable = $derived(
targetIsDefaultBranch ||
(targetBranch !== undefined &&
canonicalBranches !== undefined &&
targetBranch in canonicalBranches),
);
</script>
<style>
@@ -238,6 +293,7 @@
color: var(--color-text-brand);
}
.patch-id-chip,
.target-branch-chip,
.author-chip {
display: inline-flex;
align-items: center;
@@ -249,6 +305,24 @@
color: var(--color-text-tertiary);
font: var(--txt-body-m-regular);
}
.target-group {
display: inline-flex;
align-items: center;
height: 2rem;
overflow: hidden;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-sm);
background-color: var(--color-surface-canvas);
color: var(--color-text-tertiary);
font: var(--txt-body-m-regular);
}
.target-group .patch-id-chip,
.target-group .target-branch-chip {
height: 100%;
border: 0;
border-radius: 0;
background: none;
}
.patch-id-chip {
gap: 0.375rem;
cursor: pointer;
@@ -258,6 +332,19 @@
background-color: var(--color-surface-subtle);
color: var(--color-text-primary);
}
.target-arrow {
display: inline-flex;
align-items: center;
}
.target-branch-chip {
gap: 0.375rem;
text-decoration: none;
}
a.target-branch-chip:hover,
a.target-branch-chip:focus-visible {
background-color: var(--color-surface-subtle);
color: var(--color-text-primary);
}
.patch-id-value {
font: var(--txt-code-regular);
}
@@ -284,19 +371,44 @@
<div class="author-chip" title="Patch author">
<NodeId {...authorForNodeId(patch.author)} />
</div>
<button
type="button"
class="patch-id-chip"
title={patchIdCopied ? "Copied to clipboard" : "Copy patch ID"}
onclick={copyPatchId}>
{#if patchIdCopied}
<Icon name="checkmark" />
{:else}
<span class="pid-icon-default"><Icon name="hash" /></span>
<span class="pid-icon-hover"><Icon name="copy" /></span>
<div class="target-group">
<button
type="button"
class="patch-id-chip"
title={patchIdCopied ? "Copied to clipboard" : "Copy patch ID"}
onclick={copyPatchId}>
{#if patchIdCopied}
<Icon name="checkmark" />
{:else}
<span class="pid-icon-default"><Icon name="hash" /></span>
<span class="pid-icon-hover"><Icon name="copy" /></span>
{/if}
<span class="patch-id-value">{formatOid(patch.id)}</span>
</button>
{#if targetBranch}
<span class="target-arrow" title={targetBranchCaption}>
<Icon name="arrow-right" />
</span>
{#if targetBranchBrowsable}
<a
class="target-branch-chip"
title={targetBranchCaption}
href={routeToPath({
resource: "repo.home",
rid: repo.rid,
revision: targetBranch,
})}>
<Icon name="branch" />
<span>{targetBranch}</span>
</a>
{:else}
<span class="target-branch-chip" title={targetBranchCaption}>
<Icon name="branch" />
<span>{targetBranch}</span>
</span>
{/if}
{/if}
<span class="patch-id-value">{formatOid(patch.id)}</span>
</button>
</div>
{#if stats}
{#if onShowChanges}
<button
+7 -4
View File
@@ -9,7 +9,6 @@
import type { Thread } from "@bindings/cob/thread/Thread";
import type { Config } from "@bindings/config/Config";
import type { Commit } from "@bindings/repo/Commit";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { Snippet } from "svelte";
import partial from "lodash/partial";
@@ -38,6 +37,7 @@
didFromPublicKey,
pluralize,
publicKeyFromDid,
unqualifyBranch,
} from "@app/lib/utils";
import { announce } from "@app/components/AnnounceSwitch.svelte";
@@ -88,9 +88,10 @@
interface Props {
rid: string;
repo: RepoInfo;
repoDelegates: Author[];
patchId: string;
// The patch's resolved merge target, fully qualified.
patchTargetBranch?: string;
revision: Revision;
config: Config;
loadPatch: () => Promise<void>;
@@ -119,9 +120,9 @@
/* eslint-disable prefer-const */
let {
rid,
repo,
repoDelegates,
patchId,
patchTargetBranch,
revision,
config,
loadPatch,
@@ -183,7 +184,9 @@
: undefined;
});
const targetBranch = $derived(
repo.payloads["xyz.radicle.project"]?.data.defaultBranch,
patchTargetBranch === undefined
? undefined
: unqualifyBranch(patchTargetBranch),
);
let revisionToggles: Record<string, boolean> = $state({});
let commitGroupToggles: Record<string, boolean> = $state({});
+15
View File
@@ -3,6 +3,7 @@ import type { Issue } from "@bindings/cob/issue/Issue";
import type { Patch } from "@bindings/cob/patch/Patch";
import type { Review } from "@bindings/cob/patch/Review";
import type { Config } from "@bindings/config/Config";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { ComponentProps } from "svelte";
import bs58 from "bs58";
@@ -16,6 +17,20 @@ export const unreachable = (value: never): never => {
throw new Error(`Unreachable code: ${value}`);
};
export const REFS_HEADS = "refs/heads/";
// The single place to change once the API stops carrying the default branch
// in the project payload, which Heartwood no longer requires.
export function defaultBranch(repo: RepoInfo): string | undefined {
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 function formatRepositoryId(id: string): string {
const parsedId = parseRepositoryId(id);
+2 -2
View File
@@ -1514,9 +1514,9 @@
{#snippet revisionBody()}
<RevisionComponent
patchTargetBranch={patch.targetBranch}
bind:this={revisionComponent}
rid={repo.rid}
{repo}
repoDelegates={repo.delegates}
patchId={patch.id}
{loadPatch}
@@ -1571,8 +1571,8 @@
class:collapsed={descriptionCollapsed}>
<div class="patch-description-body" bind:this={descriptionEl}>
<RevisionComponent
patchTargetBranch={patch.targetBranch}
rid={repo.rid}
{repo}
repoDelegates={repo.delegates}
patchId={patch.id}
{loadPatch}