mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-16 03:40:37 +08:00
Polish author and committer popover
Show the email alongside each name. Co-authors are read from the commit message trailers and listed as their own section. The trigger avatars now stack and fan apart on hover, and cover everyone credited on the commit rather than just the author and committer. Timestamps now show both committed and authored times in the tooltip.
This commit is contained in:
@@ -2,14 +2,10 @@
|
||||
import type { Commit } from "@bindings/repo/Commit";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
import {
|
||||
absoluteTimestamp,
|
||||
formatTimestamp,
|
||||
gravatarURL,
|
||||
twemoji,
|
||||
} from "@app/lib/utils";
|
||||
import { commitTimes, formatTimestamp, twemoji } from "@app/lib/utils";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import CommitAuthors from "@app/components/CommitAuthors.svelte";
|
||||
import CompactCommitAuthorship from "@app/components/CompactCommitAuthorship.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import Id from "@app/components/Id.svelte";
|
||||
@@ -101,13 +97,6 @@
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.commit-avatar {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
.stacked-header .summary-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -149,11 +138,7 @@
|
||||
aria-label="commit-teaser">
|
||||
{#if stacked}
|
||||
<div class="stacked-header">
|
||||
<img
|
||||
class="commit-avatar"
|
||||
alt=""
|
||||
src={gravatarURL(commit.author.email)}
|
||||
title={commit.author.name} />
|
||||
<CommitAuthors {commit} />
|
||||
<div
|
||||
class="summary-title"
|
||||
style:cursor={hoverable ? "pointer" : "default"}
|
||||
@@ -170,9 +155,7 @@
|
||||
<span class="meta-hash">
|
||||
<Id id={commit.id} clipboard={commit.id} label="commit hash" />
|
||||
</span>
|
||||
<span
|
||||
class="meta-time"
|
||||
title={absoluteTimestamp(commit.committer.time * 1000)}>
|
||||
<span class="meta-time" title={commitTimes(commit)}>
|
||||
{formatTimestamp(commit.committer.time * 1000)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
<script lang="ts">
|
||||
import type { Commit } from "@bindings/repo/Commit";
|
||||
|
||||
import debounce from "lodash/debounce";
|
||||
|
||||
import { writeToClipboard } from "@app/lib/invoke";
|
||||
import type { GitIdentity } from "@app/lib/utils";
|
||||
import * as utils from "@app/lib/utils";
|
||||
|
||||
import HoverPopover from "@app/components/HoverPopover.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
|
||||
interface Props {
|
||||
commit: Commit;
|
||||
}
|
||||
|
||||
const { commit }: Props = $props();
|
||||
|
||||
// Which identity was last copied, so the rows keep independent feedback.
|
||||
let copied: string | undefined = $state();
|
||||
|
||||
// The identity as it appears in a commit trailer, so that a copied result can
|
||||
// be pasted straight into `Co-authored-by:` and the like.
|
||||
function identity(who: GitIdentity): string {
|
||||
return who.name ? `${who.name} <${who.email}>` : who.email;
|
||||
}
|
||||
|
||||
// Co-authors are not part of the commit's git identity fields; they are a
|
||||
// convention carried in the message trailers. The same person can be
|
||||
// trailered more than once, and squashing gathers several messages' trailers
|
||||
// into one, so only the first mention of each is kept.
|
||||
const coAuthors = $derived.by(() => {
|
||||
const credited = new Set([
|
||||
utils.identityKey(commit.author),
|
||||
utils.identityKey(commit.committer),
|
||||
]);
|
||||
|
||||
return utils.coAuthors(commit.message).filter((who, index, all) => {
|
||||
const key = utils.identityKey(who);
|
||||
|
||||
return (
|
||||
!credited.has(key) &&
|
||||
all.findIndex(other => utils.identityKey(other) === key) === index
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const authorIsCommitter = $derived(
|
||||
utils.identityKey(commit.author) === utils.identityKey(commit.committer),
|
||||
);
|
||||
|
||||
// Everyone credited on the commit, in the order the popover lists them.
|
||||
const people = $derived([
|
||||
commit.author,
|
||||
...(authorIsCommitter ? [] : [commit.committer]),
|
||||
...coAuthors,
|
||||
]);
|
||||
// The stack fans out on hover, which widens the row, so only the first few
|
||||
// get an avatar and the rest are counted. The popover lists them all.
|
||||
const STACK_LIMIT = 3;
|
||||
const stacked = $derived(people.slice(0, STACK_LIMIT));
|
||||
const overflow = $derived(people.length - stacked.length);
|
||||
|
||||
const restoreIcon = debounce(() => {
|
||||
copied = undefined;
|
||||
}, 1000);
|
||||
|
||||
async function copyIdentity(who: GitIdentity) {
|
||||
await writeToClipboard(identity(who));
|
||||
copied = utils.identityKey(who);
|
||||
restoreIcon();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.avatar {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--border-radius-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
.avatars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Everyone credited sits stacked, and the avatars fan apart on hover. The
|
||||
ring keeps the top avatar readable against the one behind it on any row
|
||||
background. */
|
||||
.avatars .avatar + .avatar {
|
||||
margin-left: -0.375rem;
|
||||
transition: margin-left 150ms ease;
|
||||
}
|
||||
.avatars:hover .avatar + .avatar {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
/* Each avatar overlaps the next, so every one needs the ring and its own
|
||||
stacking order rather than only the first. */
|
||||
.avatars .avatar {
|
||||
position: relative;
|
||||
box-shadow: 0 0 0 1px var(--color-border-subtle);
|
||||
}
|
||||
.avatars .more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
min-width: 1rem;
|
||||
padding: 0 0.1875rem;
|
||||
background-color: var(--color-surface-mid);
|
||||
color: var(--color-text-secondary);
|
||||
font: var(--txt-body-s-medium);
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
/* The counter carries text, so unlike the avatars it sits beside the stack
|
||||
rather than under its last member, which would clip the leading "+". */
|
||||
.avatars .avatar + .more {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.avatars .avatar + .avatar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
max-width: 22rem;
|
||||
}
|
||||
.role + .role {
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.role-label {
|
||||
color: var(--color-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.role-person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.role-person + .role-person {
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
/* The avatar centres against the pair, while the name and the smaller email
|
||||
share a baseline rather than each being centred on its own box. */
|
||||
.role-text {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
/* The name keeps its full width and the email gives way first, so a long
|
||||
address never squeezes out the person it belongs to. */
|
||||
.name {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.email-row {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.email-row:hover,
|
||||
.email-row:focus-visible {
|
||||
background-color: var(--color-surface-mid);
|
||||
}
|
||||
.email-row:hover .email,
|
||||
.email-row:focus-visible .email,
|
||||
.email-row:hover .email-copy,
|
||||
.email-row:focus-visible .email-copy {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.email {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-tertiary);
|
||||
font: var(--txt-code-regular);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
/* With no name to go by the address is the person, so it reads as their
|
||||
primary line instead of as a secondary detail. */
|
||||
.email-row.nameless .email {
|
||||
color: var(--color-text-primary);
|
||||
font: var(--txt-body-m-regular);
|
||||
}
|
||||
.email-copy {
|
||||
display: inline-flex;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
{#snippet role(label: string, members: GitIdentity[])}
|
||||
<div class="role">
|
||||
<div class="role-label txt-body-s-medium">{label}</div>
|
||||
{#each members as who (utils.identityKey(who))}
|
||||
<div class="role-person">
|
||||
<img class="avatar" alt="" src={utils.gravatarURL(who.email)} />
|
||||
<div class="role-text">
|
||||
{#if who.name}
|
||||
<span class="name txt-body-m-regular">{who.name}</span>
|
||||
{/if}
|
||||
{#if who.email}
|
||||
<button
|
||||
type="button"
|
||||
class="email-row"
|
||||
class:nameless={!who.name}
|
||||
title={`Copy "${identity(who)}"`}
|
||||
onclick={event => {
|
||||
event.stopPropagation();
|
||||
void copyIdentity(who);
|
||||
}}>
|
||||
<span class="email">{who.email}</span>
|
||||
<span class="email-copy">
|
||||
<Icon
|
||||
name={copied === utils.identityKey(who)
|
||||
? "checkmark"
|
||||
: "copy"} />
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<HoverPopover placement="top-start" stylePadding="0.75rem">
|
||||
{#snippet toggle()}
|
||||
<div class="avatars">
|
||||
{#each stacked as who, index (utils.identityKey(who))}
|
||||
<img
|
||||
class="avatar"
|
||||
alt=""
|
||||
title={who.name || who.email}
|
||||
style:z-index={people.length - index}
|
||||
src={utils.gravatarURL(who.email)} />
|
||||
{/each}
|
||||
{#if overflow > 0}
|
||||
<span class="avatar more">+{overflow}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet popover()}
|
||||
<div class="card">
|
||||
{#if authorIsCommitter}
|
||||
{@render role("Author & committer", [commit.author])}
|
||||
{:else}
|
||||
{@render role("Author", [commit.author])}
|
||||
{@render role("Committer", [commit.committer])}
|
||||
{/if}
|
||||
{#if coAuthors.length > 0}
|
||||
{@render role(
|
||||
coAuthors.length === 1 ? "Co-author" : "Co-authors",
|
||||
coAuthors,
|
||||
)}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</HoverPopover>
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as utils from "@app/lib/utils";
|
||||
|
||||
import HoverPopover from "@app/components/HoverPopover.svelte";
|
||||
import CommitAuthors from "@app/components/CommitAuthors.svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
@@ -22,80 +22,12 @@
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
gap: 0.5rem;
|
||||
font: var(--txt-code-regular);
|
||||
}
|
||||
.label {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.avatar {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="authorship">
|
||||
<HoverPopover placement="top-start">
|
||||
{#snippet toggle()}
|
||||
<div style="display: flex;">
|
||||
{#if commit.author.email === commit.committer.email}
|
||||
<img
|
||||
class="avatar"
|
||||
alt="avatar"
|
||||
src={utils.gravatarURL(commit.committer.email)} />
|
||||
{:else}
|
||||
<img
|
||||
style:margin-right="0.25rem"
|
||||
class="avatar"
|
||||
alt="avatar"
|
||||
src={utils.gravatarURL(commit.author.email)} />
|
||||
<img
|
||||
class="avatar"
|
||||
alt="avatar"
|
||||
src={utils.gravatarURL(commit.committer.email)} />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet popover()}
|
||||
<div class="popover">
|
||||
{#if commit.author.email === commit.committer.email}
|
||||
<div class="person">
|
||||
<div class="label">Author</div>
|
||||
<img
|
||||
class="avatar"
|
||||
alt="avatar"
|
||||
src={utils.gravatarURL(commit.committer.email)} />
|
||||
{commit.author.name}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="person">
|
||||
<div class="label">Author</div>
|
||||
<img
|
||||
class="avatar"
|
||||
alt="avatar"
|
||||
src={utils.gravatarURL(commit.author.email)} />
|
||||
{commit.author.name}
|
||||
</div>
|
||||
<div class="person">
|
||||
<div class="label">Committer</div>
|
||||
<img
|
||||
class="avatar"
|
||||
alt="avatar"
|
||||
src={utils.gravatarURL(commit.committer.email)} />
|
||||
{commit.committer.name}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</HoverPopover>
|
||||
<CommitAuthors {commit} />
|
||||
{@render children()}
|
||||
<div title={utils.absoluteTimestamp(commit.committer.time * 1000)}>
|
||||
<div title={utils.commitTimes(commit)}>
|
||||
{utils.formatTimestamp(commit.committer.time * 1000)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
|
||||
// Whether the pointer is currently over the anchor or popover.
|
||||
let hovering = false;
|
||||
// Whether focus is inside the anchor or popover, so that a keyboard user can
|
||||
// reach any controls the popover holds.
|
||||
let focused = false;
|
||||
// Whether a text-selection drag started inside the popover is ongoing.
|
||||
let selecting = false;
|
||||
|
||||
@@ -46,31 +49,49 @@
|
||||
visible = false;
|
||||
}, 200);
|
||||
|
||||
function enter() {
|
||||
hovering = true;
|
||||
function open() {
|
||||
hide.cancel();
|
||||
show();
|
||||
}
|
||||
|
||||
function leave() {
|
||||
hovering = false;
|
||||
show.cancel();
|
||||
// A selection drag may move the pointer out of the popover; keep it open
|
||||
// until the drag ends (see endSelection).
|
||||
if (!selecting) {
|
||||
// Any one of pointer, focus or an ongoing selection drag is enough to keep
|
||||
// the popover open, so closing waits until none of them holds it.
|
||||
function close() {
|
||||
if (!hovering && !focused && !selecting) {
|
||||
show.cancel();
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
function enter() {
|
||||
hovering = true;
|
||||
open();
|
||||
}
|
||||
|
||||
function leave() {
|
||||
hovering = false;
|
||||
close();
|
||||
}
|
||||
|
||||
// Focus crossing between the anchor and the portalled popover leaves and
|
||||
// re-enters within one task, which the debounced hide absorbs.
|
||||
function focus() {
|
||||
focused = true;
|
||||
open();
|
||||
}
|
||||
|
||||
function blur() {
|
||||
focused = false;
|
||||
close();
|
||||
}
|
||||
|
||||
// A text-selection drag can move the pointer outside the popover and release
|
||||
// anywhere, so we follow it for the duration of the drag with a listener that
|
||||
// removes itself on release, rather than closing on pointer-leave.
|
||||
function endSelection() {
|
||||
selecting = false;
|
||||
document.removeEventListener("pointerup", endSelection);
|
||||
if (!hovering) {
|
||||
hide();
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
function startSelection() {
|
||||
@@ -121,7 +142,9 @@
|
||||
class="container"
|
||||
bind:this={anchorEl}
|
||||
onmouseenter={enter}
|
||||
onmouseleave={leave}>
|
||||
onmouseleave={leave}
|
||||
onfocusin={focus}
|
||||
onfocusout={blur}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
role="button"
|
||||
@@ -142,6 +165,8 @@
|
||||
style:padding={stylePadding}
|
||||
onmouseenter={enter}
|
||||
onmouseleave={leave}
|
||||
onfocusin={focus}
|
||||
onfocusout={blur}
|
||||
onpointerdown={startSelection}
|
||||
onclick={e => e.stopPropagation()}>
|
||||
{@render popover()}
|
||||
|
||||
@@ -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 { Commit } from "@bindings/repo/Commit";
|
||||
import type { RepoInfo } from "@bindings/repo/RepoInfo";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
@@ -374,3 +375,54 @@ export function explorerUrl(path: string, config: Config): string {
|
||||
.replace("$host", seed)
|
||||
.replace("$rid$path", path);
|
||||
}
|
||||
|
||||
export interface GitIdentity {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Two git identities are the same person when they share an address, compared
|
||||
// case-insensitively the way `gravatarURL` normalises it. An identity may carry
|
||||
// no address at all, so those fall back to the name rather than collapsing into
|
||||
// each other.
|
||||
export function identityKey(who: GitIdentity): string {
|
||||
const email = who.email.trim().toLowerCase();
|
||||
return email ? `email:${email}` : `name:${who.name.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
// A commit carries an author date and a commit date, which come apart on a
|
||||
// rebase or an amend. Views show the commit date, so the tooltip only names the
|
||||
// two apart when there is something to tell apart.
|
||||
export function commitTimes(commit: Commit): string {
|
||||
const committed = absoluteTimestamp(commit.committer.time * 1000);
|
||||
if (commit.author.time === commit.committer.time) {
|
||||
return committed;
|
||||
}
|
||||
|
||||
return [
|
||||
`Authored — ${absoluteTimestamp(commit.author.time * 1000)}`,
|
||||
`Committed — ${committed}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// Extract `Co-authored-by:` trailers from a commit message.
|
||||
//
|
||||
// Git only treats the final paragraph of a message as trailers, so a line
|
||||
// appearing anywhere earlier — quoted in prose, or in a nested patch — is not
|
||||
// one and is ignored here too. Matching is case-insensitive because git's own
|
||||
// trailer handling is. The name may be empty, since a git identity only
|
||||
// requires an address.
|
||||
export function coAuthors(message: string): GitIdentity[] {
|
||||
const paragraphs = message.trimEnd().split(/(?:\r?\n){2,}/);
|
||||
const trailers = paragraphs[paragraphs.length - 1] ?? "";
|
||||
const authors: GitIdentity[] = [];
|
||||
|
||||
for (const line of trailers.split("\n")) {
|
||||
const match = /^co-authored-by:\s*(.*?)\s*<([^<>\s]+)>$/i.exec(line.trim());
|
||||
if (match) {
|
||||
authors.push({ name: match[1], email: match[2] });
|
||||
}
|
||||
}
|
||||
|
||||
return authors;
|
||||
}
|
||||
|
||||
@@ -13,14 +13,15 @@
|
||||
import * as router from "@app/lib/router";
|
||||
import type { SidebarData } from "@app/lib/router/definitions";
|
||||
import {
|
||||
absoluteTimestamp,
|
||||
commitTimes,
|
||||
formatOid,
|
||||
formatTimestamp,
|
||||
gravatarURL,
|
||||
identityKey,
|
||||
pluralize,
|
||||
} from "@app/lib/utils";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import CommitAuthors from "@app/components/CommitAuthors.svelte";
|
||||
import DiffActions from "@app/components/DiffActions.svelte";
|
||||
import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
@@ -51,6 +52,10 @@
|
||||
}>();
|
||||
let allCollapsed = $state(false);
|
||||
|
||||
const authorIsCommitter = $derived(
|
||||
identityKey(commit.author) === identityKey(commit.committer),
|
||||
);
|
||||
|
||||
const changedFiles = $derived(gitStatusEntries(diff.files));
|
||||
const treePaths = $derived(changedFiles.map(file => file.path));
|
||||
|
||||
@@ -143,12 +148,6 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.summary-avatar {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 999px;
|
||||
flex: none;
|
||||
}
|
||||
.summary-timestamp {
|
||||
color: var(--color-text-quaternary);
|
||||
}
|
||||
@@ -211,17 +210,12 @@
|
||||
</div>
|
||||
<div class="summary-meta">
|
||||
<span class="summary-author">
|
||||
<img
|
||||
class="summary-avatar"
|
||||
alt=""
|
||||
src={gravatarURL(commit.author.email)} />
|
||||
<CommitAuthors {commit} />
|
||||
<span class="txt-selectable">{commit.author.name}</span>
|
||||
</span>
|
||||
committed
|
||||
{authorIsCommitter ? "committed" : "authored"}
|
||||
<Id id={commit.id} clipboard={commit.id} label="commit hash" />
|
||||
<span
|
||||
class="summary-timestamp"
|
||||
title={absoluteTimestamp(commit.committer.time * 1000)}>
|
||||
<span class="summary-timestamp" title={commitTimes(commit)}>
|
||||
{formatTimestamp(commit.committer.time * 1000)}
|
||||
</span>
|
||||
<JobCob rid={repo.rid} commit={commit.id} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { safeHttpUrl } from "@app/lib/utils";
|
||||
import { coAuthors, safeHttpUrl } from "@app/lib/utils";
|
||||
|
||||
describe("safeHttpUrl", () => {
|
||||
test.each([
|
||||
@@ -27,3 +27,56 @@ describe("safeHttpUrl", () => {
|
||||
expect(safeHttpUrl(input)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("coAuthors", () => {
|
||||
test("reads trailers from the final paragraph", () => {
|
||||
expect(
|
||||
coAuthors(
|
||||
"Fix the thing\n\nSome body text.\n\nSigned-off-by: A <a@x>\nCo-authored-by: Alice Lidell <alice@lidell.com>\nCo-authored-by: Bob <bob@example.com>",
|
||||
),
|
||||
).toEqual([
|
||||
{ name: "Alice Lidell", email: "alice@lidell.com" },
|
||||
{ name: "Bob", email: "bob@example.com" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(
|
||||
coAuthors("Subject\n\nCO-AUTHORED-BY: Bob <bob@example.com>"),
|
||||
).toEqual([{ name: "Bob", email: "bob@example.com" }]);
|
||||
});
|
||||
|
||||
test("ignores a trailer that is not in the last paragraph", () => {
|
||||
expect(
|
||||
coAuthors(
|
||||
"Subject\n\nCo-authored-by: Bob <bob@example.com>\n\nThat line was quoted, not a trailer.",
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps the last paragraph rule for CRLF messages", () => {
|
||||
expect(
|
||||
coAuthors(
|
||||
"Subject\r\n\r\nCo-authored-by: Bob <bob@example.com>\r\n\r\nThat line was quoted, not a trailer.\r\n",
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("reads a trailer that carries no name", () => {
|
||||
expect(coAuthors("Subject\n\nCo-authored-by: <bob@example.com>")).toEqual([
|
||||
{ name: "", email: "bob@example.com" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("ignores malformed lines", () => {
|
||||
expect(
|
||||
coAuthors(
|
||||
"Subject\n\nCo-authored-by: Bob\nCo-authored-by:\nNot-a-trailer: x <y@z>",
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns nothing when there are no trailers", () => {
|
||||
expect(coAuthors("Subject\n\nJust a body.")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user