1import type {
2 ShTangledRepoLanguages,
3 ShTangledRepoTree,
4} from "@atcute/tangled";
5import { type Params, 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 { IconWithText } from "../../elements/icon_with_text";
17import { languageColors } from "../../util/get_language";
18import type { RepoLog } from "../../util/types";
19import { useDid } from "./context";
20import { Header } from "./main";
21import {
22 getRepoBranches,
23 getRepoDefaultBranch,
24 getRepoLanguages,
25 getRepoLog,
26 getRepoTree,
27} from "./main.data";
28import "../../styles/markdown.css";
29import { figureOutDid } from "../../util/handle";
30
31export async function preloadRepoTree({ params }: { params: Params }) {
32 const did = await figureOutDid(params.user);
33 if (!did) return;
34 getRepoTree(did, params.repo, params.ref, params.path);
35}
36
37export default function RepoTree() {
38 const params = useParams();
39 const did = useDid();
40
41 const [defaultBranch] = createResource(did, async (did) => {
42 const res = await getRepoDefaultBranch(did, params.repo);
43 if (!res.ok) return;
44 return res.data.name;
45 });
46
47 const [tree] = createResource(
48 () => {
49 const d = did();
50 return d && ([d, params.repo, params.ref, params.path] as const);
51 },
52 async ([d, repo, ref, path]) => {
53 const res = await getRepoTree(d, repo, ref, path);
54 if (!res.ok) return;
55 return res.data;
56 },
57 );
58
59 const [languages] = createResource(did, async (did) => {
60 const res = await getRepoLanguages(did, params.repo, params.ref);
61 if (!res.ok) return;
62 return res.data.languages.sort((a, b) => b.percentage - a.percentage);
63 });
64
65 const [logs] = createResource(
66 () => {
67 const d = did();
68 return d && ([d, params.repo, params.ref, params.path] as const);
69 },
70 async ([d, repo, ref, path]) => {
71 const res = await getRepoLog(d, repo, ref, path);
72 if (!res.ok) return;
73 return res.data as RepoLog;
74 },
75 );
76
77 const [branchesAndTags] = createResource(
78 () => {
79 const d = did();
80 return d && ([d, params.repo, params.ref] as const);
81 },
82 async ([d, repo, ref]) => {
83 const resBranches = await getRepoBranches(d, repo, ref);
84 if (!resBranches.ok) return;
85 return resBranches.data as RepoLog;
86 },
87 );
88
89 const [readme] = createResource(tree, async (tree) => {
90 if (!tree.readme) return;
91 return {
92 contents: tree.readme.contents,
93 type: tree.readme.filename.toLowerCase().endsWith(".md")
94 ? "markdown"
95 : "plaintext",
96 } as const;
97 });
98
99 const [filesInOrder] = createResource(tree, (tree) => {
100 if (!tree.files) return;
101 return tree.files.sort((a, b) => {
102 if (!a.is_file === b.is_file) return !a.is_file ? -1 : 1;
103
104 const aDot = a.name.startsWith(".");
105 const bDot = b.name.startsWith(".");
106 if (aDot !== bDot) return aDot ? -1 : 1;
107
108 return a.name.localeCompare(b.name, undefined, { numeric: true });
109 });
110 });
111
112 const repoData = createMemo(() => {
113 const db = defaultBranch();
114 const l = languages();
115 if (!(db && l)) return;
116 return [db, l] as const;
117 });
118
119 const pathData = createMemo(() => {
120 const t = tree();
121 const f = filesInOrder();
122 const r = readme();
123 const l = logs();
124 if (!(t && f && r && l)) return;
125 return [t, f, r, l] as const;
126 });
127
128 return (
129 <div class="mx-auto max-w-5xl">
130 <Show when={repoData()} keyed>
131 {([defaultBranch, languages]) => (
132 <Show when={pathData()} keyed>
133 {([tree, files, readme, logs]) => (
134 <div>
135 <Header user={params.user} repo={params.repo} />
136 <div class="mb-4 flex flex-col rounded bg-white dark:bg-gray-800">
137 <LanguageLine languages={languages} />
138 <div class="flex flex-row">
139 <div class="mr-1 flex w-1/2 flex-col border-gray-300 border-r p-2 pt-1 dark:border-gray-700">
140 <FileDirectory
141 user={params.user}
142 repo={params.repo}
143 files={files}
144 tree={tree}
145 defaultBranch={defaultBranch}
146 />
147 </div>
148 <div class="ml-1 flex w-1/2 flex-col p-2 pt-1">
149 <LogData
150 user={params.user}
151 repo={params.repo}
152 defaultBranch={defaultBranch}
153 files={files}
154 logs={logs}
155 />
156 </div>
157 </div>
158 </div>
159 <Show when={readme.contents}>
160 <ReadmeCard
161 path={`/${params.user}/${params.repo}/blob/${tree.ref || defaultBranch}`}
162 readme={readme}
163 />
164 </Show>
165 </div>
166 )}
167 </Show>
168 )}
169 </Show>
170 </div>
171 );
172}
173
174function ReadmeCard(props: {
175 path: string;
176 readme: {
177 type: "markdown" | "plaintext";
178 contents: string;
179 };
180}) {
181 return (
182 <div class="mb-4 rounded bg-white p-4 dark:bg-gray-800">
183 <Switch>
184 <Match when={props.readme.type === "markdown"}>
185 <SolidMarkdown
186 class="markdown"
187 transformImageUri={(href) => `${props.path}${href}`}
188 transformLinkUri={(href) => `${props.path}${href}`}
189 renderingStrategy="memo"
190 children={props.readme.contents}
191 />
192 </Match>
193 <Match when={props.readme.type === "plaintext"}>
194 <div class="font-mono"> {props.readme.contents}</div>
195 </Match>
196 </Switch>
197 </div>
198 );
199}
200
201function LanguageLine(props: { languages: ShTangledRepoLanguages.Language[] }) {
202 const [languageLineState, setLanguageLineState] = createSignal(false);
203 const toggleLanguageLineState = () =>
204 setLanguageLineState(!languageLineState());
205
206 return (
207 <div class={languageLineState() ? "h-full" : "h-4"}>
208 <div
209 class={`flex w-full flex-row overflow-hidden rounded-t duration-75 hover:h-4 ${languageLineState() ? "h-4" : "h-2"}`}
210 onclick={toggleLanguageLineState}
211 >
212 <For each={props.languages}>
213 {(language) => (
214 <div
215 class="h-full border-gray-50 border-r duration-75 hover:brightness-90 dark:border-gray-950 dark:hover:brightness-110"
216 style={`width: ${language.percentage}%; background-color: ${languageColors.get(language.name.toLowerCase().replaceAll(" ", ""))}`}
217 title={`${language.name} ${language.percentage}%`}
218 ></div>
219 )}
220 </For>
221 </div>
222 <div
223 class={`flex h-4 flex-row gap-3 border-gray-300 border-r border-b px-6 py-3.5 text-xs dark:border-gray-700 ${languageLineState() ? "" : "hidden"}`}
224 >
225 <For each={props.languages}>
226 {(language) => (
227 <div class="flex flex-row items-center gap-2">
228 <div
229 class="h-2 w-2 rounded-full"
230 style={`background-color: ${languageColors.get(language.name.toLowerCase().replaceAll(" ", ""))}`}
231 />
232 <span>
233 <span>{language.name}</span>{" "}
234 <span class="text-gray-600 dark:text-gray-400">
235 {language.percentage}%
236 </span>
237 </span>
238 </div>
239 )}
240 </For>
241 </div>
242 </div>
243 );
244}
245
246function FileDirectory(props: {
247 user: string;
248 repo: string;
249 files: ShTangledRepoTree.TreeEntry[];
250 tree: ShTangledRepoTree.$output;
251 defaultBranch: string;
252}) {
253 return (
254 <div class="flex w-full flex-col">
255 <Show when={props.tree.parent}>
256 <a
257 href={`/${props.user}/${props.repo}/tree/${props.tree.ref}/${props.tree.dotdot ? `/${props.tree.dotdot}` : ""}`}
258 class="flex flex-row items-center gap-1 p-1 hover:underline"
259 >
260 <div class="iconify gravity-ui--folder-open-fill" />
261 <span>..</span>
262 </a>
263 </Show>
264
265 <For each={props.files}>
266 {(file) => (
267 <a
268 href={`/${props.user}/${props.repo}/${file.is_file ? "blob" : "tree"}/${props.tree.ref || props.defaultBranch}/${props.tree.parent ? `${props.tree.parent}/` : ""}${file.name}`}
269 class="flex flex-row items-center gap-1 p-1 hover:underline"
270 >
271 <div
272 class={`iconify ${file.is_file ? "gravity-ui--file" : "gravity-ui--folder-fill"}`}
273 />
274 <span>{file.name}</span>
275 </a>
276 )}
277 </For>
278 </div>
279 );
280}
281
282function LogData(props: {
283 user: string;
284 repo: string;
285 defaultBranch: string;
286 files: ShTangledRepoTree.TreeEntry[];
287 logs: RepoLog;
288}) {
289 return (
290 <>
291 <a
292 class="mb-2 flex flex-row items-center gap-2 text-black hover:text-gray-600 dark:text-white hover:dark:text-gray-300"
293 href={`/${props.user}/${props.repo}/commits/${props.logs.ref || props.defaultBranch}`}
294 >
295 <IconWithText
296 icon="gravity-ui--code-commit"
297 text="commits"
298 style="font-bold"
299 />
300 <div class="rounded bg-gray-300 px-1 text-xs dark:bg-gray-700">
301 {props.logs.total}
302 </div>
303 </a>
304 <div class="mb-3 flex flex-col gap-4">
305 <For
306 each={props.logs.commits.slice(
307 0,
308 Math.max(3, Math.floor(props.files.length / 2)),
309 )}
310 >
311 {(commit) => {
312 const hash = commit.Hash.map((num) =>
313 num.toString(16).padStart(2, "0"),
314 ).join("");
315 return (
316 <div class="flex flex-col gap-1">
317 <a
318 class="text-black hover:text-gray-600 hover:underline dark:text-white hover:dark:text-gray-300"
319 href={`/${props.user}/${props.repo}/commit/${hash}`}
320 >
321 {commit.Message.slice(0, commit.Message.indexOf("\n"))}
322 </a>
323 <span class="text-gray-500 text-xs dark:text-gray-300">
324 <span class="rounded bg-gray-100 p-1 font-mono dark:bg-gray-900">
325 {hash.slice(0, 8)}
326 </span>
327 <span class="select-none px-1 before:content-['\00B7']" />
328 <span>{commit.Author.Name}</span>
329 <span class="select-none px-1 before:content-['\00B7']" />
330 <span>{`${new Date(commit.Author.When).toLocaleDateString(undefined, { dateStyle: "short" })} at ${new Date(commit.Author.When).toLocaleTimeString()}`}</span>
331 </span>
332 </div>
333 );
334 }}
335 </For>
336 </div>
337 </>
338 );
339}