Skip to content
174 linesCodeBlameRaw
1import { User } from "lucide-react";
2import { Link } from "react-router";
3
4import { codingAgentById } from "@g1t/contracts/coding-agents";
5
6import { type CommitPerson, type ShownCommit, profileHref, shownName } from "../lib/commit-people";
7import { cn } from "../lib/cn";
8
9import { AgentBadge, AgentCard, AgentMark, AuthorCard } from "./coding-agent";
10import { UserCard } from "./user-card";
11import { Avatar } from "./ui/avatar";
12
13/**
14 * A person on a commit, as an avatar: theirs and their card when the
15 * commit is matched to an account (or is g1t's); a coding agent's mark
16 * and card; else a silhouette with a card that says they're not on g1t.
17 */
18export function CommitAvatar({ person, size = 20 }: { person: CommitPerson; size?: number }) {
19 if (person.kind === "user" || person.kind === "g1t") {
20 const href = profileHref(person);
21 const avatar = <Avatar name={shownName(person)} image={person.avatar} size={size} />;
22 return (
23 <UserCard username={person.username}>
24 {href ? (
25 // Its name beside it is the link a keyboard reaches; this is for the pointer.
26 <Link to={href} tabIndex={-1} aria-hidden="true" className="relative z-10 inline-flex shrink-0 rounded-full">
27 {avatar}
28 </Link>
29 ) : (
30 <span className="relative z-10 inline-flex shrink-0">{avatar}</span>
31 )}
32 </UserCard>
33 );
34 }
35 const agent = person.kind === "agent" ? codingAgentById(person.agent) : null;
36 if (agent) {
37 return (
38 <AgentCard agentId={agent.id}>
39 <span className="relative z-10 inline-flex shrink-0 rounded-full">
40 <AgentMark agent={agent} size={size} />
41 </span>
42 </AgentCard>
43 );
44 }
45 const silhouette = (
46 <span
47 aria-hidden="true"
48 className="inline-flex shrink-0 items-center justify-center rounded-full bg-line text-faint"
49 style={{ width: size, height: size }}
50 >
51 <User size={Math.round(size * 0.62)} strokeWidth={2.25} />
52 </span>
53 );
54 // A deleted account: a plain silhouette, no card.
55 if (person.kind === "ghost") return silhouette;
56 // Nobody on g1t: the silhouette, with a card that says so.
57 return (
58 <AuthorCard name={person.name}>
59 <span className="relative z-10 inline-flex shrink-0 rounded-full">{silhouette}</span>
60 </AuthorCard>
61 );
62}
63
64/**
65 * A commit's author and co-authors, as overlapping avatars: the author's
66 * on top, at most `max`.
67 */
68export function CommitAvatars({ commit, size = 20, max = 3 }: { commit: Pick<ShownCommit, "author" | "coAuthors">; size?: number; max?: number }) {
69 const people = [commit.author, ...commit.coAuthors].slice(0, max);
70 if (people.length === 1) return <CommitAvatar person={commit.author} size={size} />;
71 return (
72 <span className="inline-flex shrink-0 items-center">
73 {people.map((person, index) => (
74 <span
75 key={`${index}:${person.username ?? person.name}`}
76 className="inline-flex rounded-full ring-2 ring-bg"
77 style={{ marginLeft: index === 0 ? 0 : -Math.round(size * 0.35), zIndex: people.length - index }}
78 >
79 <CommitAvatar person={person} size={size} />
80 </span>
81 ))}
82 </span>
83 );
84}
85
86/**
87 * A person on a commit, by name: their username, linked to their profile
88 * with their card, for an account; g1t with its label and card; ghost for
89 * a deleted account; else the name written on the commit, as text.
90 */
91export function CommitName({ person, className }: { person: CommitPerson; className?: string }) {
92 if (person.kind === "g1t") {
93 return (
94 <UserCard username="g1t">
95 <span tabIndex={0} className="relative z-10 inline-flex items-baseline gap-1 rounded outline-none focus-visible:ring-2 focus-visible:ring-accent">
96 <span className={className}>g1t</span>
97 <span className="rounded border border-line px-1 text-[0.625rem] leading-[1.35] font-medium text-muted">bot</span>
98 </span>
99 </UserCard>
100 );
101 }
102 const agent = person.kind === "agent" ? codingAgentById(person.agent) : null;
103 if (agent) {
104 return (
105 <AgentCard agentId={agent.id}>
106 <span tabIndex={0} className="relative z-10 inline-flex items-baseline gap-1 rounded outline-none focus-visible:ring-2 focus-visible:ring-accent">
107 <span className={className}>{agent.name}</span>
108 <AgentBadge agent={agent} />
109 </span>
110 </AgentCard>
111 );
112 }
113 const href = profileHref(person);
114 if (!href) {
115 if (person.kind === "ghost") return <span className={className}>{shownName(person)}</span>;
116 return (
117 <AuthorCard name={person.name}>
118 <span tabIndex={0} className={cn("relative z-10 rounded outline-none focus-visible:ring-2 focus-visible:ring-accent", className)}>
119 {shownName(person)}
120 </span>
121 </AuthorCard>
122 );
123 }
124 return (
125 <UserCard username={person.username}>
126 <Link to={href} className={cn("relative z-10 hover:underline", className)}>
127 {shownName(person)}
128 </Link>
129 </UserCard>
130 );
131}
132
133/**
134 * Who made a commit, in words: the author, and its co-authors after them
135 * ("ada and grace", "ada, grace and 2 others").
136 */
137export function CommitNames({
138 commit,
139 className,
140 all,
141}: {
142 commit: Pick<ShownCommit, "author" | "coAuthors">;
143 className?: string;
144 /** Name every co-author, rather than counting past the first. */
145 all?: boolean;
146}) {
147 const others = commit.coAuthors;
148 if (others.length === 0) return <CommitName person={commit.author} className={className} />;
149 if (all && others.length > 1) {
150 const people = [commit.author, ...others];
151 return (
152 <>
153 {people.map((person, index) => (
154 <span key={index}>
155 {index > 0 && (index === people.length - 1 ? " and " : ", ")}
156 <CommitName person={person} className={className} />
157 </span>
158 ))}
159 </>
160 );
161 }
162 if (others.length === 1) {
163 return (
164 <>
165 <CommitName person={commit.author} className={className} /> and <CommitName person={others[0]!} className={className} />
166 </>
167 );
168 }
169 return (
170 <>
171 <CommitName person={commit.author} className={className} /> and {others.length} others
172 </>
173 );
174}