pr_01m47d15m3e54sn21z27rpy5n9/apps/web/app/routes/repo/commits.tsx

99 lines4,392 bytesCodeBlame
1import { GitCommitHorizontal } from "lucide-react";
2import { Link } from "react-router";
3
4import type { Commit } from "@g1t/contracts";
5
6import type { Route } from "./+types/commits";
7import { page } from "../../lib/meta";
8import { Avatar, EmptyState, TimeAgo } from "../../components/ui";
9import { accounts, repos } from "../../lib/services.server";
10import { getViewer, unwrap } from "../../lib/session.server";
11
12const PAGE_SIZE = 50;
13
14export function meta({ params, ...args }: Route.MetaArgs) {
15 return page(args, { title: `Commits · ${params.owner}/${params.repo} · g1t` });
16}
17
18export async function loader({ params, context }: Route.LoaderArgs) {
19 const path = { namespace: params.owner, name: params.repo };
20 const commits = unwrap(await repos.log(path, getViewer(context), null, PAGE_SIZE));
21 // Who wrote each commit, by its author address: confirmed and noreply
22 // addresses only. Without an answer, the name in the commit is shown.
23 const owners = await accounts.emailOwners([...new Set(commits.map((commit) => commit.author.email))]).catch(() => ({}));
24 return { commits, owners: owners as Record<string, { username: string; avatar: string | null }> };
25}
26
27/** Commits by the day they were made, newest first. */
28function byDay(commits: Commit[]): [string, Commit[]][] {
29 const days = new Map<string, Commit[]>();
30 for (const commit of commits) {
31 const day = commit.authoredAt.slice(0, 10);
32 days.set(day, [...(days.get(day) ?? []), commit]);
33 }
34 return [...days];
35}
36
37function dayLabel(day: string): string {
38 const date = new Date(`${day}T12:00:00Z`);
39 return date.toLocaleDateString("en-US", {
40 weekday: "long",
41 month: "long",
42 day: "numeric",
43 year: "numeric",
44 timeZone: "UTC",
45 });
46}
47
48export default function Commits({ loaderData, params }: Route.ComponentProps) {
49 const { commits, owners } = loaderData;
50 const base = `/${params.owner}/${params.repo}`;
51 if (commits.length === 0) {
52 return <EmptyState title="No commits yet" />;
53 }
54 return (
55 <div className="relative space-y-8 before:absolute before:top-3 before:bottom-3 before:left-1.75 before:w-px before:bg-line">
56 {byDay(commits).map(([day, list]) => (
57 <section key={day} className="relative pl-7">
58 <h2 className="flex items-center gap-2 text-xs font-medium text-muted">
59 <GitCommitHorizontal size={15} className="absolute left-0 bg-bg text-faint" />
60 {dayLabel(day)}
61 </h2>
62 <ol className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line">
63 {list.map((commit) => {
64 const [subject, ...body] = commit.message.split("\n");
65 const rest = body.join("\n").trim();
66 const to = `${base}/commit/${commit.hash}`;
67 const owner = owners[commit.author.email.toLowerCase()];
68 return (
69 <li key={commit.hash} className="group relative flex items-start gap-3 px-4 py-3 transition-colors hover:bg-surface">
70 <Avatar name={owner?.username ?? commit.author.name} image={owner?.avatar} size={24} />
71 <div className="min-w-0 grow">
72 <Link to={to} prefetch="intent" className="block truncate font-medium after:absolute after:inset-0 group-hover:text-accent">
73 {subject}
74 </Link>
75 {rest && <p className="mt-1 line-clamp-1 text-sm text-muted">{rest}</p>}
76 <p className="mt-1 text-xs text-faint">
77 {owner ? (
78 <Link to={`/u/${owner.username}`} className="relative z-10 text-muted hover:text-fg" title={commit.author.name}>
79 {owner.username}
80 </Link>
81 ) : (
82 commit.author.name
83 )}{" "}
84 committed <TimeAgo at={commit.authoredAt} />
85 {commit.parents.length > 1 && " · merge"}
86 </p>
87 </div>
88 <span className="shrink-0 rounded-md border border-line px-2 py-0.5 font-mono text-xs text-muted group-hover:border-line-strong group-hover:text-fg">
89 {commit.hash.slice(0, 7)}
90 </span>
91 </li>
92 );
93 })}
94 </ol>
95 </section>
96 ))}
97 </div>
98 );
99}