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.

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