extremely wip tangled spa
at main 13 kB view raw
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}