csr tangled client in solid-js
15

Configure Feed

Select the types of activity you want to include in your feed.

improve file tree navigation, use slingshot, optimize issue / pr fetching perf

dawn (May 18, 2026, 6:02 PM +0300) 5de3a0d9 818eeb39

+471 -255
+3 -1
src/components/repo.tsx
··· 340 340 href: string; 341 341 icon: JSX.Element; 342 342 lastCommit?: TreeEntry['last_commit']; 343 + class?: string; 344 + onClick?: JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent>; 343 345 }> = (props) => ( 344 - <A href={props.href} class="untangled-file-row hover:underline"> 346 + <A href={props.href} class={clsx('untangled-file-row hover:underline', props.class)} onClick={props.onClick}> 345 347 <div class="flex min-w-0 items-center gap-2"> 346 348 {props.icon} 347 349 <span class="truncate">{props.name}</span>
+33
src/index.css
··· 111 111 .untangled-skeleton::after { 112 112 background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.08), transparent); 113 113 } 114 + 115 + .untangled-file-row-loading { 116 + background: rgb(255 255 255 / 0.06); 117 + } 114 118 } 115 119 116 120 @media (prefers-reduced-motion: reduce) { 117 121 .untangled-skeleton::after { 118 122 animation: none; 119 123 } 124 + 125 + .untangled-file-row-loading { 126 + animation: none; 127 + } 120 128 } 121 129 122 130 @keyframes untangled-skeleton-shimmer { ··· 125 133 } 126 134 } 127 135 136 + @keyframes untangled-file-row-breathe { 137 + 0%, 138 + 100% { 139 + opacity: 0.88; 140 + } 141 + 142 + 50% { 143 + opacity: 1; 144 + } 145 + } 146 + 128 147 .untangled-skeleton-file-row { 129 148 align-items: center; 130 149 display: grid; ··· 158 177 gap: 0.5rem; 159 178 } 160 179 180 + .untangled-repo-overview--path { 181 + grid-template-columns: minmax(0, 1fr); 182 + } 161 183 162 184 .untangled-file-tree-pane { 163 185 border-right: 1px solid rgb(55 65 81); 186 + } 187 + 188 + .untangled-repo-overview--path .untangled-file-tree-pane { 189 + border-right: 0; 190 + padding-right: 0; 164 191 } 165 192 166 193 .untangled-language-bar { ··· 307 334 gap: 1rem; 308 335 padding: 0.25rem 0.5rem 0.25rem 0; 309 336 text-decoration: none; 337 + } 338 + 339 + .untangled-file-row-loading { 340 + animation: untangled-file-row-breathe 1.1s ease-in-out infinite; 341 + background: rgb(17 24 39 / 0.035); 342 + border-radius: 0.25rem; 310 343 } 311 344 312 345 .untangled-file-row-time {
+124 -81
src/lib/api.ts
··· 1 1 import { Client, ClientResponseError, ok, simpleFetchHandler } from '@atcute/client'; 2 2 import { 3 - CompositeDidDocumentResolver, 4 - LocalActorResolver, 5 - PlcDidDocumentResolver, 3 + type ActorResolver, 4 + type ResolveActorOptions, 6 5 type ResolvedActor, 7 - WebDidDocumentResolver, 8 - XrpcHandleResolver, 9 6 } from '@atcute/identity-resolver'; 10 7 import type { 11 8 ActorIdentifier, ··· 34 31 35 32 export const PUBLIC_API_SERVICE = 'https://public.api.bsky.app'; 36 33 export const CONSTELLATION_SERVICE = 'https://constellation.microcosm.blue'; 34 + export const SLINGSHOT_SERVICE = 'https://slingshot.microcosm.blue'; 37 35 export const SPACEDUST_SERVICE = 'wss://spacedust.microcosm.blue/subscribe'; 38 36 39 37 export const TANGLED_OAUTH_SCOPE = import.meta.env.VITE_OAUTH_SCOPE; ··· 49 47 const PULL_COMMENT_COLLECTION: Nsid = 'sh.tangled.repo.pull.comment'; 50 48 const PULL_STATUS_COLLECTION: Nsid = 'sh.tangled.repo.pull.status'; 51 49 52 - export const identityResolver = new LocalActorResolver({ 53 - handleResolver: new XrpcHandleResolver({ 54 - serviceUrl: PUBLIC_API_SERVICE, 55 - }), 56 - didDocumentResolver: new CompositeDidDocumentResolver({ 57 - methods: { 58 - plc: new PlcDidDocumentResolver(), 59 - web: new WebDidDocumentResolver(), 60 - }, 61 - }), 62 - }); 50 + class SlingshotActorResolver implements ActorResolver { 51 + async resolve(actor: ActorIdentifier, options?: ResolveActorOptions): Promise<ResolvedActor> { 52 + const resolved = await ok( 53 + getRpc(SLINGSHOT_SERVICE).get('blue.microcosm.identity.resolveMiniDoc', { 54 + params: { 55 + identifier: actor, 56 + }, 57 + signal: options?.signal, 58 + }), 59 + ); 60 + 61 + return { 62 + did: resolved.did, 63 + handle: resolved.handle, 64 + pds: normalizeServiceUrl(resolved.pds), 65 + }; 66 + } 67 + } 68 + 69 + export const identityResolver = new SlingshotActorResolver(); 63 70 64 71 export interface RepoContext { 65 72 owner: ResolvedActor; ··· 381 388 })); 382 389 }; 383 390 384 - type RepoStarValue = ShTangledFeedStar.Main & { 385 - subject?: { 386 - $type?: string; 387 - did?: Did; 388 - }; 389 - subjectDid?: Did; 390 - }; 391 - 392 391 export interface RepoStarSummary { 393 392 count: number; 394 393 isStarred: boolean; 395 394 currentUserStarRkey?: string; 396 395 } 397 396 398 - const uniqueBacklinkRefs = (refs: BacklinkRecordRef[]): BacklinkRecordRef[] => { 399 - const seen = new Set<string>(); 400 - const unique: BacklinkRecordRef[] = []; 401 - 402 - for (const ref of refs) { 403 - const key = `${ref.did}/${ref.collection}/${ref.rkey}`; 404 - if (seen.has(key)) { 405 - continue; 406 - } 407 - seen.add(key); 408 - unique.push(ref); 409 - } 410 - 411 - return unique; 412 - }; 413 - 414 - const starTargetsRepo = (star: RepoStarValue, repoDid: Did): boolean => { 415 - if (star.subjectDid === repoDid) { 416 - return true; 417 - } 418 - 419 - const subject = star.subject; 420 - return subject?.$type === 'sh.tangled.feed.star#repo' && subject.did === repoDid; 421 - }; 397 + const STAR_BACKLINK_SOURCES = [ 398 + 'sh.tangled.feed.star:subject.did', 399 + 'sh.tangled.feed.star:subjectDid', 400 + ]; 422 401 423 402 export const getRepoStarSummary = async (repo: RepoContext, viewerDid?: Did | null): Promise<RepoStarSummary> => { 424 - const refs = uniqueBacklinkRefs( 425 - ( 426 - await Promise.all([ 427 - getBacklinks(repo.repoDid, 'sh.tangled.feed.star:subject.did'), 428 - getBacklinks(repo.repoDid, 'sh.tangled.feed.star:subjectDid'), 429 - ]) 430 - ).flat(), 431 - ); 432 - const stars = await hydrateBacklinks<RepoStarValue>(refs.filter((ref) => ref.collection === STAR_COLLECTION)); 433 403 const starrers = new Set<Did>(); 434 - let isStarred = false; 435 - let currentUserStarRkey: string | undefined; 404 + const backlinkDidGroups = await Promise.all(STAR_BACKLINK_SOURCES.map((source) => getBacklinkDids(repo.repoDid, source))); 436 405 437 - for (const star of stars) { 438 - if (starTargetsRepo(star.value, repo.repoDid)) { 439 - starrers.add(star.author.did); 440 - if (viewerDid && star.author.did === viewerDid) { 441 - isStarred = true; 442 - currentUserStarRkey = star.rkey; 443 - } 444 - } 406 + for (const did of backlinkDidGroups.flat()) { 407 + starrers.add(did); 445 408 } 446 409 410 + const currentUserStar = 411 + viewerDid && starrers.has(viewerDid) 412 + ? await findBacklinkRefByDid(repo.repoDid, STAR_BACKLINK_SOURCES, viewerDid) 413 + : undefined; 414 + 447 415 return { 448 416 count: starrers.size, 449 - isStarred, 450 - currentUserStarRkey, 417 + isStarred: !!currentUserStar, 418 + currentUserStarRkey: currentUserStar?.rkey, 451 419 }; 452 420 }; 453 421 ··· 642 610 return page as { records: BacklinkRecordRef[]; cursor?: string; total?: number }; 643 611 }; 644 612 613 + const getBacklinkDidsPage = async ( 614 + subject: GenericUri, 615 + source: string, 616 + limit: number, 617 + cursor?: string, 618 + ): Promise<{ linking_dids: Did[]; cursor?: string | null; total?: number }> => { 619 + const constellation = getRpc(CONSTELLATION_SERVICE); 620 + const page = await ok( 621 + constellation.get('blue.microcosm.links.getBacklinkDids', { 622 + params: { 623 + subject, 624 + source, 625 + limit, 626 + ...(cursor ? { cursor } : {}), 627 + }, 628 + }), 629 + ); 630 + 631 + return page as { linking_dids: Did[]; cursor?: string | null; total?: number }; 632 + }; 633 + 634 + const getBacklinkDids = async (subject: GenericUri, source: string): Promise<Did[]> => { 635 + const dids: Did[] = []; 636 + let cursor: string | null = null; 637 + 638 + do { 639 + const page = await getBacklinkDidsPage(subject, source, 100, cursor ?? undefined); 640 + dids.push(...page.linking_dids); 641 + cursor = page.cursor ?? null; 642 + } while (cursor); 643 + 644 + return dids; 645 + }; 646 + 647 + const findBacklinkRefByDidForSource = async ( 648 + subject: GenericUri, 649 + source: string, 650 + did: Did, 651 + ): Promise<BacklinkRecordRef | undefined> => { 652 + let cursor: string | null = null; 653 + 654 + do { 655 + const page = await getBacklinksPage(subject, source, 100, cursor ?? undefined); 656 + const ref = page.records.find((record) => record.did === did && record.collection === STAR_COLLECTION); 657 + if (ref) { 658 + return ref; 659 + } 660 + cursor = page.cursor ?? null; 661 + } while (cursor); 662 + }; 663 + 664 + const findBacklinkRefByDid = async ( 665 + subject: GenericUri, 666 + sources: string[], 667 + did: Did, 668 + ): Promise<BacklinkRecordRef | undefined> => { 669 + const refs = await Promise.all(sources.map((source) => findBacklinkRefByDidForSource(subject, source, did))); 670 + return refs.find((ref) => ref); 671 + }; 672 + 645 673 const getBacklinks = async (subject: GenericUri, source: string): Promise<BacklinkRecordRef[]> => { 646 674 const records: BacklinkRecordRef[] = []; 647 675 let cursor: string | null = null; ··· 669 697 ref: BacklinkRecordRef | { did: Did; collection: Nsid; rkey: string }, 670 698 ): Promise<HydratedRecord<T>> => { 671 699 const actor = await resolveActor(ref.did); 672 - const rpc = getRpc(actor.pds); 673 700 const record = await ok( 674 - rpc.get('com.atproto.repo.getRecord', { 701 + getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { 675 702 params: { 676 703 repo: ref.did, 677 704 collection: ref.collection, ··· 690 717 }; 691 718 }; 692 719 720 + const fetchRecordValue = async <T>( 721 + ref: BacklinkRecordRef | { did: Did; collection: Nsid; rkey: string }, 722 + ): Promise<T> => { 723 + const record = await ok( 724 + getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { 725 + params: { 726 + repo: ref.did, 727 + collection: ref.collection, 728 + rkey: ref.rkey, 729 + }, 730 + }), 731 + ); 732 + 733 + return record.value as T; 734 + }; 735 + 693 736 const hydrateBacklinks = async <T>(refs: BacklinkRecordRef[]): Promise<Array<HydratedRecord<T>>> => 694 737 Promise.all(refs.map((ref) => hydrateRecord<T>(ref))); 695 738 ··· 698 741 collection: Nsid, 699 742 rkey: string, 700 743 ): Promise<T | null> => { 701 - const rpc = getRpc(actor.pds); 702 - 703 744 try { 704 745 const record = await ok( 705 - rpc.get('com.atproto.repo.getRecord', { 746 + getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { 706 747 params: { 707 748 repo: actor.did, 708 749 collection, ··· 755 796 return 'open'; 756 797 }; 757 798 799 + const latestBacklinkRefByRkey = (refs: BacklinkRecordRef[]): BacklinkRecordRef | undefined => 800 + [...refs].sort((left, right) => left.rkey.localeCompare(right.rkey)).at(-1); 801 + 758 802 const BACKLINK_BATCH_LIMIT = 50; 759 803 760 804 const listBacklinkedRecordsPage = async <T extends { createdAt?: string }, State extends string>( ··· 766 810 const requestedEnd = options.offset + options.limit; 767 811 const items: Array<HydratedRecord<T> & { number: number; state: State }> = []; 768 812 const sources = Array.isArray(source) ? source : [source]; 813 + const batchLimit = Math.min(BACKLINK_BATCH_LIMIT, Math.max(options.limit + 1, 1)); 769 814 const cursors = new Map(sources.map((entry) => [entry, undefined as string | undefined])); 770 815 const exhausted = new Set<string>(); 771 816 const seenRefs = new Set<string>(); ··· 779 824 .filter((entry) => !exhausted.has(entry)) 780 825 .map(async (entry) => ({ 781 826 source: entry, 782 - page: await getBacklinksPage(subject, entry, BACKLINK_BATCH_LIMIT, cursors.get(entry)), 827 + page: await getBacklinksPage(subject, entry, batchLimit, cursors.get(entry)), 783 828 })), 784 829 ); 785 830 ··· 854 899 return 'open'; 855 900 } 856 901 857 - const states = await hydrateBacklinks<ShTangledRepoIssueState.Main>(refs); 858 - states.sort((left, right) => left.rkey.localeCompare(right.rkey)); 859 - return normalizeIssueState(states.at(-1)?.value.state); 902 + const state = await fetchRecordValue<ShTangledRepoIssueState.Main>(latestBacklinkRefByRkey(refs)!); 903 + return normalizeIssueState(state.state); 860 904 }; 861 905 862 906 const getLatestPullStatus = async (pullUri: ResourceUri): Promise<'open' | 'closed' | 'merged'> => { ··· 865 909 return 'open'; 866 910 } 867 911 868 - const states = await hydrateBacklinks<ShTangledRepoPullStatus.Main>(refs); 869 - states.sort((left, right) => left.rkey.localeCompare(right.rkey)); 870 - return normalizePullStatus(states.at(-1)?.value.status); 912 + const status = await fetchRecordValue<ShTangledRepoPullStatus.Main>(latestBacklinkRefByRkey(refs)!); 913 + return normalizePullStatus(status.status); 871 914 }; 872 915 873 916 export const listIssues = async (repo: RepoContext): Promise<IssueSummary[]> => {
+2 -21
src/lib/auth.tsx
··· 7 7 getSession, 8 8 listStoredSessions, 9 9 } from '@atcute/oauth-browser-client'; 10 - import { 11 - CompositeDidDocumentResolver, 12 - LocalActorResolver, 13 - PlcDidDocumentResolver, 14 - WebDidDocumentResolver, 15 - XrpcHandleResolver, 16 - } from '@atcute/identity-resolver'; 17 10 import type { ActorIdentifier, Did } from '@atcute/lexicons/syntax'; 18 11 import type { Component, JSX } from 'solid-js'; 19 12 import { createContext, createSignal, onMount, useContext } from 'solid-js'; 20 13 21 - import { PUBLIC_API_SERVICE, TANGLED_OAUTH_SCOPE } from './api'; 14 + import { TANGLED_OAUTH_SCOPE, identityResolver } from './api'; 22 15 23 16 const CURRENT_DID_KEY = 'untangled.currentDid'; 24 17 25 - const oauthIdentityResolver = new LocalActorResolver({ 26 - handleResolver: new XrpcHandleResolver({ 27 - serviceUrl: PUBLIC_API_SERVICE, 28 - }), 29 - didDocumentResolver: new CompositeDidDocumentResolver({ 30 - methods: { 31 - plc: new PlcDidDocumentResolver(), 32 - web: new WebDidDocumentResolver(), 33 - }, 34 - }), 35 - }); 36 - 37 18 configureOAuth({ 38 19 metadata: { 39 20 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 40 21 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URI, 41 22 }, 42 - identityResolver: oauthIdentityResolver, 23 + identityResolver, 43 24 }); 44 25 45 26 export interface AuthContextValue {
+309 -152
src/pages/repo/code.tsx
··· 1 1 import clsx from 'clsx'; 2 2 import { Download, File, Folder, GitBranch, GitCommitHorizontal, List } from 'lucide-solid'; 3 3 import { A, useNavigate, useParams } from '@solidjs/router'; 4 - import { createQuery, keepPreviousData } from '@tanstack/solid-query'; 5 - import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; 4 + import { createQuery, keepPreviousData, useQueryClient } from '@tanstack/solid-query'; 5 + import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, type Component } from 'solid-js'; 6 6 import { buildBlobDataUrl, decodeBlobText, getRepoBlob, getRepoBranches, getRepoDefaultBranch, getRepoLanguages, getRepoLog, getRepoTags, getRepoTree } from '../../lib/api'; 7 7 import { Avatar, ErrorState, PlaceholderAvatar, buttonStyles, cardStyles } from '../../components/common'; 8 - import { CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoBlobSkeleton, RepoTreeSkeleton } from '../../components/repo'; 8 + import { CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoTreeSkeleton } from '../../components/repo'; 9 9 import { RepoFrame, useRepoQuery } from './shared'; 10 10 import { blobHref, countLines, decodeRoutePath, formatBytes, formatLanguagePercent, formatRelativeTime, getErrorMessage, getParentPath, imageLike, isDirectory, joinPath, languageColor, markdownLike, safeDecode, sortedTreeEntries, svgLike, treeHref, videoLike } from '../../lib/repo-utils'; 11 11 ··· 53 53 })) 54 54 .sort((left, right) => right.size - left.size || left.name.localeCompare(right.name)); 55 55 }; 56 + 57 + const LOADING_DELAY_MS = 100; 58 + 59 + const useDelayedLoading = (pending: () => boolean, delayMs = LOADING_DELAY_MS) => { 60 + const [visible, setVisible] = createSignal(false); 61 + let timer: ReturnType<typeof setTimeout> | undefined; 62 + 63 + createEffect(() => { 64 + if (pending()) { 65 + if (timer === undefined) { 66 + timer = setTimeout(() => { 67 + timer = undefined; 68 + setVisible(true); 69 + }, delayMs); 70 + } 71 + return; 72 + } 73 + 74 + if (timer !== undefined) { 75 + clearTimeout(timer); 76 + timer = undefined; 77 + } 78 + setVisible(false); 79 + }); 80 + 81 + onCleanup(() => { 82 + if (timer !== undefined) clearTimeout(timer); 83 + }); 84 + 85 + return visible; 86 + }; 87 + 88 + const PathBreadcrumbs: Component<{ 89 + repo: Parameters<typeof treeHref>[0]; 90 + refName: string; 91 + path: string; 92 + withDivider?: boolean; 93 + }> = (props) => { 94 + const segments = createMemo(() => props.path.split('/').filter(Boolean)); 95 + 96 + return ( 97 + <div class={clsx('text-base', props.withDivider && 'pb-2 mb-3 border-b border-gray-200 dark:border-gray-700')}> 98 + <div class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> 99 + <A href={treeHref(props.repo, props.refName)} class="text-gray-500 dark:text-gray-400 hover:underline"> 100 + {props.repo.slug} 101 + </A> 102 + <For each={segments()}> 103 + {(segment, index) => ( 104 + <> 105 + <span class="px-1">/</span> 106 + <Show 107 + when={index() < segments().length - 1} 108 + fallback={<span class="text-gray-900 dark:text-gray-100">{segment}</span>} 109 + > 110 + <A 111 + href={treeHref(props.repo, props.refName, segments().slice(0, index() + 1).join('/'))} 112 + class="text-gray-500 dark:text-gray-400 hover:underline" 113 + > 114 + {segment} 115 + </A> 116 + </Show> 117 + </> 118 + )} 119 + </For> 120 + </div> 121 + </div> 122 + ); 123 + }; 124 + 125 + const LoadingFilePath: Component<{ 126 + repo: Parameters<typeof treeHref>[0]; 127 + refName: string; 128 + path: string; 129 + }> = (props) => { 130 + const segments = createMemo(() => props.path.split('/').filter(Boolean)); 131 + const filename = createMemo(() => segments().at(-1) ?? props.path); 132 + 133 + return ( 134 + <div class="untangled-blob-card"> 135 + <div class="untangled-blob-header pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 136 + <div class="flex flex-col md:flex-row md:justify-between gap-2"> 137 + <PathBreadcrumbs repo={props.repo} refName={props.refName} path={props.path} /> 138 + <div class="untangled-blob-file-info text-gray-500 md:text-sm flex flex-wrap md:gap-0 items-center"> 139 + <span> 140 + at <A href={treeHref(props.repo, props.refName)}>{props.refName}</A> 141 + </span> 142 + </div> 143 + </div> 144 + </div> 145 + <div class="space-y-3"> 146 + <OverviewFileRow 147 + name={filename()} 148 + href={blobHref(props.repo, props.refName, props.path)} 149 + icon={<File class="size-4 flex-shrink-0" />} 150 + class="untangled-file-row-loading" 151 + /> 152 + </div> 153 + </div> 154 + ); 155 + }; 156 + 157 + const shouldAnimateNavigation = (event: MouseEvent) => 158 + event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey && !event.defaultPrevented; 56 159 57 160 const RepoCodePageLegacy: Component = () => { 58 161 const params = useParams(); ··· 314 417 export const RepoCodePage: Component = () => { 315 418 const params = useParams(); 316 419 const navigate = useNavigate(); 420 + const queryClient = useQueryClient(); 317 421 const repoQuery = useRepoQuery(); 422 + const [pendingTreeHref, setPendingTreeHref] = createSignal<string>(); 423 + let navigationAttempt = 0; 318 424 const routeRef = createMemo(() => safeDecode(params.ref ?? 'HEAD')); 319 425 const routePath = createMemo(() => decodeRoutePath(params.path)); 320 426 ··· 395 501 const overviewReady = createMemo(() => 396 502 Boolean(repoQuery.data && treeQuery.data && branchesQuery.data && tagsQuery.data && logQuery.data), 397 503 ); 504 + const showOverviewSkeleton = useDelayedLoading(() => !overviewReady() && !overviewError()); 505 + 506 + createEffect(() => { 507 + if (!treeQuery.isFetching) setPendingTreeHref(undefined); 508 + }); 509 + 510 + const handleFileClick = ( 511 + event: MouseEvent, 512 + href: string, 513 + repo: Parameters<typeof getRepoBlob>[0], 514 + refName: string, 515 + filePath: string, 516 + ) => { 517 + if (!shouldAnimateNavigation(event)) return; 518 + 519 + event.preventDefault(); 520 + const attempt = ++navigationAttempt; 521 + setPendingTreeHref(href); 522 + 523 + const delay = new Promise<'delay'>((resolve) => { 524 + setTimeout(() => resolve('delay'), LOADING_DELAY_MS); 525 + }); 526 + const load = queryClient 527 + .ensureQueryData({ 528 + queryKey: ['blob', repo.repoDid, refName, filePath], 529 + queryFn: async () => getRepoBlob(repo, refName, filePath), 530 + }) 531 + .then( 532 + () => 'loaded' as const, 533 + () => 'loaded' as const, 534 + ); 535 + 536 + void Promise.race([delay, load]).then((result) => { 537 + if (attempt !== navigationAttempt) return; 538 + if (result === 'loaded') setPendingTreeHref(undefined); 539 + navigate(href); 540 + }); 541 + }; 398 542 399 543 return ( 400 544 <RepoFrame active="code"> 401 545 <Switch> 402 - <Match when={!overviewReady() && !overviewError()}> 546 + <Match when={!overviewReady() && !overviewError() && showOverviewSkeleton()}> 403 547 <RepoTreeSkeleton /> 404 548 </Match> 405 549 <Match when={overviewError()}> ··· 419 563 const currentDefaultBranch = defaultBranch() ?? ref(); 420 564 const activeRef = ref() === 'HEAD' ? currentDefaultBranch : ref(); 421 565 const activeRefInOptions = hasNamedRef(activeRef, branches, tags); 422 - const treeRef = treeData.ref === 'HEAD' ? currentDefaultBranch : treeData.ref; 566 + const treeRef = treeData.ref === 'HEAD' ? currentDefaultBranch : treeData.ref; 423 567 const treePath = treeData.path; 424 568 const parentPath = tree.dotdot ?? getParentPath(treePath); 425 569 const sortedFiles = sortedTreeEntries(tree.files ?? []); 570 + const isNestedTreePath = treePath.length > 0; 426 571 427 572 return ( 428 - <> 573 + <> 429 574 <Show when={languages.length > 0}> 430 575 <details class="group"> 431 576 <summary class="untangled-language-bar"> ··· 505 650 <a href={treeHref(repo, activeRef, path())} class={clsx(buttonStyles('primary'), 'bg-green-700 border-green-700 hover:bg-green-800 hover:border-green-800')}> 506 651 <Download class="size-4" /> 507 652 code 508 - </a> 509 - </div> 653 + </a> 654 + </div> 655 + 656 + <Show when={isNestedTreePath}> 657 + <div class="px-6"> 658 + <PathBreadcrumbs repo={repo} refName={treeRef} path={treePath} withDivider /> 659 + </div> 660 + </Show> 510 661 511 - <div class="untangled-repo-overview px-6 py-4"> 512 - <div class="untangled-file-tree-pane min-w-0 pr-0 md:pr-6"> 513 - <div class="space-y-3"> 514 - <Show when={parentPath !== null}> 515 - <OverviewFileRow 516 - name=".." 662 + <div class={clsx('untangled-repo-overview px-6 py-4', isNestedTreePath && 'untangled-repo-overview--path')}> 663 + <div class="untangled-file-tree-pane min-w-0 pr-0 md:pr-6"> 664 + <div class="space-y-3"> 665 + <Show when={parentPath !== null}> 666 + <OverviewFileRow 667 + name=".." 517 668 href={treeHref(repo, treeRef, parentPath ?? '')} 518 - icon={<Folder class="size-4 flex-shrink-0 fill-current" />} 519 - /> 520 - </Show> 521 - <For each={sortedFiles}> 522 - {(entry) => ( 523 - <OverviewFileRow 524 - name={entry.name} 525 - href={ 526 - isDirectory(entry) 527 - ? treeHref(repo, treeRef, joinPath(treePath, entry.name)) 528 - : blobHref(repo, treeRef, joinPath(treePath, entry.name)) 529 - } 530 - icon={ 531 - isDirectory(entry) ? ( 532 - <Folder class="size-4 flex-shrink-0 fill-current" /> 533 - ) : ( 534 - <File class="size-4 flex-shrink-0" /> 535 - ) 536 - } 537 - lastCommit={entry.last_commit} 669 + icon={<Folder class="size-4 flex-shrink-0 fill-current" />} 670 + class={pendingTreeHref() === treeHref(repo, treeRef, parentPath ?? '') ? 'untangled-file-row-loading' : undefined} 671 + onClick={(event) => { 672 + if (shouldAnimateNavigation(event)) { 673 + navigationAttempt += 1; 674 + setPendingTreeHref(treeHref(repo, treeRef, parentPath ?? '')); 675 + } 676 + }} 538 677 /> 539 - )} 540 - </For> 678 + </Show> 679 + <For each={sortedFiles}> 680 + {(entry) => { 681 + const entryPath = joinPath(treePath, entry.name); 682 + const directory = isDirectory(entry); 683 + const href = directory ? treeHref(repo, treeRef, entryPath) : blobHref(repo, treeRef, entryPath); 684 + 685 + return ( 686 + <OverviewFileRow 687 + name={entry.name} 688 + href={href} 689 + icon={ 690 + directory ? ( 691 + <Folder class="size-4 flex-shrink-0 fill-current" /> 692 + ) : ( 693 + <File class="size-4 flex-shrink-0" /> 694 + ) 695 + } 696 + lastCommit={entry.last_commit} 697 + class={pendingTreeHref() === href ? 'untangled-file-row-loading' : undefined} 698 + onClick={(event) => { 699 + if (directory) { 700 + if (shouldAnimateNavigation(event)) { 701 + navigationAttempt += 1; 702 + setPendingTreeHref(href); 703 + } 704 + return; 705 + } 706 + 707 + handleFileClick(event, href, repo, treeRef, entryPath); 708 + }} 709 + /> 710 + ); 711 + }} 712 + </For> 713 + </div> 541 714 </div> 542 - </div> 543 715 544 - <div class="min-w-0 md:pt-0 md:pl-6 border-gray-100 dark:border-gray-700 md:border-t-0 md:border-l"> 545 - <div class="flex items-center gap-2 pb-3 font-bold"> 546 - <List class="size-4" /> 547 - <span>commits</span> 548 - <span class="rounded bg-gray-200 px-1 py-0.5 text-sm font-normal dark:bg-gray-700"> 549 - {logQuery.data?.total ?? commits.length} 550 - </span> 551 - </div> 552 - <div class="space-y-6"> 553 - <For each={commits.slice(0, 6)}> 554 - {(commit) => ( 555 - <div> 556 - <div id="commit-message"> 557 - <div class="text-base cursor-pointer"> 558 - <div><div> 559 - <a 560 - href={`/${repo.owner.handle}/${repo.slug}/commit/${commit.this}`} 561 - class="inline no-underline hover:underline dark:text-white" 562 - > 563 - {commit.message.trim().split('\n')[0]} 564 - </a> 565 - </div></div> 716 + <Show when={!isNestedTreePath}> 717 + <div class="min-w-0 md:pt-0 md:pl-6 border-gray-100 dark:border-gray-700 md:border-t-0 md:border-l"> 718 + <div class="flex items-center gap-2 pb-3 font-bold"> 719 + <List class="size-4" /> 720 + <span>commits</span> 721 + <span class="rounded bg-gray-200 px-1 py-0.5 text-sm font-normal dark:bg-gray-700"> 722 + {logQuery.data?.total ?? commits.length} 723 + </span> 724 + </div> 725 + <div class="space-y-6"> 726 + <For each={commits.slice(0, 6)}> 727 + {(commit) => ( 728 + <div> 729 + <div id="commit-message"> 730 + <div class="text-base cursor-pointer"> 731 + <div><div> 732 + <a 733 + href={`/${repo.owner.handle}/${repo.slug}/commit/${commit.this}`} 734 + class="inline no-underline hover:underline dark:text-white" 735 + > 736 + {commit.message.trim().split('\n')[0]} 737 + </a> 738 + </div></div> 739 + </div> 740 + </div> 741 + <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center flex-wrap"> 742 + <span class="font-mono"> 743 + <a 744 + href={`/${repo.owner.handle}/${repo.slug}/commit/${commit.this}`} 745 + class="no-underline hover:underline bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 px-2 py-1 rounded flex items-center gap-2" 746 + > 747 + {commit.this.slice(0, 8)} 748 + <GitCommitHorizontal class="w-3 h-3" /> 749 + </a> 750 + </span> 751 + <span class="mx-1 before:content-['·'] before:select-none" /> 752 + <span class="flex items-center gap-1"> 753 + <Show 754 + when={commit.author?.Email?.startsWith('did:') ? commit.author.Email : undefined} 755 + fallback={<PlaceholderAvatar size="size-6" iconSize="size-4" />} 756 + > 757 + {(did) => <Avatar did={did()} size="size-6" />} 758 + </Show> 759 + <span>{commit.author?.Name || commit.committer?.Name || 'unknown author'}</span> 760 + </span> 761 + <div class="inline-block px-1 select-none after:content-['·']" /> 762 + <time datetime={commit.committer?.When || commit.author?.When || ''}> 763 + {formatRelativeTime(commit.committer?.When || commit.author?.When || '')} 764 + </time> 765 + <Show when={activeRef === currentDefaultBranch}> 766 + <div class="inline-block px-1 select-none after:content-['·']" /> 767 + <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-[2px] inline-flex items-center"> 768 + {currentDefaultBranch} 769 + </span> 770 + </Show> 771 + </div> 566 772 </div> 567 - </div> 568 - <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center flex-wrap"> 569 - <span class="font-mono"> 570 - <a 571 - href={`/${repo.owner.handle}/${repo.slug}/commit/${commit.this}`} 572 - class="no-underline hover:underline bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 px-2 py-1 rounded flex items-center gap-2" 573 - > 574 - {commit.this.slice(0, 8)} 575 - <GitCommitHorizontal class="w-3 h-3" /> 576 - </a> 577 - </span> 578 - <span class="mx-1 before:content-['·'] before:select-none" /> 579 - <span class="flex items-center gap-1"> 580 - <Show 581 - when={commit.author?.Email?.startsWith('did:') ? commit.author.Email : undefined} 582 - fallback={<PlaceholderAvatar size="size-6" iconSize="size-4" />} 583 - > 584 - {(did) => <Avatar did={did()} size="size-6" />} 585 - </Show> 586 - <span>{commit.author?.Name || commit.committer?.Name || 'unknown author'}</span> 587 - </span> 588 - <div class="inline-block px-1 select-none after:content-['·']" /> 589 - <time datetime={commit.committer?.When || commit.author?.When || ''}> 590 - {formatRelativeTime(commit.committer?.When || commit.author?.When || '')} 591 - </time> 592 - <Show when={activeRef === currentDefaultBranch}> 593 - <div class="inline-block px-1 select-none after:content-['·']" /> 594 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-[2px] inline-flex items-center"> 595 - {currentDefaultBranch} 596 - </span> 597 - </Show> 598 - </div> 599 - </div> 600 - )} 601 - </For> 602 - </div> 773 + )} 774 + </For> 775 + </div> 603 776 604 - <div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700"> 605 - <div class="flex items-center gap-2 pb-2 font-bold"> 606 - <GitBranch class="size-4" /> 607 - <span>branches</span> 608 - <span class="rounded bg-gray-200 px-1 py-0.5 text-sm font-normal dark:bg-gray-700"> 609 - {branches.length} 610 - </span> 611 - </div> 612 - <div class="space-y-1 text-sm"> 613 - <For each={branches.slice(0, 3)}> 614 - {(branch) => ( 615 - <div class="flex items-center justify-between gap-2"> 616 - <span> 617 - {branch.reference.name} 618 - <Show when={branch.is_default}> 619 - <span class="ml-2 rounded bg-gray-200 px-1 py-0.5 font-mono text-xs dark:bg-gray-700"> 620 - default 777 + <div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700"> 778 + <div class="flex items-center gap-2 pb-2 font-bold"> 779 + <GitBranch class="size-4" /> 780 + <span>branches</span> 781 + <span class="rounded bg-gray-200 px-1 py-0.5 text-sm font-normal dark:bg-gray-700"> 782 + {branches.length} 783 + </span> 784 + </div> 785 + <div class="space-y-1 text-sm"> 786 + <For each={branches.slice(0, 3)}> 787 + {(branch) => ( 788 + <div class="flex items-center justify-between gap-2"> 789 + <span> 790 + {branch.reference.name} 791 + <Show when={branch.is_default}> 792 + <span class="ml-2 rounded bg-gray-200 px-1 py-0.5 font-mono text-xs dark:bg-gray-700"> 793 + default 794 + </span> 795 + </Show> 621 796 </span> 622 - </Show> 623 - </span> 624 - <span class="text-xs text-gray-500 dark:text-gray-400"> 625 - {branch.commit?.Committer?.When ? formatRelativeTime(branch.commit.Committer.When) : ''} 626 - </span> 627 - </div> 628 - )} 629 - </For> 797 + <span class="text-xs text-gray-500 dark:text-gray-400"> 798 + {branch.commit?.Committer?.When ? formatRelativeTime(branch.commit.Committer.When) : ''} 799 + </span> 800 + </div> 801 + )} 802 + </For> 803 + </div> 804 + </div> 630 805 </div> 631 - </div> 806 + </Show> 632 807 </div> 633 808 </div> 634 - </div> 635 809 636 810 <Show when={tree.readme}> 637 811 <ReadmeCard filename={tree.readme!.filename} markdown={tree.readme!.contents} /> ··· 671 845 return ( 672 846 <RepoFrame active="code"> 673 847 <Switch> 674 - <Match when={blobQuery.isLoading}> 675 - <RepoBlobSkeleton /> 848 + <Match when={blobQuery.isLoading && repoQuery.data}> 849 + <LoadingFilePath repo={repoQuery.data!} refName={ref()} path={path()} /> 676 850 </Match> 677 851 <Match when={blobQuery.error}> 678 852 <div class={cardStyles('p-6')}> ··· 690 864 const isSvg = createMemo(() => svgLike(path(), blob.mimeType)); 691 865 const isVideo = createMemo(() => videoLike(path(), blob.mimeType)); 692 866 const isImage = createMemo(() => imageLike(path(), blob.mimeType)); 693 - const showingText = createMemo( 694 - () => blob.content !== undefined && !blob.fileTooLarge && (!blob.isBinary || isSvg()), 695 - ); 696 - const lineCount = createMemo(() => countLines(text())); 697 - const segments = path().split('/'); 867 + const showingText = createMemo( 868 + () => blob.content !== undefined && !blob.fileTooLarge && (!blob.isBinary || isSvg()), 869 + ); 870 + const lineCount = createMemo(() => countLines(text())); 698 871 699 872 return ( 700 873 <div class="untangled-blob-card peer"> 701 874 <div class="untangled-blob-header pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 702 875 <div class="flex flex-col md:flex-row md:justify-between gap-2"> 703 - <div class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> 704 - <A href={treeHref(repo, ref())} class="text-gray-500 dark:text-gray-400 hover:underline"> 705 - {repo.slug} 706 - </A> 707 - <For each={segments}> 708 - {(segment, index) => ( 709 - <> 710 - <span class="px-1">/</span> 711 - <Show 712 - when={index() < segments.length - 1} 713 - fallback={<span class="text-gray-900 dark:text-gray-100">{segment}</span>} 714 - > 715 - <A 716 - href={treeHref(repo, ref(), segments.slice(0, index() + 1).join('/'))} 717 - class="text-gray-500 dark:text-gray-400 hover:underline" 718 - > 719 - {segment} 720 - </A> 721 - </Show> 722 - </> 723 - )} 724 - </For> 725 - </div> 876 + <PathBreadcrumbs repo={repo} refName={ref()} path={path()} /> 726 877 727 878 <div class="untangled-blob-file-info text-gray-500 md:text-sm flex flex-wrap md:gap-0 items-center"> 728 879 <span>at <A href={treeHref(repo, ref())}>{ref()}</A></span> ··· 767 918 {(commit) => ( 768 919 <div class="untangled-blob-commit-panel"> 769 920 <div class="flex min-w-0 flex-wrap items-center gap-1"> 921 + <Show 922 + when={commit().author?.email?.startsWith('did:') ? commit().author!.email : undefined} 923 + fallback={<PlaceholderAvatar size="size-6" iconSize="size-4" />} 924 + > 925 + {(did) => <Avatar did={did()} size="size-6" />} 926 + </Show> 770 927 <span class="font-medium text-gray-100"> 771 928 {commit().author?.name || 'unknown author'} 772 929 </span>