From 5de3a0d98e7d1279b360347f2d538bcfa3c8073d Mon Sep 17 00:00:00 2001 From: dawn <90008@gaze.systems> Date: Mon, 18 May 2026 18:02:08 +0300 Subject: [PATCH] improve file tree navigation, use slingshot, optimize issue / pr fetching perf --- src/components/repo.tsx | 4 +- src/index.css | 33 +++ src/lib/api.ts | 207 +++++++++++------- src/lib/auth.tsx | 23 +- src/pages/repo/code.tsx | 461 +++++++++++++++++++++++++++------------- 5 files changed, 472 insertions(+), 256 deletions(-) diff --git a/src/components/repo.tsx b/src/components/repo.tsx index 6a2cb48..b04d757 100644 --- a/src/components/repo.tsx +++ b/src/components/repo.tsx @@ -340,8 +340,10 @@ export const OverviewFileRow: Component<{ href: string; icon: JSX.Element; lastCommit?: TreeEntry['last_commit']; + class?: string; + onClick?: JSX.EventHandlerUnion; }> = (props) => ( - +
{props.icon} {props.name} diff --git a/src/index.css b/src/index.css index 4e57cfd..710908d 100644 --- a/src/index.css +++ b/src/index.css @@ -111,12 +111,20 @@ pre { .untangled-skeleton::after { background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.08), transparent); } + + .untangled-file-row-loading { + background: rgb(255 255 255 / 0.06); + } } @media (prefers-reduced-motion: reduce) { .untangled-skeleton::after { animation: none; } + + .untangled-file-row-loading { + animation: none; + } } @keyframes untangled-skeleton-shimmer { @@ -125,6 +133,17 @@ pre { } } +@keyframes untangled-file-row-breathe { + 0%, + 100% { + opacity: 0.88; + } + + 50% { + opacity: 1; + } +} + .untangled-skeleton-file-row { align-items: center; display: grid; @@ -158,11 +177,19 @@ pre { gap: 0.5rem; } +.untangled-repo-overview--path { + grid-template-columns: minmax(0, 1fr); +} .untangled-file-tree-pane { border-right: 1px solid rgb(55 65 81); } +.untangled-repo-overview--path .untangled-file-tree-pane { + border-right: 0; + padding-right: 0; +} + .untangled-language-bar { display: flex; height: 1rem; @@ -309,6 +336,12 @@ details[open] > .untangled-language-bar { text-decoration: none; } +.untangled-file-row-loading { + animation: untangled-file-row-breathe 1.1s ease-in-out infinite; + background: rgb(17 24 39 / 0.035); + border-radius: 0.25rem; +} + .untangled-file-row-time { text-align: right; } diff --git a/src/lib/api.ts b/src/lib/api.ts index ca295f9..332a71e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,11 +1,8 @@ import { Client, ClientResponseError, ok, simpleFetchHandler } from '@atcute/client'; import { - CompositeDidDocumentResolver, - LocalActorResolver, - PlcDidDocumentResolver, + type ActorResolver, + type ResolveActorOptions, type ResolvedActor, - WebDidDocumentResolver, - XrpcHandleResolver, } from '@atcute/identity-resolver'; import type { ActorIdentifier, @@ -34,6 +31,7 @@ import type {} from '@atcute/tangled'; export const PUBLIC_API_SERVICE = 'https://public.api.bsky.app'; export const CONSTELLATION_SERVICE = 'https://constellation.microcosm.blue'; +export const SLINGSHOT_SERVICE = 'https://slingshot.microcosm.blue'; export const SPACEDUST_SERVICE = 'wss://spacedust.microcosm.blue/subscribe'; export const TANGLED_OAUTH_SCOPE = import.meta.env.VITE_OAUTH_SCOPE; @@ -49,17 +47,26 @@ const PULL_COLLECTION: Nsid = 'sh.tangled.repo.pull'; const PULL_COMMENT_COLLECTION: Nsid = 'sh.tangled.repo.pull.comment'; const PULL_STATUS_COLLECTION: Nsid = 'sh.tangled.repo.pull.status'; -export const identityResolver = new LocalActorResolver({ - handleResolver: new XrpcHandleResolver({ - serviceUrl: PUBLIC_API_SERVICE, - }), - didDocumentResolver: new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver(), - web: new WebDidDocumentResolver(), - }, - }), -}); +class SlingshotActorResolver implements ActorResolver { + async resolve(actor: ActorIdentifier, options?: ResolveActorOptions): Promise { + const resolved = await ok( + getRpc(SLINGSHOT_SERVICE).get('blue.microcosm.identity.resolveMiniDoc', { + params: { + identifier: actor, + }, + signal: options?.signal, + }), + ); + + return { + did: resolved.did, + handle: resolved.handle, + pds: normalizeServiceUrl(resolved.pds), + }; + } +} + +export const identityResolver = new SlingshotActorResolver(); export interface RepoContext { owner: ResolvedActor; @@ -381,73 +388,34 @@ export const listRepoRecords = async (owner: string | ResolvedActor): Promise { - const seen = new Set(); - const unique: BacklinkRecordRef[] = []; - - for (const ref of refs) { - const key = `${ref.did}/${ref.collection}/${ref.rkey}`; - if (seen.has(key)) { - continue; - } - seen.add(key); - unique.push(ref); - } - - return unique; -}; - -const starTargetsRepo = (star: RepoStarValue, repoDid: Did): boolean => { - if (star.subjectDid === repoDid) { - return true; - } - - const subject = star.subject; - return subject?.$type === 'sh.tangled.feed.star#repo' && subject.did === repoDid; -}; +const STAR_BACKLINK_SOURCES = [ + 'sh.tangled.feed.star:subject.did', + 'sh.tangled.feed.star:subjectDid', +]; export const getRepoStarSummary = async (repo: RepoContext, viewerDid?: Did | null): Promise => { - const refs = uniqueBacklinkRefs( - ( - await Promise.all([ - getBacklinks(repo.repoDid, 'sh.tangled.feed.star:subject.did'), - getBacklinks(repo.repoDid, 'sh.tangled.feed.star:subjectDid'), - ]) - ).flat(), - ); - const stars = await hydrateBacklinks(refs.filter((ref) => ref.collection === STAR_COLLECTION)); const starrers = new Set(); - let isStarred = false; - let currentUserStarRkey: string | undefined; - - for (const star of stars) { - if (starTargetsRepo(star.value, repo.repoDid)) { - starrers.add(star.author.did); - if (viewerDid && star.author.did === viewerDid) { - isStarred = true; - currentUserStarRkey = star.rkey; - } - } + const backlinkDidGroups = await Promise.all(STAR_BACKLINK_SOURCES.map((source) => getBacklinkDids(repo.repoDid, source))); + + for (const did of backlinkDidGroups.flat()) { + starrers.add(did); } + const currentUserStar = + viewerDid && starrers.has(viewerDid) + ? await findBacklinkRefByDid(repo.repoDid, STAR_BACKLINK_SOURCES, viewerDid) + : undefined; + return { count: starrers.size, - isStarred, - currentUserStarRkey, + isStarred: !!currentUserStar, + currentUserStarRkey: currentUserStar?.rkey, }; }; @@ -642,6 +610,66 @@ const getBacklinksPage = async ( return page as { records: BacklinkRecordRef[]; cursor?: string; total?: number }; }; +const getBacklinkDidsPage = async ( + subject: GenericUri, + source: string, + limit: number, + cursor?: string, +): Promise<{ linking_dids: Did[]; cursor?: string | null; total?: number }> => { + const constellation = getRpc(CONSTELLATION_SERVICE); + const page = await ok( + constellation.get('blue.microcosm.links.getBacklinkDids', { + params: { + subject, + source, + limit, + ...(cursor ? { cursor } : {}), + }, + }), + ); + + return page as { linking_dids: Did[]; cursor?: string | null; total?: number }; +}; + +const getBacklinkDids = async (subject: GenericUri, source: string): Promise => { + const dids: Did[] = []; + let cursor: string | null = null; + + do { + const page = await getBacklinkDidsPage(subject, source, 100, cursor ?? undefined); + dids.push(...page.linking_dids); + cursor = page.cursor ?? null; + } while (cursor); + + return dids; +}; + +const findBacklinkRefByDidForSource = async ( + subject: GenericUri, + source: string, + did: Did, +): Promise => { + let cursor: string | null = null; + + do { + const page = await getBacklinksPage(subject, source, 100, cursor ?? undefined); + const ref = page.records.find((record) => record.did === did && record.collection === STAR_COLLECTION); + if (ref) { + return ref; + } + cursor = page.cursor ?? null; + } while (cursor); +}; + +const findBacklinkRefByDid = async ( + subject: GenericUri, + sources: string[], + did: Did, +): Promise => { + const refs = await Promise.all(sources.map((source) => findBacklinkRefByDidForSource(subject, source, did))); + return refs.find((ref) => ref); +}; + const getBacklinks = async (subject: GenericUri, source: string): Promise => { const records: BacklinkRecordRef[] = []; let cursor: string | null = null; @@ -669,9 +697,8 @@ const hydrateRecord = async ( ref: BacklinkRecordRef | { did: Did; collection: Nsid; rkey: string }, ): Promise> => { const actor = await resolveActor(ref.did); - const rpc = getRpc(actor.pds); const record = await ok( - rpc.get('com.atproto.repo.getRecord', { + getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { params: { repo: ref.did, collection: ref.collection, @@ -690,6 +717,22 @@ const hydrateRecord = async ( }; }; +const fetchRecordValue = async ( + ref: BacklinkRecordRef | { did: Did; collection: Nsid; rkey: string }, +): Promise => { + const record = await ok( + getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { + params: { + repo: ref.did, + collection: ref.collection, + rkey: ref.rkey, + }, + }), + ); + + return record.value as T; +}; + const hydrateBacklinks = async (refs: BacklinkRecordRef[]): Promise>> => Promise.all(refs.map((ref) => hydrateRecord(ref))); @@ -698,11 +741,9 @@ const getOptionalRecord = async ( collection: Nsid, rkey: string, ): Promise => { - const rpc = getRpc(actor.pds); - try { const record = await ok( - rpc.get('com.atproto.repo.getRecord', { + getRpc(SLINGSHOT_SERVICE).get('com.atproto.repo.getRecord', { params: { repo: actor.did, collection, @@ -755,6 +796,9 @@ const normalizePullStatus = (value?: string): 'open' | 'closed' | 'merged' => { return 'open'; }; +const latestBacklinkRefByRkey = (refs: BacklinkRecordRef[]): BacklinkRecordRef | undefined => + [...refs].sort((left, right) => left.rkey.localeCompare(right.rkey)).at(-1); + const BACKLINK_BATCH_LIMIT = 50; const listBacklinkedRecordsPage = async ( @@ -766,6 +810,7 @@ const listBacklinkedRecordsPage = async & { number: number; state: State }> = []; const sources = Array.isArray(source) ? source : [source]; + const batchLimit = Math.min(BACKLINK_BATCH_LIMIT, Math.max(options.limit + 1, 1)); const cursors = new Map(sources.map((entry) => [entry, undefined as string | undefined])); const exhausted = new Set(); const seenRefs = new Set(); @@ -779,7 +824,7 @@ const listBacklinkedRecordsPage = async !exhausted.has(entry)) .map(async (entry) => ({ source: entry, - page: await getBacklinksPage(subject, entry, BACKLINK_BATCH_LIMIT, cursors.get(entry)), + page: await getBacklinksPage(subject, entry, batchLimit, cursors.get(entry)), })), ); @@ -854,9 +899,8 @@ const getLatestIssueState = async (issueUri: ResourceUri): Promise<'open' | 'clo return 'open'; } - const states = await hydrateBacklinks(refs); - states.sort((left, right) => left.rkey.localeCompare(right.rkey)); - return normalizeIssueState(states.at(-1)?.value.state); + const state = await fetchRecordValue(latestBacklinkRefByRkey(refs)!); + return normalizeIssueState(state.state); }; const getLatestPullStatus = async (pullUri: ResourceUri): Promise<'open' | 'closed' | 'merged'> => { @@ -865,9 +909,8 @@ const getLatestPullStatus = async (pullUri: ResourceUri): Promise<'open' | 'clos return 'open'; } - const states = await hydrateBacklinks(refs); - states.sort((left, right) => left.rkey.localeCompare(right.rkey)); - return normalizePullStatus(states.at(-1)?.value.status); + const status = await fetchRecordValue(latestBacklinkRefByRkey(refs)!); + return normalizePullStatus(status.status); }; export const listIssues = async (repo: RepoContext): Promise => { diff --git a/src/lib/auth.tsx b/src/lib/auth.tsx index 5f96b75..ed178d4 100644 --- a/src/lib/auth.tsx +++ b/src/lib/auth.tsx @@ -7,39 +7,20 @@ import { getSession, listStoredSessions, } from '@atcute/oauth-browser-client'; -import { - CompositeDidDocumentResolver, - LocalActorResolver, - PlcDidDocumentResolver, - WebDidDocumentResolver, - XrpcHandleResolver, -} from '@atcute/identity-resolver'; import type { ActorIdentifier, Did } from '@atcute/lexicons/syntax'; import type { Component, JSX } from 'solid-js'; import { createContext, createSignal, onMount, useContext } from 'solid-js'; -import { PUBLIC_API_SERVICE, TANGLED_OAUTH_SCOPE } from './api'; +import { TANGLED_OAUTH_SCOPE, identityResolver } from './api'; const CURRENT_DID_KEY = 'untangled.currentDid'; -const oauthIdentityResolver = new LocalActorResolver({ - handleResolver: new XrpcHandleResolver({ - serviceUrl: PUBLIC_API_SERVICE, - }), - didDocumentResolver: new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver(), - web: new WebDidDocumentResolver(), - }, - }), -}); - configureOAuth({ metadata: { client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URI, }, - identityResolver: oauthIdentityResolver, + identityResolver, }); export interface AuthContextValue { diff --git a/src/pages/repo/code.tsx b/src/pages/repo/code.tsx index 61a7502..bfbd1b5 100644 --- a/src/pages/repo/code.tsx +++ b/src/pages/repo/code.tsx @@ -1,11 +1,11 @@ import clsx from 'clsx'; import { Download, File, Folder, GitBranch, GitCommitHorizontal, List } from 'lucide-solid'; import { A, useNavigate, useParams } from '@solidjs/router'; -import { createQuery, keepPreviousData } from '@tanstack/solid-query'; -import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; +import { createQuery, keepPreviousData, useQueryClient } from '@tanstack/solid-query'; +import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, type Component } from 'solid-js'; import { buildBlobDataUrl, decodeBlobText, getRepoBlob, getRepoBranches, getRepoDefaultBranch, getRepoLanguages, getRepoLog, getRepoTags, getRepoTree } from '../../lib/api'; import { Avatar, ErrorState, PlaceholderAvatar, buttonStyles, cardStyles } from '../../components/common'; -import { CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoBlobSkeleton, RepoTreeSkeleton } from '../../components/repo'; +import { CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoTreeSkeleton } from '../../components/repo'; import { RepoFrame, useRepoQuery } from './shared'; import { blobHref, countLines, decodeRoutePath, formatBytes, formatLanguagePercent, formatRelativeTime, getErrorMessage, getParentPath, imageLike, isDirectory, joinPath, languageColor, markdownLike, safeDecode, sortedTreeEntries, svgLike, treeHref, videoLike } from '../../lib/repo-utils'; @@ -54,6 +54,109 @@ const normalizeLanguages = ( .sort((left, right) => right.size - left.size || left.name.localeCompare(right.name)); }; +const LOADING_DELAY_MS = 100; + +const useDelayedLoading = (pending: () => boolean, delayMs = LOADING_DELAY_MS) => { + const [visible, setVisible] = createSignal(false); + let timer: ReturnType | undefined; + + createEffect(() => { + if (pending()) { + if (timer === undefined) { + timer = setTimeout(() => { + timer = undefined; + setVisible(true); + }, delayMs); + } + return; + } + + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + setVisible(false); + }); + + onCleanup(() => { + if (timer !== undefined) clearTimeout(timer); + }); + + return visible; +}; + +const PathBreadcrumbs: Component<{ + repo: Parameters[0]; + refName: string; + path: string; + withDivider?: boolean; +}> = (props) => { + const segments = createMemo(() => props.path.split('/').filter(Boolean)); + + return ( +
+
+ + {props.repo.slug} + + + {(segment, index) => ( + <> + / + {segment}} + > + + {segment} + + + + )} + +
+
+ ); +}; + +const LoadingFilePath: Component<{ + repo: Parameters[0]; + refName: string; + path: string; +}> = (props) => { + const segments = createMemo(() => props.path.split('/').filter(Boolean)); + const filename = createMemo(() => segments().at(-1) ?? props.path); + + return ( +
+
+
+ +
+ + at {props.refName} + +
+
+
+
+ } + class="untangled-file-row-loading" + /> +
+
+ ); +}; + +const shouldAnimateNavigation = (event: MouseEvent) => + event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey && !event.defaultPrevented; + const RepoCodePageLegacy: Component = () => { const params = useParams(); const navigate = useNavigate(); @@ -314,7 +417,10 @@ void RepoCodePageLegacy; export const RepoCodePage: Component = () => { const params = useParams(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const repoQuery = useRepoQuery(); + const [pendingTreeHref, setPendingTreeHref] = createSignal(); + let navigationAttempt = 0; const routeRef = createMemo(() => safeDecode(params.ref ?? 'HEAD')); const routePath = createMemo(() => decodeRoutePath(params.path)); @@ -395,11 +501,49 @@ export const RepoCodePage: Component = () => { const overviewReady = createMemo(() => Boolean(repoQuery.data && treeQuery.data && branchesQuery.data && tagsQuery.data && logQuery.data), ); + const showOverviewSkeleton = useDelayedLoading(() => !overviewReady() && !overviewError()); + + createEffect(() => { + if (!treeQuery.isFetching) setPendingTreeHref(undefined); + }); + + const handleFileClick = ( + event: MouseEvent, + href: string, + repo: Parameters[0], + refName: string, + filePath: string, + ) => { + if (!shouldAnimateNavigation(event)) return; + + event.preventDefault(); + const attempt = ++navigationAttempt; + setPendingTreeHref(href); + + const delay = new Promise<'delay'>((resolve) => { + setTimeout(() => resolve('delay'), LOADING_DELAY_MS); + }); + const load = queryClient + .ensureQueryData({ + queryKey: ['blob', repo.repoDid, refName, filePath], + queryFn: async () => getRepoBlob(repo, refName, filePath), + }) + .then( + () => 'loaded' as const, + () => 'loaded' as const, + ); + + void Promise.race([delay, load]).then((result) => { + if (attempt !== navigationAttempt) return; + if (result === 'loaded') setPendingTreeHref(undefined); + navigate(href); + }); + }; return ( - + @@ -419,13 +563,14 @@ export const RepoCodePage: Component = () => { const currentDefaultBranch = defaultBranch() ?? ref(); const activeRef = ref() === 'HEAD' ? currentDefaultBranch : ref(); const activeRefInOptions = hasNamedRef(activeRef, branches, tags); - const treeRef = treeData.ref === 'HEAD' ? currentDefaultBranch : treeData.ref; + const treeRef = treeData.ref === 'HEAD' ? currentDefaultBranch : treeData.ref; const treePath = treeData.path; const parentPath = tree.dotdot ?? getParentPath(treePath); const sortedFiles = sortedTreeEntries(tree.files ?? []); + const isNestedTreePath = treePath.length > 0; return ( - <> + <> 0}>
@@ -505,133 +650,162 @@ export const RepoCodePage: Component = () => { code - -
+ + -
-
-
- - } - /> - - - {(entry) => ( + +
+ +
+
+ +
+
+
+ - ) : ( - - ) - } - lastCommit={entry.last_commit} + name=".." + href={treeHref(repo, treeRef, parentPath ?? '')} + icon={} + class={pendingTreeHref() === treeHref(repo, treeRef, parentPath ?? '') ? 'untangled-file-row-loading' : undefined} + onClick={(event) => { + if (shouldAnimateNavigation(event)) { + navigationAttempt += 1; + setPendingTreeHref(treeHref(repo, treeRef, parentPath ?? '')); + } + }} /> - )} - + + + {(entry) => { + const entryPath = joinPath(treePath, entry.name); + const directory = isDirectory(entry); + const href = directory ? treeHref(repo, treeRef, entryPath) : blobHref(repo, treeRef, entryPath); + + return ( + + ) : ( + + ) + } + lastCommit={entry.last_commit} + class={pendingTreeHref() === href ? 'untangled-file-row-loading' : undefined} + onClick={(event) => { + if (directory) { + if (shouldAnimateNavigation(event)) { + navigationAttempt += 1; + setPendingTreeHref(href); + } + return; + } + + handleFileClick(event, href, repo, treeRef, entryPath); + }} + /> + ); + }} + +
-
-
-
- - commits - - {logQuery.data?.total ?? commits.length} - -
-
- - {(commit) => ( -
-
-
- + +
+
+ + commits + + {logQuery.data?.total ?? commits.length} + +
+
+ + {(commit) => ( +
+ +
+ + + {commit.this.slice(0, 8)} + + + + + + } + > + {(did) => } + + {commit.author?.Name || commit.committer?.Name || 'unknown author'} + +
+ + +
+ + {currentDefaultBranch} + + +
-
-
- - - {commit.this.slice(0, 8)} - - - - - - } - > - {(did) => } - - {commit.author?.Name || commit.committer?.Name || 'unknown author'} - -
- - -
- - {currentDefaultBranch} - - -
-
- )} - -
+ )} + +
-
-
- - branches - - {branches.length} - -
-
- - {(branch) => ( -
- - {branch.reference.name} - - - default +
+
+ + branches + + {branches.length} + +
+
+ + {(branch) => ( +
+ + {branch.reference.name} + + + default + + - - - - {branch.commit?.Committer?.When ? formatRelativeTime(branch.commit.Committer.When) : ''} - -
- )} -
+ + {branch.commit?.Committer?.When ? formatRelativeTime(branch.commit.Committer.When) : ''} + +
+ )} + +
+
-
+
-
@@ -671,8 +845,8 @@ export const BlobPage: Component = () => { return ( - - + +
@@ -690,39 +864,16 @@ export const BlobPage: Component = () => { const isSvg = createMemo(() => svgLike(path(), blob.mimeType)); const isVideo = createMemo(() => videoLike(path(), blob.mimeType)); const isImage = createMemo(() => imageLike(path(), blob.mimeType)); - const showingText = createMemo( - () => blob.content !== undefined && !blob.fileTooLarge && (!blob.isBinary || isSvg()), - ); - const lineCount = createMemo(() => countLines(text())); - const segments = path().split('/'); + const showingText = createMemo( + () => blob.content !== undefined && !blob.fileTooLarge && (!blob.isBinary || isSvg()), + ); + const lineCount = createMemo(() => countLines(text())); return (
-
- - {repo.slug} - - - {(segment, index) => ( - <> - / - {segment}} - > - - {segment} - - - - )} - -
+
at {ref()} @@ -767,6 +918,12 @@ export const BlobPage: Component = () => { {(commit) => (
+ } + > + {(did) => } + {commit().author?.name || 'unknown author'} -- 2.51.2