1import { useParams } from "@solidjs/router";
2import {
3 createMemo,
4 createResource,
5 createSignal,
6 For,
7 Match,
8 onMount,
9 Show,
10 Suspense,
11 Switch,
12} from "solid-js";
13import type { Commit, DID, DiffTextFragment } from "../../../util/types";
14import { useDid } from "../context";
15import { Header } from "../main";
16import { getRepoCommit } from "../main.data";
17import { buildTree, type TreeNode } from "./commit.data";
18
19function RenderTree(props: { tree: TreeNode; skip?: boolean }) {
20 if (props.skip)
21 return (
22 <For each={props.tree.children}>
23 {(node) => <RenderTree tree={node} />}
24 </For>
25 );
26 const [displayChildren, setDisplayChildren] = createSignal(true);
27 return (
28 <Switch>
29 <Match when={props.tree.type === "file"}>
30 <a
31 class="flex cursor-default select-none flex-row items-center gap-1 rounded p-1 text-xs hover:bg-gray-100 hover:dark:bg-gray-700"
32 href={`#file-${props.tree.fullPath}`}
33 >
34 <div class="iconify gravity-ui--file" />
35 <span class="select-text">{props.tree.name}</span>
36 </a>
37 </Match>
38 <Match when={props.tree.type === "directory"}>
39 <div
40 class="flex select-none flex-row items-center gap-1 rounded p-1 text-xs hover:bg-gray-100 hover:dark:bg-gray-700"
41 onclick={() => setDisplayChildren(!displayChildren())}
42 >
43 <div class="iconify gravity-ui--folder-fill" />
44 <span class="select-text">{props.tree.name}</span>
45 </div>
46 <div
47 class={`ml-1 flex flex-col border-gray-200 border-l pl-1 dark:border-gray-700 ${displayChildren() ? "" : "hidden"}`}
48 >
49 <For each={props.tree.children}>
50 {(node) => <RenderTree tree={node} />}
51 </For>
52 </div>
53 </Match>
54 </Switch>
55 );
56}
57
58function Fragment(props: {
59 file: string;
60 data: DiffTextFragment;
61 index: number;
62 numberSize: number;
63}) {
64 let lineNumber = props.data.NewPosition;
65 let iOld = props.data.OldPosition;
66 let iNew = props.data.NewPosition;
67
68 return (
69 <Show when={!props.data.is_binary} fallback={<div>binary data</div>}>
70 <Show when={props.index !== 0}>
71 <div class="h-5 w-full select-none bg-gray-100 text-center font-mono text-gray-700 dark:bg-gray-700 dark:text-gray-300">
72 ···
73 </div>
74 </Show>
75 <div class="w-full whitespace-pre font-mono">
76 <For each={props.data.Lines}>
77 {(line) => {
78 const lineNumberOld = line.Op === 2 ? "" : (iOld++).toString();
79 const lineNumberNew = line.Op === 1 ? "" : (iNew++).toString();
80 const fillerOld = " ".repeat(
81 props.numberSize - lineNumberOld.length,
82 );
83 const fillerNew = " ".repeat(
84 props.numberSize - lineNumberNew.length,
85 );
86 return (
87 <Line
88 file={props.file}
89 index={props.index}
90 line={line}
91 lineNumber={lineNumber++}
92 lineNumberNew={lineNumberNew}
93 lineNumberOld={lineNumberOld}
94 fillerOld={fillerOld}
95 fillerNew={fillerNew}
96 />
97 );
98 }}
99 </For>
100 </div>
101 </Show>
102 );
103}
104
105function Line(props: {
106 file: string;
107 index: number;
108 line: { Op: number; Line: string };
109 lineNumber: number;
110 lineNumberOld: string;
111 lineNumberNew: string;
112 fillerOld: string;
113 fillerNew: string;
114}) {
115 const id = `line-${props.file}-${props.index}-${props.lineNumber.toString()}`;
116 return (
117 <div
118 class="flex scroll-mt-10 flex-row text-gray-400 *:flex *:flex-row dark:text-gray-500"
119 id={id}
120 >
121 <div class="sticky left-0 select-none border-gray-200 border-r bg-white px-1 *:flex dark:border-gray-700 dark:bg-gray-800">
122 <span class="float-right mr-1 w-1/2 justify-end">
123 <span>{props.fillerOld}</span>
124 <Show when={props.lineNumberOld}>
125 <a
126 class="hover:text-gray-700 hover:underline hover:dark:text-gray-200"
127 href={`#${id}`}
128 >
129 {props.lineNumberOld}
130 </a>
131 </Show>
132 </span>
133 <span class="float-right mr-1 w-1/2 justify-end">
134 {props.fillerNew}
135 <Show when={props.lineNumberNew}>
136 <a
137 class="hover:text-gray-700 hover:underline hover:dark:text-gray-200"
138 href={`#${id}`}
139 >
140 {props.lineNumberNew}
141 </a>
142 </Show>
143 </span>
144 </div>
145 <Switch>
146 <Match when={props.line.Op === 0}>
147 <div class="w-full text-gray-500 dark:text-gray-500">
148 <div class="select-none">{" "}</div>
149 {props.line.Line}
150 </div>
151 </Match>
152 <Match when={props.line.Op === 2}>
153 <div class="w-full bg-green-100 text-green-700 dark:bg-green-800/30 dark:text-green-400">
154 <div class="select-none">{" + "}</div>
155 {props.line.Line}
156 </div>
157 </Match>
158 <Match when={props.line.Op === 1}>
159 <div class="w-full bg-red-100 text-red-700 dark:bg-red-800/30 dark:text-red-400">
160 <div class="select-none">{" - "}</div>
161 {props.line.Line}
162 </div>
163 </Match>
164 </Switch>
165 </div>
166 );
167}
168
169function DiffView(props: { commit: Commit }) {
170 return (
171 <For each={props.commit.diff.diff}>
172 {(diff) => {
173 const [show, setShow] = createSignal(true);
174 const [addedLines, removedLines] = diff.text_fragments.reduce(
175 (acc, v) => [acc[0] + v.NewLines, acc[1] + v.LinesDeleted],
176 [0, 0],
177 );
178
179 const lastFrag = diff.text_fragments[diff.text_fragments.length - 1];
180 const numberSize = Math.max(
181 2,
182 (
183 Math.max(lastFrag.NewPosition, lastFrag.OldPosition) +
184 lastFrag.Lines.length
185 ).toString().length,
186 );
187
188 return (
189 <div
190 id={`file-${diff.name.new}`}
191 class="not-last:mb-1 flex w-full flex-col rounded border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
192 >
193 <div
194 class={`sticky top-0 z-10 flex cursor-default select-none flex-row items-center gap-2 bg-white p-2 hover:bg-gray-100 dark:bg-gray-800 hover:dark:bg-gray-700 ${show() ? "rounded-t border-gray-200 border-b dark:border-gray-700" : "rounded"}`}
195 onclick={() => setShow(!show())}
196 >
197 <div
198 class={`iconify ${show() ? "gravity-ui--chevron-down" : "gravity-ui--chevron-right"}`}
199 />
200 <div class="flex h-6 select-text flex-row items-center overflow-hidden rounded font-mono text-xs *:h-full *:content-center *:px-1">
201 <Show when={addedLines > 0}>
202 <div class="bg-green-100 text-green-700 dark:bg-green-800/30 dark:text-green-400">{`+${addedLines}`}</div>
203 </Show>
204 <Show when={removedLines > 0}>
205 <div class="bg-red-100 text-red-700 dark:bg-red-700/30 dark:text-red-400">{`-${removedLines}`}</div>
206 </Show>
207 </div>
208 <Show
209 when={diff.name.old !== "" && diff.name.new !== diff.name.old}
210 >
211 <div class="select-text">{diff.name.old}</div>
212 <div class="iconify gravity-ui--arrow-right" />
213 </Show>
214 <div class="select-text">{diff.name.new}</div>
215 </div>
216 <div
217 class={`select-text overflow-x-auto rounded-b bg-white dark:bg-gray-800 ${show() ? "" : "hidden"}`}
218 >
219 <div class="min-w-max">
220 <For each={diff.text_fragments}>
221 {(frag, i) => (
222 <Fragment
223 file={diff.name.new}
224 data={frag}
225 index={i()}
226 numberSize={numberSize}
227 />
228 )}
229 </For>
230 </div>
231 </div>
232 </div>
233 );
234 }}
235 </For>
236 );
237}
238
239function CommitHeader(props: {
240 user: string;
241 repo: string;
242 message: { title: string; content: string };
243 commit: Commit;
244}) {
245 return (
246 <div>
247 <Header user={props.user} repo={props.repo} />
248 <div class="mx-1 flex flex-col gap-2 rounded bg-white p-4 dark:bg-gray-800">
249 <div>{props.message.title}</div>
250 <Show when={props.message.content}>
251 <div class="text-xs">{props.message.content}</div>
252 </Show>
253 <div class="text-gray-500 text-xs dark:text-gray-300">
254 <span>{`${new Date(props.commit.diff.commit.author.When).toLocaleDateString(undefined, { dateStyle: "long" })} at ${new Date(props.commit.diff.commit.author.When).toLocaleTimeString()}`}</span>
255 <span class="select-none px-1 before:content-['\00B7']" />
256 <span>{`${props.commit.diff.commit.author.Name} <${props.commit.diff.commit.author.Email}>`}</span>
257 <span class="select-none px-1 before:content-['\00B7']" />
258 <a
259 class="hover:text-gray-600 hover:underline hover:dark:text-gray-200"
260 href={`/${props.user}/${props.repo}/commit/${props.commit.ref}`}
261 >
262 {props.commit.ref.slice(0, 8)}
263 </a>
264 <Show when={props.commit.diff.commit.parent}>
265 <div class="iconify gravity-ui--arrow-left mx-1 text-[0.6rem]" />
266 <a
267 class="hover:text-gray-600 hover:underline hover:dark:text-gray-200"
268 href={`/${props.user}/${props.repo}/commit/${props.commit.diff.commit.parent}`}
269 >
270 {props.commit.diff.commit.parent.slice(0, 8)}
271 </a>
272 </Show>
273 </div>
274 </div>
275 </div>
276 );
277}
278
279export default function RepoCommit() {
280 const params = useParams();
281 const did = useDid();
282
283 const [commit] = createResource(
284 () => {
285 const d = did();
286 if (!d) return;
287 return [d, params.repo, params.ref];
288 },
289 async ([did, repo, ref]) => {
290 const res = await getRepoCommit(did as DID, repo, ref);
291 if (!res.ok) return;
292 return res.data as Commit;
293 },
294 );
295
296 const [sidebar] = createResource(commit, async (commit) => {
297 if (!commit.diff.diff)
298 return { name: "", fullPath: "", type: "directory" } as TreeNode;
299 return buildTree(commit.diff.diff.map((v) => v.name.new));
300 });
301
302 const allData = createMemo(() => {
303 const s = sidebar();
304 const c = commit();
305 if (!(s && c)) return;
306
307 return [s, c] as const;
308 });
309
310 const headerData = createMemo(() => {
311 const c = commit();
312 if (!c) return;
313
314 const titleEnd = c.diff.commit.message.indexOf("\n");
315 const message = {
316 title: c.diff.commit.message.slice(0, titleEnd),
317 content: c.diff.commit.message.slice(titleEnd + 1),
318 };
319
320 return [c, message] as const;
321 });
322
323 onMount(() => {
324 if (window.location.hash) {
325 const element = document.getElementById(window.location.hash.slice(1));
326 if (element)
327 element.scrollIntoView({ behavior: "instant", block: "start" });
328 }
329 });
330
331 return (
332 <div class="mx-auto max-w-10xl">
333 <Suspense>
334 <Show when={headerData()} keyed>
335 {([commit, message]) => (
336 <CommitHeader
337 user={params.user}
338 repo={params.repo}
339 message={message}
340 commit={commit}
341 />
342 )}
343 </Show>
344 <Show when={allData()} keyed>
345 {([sidebar, commit]) => (
346 <>
347 <div class="flex flex-row gap-1">
348 <div class="sticky top-0 flex max-h-screen min-w-50 overflow-auto p-1 pr-0">
349 <Show when={sidebar.children}>
350 <div class="flex min-h-max w-full grow cursor-default flex-col rounded border border-gray-200 bg-white p-1 dark:border-gray-700 dark:bg-gray-800">
351 <div class="flex flex-row items-center justify-between gap-1 p-1">
352 <div class="font-bold">CHANGED FILES</div>
353 <div class="flex h-6 select-text flex-row items-center overflow-hidden rounded font-mono text-xs *:h-full *:content-center *:px-1">
354 <Show when={commit.diff.stat.insertions > 0}>
355 <div class="bg-green-100 text-green-700 dark:bg-green-800/30 dark:text-green-400">{`+${commit.diff.stat.insertions}`}</div>
356 </Show>
357 <Show when={commit.diff.stat.deletions > 0}>
358 <div class="bg-red-100 text-red-700 dark:bg-red-700/30 dark:text-red-400">{`-${commit.diff.stat.deletions}`}</div>
359 </Show>
360 </div>
361 </div>
362 <RenderTree tree={sidebar} skip={true} />
363 </div>
364 </Show>
365 </div>
366 <div class="min-w-0 flex-1 flex-col gap-1 p-1 pl-0">
367 <DiffView commit={commit} />
368 </div>
369 </div>
370 </>
371 )}
372 </Show>
373 </Suspense>
374 </div>
375 );
376}