Skip to content

g1t/apps/web/app/components/work.tsx

540 lines17,170 bytesCodeBlame
1import { Bot, CircleCheck, CircleSlash } from "lucide-react";
2import type { CSSProperties, ReactNode } from "react";
3import { Form, Link } from "react-router";
4
5import type { Comment, Issue, Pull, State } from "@g1t/contracts";
6
7import { chipStyle } from "../lib/labels";
8import { repoAt } from "../lib/markdown-plugins";
9import { Markdown } from "./markdown";
10import { IssueIcon, PullIcon } from "./work-icons";
11import { MentionTextarea } from "./mention-textarea";
12import { Avatar, SubmitButton, TimeAgo } from "./ui";
13import { CheckboxOption } from "./ui/checkbox";
14import { Hint } from "./ui/hint";
15
16export { IssueIcon, PullIcon };
17
18/** Hues for the labels every repository starts with. */
19const LABEL_HUES: Record<string, number> = {
20 bug: 25,
21 feature: 150,
22 docs: 240,
23 chore: 80,
24 question: 310,
25};
26
27/** A stable hue for any other label, from its name. */
28function hueFor(name: string): number {
29 let hash = 0;
30 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) % 360;
31 return hash;
32}
33
34/**
35 * A label's chip: in its own color when the page knows it (the
36 * repository's labels, by name), else in a hue chosen from its name.
37 */
38export function Label({ name, color }: { name: string; color?: string | null }) {
39 const hue = LABEL_HUES[name] ?? hueFor(name);
40 const own = chipStyle(color);
41 const style: CSSProperties = Object.keys(own).length > 0 ? own : {
42 color: `oklch(0.84 0.11 ${hue})`,
43 borderColor: `oklch(0.84 0.11 ${hue} / 0.35)`,
44 backgroundColor: `oklch(0.84 0.11 ${hue} / 0.1)`,
45 };
46 return (
47 <span
48 style={style}
49 className="inline-flex shrink-0 items-center rounded-full border px-2 py-px text-xs font-medium"
50 >
51 {name}
52 </span>
53 );
54}
55
56/** The people an issue is assigned to, as overlapping avatars. */
57export function AssigneeStack({ people }: { people: string[] }) {
58 if (people.length === 0) return null;
59 const shown = people.slice(0, 3);
60 return (
61 <Hint label={`Assigned to ${people.join(", ")}`}>
62 <span className="flex shrink-0 items-center">
63 <span className="sr-only">Assigned to {people.join(", ")}</span>
64 {shown.map((name, index) => (
65 <span key={name} className={`rounded-full ring-2 ring-bg ${index > 0 ? "-ml-1.5" : ""}`} aria-hidden>
66 <Avatar name={name} size={18} />
67 </span>
68 ))}
69 {people.length > shown.length && (
70 <span className="ml-1 text-xs text-faint" aria-hidden>
71 +{people.length - shown.length}
72 </span>
73 )}
74 </span>
75 </Hint>
76 );
77}
78
79/** The agent working on an issue now. */
80export function Assignee({ agent }: { agent: string }) {
81 return (
82 <Hint label={`Assigned to ${agent}`}>
83 <span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-accent/35 bg-accent/10 px-2 py-px text-xs font-medium text-accent">
84 <Bot size={12} />
85 <span className="sr-only">Assigned to </span>
86 {agent}
87 </span>
88 </Hint>
89 );
90}
91
92const PILL = "inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium";
93
94/** The large state badge at the top of an issue. */
95export function IssueState({ issue }: { issue: Pick<Issue, "state" | "reason"> }) {
96 const style =
97 issue.state === "open"
98 ? "border-success/30 bg-success/10 text-success"
99 : issue.reason === "not_planned"
100 ? "border-line bg-surface text-muted"
101 : "border-merged/30 bg-merged/10 text-merged";
102 return (
103 <span className={`${PILL} ${style}`}>
104 <IssueIcon issue={issue} size={14} />
105 {issue.state === "open"
106 ? "Open"
107 : issue.reason === "not_planned"
108 ? "Closed as not planned"
109 : "Closed"}
110 </span>
111 );
112}
113
114const PULL_STYLES: Record<Pull["status"], string> = {
115 draft: "border-line bg-surface text-muted",
116 open: "border-success/30 bg-success/10 text-success",
117 merged: "border-merged/30 bg-merged/10 text-merged",
118 closed: "border-danger/30 bg-danger/10 text-danger",
119};
120
121const PULL_NAMES: Record<Pull["status"], string> = {
122 draft: "Draft",
123 open: "Open",
124 merged: "Merged",
125 closed: "Closed",
126};
127
128export function PullState({ status }: { status: Pull["status"] }) {
129 return (
130 <span className={`${PILL} ${PULL_STYLES[status]}`}>
131 <PullIcon status={status} size={14} />
132 {PULL_NAMES[status]}
133 </span>
134 );
135}
136
137/** The open and closed switch above a list. `to` is the list's own path. */
138export function StateTabs({
139 to,
140 state,
141 query = "",
142 action,
143}: {
144 to: string;
145 state: State;
146 /** Other query parameters to keep, e.g. `label=bug`. */
147 query?: string;
148 action?: ReactNode;
149}) {
150 const link = (value: State) =>
151 `${to}?${[value === "open" ? "" : "state=closed", query].filter(Boolean).join("&")}`;
152 const tab = (value: State, label: string) => (
153 <Link
154 to={link(value)}
155 className={
156 "rounded-md px-3 py-1.5 text-sm transition-colors " +
157 (state === value ? "bg-raised font-medium text-fg" : "text-muted hover:text-fg")
158 }
159 >
160 {label}
161 </Link>
162 );
163 return (
164 <div className="flex flex-wrap items-center justify-between gap-3">
165 <div className="flex gap-1 rounded-lg border border-line p-1">
166 {tab("open", "Open")}
167 {tab("closed", "Closed")}
168 </div>
169 {action}
170 </div>
171 );
172}
173
174const VERDICTS = {
175 approve: { label: "approved these changes", style: "text-success" },
176 request_changes: { label: "requested changes", style: "text-danger" },
177} as const;
178
179/**
180 * A person's name (or `children`) linking to their profile at `/u/<name>`.
181 * g1t, and names that are not usernames, stay plain text.
182 */
183export function PersonLink({
184 name,
185 className,
186 label,
187 children,
188}: {
189 name: string;
190 className?: string;
191 label?: string;
192 children?: ReactNode;
193}) {
194 // g1t itself has no profile; its name carries a small label instead.
195 if (name === "g1t" && !children) {
196 return (
197 <span className="inline-flex items-baseline gap-1">
198 <span className={className}>g1t</span>
199 <span className="rounded border border-line px-1 text-[0.625rem] leading-[1.35] font-medium text-muted">bot</span>
200 </span>
201 );
202 }
203 if (name === "g1t" || !/^[a-z0-9-]{1,39}$/i.test(name)) {
204 return <span className={className}>{children ?? name}</span>;
205 }
206 return (
207 <Link to={`/u/${name.toLowerCase()}`} className={className} aria-label={label}>
208 {children ?? name}
209 </Link>
210 );
211}
212
213/**
214 * Comments in order, then the box to add one. On a pull request, `review`
215 * says where its changes are shown and whether the viewer may give a
216 * verdict.
217 */
218/**
219 * One entry in a conversation: who, what they did and when, then what they
220 * wrote. The author's avatar sits beside it, as on any forge.
221 */
222export function TimelineItem({
223 author,
224 action,
225 at,
226 aside,
227 children,
228}: {
229 author: string;
230 /** What they did, after their name: "commented", "opened this". */
231 action: ReactNode;
232 at?: string;
233 /** Shown at the right of the header. */
234 aside?: ReactNode;
235 children?: ReactNode;
236}) {
237 return (
238 <div className="flex gap-3">
239 <span className="mt-1 hidden shrink-0 sm:block">
240 <PersonLink name={author} label={`${author}'s profile`}>
241 <Avatar name={author} size={32} />
242 </PersonLink>
243 </span>
244 <article className="min-w-0 grow overflow-hidden rounded-xl border border-line bg-surface">
245 <header className="flex flex-wrap items-center gap-x-2 gap-y-1 border-b border-line bg-raised/40 px-4 py-2 text-sm text-muted">
246 <span className="sm:hidden">
247 <Avatar name={author} size={18} />
248 </span>
249 <PersonLink name={author} className="font-medium text-fg hover:underline" />
250 {action}
251 {at && <TimeAgo at={at} />}
252 {aside && <span className="ml-auto min-w-0">{aside}</span>}
253 </header>
254 {children && <div className="px-4 py-3">{children}</div>}
255 </article>
256 </div>
257 );
258}
259
260type Review = { changesUrl: string; canJudge: boolean };
261
262/**
263 * Text with each `#12` linked to the issue or pull request of that number,
264 * and each file named in backticks shown as code, linked to its diff when
265 * `filesUrl` says where the changes are.
266 */
267function WithReferences({ text, base, filesUrl }: { text: string; base?: string; filesUrl?: string }) {
268 if (!base) return <>{text}</>;
269 return (
270 <>
271 {text.split(/(#\d+|`[^`\n]+`)/).map((part, index) =>
272 /^`[^`]+`$/.test(part) ? (
273 filesUrl ? (
274 <Link
275 key={index}
276 to={`${filesUrl}#file-${part.slice(1, -1)}`}
277 className="font-mono text-xs text-fg hover:underline"
278 >
279 {part.slice(1, -1)}
280 </Link>
281 ) : (
282 <code key={index} className="font-mono text-xs text-fg">
283 {part.slice(1, -1)}
284 </code>
285 )
286 ) : /^#\d+$/.test(part) ? (
287 // Issues and pull requests share numbers; the issue page forwards.
288 <Link
289 key={index}
290 to={`${base}/issues/${part.slice(1)}`}
291 className="font-medium text-fg hover:underline"
292 >
293 {part}
294 </Link>
295 ) : (
296 part
297 ),
298 )}
299 </>
300 );
301}
302
303/** Something that happened, as one line on the conversation's rail. */
304function TimelineEvent({ comment, base, filesUrl }: { comment: Comment; base?: string; filesUrl?: string }) {
305 return (
306 <div className="flex items-center gap-3 text-sm text-muted">
307 <span className="hidden w-8 shrink-0 justify-center sm:flex">
308 <span className="size-2 rounded-full border border-line-strong bg-bg" />
309 </span>
310 <span className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
311 <Avatar name={comment.author.username} size={16} />
312 <PersonLink name={comment.author.username} className="font-medium text-fg hover:underline" />
313 <span>
314 <WithReferences text={comment.body} base={base} filesUrl={filesUrl} />
315 </span>
316 <span className="text-faint">
317 · <TimeAgo at={comment.createdAt} />
318 </span>
319 </span>
320 </div>
321 );
322}
323
324/**
325 * The conversation of an issue or a pull request, oldest first: what
326 * people and agents wrote, and between those, what happened.
327 */
328export function CommentList({
329 comments,
330 review,
331 base,
332}: {
333 comments: Comment[];
334 review?: Review;
335 /** The repository's path, so that `#12` in an event can be linked. */
336 base?: string;
337}) {
338 return (
339 <>
340 {comments.map((comment) => {
341 if (comment.kind === "event") {
342 return <TimelineEvent key={comment.id} comment={comment} base={base} filesUrl={review?.changesUrl} />;
343 }
344 const verdict = comment.verdict && VERDICTS[comment.verdict];
345 return (
346 <TimelineItem
347 key={comment.id}
348 author={comment.author.username}
349 at={comment.createdAt}
350 action={
351 verdict ? (
352 <span className={`flex items-center gap-1 font-medium ${verdict.style}`}>
353 {comment.verdict === "approve" ? (
354 <CircleCheck size={14} />
355 ) : (
356 <CircleSlash size={14} />
357 )}
358 {verdict.label}
359 </span>
360 ) : (
361 <span>commented</span>
362 )
363 }
364 aside={
365 comment.path &&
366 review && (
367 <Link
368 to={`${review.changesUrl}#file-${comment.path}`}
369 className="block truncate font-mono text-xs text-faint hover:text-fg"
370 >
371 {comment.path}
372 {comment.line != null && `:${comment.line}`}
373 </Link>
374 )
375 }
376 >
377 {comment.body && <Markdown source={comment.body} repo={repoAt(base)} />}
378 </TimelineItem>
379 );
380 })}
381 </>
382 );
383}
384
385/** Where a signed-in person writes a comment, or a review. */
386export function CommentForm({
387 author,
388 resetKey,
389 review,
390 handles,
391}: {
392 /** The viewer's username, or null if they are signed out. */
393 author: string | null;
394 /** Changes when a comment is added, which clears the box. */
395 resetKey: number;
396 review?: Review;
397 /** Teams the box offers to mention, as `@workspace/team`. */
398 handles?: readonly string[];
399}) {
400 if (!author) {
401 return (
402 <p className="text-sm text-muted">
403 <Link to="/login" className="text-fg underline underline-offset-4">
404 Sign in
405 </Link>{" "}
406 to comment.
407 </p>
408 );
409 }
410 return (
411 <div className="flex gap-3">
412 <span className="mt-1 hidden shrink-0 sm:block">
413 <Avatar name={author} size={32} />
414 </span>
415 <Form method="post" className="min-w-0 grow space-y-2" key={resetKey}>
416 <input type="hidden" name="action" value="comment" />
417 <MentionTextarea
418 name="body"
419 handles={handles}
420 rows={3}
421 placeholder={
422 review?.canJudge
423 ? "Leave a comment, or a review. Markdown works."
424 : "Leave a comment. Markdown works."
425 }
426 />
427 <div className="flex flex-wrap gap-2">
428 {/* A plain comment's verdict is none the action knows, so each button tells its own post apart. */}
429 <SubmitButton name="verdict" value="comment" match={{ action: "comment" }} pending="Commenting…">
430 Comment
431 </SubmitButton>
432 {review?.canJudge && (
433 <>
434 <SubmitButton variant="quiet" name="verdict" value="approve" match={{ action: "comment" }} pending="Approving…">
435 <CircleCheck size={14} className="text-success" />
436 Approve
437 </SubmitButton>
438 <SubmitButton
439 variant="quiet"
440 name="verdict"
441 value="request_changes"
442 match={{ action: "comment" }}
443 pending="Requesting changes…"
444 >
445 <CircleSlash size={14} className="text-danger" />
446 Request changes
447 </SubmitButton>
448 </>
449 )}
450 </div>
451 </Form>
452 </div>
453 );
454}
455
456/**
457 * Checkboxes for choosing people: the workspace's members, anyone already
458 * chosen, and a box for other usernames. Posts `name` for each ticked and
459 * `others` for the rest.
460 */
461export function PeoplePicker({
462 name,
463 members,
464 chosen,
465 placeholder = "Other usernames, comma separated",
466}: {
467 name: string;
468 members: string[];
469 chosen: string[];
470 placeholder?: string;
471}) {
472 const people = [...new Set([...members, ...chosen])];
473 return (
474 <>
475 {people.length > 0 && (
476 <div className="space-y-1.5">
477 {people.map((person) => (
478 <CheckboxOption
479 key={person}
480 name={name}
481 value={person}
482 defaultChecked={chosen.includes(person)}
483 className="items-center"
484 labelClassName="flex items-center gap-2"
485 label={
486 <>
487 <Avatar name={person} size={18} />
488 <span className="font-mono text-xs">{person}</span>
489 </>
490 }
491 />
492 ))}
493 </div>
494 )}
495 <input
496 name="others"
497 placeholder={placeholder}
498 autoComplete="off"
499 data-1p-ignore
500 className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
501 />
502 </>
503 );
504}
505
506/** Where each reviewer stands: their most recent verdict. */
507export function verdicts(comments: Comment[]): { reviewer: string; verdict: NonNullable<Comment["verdict"]> }[] {
508 const latest = new Map<string, NonNullable<Comment["verdict"]>>();
509 for (const comment of comments) {
510 if (comment.verdict) latest.set(comment.author.username, comment.verdict);
511 }
512 return [...latest].map(([reviewer, verdict]) => ({ reviewer, verdict }));
513}
514
515/** `2 files +12 −3`: the size of a pull request's change. */
516export function ChangeSize({ files }: { files: Pull["files"] }) {
517 if (files.length === 0) return null;
518 const additions = files.reduce((sum, file) => sum + file.additions, 0);
519 const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
520 return (
521 <span className="flex shrink-0 items-center gap-1.5 font-mono text-xs text-faint">
522 <span>
523 {files.length} {files.length === 1 ? "file" : "files"}
524 </span>
525 <span className="text-success">+{additions}</span>
526 <span className="text-danger">−{deletions}</span>
527 </span>
528 );
529}
530
531/** The words of a Markdown text, without its markup, for a one-line preview. */
532export function plainText(markdown: string): string {
533 return markdown
534 .replace(/```[\s\S]*?```/g, " ")
535 .replace(/^\s{0,3}(#{1,6}|[-*+]|\d+\.)\s+/gm, "")
536 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
537 .replace(/[*_`~]/g, "")
538 .replace(/\s+/g, " ")
539 .trim();
540}