flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

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