diff --git a/src/index.css b/src/index.css --- a/src/index.css +++ b/src/index.css @@ -3874,3 +3874,5 @@ } + + diff --git a/src/components/common.tsx b/src/components/common.tsx --- a/src/components/common.tsx +++ b/src/components/common.tsx @@ -254,23 +254,3 @@ export const textareaStyles = (): string => 'untangled-textarea w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100'; - -export const usePreloader = () => { - const queryClient = useQueryClient(); - - return (queryKey: readonly unknown[], queryFn: () => Promise, delayMs = 30) => { - let hoverTimer: ReturnType | undefined; - - const onMouseEnter = () => { - hoverTimer = setTimeout(() => { - queryClient.prefetchQuery({ queryKey, queryFn }); - }, delayMs); - }; - - const onMouseLeave = () => { - clearTimeout(hoverTimer); - }; - - return { onMouseEnter, onMouseLeave }; - }; -}; diff --git a/src/components/repo.tsx b/src/components/repo.tsx --- a/src/components/repo.tsx +++ b/src/components/repo.tsx @@ -32,8 +32,9 @@ import { getRepoPullCount } from '../lib/api/pulls'; import { formatRelativeTime, languageColor, encodePath, formatLanguagePercent, blobHref, treeHref } from '../lib/repo-utils'; import { getTangledAppviewService } from '../lib/settings'; -import { Avatar, SkeletonBlock, StateBadge, PlaceholderAvatar, buttonStyles, cardStyles, usePreloader } from './common'; -import { repoQueryKey } from '../pages/repo/shared'; +import { Avatar, SkeletonBlock, StateBadge, PlaceholderAvatar, buttonStyles, cardStyles } from './common'; +import { useRepoPreloader } from '../lib/preloading'; + marked.setOptions({ gfm: true, @@ -113,7 +114,7 @@ ); -const TreeRowsSkeleton: Component = () => ( +export const TreeRowsSkeleton: Component = () => (
{(_, index) => ( @@ -129,7 +130,7 @@
); -const RepoOverviewSidebarSkeleton: Component = () => ( +export const RepoOverviewSidebarSkeleton: Component = () => (
@@ -1235,7 +1236,7 @@ export const RepoCard: Component = (props) => { const linkHref = () => props.href || `/${props.owner}/${props.name}`; - const preload = usePreloader(); + const preloadRepo = useRepoPreloader(); return (
getRepo(props.owner, props.name))} + {...preloadRepo(props.owner, props.name)} > {props.owner}/{props.name} diff --git a/src/lib/preloading.tsx b/src/lib/preloading.tsx --- a/src/lib/preloading.tsx +++ b/src/lib/preloading.tsx @@ -1,9 +1,138 @@ import { useQueryClient } from '@tanstack/solid-query'; -import { usePreloader } from '../components/common'; -import { getRepo, type RepoContext } from './api/repos'; -import { getIssue } from './api/issues'; -import { fetchPullRoundPatch, getPull } from './api/pulls'; -import { repoQueryKey, issueQueryKey, pullQueryKey, pullPatchQueryKey } from './api/keys'; +import { useAuth } from './auth'; +import { + getRepo, + getRepoBranches, + getRepoDefaultBranch, + getRepoTags, + getRepoForkCount, + getRepoTree, + getRepoLog, + getRepoLanguages, + type RepoContext, +} from './api/repos'; +import { getIssue, getRepoIssueCount } from './api/issues'; +import { fetchPullRoundPatch, getPull, getRepoPullCount } from './api/pulls'; +import { getString } from './api/strings'; +import { getRepoStarSummary } from './api/stars'; +import { repoQueryKey, issueQueryKey, pullQueryKey, pullPatchQueryKey, issuesQueryKey, pullsQueryKey } from './api/keys'; +import { resolveDefaultBranchName } from '../pages/repo/code-helpers'; + +export const PRELOAD_HOVER_TIME = 30; + +export const usePreloader = () => { + const queryClient = useQueryClient(); + + return (queryKey: readonly unknown[], queryFn: () => Promise, delayMs = PRELOAD_HOVER_TIME) => { + let hoverTimer: ReturnType | undefined; + + const onMouseEnter = () => { + hoverTimer = setTimeout(() => { + queryClient.prefetchQuery({ queryKey, queryFn }); + }, delayMs); + }; + + const onMouseLeave = () => { + clearTimeout(hoverTimer); + }; + + return { onMouseEnter, onMouseLeave }; + }; +}; + +export const useRepoPreloader = () => { + const queryClient = useQueryClient(); + const auth = useAuth(); + const preload = usePreloader(); + + return (owner: string, name: string) => { + return preload( + ['repo-prefetch', owner, name], + async () => { + const repo = await queryClient.fetchQuery({ + queryKey: repoQueryKey(owner, name), + queryFn: () => getRepo(owner, name), + }); + + if (!repo) { + return repo; + } + + const currentDid = auth.currentDid(); + + // Fetch branches and default-branch, and prefetch counts and tags concurrently + const [branches, defaultBranchRes] = await Promise.all([ + queryClient.fetchQuery({ + queryKey: ['repo-branches', repo.repoDid], + queryFn: () => getRepoBranches(repo), + }), + queryClient.fetchQuery({ + queryKey: ['repo-default-branch', repo.repoDid], + queryFn: () => getRepoDefaultBranch(repo), + }), + queryClient.prefetchQuery({ + queryKey: ['repo-star-summary', repo.repoDid, currentDid], + queryFn: () => getRepoStarSummary(repo, currentDid), + }), + queryClient.prefetchQuery({ + queryKey: ['repo-fork-count', repo.repoDid], + queryFn: () => getRepoForkCount(repo), + }), + queryClient.prefetchQuery({ + queryKey: [...issuesQueryKey(repo.repoDid), 'count'], + queryFn: () => getRepoIssueCount(repo), + }), + queryClient.prefetchQuery({ + queryKey: [...pullsQueryKey(repo.repoDid), 'count'], + queryFn: () => getRepoPullCount(repo), + }), + queryClient.prefetchQuery({ + queryKey: ['repo-tags', repo.repoDid], + queryFn: () => getRepoTags(repo), + }), + ]); + + const defaultBranchName = + defaultBranchRes?.name ?? + defaultBranchRes?.branch ?? + defaultBranchRes?.reference?.name ?? + resolveDefaultBranchName(branches?.branches) ?? + 'main'; + + // Now that we have the default branch name, prefetch tree, log and languages + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: ['repo-tree', repo.repoDid, defaultBranchName, ''], + queryFn: async () => ({ + tree: await getRepoTree(repo, defaultBranchName, ''), + path: '', + ref: defaultBranchName, + }), + }), + queryClient.prefetchQuery({ + queryKey: ['repo-log', repo.repoDid, defaultBranchName], + queryFn: () => getRepoLog(repo, defaultBranchName), + }), + queryClient.prefetchQuery({ + queryKey: ['repo-languages', repo.repoDid, defaultBranchName], + queryFn: () => getRepoLanguages(repo, defaultBranchName), + }), + ]).catch(() => { + // Ignore background prefetch errors + }); + + return repo; + } + ); + }; +}; + +export const useStringPreloader = () => { + const preload = usePreloader(); + return (actor: string, rkey: string) => { + return preload(['string-detail', actor, rkey], () => getString(actor, rkey)); + }; +}; export const useIssuePreloader = () => { const queryClient = useQueryClient(); @@ -23,7 +152,7 @@ [kind, uri, 'prefetch'], async () => { const repo = - repoContext || + repoContext || (await queryClient.fetchQuery({ queryKey: repoQueryKey(owner, slug), queryFn: () => getRepo(owner, slug), diff --git a/src/pages/home.tsx b/src/pages/home.tsx --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -17,16 +17,15 @@ import { createQuery } from '@tanstack/solid-query'; import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component, type JSX } from 'solid-js'; -import { Avatar, ErrorState, LoadingState, StateBadge, usePreloader } from '../components/common'; +import { Avatar, ErrorState, LoadingState, StateBadge } from '../components/common'; import { RepoCard } from '../components/repo'; import { listFollowRecords } from '../lib/api/graph'; import { resolveActor } from '../lib/api/identity'; -import { getRepo, listRepoRecords, type RepoRecord } from '../lib/api/repos'; +import { listRepoRecords, type RepoRecord } from '../lib/api/repos'; import { useAuth } from '../lib/auth'; import { useLiveEvents, type LiveEvent } from '../lib/live-events'; -import { useIssuePreloader } from '../lib/preloading'; +import { useIssuePreloader, useRepoPreloader } from '../lib/preloading'; import { formatRelativeTime, getErrorMessage, loadRecentRepos, loadRecentIssuesPulls } from '../lib/repo-utils'; -import { repoQueryKey } from '../lib/api/keys'; const SAMPLE_REPOS: Array<{ owner: string; @@ -747,7 +746,7 @@ const ActivityRow: Component<{ item: HomeActivityItem; index: number }> = (props) => { const repoUrl = () => `/${props.item.repoLabel}`; const actorUrl = () => (props.item.actorHandle ? `/${props.item.actorHandle}` : '#'); - const preload = usePreloader(); + const preloadRepo = useRepoPreloader(); const issuePreloader = useIssuePreloader(); const actionText = () => { @@ -768,7 +767,7 @@ const preloadRepoProps = () => { const [owner, slug] = props.item.repoLabel.split('/'); - return preload(repoQueryKey(owner, slug), () => getRepo(owner, slug)); + return preloadRepo(owner, slug); }; return ( diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx --- a/src/pages/profile.tsx +++ b/src/pages/profile.tsx @@ -23,8 +23,9 @@ import { createQuery, useQueryClient } from '@tanstack/solid-query'; import { For, Show, Switch, Match, createMemo, createSignal, createEffect, type Component } from 'solid-js'; import type { Did } from '@atcute/lexicons/syntax'; -import { ErrorState, LoadingState, PlaceholderAvatar, SkeletonBlock, textareaStyles, inputStyles, usePreloader } from '../components/common'; +import { ErrorState, LoadingState, PlaceholderAvatar, SkeletonBlock, textareaStyles, inputStyles } from '../components/common'; import { RepoCard, RepoCardSkeleton, StringCardSkeleton } from '../components/repo'; +import { useStringPreloader } from '../lib/preloading'; import { listFollowRecords, createFollow, @@ -40,7 +41,7 @@ } from '../lib/api/graph'; import { resolveActor, getActorProfile, resolveAvatarUrl, putActorProfile } from '../lib/api/identity'; import { listRepoRecords, getRepoByDid, type RepoContext } from '../lib/api/repos'; -import { listStringRecords, getString } from '../lib/api/strings'; +import { listStringRecords } from '../lib/api/strings'; import { useAuth } from '../lib/auth'; import { listAllAppviewRecords } from '../lib/api/appview'; import { formatRelativeTime, getErrorMessage } from '../lib/repo-utils'; @@ -972,7 +973,7 @@ const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); const auth = useAuth(); - const preload = usePreloader(); + const preloadString = useStringPreloader(); const navigate = useNavigate(); @@ -1555,7 +1556,7 @@ getString(resolvedActor().handle || resolvedActor().did, item.rkey))} + {...preloadString(resolvedActor().handle || resolvedActor().did, item.rkey)} > {item.value.filename}
diff --git a/src/pages/search.tsx b/src/pages/search.tsx --- a/src/pages/search.tsx +++ b/src/pages/search.tsx @@ -17,13 +17,13 @@ import type { Did, ResourceUri } from '@atcute/lexicons/syntax'; import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js'; -import { ErrorState, LoadingState, PaginationControls, usePreloader } from '../components/common'; +import { ErrorState, LoadingState, PaginationControls } from '../components/common'; +import { usePreloader, useRepoPreloader, useStringPreloader } from '../lib/preloading'; import { RepoStatsList } from '../components/repo'; import { getIssueRecord, getIssue } from '../lib/api/issues'; import { getPullRecord, getPull } from '../lib/api/pulls'; -import { getRepoByDid, getRepo } from '../lib/api/repos'; -import { getString } from '../lib/api/strings'; -import { issueQueryKey, pullQueryKey, repoQueryKey } from './repo/shared'; +import { getRepoByDid } from '../lib/api/repos'; +import { issueQueryKey, pullQueryKey } from './repo/shared'; import { ISSUE_COLLECTION, } from '../lib/api/constants'; @@ -319,7 +319,8 @@ }; const ResultTitle: Component<{ hit: SearchHit }> = (props) => { - const preload = usePreloader(); + const preloadRepo = useRepoPreloader(); + const preloadString = useStringPreloader(); const title = createMemo(() => { const value = props.hit.value as { @@ -343,7 +344,7 @@ getRepo(props.hit.author.handle, repoName()))} + {...preloadRepo(props.hit.author.handle, repoName())} > {props.hit.author.handle}/{repoName()} @@ -367,7 +368,7 @@ getString(props.hit.author.handle || props.hit.author.did, props.hit.rkey))} + {...preloadString(props.hit.author.handle || props.hit.author.did, props.hit.rkey)} > {props.hit.author.handle || props.hit.author.did}/{title()} diff --git a/src/pages/repo/code.tsx b/src/pages/repo/code.tsx --- a/src/pages/repo/code.tsx +++ b/src/pages/repo/code.tsx @@ -5,7 +5,7 @@ import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, type Accessor, type Component } from 'solid-js'; import { buildBlobDataUrl, decodeBlobBytes, decodeBlobText, getRepoBlob, getRepoBranches, getRepoDefaultBranch, getRepoLanguages, getRepoLog, getRepoTags, getRepoTree, type CommitHeadline, type RepoContext, type BranchEntry } from '../../lib/api/repos'; import { Avatar, ErrorState, PlaceholderAvatar, buttonStyles, cardStyles } from '../../components/common'; -import { CloneDropdown, CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoBlobSkeleton, RepoTreeSkeleton, PathBreadcrumbs, TreeLatestCommitPanel, RepoLanguageBar, LoadingFilePath } from '../../components/repo'; +import { CloneDropdown, CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoBlobSkeleton, RepoTreeSkeleton, PathBreadcrumbs, TreeLatestCommitPanel, RepoLanguageBar, LoadingFilePath, TreeRowsSkeleton, RepoOverviewSidebarSkeleton } from '../../components/repo'; import { RepoFrame, useRepoQuery } from './shared'; import { blobHref, commitHref, commitsHref, countLines, decodeRoutePath, formatBytes, formatRelativeTime, getErrorMessage, getParentPath, imageLike, isDirectory, joinPath, markdownLike, safeDecode, sortedTreeEntries, svgLike, treeHref, updateRecentRepoLanguage, videoLike } from '../../lib/repo-utils'; import { LOADING_DELAY_MS, hasNamedRef, normalizeLanguages, resolveDefaultBranchName, resolveRouteRefAndPath, shouldAnimateNavigation, useDelayedLoading } from './code-helpers'; @@ -451,10 +451,10 @@ }; }); const overviewError = createMemo( - () => treeQuery.error || branchesQuery.error || tagsQuery.error || logQuery.error || languagesQuery.error, + () => branchesQuery.error || tagsQuery.error, ); const overviewReady = createMemo(() => - Boolean(repoQuery.data && treeQuery.data && branchesQuery.data && tagsQuery.data && logQuery.data), + Boolean(repoQuery.data && branchesQuery.data && tagsQuery.data), ); const languagesLoading = createMemo(() => !languagesQuery.data && (languagesQuery.isLoading || languagesQuery.isFetching)); const showOverviewSkeleton = useDelayedLoading(() => !overviewReady() && !overviewError()); @@ -564,24 +564,23 @@
- - + {(() => { const repo = repoQuery.data!; - const treeData = treeQuery.data!; - const tree = treeData.tree; - const languages = normalizeLanguages(languagesQuery.data?.languages ?? [], languagesQuery.data?.totalSize); + const treeData = () => treeQuery.data; + const tree = () => treeData()?.tree; + const languages = normalizeLanguages(languagesQuery.data?.languages ?? [], languagesQuery.data?.totalSize); const branches = branchesQuery.data?.branches ?? []; - const tags = tagsQuery.data?.tags ?? []; - const commits = logQuery.data?.commits ?? []; - const activeRef = selectedTreeRef(); - const currentDefaultBranch = defaultBranch() ?? activeRef; - const activeRefInOptions = hasNamedRef(activeRef, branches, tags); - const treeRef = activeRef; - const treePath = treeData.path; - const parentPath = tree.dotdot ?? getParentPath(treePath); - const sortedFiles = sortedTreeEntries(tree.files ?? []); - const isNestedTreePath = treePath.length > 0; + const tags = tagsQuery.data?.tags ?? []; + const commits = () => logQuery.data?.commits ?? []; + const activeRef = selectedTreeRef(); + const currentDefaultBranch = defaultBranch() ?? activeRef; + const activeRefInOptions = hasNamedRef(activeRef, branches, tags); + const treeRef = activeRef; + const treePath = () => treeData()?.path ?? ''; + const parentPath = () => tree() ? (tree()!.dotdot ?? getParentPath(treePath())) : null; + const sortedFiles = () => sortedTreeEntries(tree()?.files ?? []); + const isNestedTreePath = () => treePath().length > 0; return ( <> @@ -639,159 +638,178 @@
- +
- - + + {(commit) => }
-
-
-
- - } - 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); - const currentRef = effectiveRef(); - - let hoverTimer: ReturnType | undefined; - - const handleMouseEnter = () => { - hoverTimer = setTimeout(() => { - if (!directory) { - queryClient.prefetchQuery({ - queryKey: ['blob', repo.repoDid, treeRef, entryPath], - queryFn: () => getRepoBlob(repo, treeRef, entryPath), - }); - return; +
+
+ }> + + + } + 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); + const currentRef = effectiveRef(); - queryClient.prefetchQuery({ - queryKey: ['repo-tree', repo.repoDid, currentRef, entryPath], - queryFn: async () => ({ - tree: await getRepoTree(repo, currentRef!, entryPath), - path: entryPath, - ref: currentRef!, - }), - }); - }, 30); - }; - const handleMouseLeave = () => { - clearTimeout(hoverTimer); - }; + let hoverTimer: ReturnType | undefined; - return ( - - ) : ( - - ) - } - lastCommit={entry.last_commit} - lastCommitHref={entry.last_commit ? commitHref(repo, entry.last_commit.hash) : undefined} - class={pendingTreeHref() === href ? 'untangled-file-row-loading' : undefined} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} - onClick={(event) => { - if (directory) { - if (shouldAnimateNavigation(event)) { - navigationAttempt += 1; - setPendingTreeHref(href); - } + const handleMouseEnter = () => { + hoverTimer = setTimeout(() => { + if (!directory) { + queryClient.prefetchQuery({ + queryKey: ['blob', repo.repoDid, treeRef, entryPath], + queryFn: () => getRepoBlob(repo, treeRef, entryPath), + }); return; } - handleFileClick(event, href, repo, treeRef, entryPath); - }} - /> - ); - }} - -
-
+ queryClient.prefetchQuery({ + queryKey: ['repo-tree', repo.repoDid, currentRef, entryPath], + queryFn: async () => ({ + tree: await getRepoTree(repo, currentRef!, entryPath), + path: entryPath, + ref: currentRef!, + }), + }); + }, 30); + }; + const handleMouseLeave = () => { + clearTimeout(hoverTimer); + }; - + return ( + + ) : ( + + ) + } + lastCommit={entry.last_commit} + lastCommitHref={entry.last_commit ? commitHref(repo, entry.last_commit.hash) : undefined} + class={pendingTreeHref() === href ? 'untangled-file-row-loading' : undefined} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + onClick={(event) => { + if (directory) { + if (shouldAnimateNavigation(event)) { + navigationAttempt += 1; + setPendingTreeHref(href); + } + return; + } + + handleFileClick(event, href, repo, treeRef, entryPath); + }} + /> + ); + }} +
+
+ }> + +
+ +
+
+ + +
+ + + }>
commits - {logQuery.data?.total ?? commits.length} + {logQuery.data?.total ?? commits().length} -
- - {(commit) => ( -
-
-
-
+ + + {(commit) => ( + + + + + } + > + {(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} - - -
-
- )} - -
+ )} +
+
+ }> + +
+ +
+
+
@@ -823,20 +841,19 @@
-
- + +
+ - - {(readme) => ( - - )} - + + + ); })()}