diff --git a/src/lib/api/repos.ts b/src/lib/api/repos.ts index fbc784d..bff5e3e 100644 --- a/src/lib/api/repos.ts +++ b/src/lib/api/repos.ts @@ -402,18 +402,20 @@ const branchUpdatedTimestamp = (branch: BranchEntry): number => const sortBranchResponseByUpdatedAt = (response: BranchResponse): BranchResponse => ({ ...response, - branches: [...response.branches].sort((left, right) => { - const updatedDiff = branchUpdatedTimestamp(right) - branchUpdatedTimestamp(left); - if (updatedDiff !== 0) { - return updatedDiff; - } - - if (left.is_default !== right.is_default) { - return left.is_default ? -1 : 1; - } - - return left.reference.name.localeCompare(right.reference.name); - }), + branches: Array.isArray(response?.branches) + ? [...response.branches].sort((left, right) => { + const updatedDiff = branchUpdatedTimestamp(right) - branchUpdatedTimestamp(left); + if (updatedDiff !== 0) { + return updatedDiff; + } + + if (left.is_default !== right.is_default) { + return left.is_default ? -1 : 1; + } + + return left.reference.name.localeCompare(right.reference.name); + }) + : [], }); const getRepoDefaultBranchFromKnot = async (repo: RepoContext): Promise => @@ -524,11 +526,46 @@ export const getRepoTree = async ( () => getRepoTreeFromKnot(repo, ref, path), ); +const isRefNotFoundError = (err: unknown): boolean => { + if (!err) return false; + if (typeof err === 'object') { + const e = err as any; + if (e.error === 'RefNotFound' || e.status === 404 || e.name === 'RefNotFound' || e.code === 'RefNotFound') { + return true; + } + if (e.message) { + const lower = String(e.message).toLowerCase(); + if ( + lower.includes('refnotfound') || + lower.includes('failed to access ref') || + lower.includes('reference not found') || + lower.includes('ref not found') + ) { + return true; + } + } + } + const msg = String(err).toLowerCase(); + return ( + msg.includes('refnotfound') || + msg.includes('failed to access ref') || + msg.includes('reference not found') || + msg.includes('ref not found') + ); +}; + export const getRepoBranches = async (repo: RepoContext): Promise => appviewOptionalKnotRequest( () => getRepoViaAppview('sh.tangled.repo.branches', repo), () => getRepoBranchesFromKnot(repo), - ).then(sortBranchResponseByUpdatedAt); + ) + .then(sortBranchResponseByUpdatedAt) + .catch((err) => { + if (isRefNotFoundError(err)) { + return { branches: [] }; + } + throw err; + }); export const getRepoDefaultBranch = async (repo: RepoContext): Promise => appviewOptionalKnotRequest( diff --git a/src/pages/repo/code.tsx b/src/pages/repo/code.tsx index 916ca55..4a2217a 100644 --- a/src/pages/repo/code.tsx +++ b/src/pages/repo/code.tsx @@ -3,12 +3,13 @@ import { File, Folder, GitBranch, GitCommitHorizontal, List } from 'lucide-solid import { A, useNavigate, useParams } from '@solidjs/router'; import { createQuery, keepPreviousData, useQueryClient } from '@tanstack/solid-query'; 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 } from '../../lib/api/repos'; +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 { 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'; +import { useAuth } from '../../lib/auth'; const readmeIsAvailable = ( readme: { filename: string; contents: string } | undefined, @@ -236,6 +237,118 @@ const RepoCodePageLegacy: Component = () => { void RepoCodePageLegacy; +const getKnotHost = (knotUrl: string): string => { + try { + const url = new URL(knotUrl); + let host = url.hostname; + if (host === 'knot1.tangled.sh') { + host = 'tangled.org'; + } + return host; + } catch { + return knotUrl; + } +}; + +interface EmptyRepoViewProps { + repo: RepoContext; + branches: BranchEntry[]; +} + +const EmptyRepoView: Component = (props) => { + const auth = useAuth(); + const isOwner = createMemo(() => { + const current = auth.currentDid(); + return Boolean(current && current === props.repo.owner.did); + }); + + return ( +
+ + 0}> +
+

+ This branch is empty. Other branches in this repository are populated: +

+ +
+
+ +
+
+

This is an empty repository. To get started:

+
+

+ 1 + + First, generate a new{' '} + + SSH key pair + + . + +

+

+ 2 + + Then add the public key to your account from the{' '} + + keys page + {' '} + in your settings. + +

+

+ 3 + + Configure your remote to:{' '} + + git@{getKnotHost(props.repo.knot)}:{props.repo.repoDid} + + +

+

+ 4 + Push! +

+
+
+
+
+ +

+ This is an empty repository. +

+
+
+
+ ); +}; + export const RepoCodePage: Component = () => { const params = useParams(); const navigate = useNavigate(); @@ -253,12 +366,12 @@ export const RepoCodePage: Component = () => { enabled: Boolean(repo), queryFn: async () => getRepoBranches(repo!), }; - }); - const defaultBranchQuery = createQuery(() => { + }); const defaultBranchQuery = createQuery(() => { const repo = repoQuery.data; + const hasBranches = branchesQuery.data && branchesQuery.data.branches.length > 0; return { queryKey: ['repo-default-branch', repo?.repoDid], - enabled: Boolean(repo), + enabled: Boolean(repo && hasBranches), queryFn: async () => getRepoDefaultBranch(repo!), }; }); @@ -279,7 +392,13 @@ export const RepoCodePage: Component = () => { ); const ref = createMemo(() => routeTarget().ref); const path = createMemo(() => routeTarget().path); - const effectiveRef = createMemo(() => (ref() === 'HEAD' ? (defaultBranch() ?? (defaultBranchReady() ? 'HEAD' : undefined)) : ref())); + const effectiveRef = createMemo(() => { + const branches = branchesQuery.data?.branches; + if (!branches || branches.length === 0) { + return undefined; + } + return (ref() === 'HEAD' ? (defaultBranch() ?? (defaultBranchReady() ? 'HEAD' : undefined)) : ref()); + }); const treeQuery = createQuery(() => { const repo = repoQuery.data; const currentRef = effectiveRef(); @@ -303,9 +422,10 @@ export const RepoCodePage: Component = () => { }); const tagsQuery = createQuery(() => { const repo = repoQuery.data; + const hasBranches = branchesQuery.data && branchesQuery.data.branches.length > 0; return { queryKey: ['repo-tags', repo?.repoDid], - enabled: Boolean(repo), + enabled: Boolean(repo && hasBranches), queryFn: async () => getRepoTags(repo!), }; }); @@ -339,6 +459,36 @@ export const RepoCodePage: Component = () => { const languagesLoading = createMemo(() => !languagesQuery.data && (languagesQuery.isLoading || languagesQuery.isFetching)); const showOverviewSkeleton = useDelayedLoading(() => !overviewReady() && !overviewError()); + const isRefNotFoundError = (err: unknown): boolean => { + if (!err) return false; + if (typeof err === 'object') { + const e = err as any; + if (e.error === 'RefNotFound' || e.status === 404 || e.name === 'RefNotFound' || e.code === 'RefNotFound') { + return true; + } + } + const msg = getErrorMessage(err).toLowerCase(); + return ( + msg.includes('refnotfound') || + msg.includes('failed to access ref') || + msg.includes('reference not found') || + msg.includes('ref not found') + ); + }; + + const isEmptyRepo = createMemo(() => { + return Boolean(branchesQuery.data && branchesQuery.data.branches.length === 0); + }); + + const isEmptyBranch = createMemo(() => { + if (branchesQuery.data && branchesQuery.data.branches.length > 0) { + if (treeQuery.error && isRefNotFoundError(treeQuery.error)) { + return true; + } + } + return false; + }); + createEffect(() => { if (!treeQuery.isFetching) setPendingTreeHref(undefined); }); @@ -401,9 +551,15 @@ export const RepoCodePage: Component = () => { return ( }> - + + + + + + +
diff --git a/src/pages/repo/commits.tsx b/src/pages/repo/commits.tsx index 23a1599..80fff90 100644 --- a/src/pages/repo/commits.tsx +++ b/src/pages/repo/commits.tsx @@ -6,9 +6,9 @@ import { Ellipsis, FolderCode, } from 'lucide-solid'; -import { A, useParams, useSearchParams } from '@solidjs/router'; +import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router'; import { createQuery } from '@tanstack/solid-query'; -import { For, Match, Show, Switch, createMemo, createSignal, type Component } from 'solid-js'; +import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; import { getRepoBranches, getRepoCommit, @@ -445,36 +445,39 @@ const CommitListContent: Component<{ export const CommitsPage: Component = () => { const params = useParams(); const [searchParams] = useSearchParams(); + const navigate = useNavigate(); const repoQuery = useRepoQuery(); const ref = createMemo(() => safeDecode(params.ref ?? 'HEAD')); const page = createMemo(() => parseIntegerSearchParam(searchParams.page, 1, 1)); const pageOffset = createMemo(() => (page() - 1) * COMMITS_PAGE_LIMIT); const cursor = createMemo(() => String(pageOffset())); + const branchesQuery = createQuery(() => { + const repo = repoQuery.data; + return { + queryKey: ['repo-branches', repo?.repoDid], + enabled: Boolean(repo), + queryFn: async () => getRepoBranches(repo!), + }; + }); const logQuery = createQuery(() => { const repo = repoQuery.data; + const hasBranches = branchesQuery.data && branchesQuery.data.branches.length > 0; return { queryKey: ['repo-commits', repo?.repoDid, ref(), page()], - enabled: Boolean(repo), + enabled: Boolean(repo && hasBranches), queryFn: async () => getRepoLog(repo!, ref(), { limit: COMMITS_PAGE_LIMIT, cursor: page() > 1 ? cursor() : undefined, }), }; }); - const branchesQuery = createQuery(() => { - const repo = repoQuery.data; - return { - queryKey: ['repo-branches', repo?.repoDid], - enabled: Boolean(repo), - queryFn: async () => getRepoBranches(repo!), - }; - }); const tagsQuery = createQuery(() => { const repo = repoQuery.data; + const hasBranches = branchesQuery.data && branchesQuery.data.branches.length > 0; return { queryKey: ['repo-tags', repo?.repoDid], - enabled: Boolean(repo), + enabled: Boolean(repo && hasBranches), queryFn: async () => getRepoTags(repo!), }; }); @@ -502,6 +505,12 @@ export const CommitsPage: Component = () => { return data.commits.length === COMMITS_PAGE_LIMIT; }); + createEffect(() => { + if (repoQuery.data && branchesQuery.data && branchesQuery.data.branches.length === 0) { + navigate(`/${repoQuery.data.owner.handle}/${repoQuery.data.slug}`, { replace: true }); + } + }); + return (