Skip to content

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

150 linesCodeBlameRaw
1import { GitCommitHorizontal, MessageSquare } from "lucide-react";
2import { Suspense } from "react";
3import { Await, Link } from "react-router";
4
5import type { ShownCommit } from "../../lib/commit-people";
6import { parseCommitMessage } from "../../lib/commit-message";
7
8import type { Route } from "./+types/commits";
9import { page } from "../../lib/meta";
10import { CommitChecksBadge } from "../../components/commit-checks";
11import { CommitAvatars, CommitNames } from "../../components/commit-person";
12import { NewItemsBar } from "../../components/repo-live";
13import { EmptyState, TimeAgo } from "../../components/ui";
14import { Card } from "../../components/ui/card";
15import { commitChecksFor } from "../../lib/commit-checks.server";
16import { showCommits } from "../../lib/commit-people.server";
17import { repos, work } from "../../lib/services.server";
18import { countByCommit } from "../../lib/commit-comments";
19import { Hint } from "../../components/ui/hint";
20import { isPushTo, newCommits, repoPages } from "../../lib/repo-live";
21import { useRepoLive } from "../../lib/use-repo-live";
22import { getViewer, unwrap } from "../../lib/session.server";
23import { useProject } from "./layout";
24
25const PAGE_SIZE = 50;
26
27export function meta({ params, ...args }: Route.MetaArgs) {
28 return page(args, { title: `Commits · ${params.owner}/${params.repo} · g1t` });
29}
30
31export async function loader({ params, context }: Route.LoaderArgs) {
32 const path = { namespace: params.owner, name: params.repo };
33 const viewer = getViewer(context);
34 const log = unwrap(await repos.log(path, viewer, null, PAGE_SIZE));
35 // Every commit's checks in one call, streamed in beside each.
36 const checks = commitChecksFor(path, viewer, log.map((commit) => commit.hash));
37 // Who wrote each commit, as their account where there is one.
38 const commits = await showCommits(log);
39 // How many comments each has, from the repository's latest: streamed.
40 const comments = work
41 .commitComments(viewer, path, null)
42 .then((found) => (found.ok ? countByCommit(found.value) : {}))
43 .catch(() => ({}) as Record<string, number>);
44 return { commits, checks, comments };
45}
46
47/** How many comments a commit has, linking to them; nothing when none. */
48function CommentCount({ count, to }: { count: number; to: string }) {
49 if (count === 0) return null;
50 const label = `${count} ${count === 1 ? "comment" : "comments"}`;
51 return (
52 <Hint label={label}>
53 <Link
54 to={to}
55 aria-label={label}
56 className="relative z-10 flex shrink-0 items-center gap-1 rounded px-1 text-xs text-faint tabular-nums hover:text-fg"
57 >
58 <MessageSquare size={13} />
59 {count}
60 </Link>
61 </Hint>
62 );
63}
64
65/** Commits by the day they were made, newest first. */
66function byDay(commits: ShownCommit[]): [string, ShownCommit[]][] {
67 const days = new Map<string, ShownCommit[]>();
68 for (const commit of commits) {
69 const day = commit.authoredAt.slice(0, 10);
70 days.set(day, [...(days.get(day) ?? []), commit]);
71 }
72 return [...days];
73}
74
75function dayLabel(day: string): string {
76 const date = new Date(`${day}T12:00:00Z`);
77 return date.toLocaleDateString("en-US", {
78 weekday: "long",
79 month: "long",
80 day: "numeric",
81 year: "numeric",
82 timeZone: "UTC",
83 });
84}
85
86export default function Commits({ loaderData, params }: Route.ComponentProps) {
87 const { commits, checks, comments } = loaderData;
88 const base = `/${params.owner}/${params.repo}`;
89 // Live: new commits on the branch wait under "N new commits" rather than
90 // moving the list under the reader; checks on the shown ones update.
91 const branch = useProject()?.repo.defaultBranch ?? null;
92 const pushed = (change: Parameters<typeof isPushTo>[0]) => change.kind === "git.push" && isPushTo(change, branch);
93 const { held, show } = useRepoLive({ match: repoPages.commits(branch, commits.map((commit) => commit.hash)), hold: commits.length > 0 ? pushed : undefined });
94 if (commits.length === 0) {
95 return <EmptyState title="No commits yet" />;
96 }
97 return (
98 <>
99 <NewItemsBar held={held.length > 0} count={newCommits(held, branch)} noun={["commit", "commits"]} onShow={show} />
100 <div className="relative space-y-8 before:absolute before:top-3 before:bottom-3 before:left-1.75 before:w-px before:bg-line">
101 {byDay(commits).map(([day, list]) => (
102 <section key={day} className="relative pl-7">
103 <h2 className="flex items-center gap-2 text-xs font-medium text-muted">
104 <GitCommitHorizontal size={15} className="absolute left-0 bg-bg text-faint" />
105 {dayLabel(day)}
106 </h2>
107 <Card asChild tone="plain" divided className="mt-3 overflow-hidden">
108 <ol>
109 {list.map((commit) => {
110 // The body's prose: trailers (co-authors and their addresses) are not shown.
111 const { subject, body: rest } = parseCommitMessage(commit.message);
112 const to = `${base}/commit/${commit.hash}`;
113 return (
114 <li key={commit.hash} className="group relative flex items-start gap-3 px-4 py-3 transition-colors hover:bg-surface">
115 <span className="mt-0.5 flex">
116 <CommitAvatars commit={commit} size={24} />
117 </span>
118 <div className="min-w-0 grow">
119 <div className="flex min-w-0 items-start gap-1.5 sm:items-center">
120 <Link to={to} prefetch="intent" className="line-clamp-2 font-medium wrap-break-word after:absolute after:inset-0 group-hover:text-accent sm:line-clamp-1">
121 {subject}
122 </Link>
123 <CommitChecksBadge checks={checks} sha={commit.hash} />
124 <Suspense fallback={null}>
125 <Await resolve={comments}>
126 {(counts) => <CommentCount count={counts[commit.hash] ?? 0} to={`${to}#commit-comments`} />}
127 </Await>
128 </Suspense>
129 </div>
130 {rest && <p className="mt-1 line-clamp-1 text-sm text-muted">{rest}</p>}
131 <p className="mt-1 text-xs text-faint">
132 <CommitNames commit={commit} className="text-muted hover:text-fg" />{" "}
133 committed <TimeAgo at={commit.authoredAt} />
134 {commit.parents.length > 1 && " · merge"}
135 </p>
136 </div>
137 <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">
138 {commit.hash.slice(0, 7)}
139 </span>
140 </li>
141 );
142 })}
143 </ol>
144 </Card>
145 </section>
146 ))}
147 </div>
148 </>
149 );
150}