1import type {
2 ShTangledRepoLanguages,
3 ShTangledRepoTree,
4} from "@atcute/tangled";
5import { type Params, useNavigate, useParams } from "@solidjs/router";
6import {
7 createMemo,
8 createResource,
9 createSignal,
10 For,
11 Match,
12 Show,
13 Switch,
14} from "solid-js";
15import { SolidMarkdown } from "solid-markdown";
16import { languageColors } from "../../util/get_language";
17import type { Branches, RepoLog, Tags } from "../../util/types";
18import { Header } from "./components/header";
19import { useDid } from "./context";
20import {
21 getRepoBranches,
22 getRepoDefaultBranch,
23 getRepoLanguages,
24 getRepoLog,
25 getRepoTags,
26 getRepoTree,
27} from "./main.data";
28import "../../styles/markdown.css";
29import { figureOutDid } from "../../util/handle";
30import { toRelativeTime } from "../../util/time";
31import { PathBar } from "./components/pathbar";
32
33export async function preloadRepoTree({ params }: { params: Params }) {
34 const did = await figureOutDid(params.user!);
35 if (!did) return;
36 getRepoTree(did, params.repo!, params.ref!, params.path!);
37}
38
39export default function RepoTree() {
40 const navigate = useNavigate();
41 const params = useParams() as {
42 user: string;
43 repo: string;
44 ref: string;
45 path: string;
46 };
47 const did = useDid();
48
49 const [defaultBranch] = createResource(did, async (did) => {
50 const res = await getRepoDefaultBranch(did, params.repo);
51 if (!res.ok) return;
52 return res.data.name;
53 });
54
55 const [tree] = createResource(
56 () => {
57 const d = did();
58 return d && ([d, params.repo, params.ref, params.path] as const);
59 },
60 async ([d, repo, ref, path]) => {
61 const res = await getRepoTree(d, repo, ref, path);
62 if (!res.ok) return;
63 return res.data;
64 },
65 );
66
67 const [languages] = createResource(did, async (did) => {
68 const res = await getRepoLanguages(did, params.repo, params.ref);
69 if (!res.ok) return;
70 return res.data.languages.sort((a, b) =>
71 b.name === "" ? -1 : b.percentage - a.percentage,
72 );
73 });
74
75 const [logs] = createResource(
76 () => {
77 const d = did();
78 return d && ([d, params.repo, params.ref] as const);
79 },
80 async ([d, repo, ref]) => {
81 const res = await getRepoLog(d, repo, ref);
82 if (!res.ok) return;
83 return res.data as RepoLog;
84 },
85 );
86
87 const [branches] = createResource(
88 () => {
89 const d = did();
90 return d && ([d, params.repo, params.ref] as const);
91 },
92 async ([d, repo, ref]) => {
93 const res = await getRepoBranches(d, repo, ref);
94 if (!res.ok) return;
95 return res.data as Branches;
96 },
97 );
98
99 const [tags] = createResource(
100 () => {
101 const d = did();
102 return d && ([d, params.repo, params.ref, params.path] as const);
103 },
104 async ([d, repo, ref, path]) => {
105 if (path) return;
106 const res = await getRepoTags(d, repo, ref);
107 if (!res.ok) return;
108 return res.data as Tags;
109 },
110 );
111
112 const readme = createMemo(() => {
113 const readme = tree()?.readme;
114 if (!readme) return;
115
116 return {
117 contents: readme.contents,
118 type: readme.filename.toLowerCase().endsWith(".md")
119 ? "markdown"
120 : "plaintext",
121 } as const;
122 });
123
124 const sortedFiles = createMemo(() => {
125 const files = tree()?.files;
126 if (!files) return;
127
128 return files.sort((a, b) => {
129 if (a.mode.startsWith("01") !== b.mode.startsWith("01"))
130 return a.mode.startsWith("01") ? 1 : -1;
131
132 const aDot = a.name.startsWith(".");
133 const bDot = b.name.startsWith(".");
134 if (aDot !== bDot) return aDot ? -1 : 1;
135
136 return a.name.localeCompare(b.name, undefined, { numeric: true });
137 });
138 });
139
140 const isANonDefaultBranchSelected = createMemo(() => {
141 if (!branches()) return;
142 return (
143 defaultBranch() !== ref() &&
144 Boolean(
145 branches()!.branches.find((branch) => branch.reference.name === ref()),
146 )
147 );
148 });
149
150 const ref = createMemo(() => params.ref || defaultBranch());
151
152 return (
153 <div class="mx-auto max-w-5xl">
154 <div>
155 <Header user={params.user} repo={params.repo} />
156 <div class="mb-4 flex flex-col rounded bg-white dark:bg-gray-800">
157 <Switch>
158 <Match when={!params.path}>
159 <Show when={languages()} fallback={<div class="h-4" />}>
160 <LanguageLine languages={languages()!} />
161 </Show>
162 <div class="flex flex-row px-5 py-2">
163 <Show when={branches() && tags() && ref() && defaultBranch()}>
164 <select
165 class="w-40 border border-gray-200 bg-white p-1 dark:border-gray-700 dark:bg-gray-800"
166 onInput={(e) =>
167 navigate(
168 `/${params.user}/${params.repo}/tree/${e.target.value}`,
169 )
170 }
171 >
172 <Show when={branches()?.branches}>
173 <optgroup
174 label={`branches (${branches()?.branches.length})`}
175 >
176 <Show when={isANonDefaultBranchSelected()}>
177 <option selected>{ref()}</option>
178 </Show>
179 <option selected={ref() === defaultBranch()}>
180 {defaultBranch()}
181 </option>
182 <For each={branches()!.branches}>
183 {(branch) =>
184 branch.reference.name !== ref() &&
185 branch.reference.name !== defaultBranch() && (
186 <option>{branch.reference.name}</option>
187 )
188 }
189 </For>
190 </optgroup>
191 </Show>
192 <Show when={tags()?.tags}>
193 <optgroup label={`tags (${tags()?.tags.length})`}>
194 <For each={tags()!.tags}>
195 {(tag) => (
196 <option selected={tag.name === ref()}>
197 {tag.name}
198 </option>
199 )}
200 </For>
201 </optgroup>
202 </Show>
203 </select>
204 </Show>
205 </div>
206 <div class="flex flex-row py-2">
207 <div class="flex flex-1 flex-col border-gray-300 px-5 md:mr-1 md:w-1/2 md:border-r md:pr-3 dark:border-gray-700">
208 <Show when={defaultBranch() && tree() && sortedFiles()}>
209 <FileDirectory
210 user={params.user}
211 repo={params.repo}
212 files={sortedFiles()!}
213 tree={tree()!}
214 defaultBranch={defaultBranch()!}
215 />
216 </Show>
217 </div>
218 <div class="flex w-1/2 flex-col pr-5 pl-2 max-md:hidden">
219 <Show when={defaultBranch() && sortedFiles() && logs()}>
220 <LogData
221 user={params.user}
222 repo={params.repo}
223 defaultBranch={defaultBranch()!}
224 files={sortedFiles()!}
225 logs={logs()!}
226 />
227 </Show>
228 </div>
229 </div>
230 </Match>
231 <Match when={params.path && ref()}>
232 <div class="mx-5 flex flex-row border-gray-300 border-b pt-4 pb-2 md:items-center dark:border-gray-700">
233 <PathBar
234 user={params.user}
235 repo={params.repo}
236 gitref={ref()!}
237 path={params.path}
238 is_file={false}
239 />
240 </div>
241 <div class="flex flex-row px-5 py-2">
242 <Show when={defaultBranch() && tree() && sortedFiles()}>
243 <FileDirectory
244 user={params.user}
245 repo={params.repo}
246 files={sortedFiles()!}
247 tree={tree()!}
248 defaultBranch={defaultBranch()!}
249 />
250 </Show>
251 </div>
252 </Match>
253 </Switch>
254 </div>
255 <Show when={readme()?.contents}>
256 <ReadmeCard
257 path={`/${params.user}/${params.repo}/blob/${tree()!.ref || defaultBranch}`}
258 readme={readme()!}
259 />
260 </Show>
261 </div>
262 </div>
263 );
264}
265
266function ReadmeCard(props: {
267 path: string;
268 readme: {
269 type: "markdown" | "plaintext";
270 contents: string;
271 };
272}) {
273 return (
274 <div class="rounded bg-white p-4 dark:bg-gray-800">
275 <Switch>
276 <Match when={props.readme.type === "markdown"}>
277 <SolidMarkdown
278 class="markdown"
279 transformImageUri={(href) => `${props.path}${href}`}
280 transformLinkUri={(href) => `${props.path}${href}`}
281 renderingStrategy="memo"
282 children={props.readme.contents}
283 />
284 </Match>
285 <Match when={props.readme.type === "plaintext"}>
286 <div class="font-mono"> {props.readme.contents}</div>
287 </Match>
288 </Switch>
289 </div>
290 );
291}
292
293function LanguageLine(props: { languages: ShTangledRepoLanguages.Language[] }) {
294 const [languageLineState, setLanguageLineState] = createSignal(false);
295 const toggleLanguageLineState = () =>
296 setLanguageLineState(!languageLineState());
297
298 return (
299 <div class={languageLineState() ? "h-full" : "h-4"}>
300 <button
301 type="button"
302 class={`flex w-full flex-row overflow-hidden rounded-t duration-75 hover:h-4 ${languageLineState() ? "h-4" : "h-2"}`}
303 onClick={toggleLanguageLineState}
304 >
305 <For each={props.languages}>
306 {(language) => (
307 <div
308 class="h-full border-gray-50 not-last:border-r duration-75 hover:brightness-90 dark:border-gray-950 dark:hover:brightness-110"
309 style={`width: ${language.percentage}%; background-color: ${languageColors.get(language.name.toLowerCase().replaceAll(" ", "")) ?? "aaa"}`}
310 title={`${language.name} ${language.percentage}%`}
311 ></div>
312 )}
313 </For>
314 </button>
315 <div
316 class={`flex h-4 flex-row justify-around gap-3 border-gray-300 border-b px-6 py-3.5 text-xs dark:border-gray-700 ${languageLineState() ? "" : "hidden"}`}
317 >
318 <For each={props.languages}>
319 {(language) => (
320 <div class="flex flex-row items-center gap-2">
321 <div
322 class="h-2 w-2 rounded-full"
323 style={`background-color: ${languageColors.get(language.name.toLowerCase().replaceAll(" ", "")) ?? "#aaa"}`}
324 />
325 <span>
326 <span>{language.name || "Other"}</span>{" "}
327 <span class="text-gray-600 dark:text-gray-400">
328 {language.percentage}%
329 </span>
330 </span>
331 </div>
332 )}
333 </For>
334 </div>
335 </div>
336 );
337}
338
339function FileDirectory(props: {
340 user: string;
341 repo: string;
342 files: ShTangledRepoTree.TreeEntry[];
343 tree: ShTangledRepoTree.$output;
344 defaultBranch: string;
345}) {
346 return (
347 <div class="@container flex w-full flex-col">
348 <For each={props.files}>
349 {(file) => {
350 const is_file = file.mode.startsWith("01");
351 const last_commit_date =
352 file.last_commit && new Date(file.last_commit.when);
353 return (
354 <div class="flex flex-row justify-between">
355 <a
356 href={`/${props.user}/${props.repo}/${is_file ? "blob" : "tree"}/${props.tree.ref || props.defaultBranch}/${props.tree.parent ? `${props.tree.parent}/` : ""}${file.name}`}
357 class="flex flex-1 @max-xl:grow flex-row items-center gap-1.5 truncate p-1 hover:underline"
358 >
359 <div
360 class={`iconify ${is_file ? "gravity-ui--file" : "gravity-ui--folder-fill"}`}
361 />
362 <span>{file.name}</span>
363 </a>
364 <Show when={last_commit_date}>
365 <a
366 href={`/${props.user}/${props.repo}/commit/${file.last_commit!.hash}`}
367 class="mr-2 @max-xl:hidden min-w-0 flex-1 grow truncate text-ellipsis text-left text-gray-500 hover:text-gray-700 hover:underline dark:text-gray-400 hover:dark:text-gray-200"
368 >
369 {file.last_commit!.message}
370 </a>
371 <a
372 href={`/${props.user}/${props.repo}/commit/${file.last_commit!.hash}`}
373 title={last_commit_date!.toLocaleString(undefined, {
374 dateStyle: "full",
375 timeStyle: "short",
376 })}
377 class="shrink-0 whitespace-nowrap text-gray-500 text-xs hover:text-gray-700 hover:underline dark:text-gray-400 hover:dark:text-gray-200"
378 >
379 {toRelativeTime(last_commit_date!)}
380 </a>
381 </Show>
382 </div>
383 );
384 }}
385 </For>
386 </div>
387 );
388}
389
390function LogData(props: {
391 user: string;
392 repo: string;
393 defaultBranch: string;
394 files: ShTangledRepoTree.TreeEntry[];
395 logs: RepoLog;
396}) {
397 return (
398 <>
399 <a
400 class="mb-2 flex flex-row items-center gap-2 text-black hover:text-gray-600 dark:text-white hover:dark:text-gray-300"
401 href={`/${props.user}/${props.repo}/commits/${props.logs.ref || props.defaultBranch}`}
402 >
403 <div class="flex select-none flex-row items-center gap-1 font-bold">
404 <div class="iconify gravity-ui--code-commit" />
405 <span>commits</span>
406 </div>
407 <div class="rounded bg-gray-300 px-1 text-xs dark:bg-gray-700">
408 {props.logs.total}
409 </div>
410 </a>
411 <div class="mb-3 flex flex-col gap-4">
412 <For
413 each={props.logs.commits.slice(
414 0,
415 Math.max(3, Math.floor(props.files.length / 2)),
416 )}
417 >
418 {(commit) => {
419 const hash = commit.Hash.map((num) =>
420 num.toString(16).padStart(2, "0"),
421 ).join("");
422 const date = new Date(commit.Author.When);
423
424 return (
425 <div class="flex flex-col gap-1">
426 <a
427 class="text-black hover:text-gray-600 hover:underline dark:text-white hover:dark:text-gray-300"
428 href={`/${props.user}/${props.repo}/commit/${hash}`}
429 >
430 {commit.Message.slice(0, commit.Message.indexOf("\n"))}
431 </a>
432 <span class="text-gray-500 text-xs dark:text-gray-300">
433 <span class="rounded bg-gray-100 p-1 font-mono dark:bg-gray-900">
434 {hash.slice(0, 8)}
435 </span>
436 <span class="select-none px-1 before:content-['\00B7']" />
437 <span>{commit.Author.Name}</span>
438 <span class="select-none px-1 before:content-['\00B7']" />
439 <span
440 title={date.toLocaleString(undefined, {
441 dateStyle: "full",
442 timeStyle: "short",
443 })}
444 >{`${toRelativeTime(date)}`}</span>
445 </span>
446 </div>
447 );
448 }}
449 </For>
450 </div>
451 </>
452 );
453}