g1t/apps/web/app/routes/repo/compare.tsx

155 lines7,626 bytesCodeBlame
1import { ArrowLeft, GitCommitHorizontal, GitPullRequest } from "lucide-react";
2import { Form, Link } from "react-router";
3
4import type { Comparison } from "@g1t/contracts";
5
6import type { Route } from "./+types/compare";
7import { DiffView } from "../../components/diff-view";
8import { Avatar, ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
9import { page } from "../../lib/meta";
10import { repos } from "../../lib/services.server";
11import { getViewer, unwrap } from "../../lib/session.server";
12
13/** How far back each side's history is read to find what head has that base does not. */
14const HEAD_DEPTH = 100;
15const BASE_DEPTH = 300;
16
17export function meta({ params, ...args }: Route.MetaArgs) {
18 const range = params["*"] ? ` ${params["*"]}` : "";
19 return page(args, { title: `Compare${range} · ${params.owner}/${params.repo} · g1t` });
20}
21
22/** `main...feature` into its two sides; either may be missing. */
23function sides(range: string): { base: string | null; head: string | null } {
24 const at = range.indexOf("...");
25 if (at === -1) return { base: null, head: range || null };
26 return { base: range.slice(0, at) || null, head: range.slice(at + 3) || null };
27}
28
29export async function loader({ params, context, request }: Route.LoaderArgs) {
30 const path = { namespace: params.owner, name: params.repo };
31 const viewer = getViewer(context);
32 const url = new URL(request.url);
33 // The pickers submit as ?base=&head=; the address keeps base...head.
34 if (url.searchParams.has("base") || url.searchParams.has("head")) {
35 const base = url.searchParams.get("base") ?? "";
36 const head = url.searchParams.get("head") ?? "";
37 return Response.redirect(new URL(`/${params.owner}/${params.repo}/compare/${base}...${head}`, url).toString(), 302);
38 }
39 const [repo, list] = await Promise.all([repos.get(path, viewer), repos.branches(path, viewer)]);
40 const found = unwrap(repo);
41 const branches = unwrap(list)
42 .map((branch) => branch.name)
43 .filter((name) => !name.startsWith("g1t-queue/"));
44 const asked = sides(params["*"] ?? "");
45 const base = asked.base ?? found.defaultBranch;
46 const head = asked.head;
47 if (!head || head === base) return { base, head, branches, comparison: null, commits: [], same: head === base };
48 const [compared, headLog, baseLog] = await Promise.all([
49 repos.compare(found.id, viewer, base, head),
50 repos.log(path, viewer, head, HEAD_DEPTH),
51 repos.log(path, viewer, base, BASE_DEPTH),
52 ]);
53 if (!headLog.ok || headLog.value.length === 0) throw new Response(`There is no branch, tag or commit named ${head}.`, { status: 404 });
54 // What head has that base does not: its history down to the first commit base also has.
55 const onBase = new Set(baseLog.ok ? baseLog.value.map((commit) => commit.hash) : []);
56 const shared = headLog.value.findIndex((commit) => onBase.has(commit.hash));
57 const commits = (shared === -1 ? headLog.value : headLog.value.slice(0, shared)).map((commit) => ({
58 hash: commit.hash,
59 message: commit.message.split("\n")[0] ?? "",
60 author: commit.author.name,
61 at: commit.authoredAt,
62 }));
63 const comparison: Comparison | null = compared.ok ? compared.value : null;
64 return { base, head, branches, comparison, commits, same: false };
65}
66
67function RefPicker({ name, label, value, branches }: { name: string; label: string; value: string | null; branches: string[] }) {
68 const options = value && !branches.includes(value) ? [value, ...branches] : branches;
69 return (
70 <label className="inline-flex items-center gap-2 text-sm">
71 <span className="text-muted">{label}</span>
72 <select
73 name={name}
74 defaultValue={value ?? ""}
75 className="h-8 max-w-56 rounded-md border border-line bg-surface px-2 font-mono text-[0.8125rem] outline-none hover:border-line-strong focus:border-accent-dim"
76 >
77 {value == null && <option value="">Choose a branch</option>}
78 {options.map((option) => (
79 <option key={option} value={option}>
80 {option}
81 </option>
82 ))}
83 </select>
84 </label>
85 );
86}
87
88export default function Compare({ loaderData, params }: Route.ComponentProps) {
89 const { base, head, branches, comparison, commits, same } = loaderData;
90 const repoBase = `/${params.owner}/${params.repo}`;
91 return (
92 <div className="space-y-5">
93 <div>
94 <h2 className="text-lg font-semibold tracking-tight">Compare changes</h2>
95 <p className="mt-1 text-sm text-muted">Choose two branches to see what one has that the other does not, then open a pull request for it.</p>
96 </div>
97 <Form method="get" className="flex flex-wrap items-center gap-3 rounded-xl border border-line bg-surface px-4 py-3">
98 <RefPicker name="base" label="base" value={base} branches={branches} />
99 <ArrowLeft size={14} className="text-faint" aria-hidden="true" />
100 <RefPicker name="head" label="compare" value={head} branches={branches} />
101 <button
102 type="submit"
103 className="h-8 rounded-md border border-line px-3 text-sm text-fg/90 transition-colors hover:border-line-strong hover:bg-raised"
104 >
105 Compare
106 </button>
107 {head && !same && commits.length > 0 && (
108 <ButtonLink to={`${repoBase}/pulls/new?branch=${encodeURIComponent(head)}`} variant="accent">
109 <GitPullRequest size={15} />
110 Open a pull request
111 </ButtonLink>
112 )}
113 </Form>
114
115 {!head ? (
116 <EmptyState title="Pick a branch to compare">Its commits and changes against {base} show here.</EmptyState>
117 ) : same ? (
118 <EmptyState title="Nothing to compare">Both sides are {base}. Choose another branch to compare with it.</EmptyState>
119 ) : commits.length === 0 ? (
120 <EmptyState title={`${base} has everything ${head} has`}>There is nothing on {head} to bring into {base}.</EmptyState>
121 ) : (
122 <>
123 <section>
124 <h3 className="mb-2 text-sm font-medium text-muted">
125 {commits.length === HEAD_DEPTH ? `${HEAD_DEPTH}+` : commits.length} {commits.length === 1 ? "commit" : "commits"}
126 </h3>
127 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
128 {commits.map((commit) => (
129 <li key={commit.hash} className="flex items-center gap-3 px-4 py-2.5 text-sm">
130 <GitCommitHorizontal size={15} className="shrink-0 text-faint" />
131 <Avatar name={commit.author} size={16} />
132 <Link to={`${repoBase}/commit/${commit.hash}`} className="min-w-0 grow truncate hover:text-accent" title={commit.message}>
133 {commit.message}
134 </Link>
135 <span className="hidden shrink-0 text-xs text-muted sm:inline">{commit.author}</span>
136 <Link to={`${repoBase}/commit/${commit.hash}`} className="shrink-0 font-mono text-xs text-faint hover:text-fg">
137 {commit.hash.slice(0, 7)}
138 </Link>
139 <span className="shrink-0 text-xs text-faint">
140 <TimeAgo at={commit.at} />
141 </span>
142 </li>
143 ))}
144 </ul>
145 </section>
146 {comparison ? (
147 <DiffView comparison={comparison} fileBase={`${repoBase}/blob/${encodeURIComponent(head)}`} empty="The two sides have the same files." />
148 ) : (
149 <p className="text-sm text-muted">The changes could not be read just now. The commits above are what {head} adds.</p>
150 )}
151 </>
152 )}
153 </div>
154 );
155}