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.

97 linesCodeBlameRaw
1import { isCodeownersPath } from "@g1t/contracts";
2import { useEffect, useState } from "react";
3
4import type { Route } from "./+types/blob";
5import { page } from "../../lib/meta";
6import { CodeownersFileErrors } from "../../components/codeowners";
7import { BlobView } from "../../components/repo-view";
8import type { ShownBlame } from "../../lib/commit-people";
9import { highlightLines } from "../../lib/highlight.server";
10import { repos, work } from "../../lib/services.server";
11import { redirectIfBranchRenamed } from "../../lib/branch-redirect.server";
12import { getViewer, unwrap } from "../../lib/session.server";
13import { isFailed, part } from "../../lib/partial";
14import { Unavailable } from "../../components/boundary";
15
16export function meta({ params, ...args }: Route.MetaArgs) {
17 const tags = page(args, { title: `${params["*"]} · ${params.owner}/${params.repo} · g1t` });
18 // A file's blame is kept out of search indexes; the file itself is not (lib/robots.ts).
19 const blame = new URLSearchParams(args.location.search).has("blame");
20 return blame ? [...tags, { name: "robots", content: "noindex, nofollow" }] : tags;
21}
22
23export async function loader({ params, context, request }: Route.LoaderArgs) {
24 const viewer = getViewer(context);
25 const path = { namespace: params.owner, name: params.repo };
26 const file = params["*"] ?? "";
27 const wantsBlame = new URL(request.url).searchParams.has("blame");
28 const [found, list, checked] = await Promise.all([
29 repos.blob(path, viewer, params.ref, file),
30 // For the branch menu; the page still shows without it, and the menu
31 // says when it could not be loaded.
32 part(repos.branches(path, viewer), "Branches"),
33 // A CODEOWNERS file is checked as it is shown; the file shows without
34 // it, saying it could not be checked rather than looking clean.
35 isCodeownersPath(file) ? part(work.codeownersErrors(path, viewer, params.ref), "This file's check") : null,
36 ]);
37 const codeowners = isFailed(checked) ? checked : checked?.ok ? checked.value : null;
38 const branches = isFailed(list) ? list : list.ok ? list.value : null;
39 // A branch that was renamed: the same file on its new name.
40 if (!found.ok && found.error.code === "not_found") await redirectIfBranchRenamed(request, path, viewer, params.ref);
41 const blob = unwrap(found);
42 const html = blob.text == null ? null : await highlightLines(blob.path, blob.text);
43 // The blame itself is fetched by the page once the file shows
44 // (routes/repo/blame-data.ts): a first one can take longer than the
45 // page's stream may stay open.
46 return { blob, html, wantsBlame: wantsBlame && blob.text != null, branches, codeowners };
47}
48
49export default function Blob({ loaderData, params }: Route.ComponentProps) {
50 const { codeowners, blob, html, wantsBlame } = loaderData;
51 // Its errors' lines are marked, when they are this file's.
52 const own = codeowners != null && !isFailed(codeowners) && codeowners.path === blob.path;
53 const blame = useBlame(wantsBlame ? `/${params.owner}/${params.repo}/blame/${encodeURIComponent(blob.ref)}/${encodePath(blob.path)}` : null);
54 return (
55 <BlobView
56 blob={blob}
57 html={html}
58 blame={blame === null ? null : typeof blame === "string" ? blame : { blame, lines: html }}
59 branches={loaderData.branches}
60 notice={
61 isFailed(codeowners) ? (
62 <div className="mb-4">
63 <Unavailable compact what={codeowners.what} />
64 </div>
65 ) : (
66 codeowners && <CodeownersFileErrors report={codeowners} path={blob.path} base={`/${params.owner}/${params.repo}`} />
67 )
68 }
69 marked={own ? codeowners.errors.map((error) => error.line).filter((line) => line > 0) : undefined}
70 />
71 );
72}
73
74/** The blame at `url`: "waiting" until it comes, "failed" when it could not
75 * be had, null when none was asked for. */
76function useBlame(url: string | null): ShownBlame | "waiting" | "failed" | null {
77 const [answered, setAnswered] = useState<{ url: string; blame: ShownBlame | "failed" } | null>(null);
78 useEffect(() => {
79 if (!url) return;
80 const stop = new AbortController();
81 fetch(url, { signal: stop.signal, headers: { accept: "application/json" } })
82 .then(async (response) => {
83 const body = (await response.json()) as { blame: ShownBlame | null };
84 setAnswered({ url, blame: response.ok && body.blame ? body.blame : "failed" });
85 })
86 .catch(() => {
87 if (!stop.signal.aborted) setAnswered({ url, blame: "failed" });
88 });
89 return () => stop.abort();
90 }, [url]);
91 if (!url) return null;
92 return answered?.url === url ? answered.blame : "waiting";
93}
94
95function encodePath(path: string): string {
96 return path.split("/").map(encodeURIComponent).join("/");
97}