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:
Rūdolfs Ošiņš
2026-09-09 10:49:50 +02:00
parent ba3316c246
commit 982a568538
7 changed files with 439 additions and 121 deletions
+4 -21
View File
@@ -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>
+279
View File
@@ -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>
+3 -71
View File
@@ -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>
+37 -12
View File
@@ -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()}
+52
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 { 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;
}
+10 -16
View File
@@ -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} />
+54 -1
View File
@@ -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([]);
});
});