From 394a3a590ee17bd692a6a237c5f4532ff54a93f9 Mon Sep 17 00:00:00 2001 From: codens-agent Date: Sun, 28 Jun 2026 09:42:29 +0000 Subject: [PATCH] chore: Add ErrorBoundary, Skeleton loading states, and EmptyRepoState to repository pages with unit tests --- __tests__/components/ErrorBoundary.test.tsx | 25 +++ .../components/repo/EmptyRepoState.test.tsx | 17 ++ .../[repo]/blob/[branch]/[...path]/page.tsx | 173 +++++++++------- app/(app)/[owner]/[repo]/commits/page.tsx | 194 +++++++++++------- app/(app)/[owner]/[repo]/layout.tsx | 9 + app/(app)/[owner]/[repo]/page.tsx | 116 ++++++++--- components/ErrorBoundary.tsx | 56 +++++ components/repo/EmptyRepoState.tsx | 32 +++ 8 files changed, 451 insertions(+), 171 deletions(-) create mode 100644 __tests__/components/ErrorBoundary.test.tsx create mode 100644 __tests__/components/repo/EmptyRepoState.test.tsx create mode 100644 app/(app)/[owner]/[repo]/layout.tsx create mode 100644 components/ErrorBoundary.tsx create mode 100644 components/repo/EmptyRepoState.tsx diff --git a/__tests__/components/ErrorBoundary.test.tsx b/__tests__/components/ErrorBoundary.test.tsx new file mode 100644 index 00000000..ee93208a --- /dev/null +++ b/__tests__/components/ErrorBoundary.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import ErrorBoundary from "@/components/ErrorBoundary"; + +function ThrowingChild(): never { + throw new Error("Something went wrong"); +} + +describe("ErrorBoundary", () => { + it("renders fallback UI with message and Retry button when child throws", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + render( + + + , + ); + + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + + consoleError.mockRestore(); + }); +}); diff --git a/__tests__/components/repo/EmptyRepoState.test.tsx b/__tests__/components/repo/EmptyRepoState.test.tsx new file mode 100644 index 00000000..be96972a --- /dev/null +++ b/__tests__/components/repo/EmptyRepoState.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import EmptyRepoState from "@/components/repo/EmptyRepoState"; + +describe("EmptyRepoState", () => { + it('renders "No code yet" heading and clone URL', () => { + const cloneUrl = "https://git.example.com/org/repo.git"; + + render(); + + expect( + screen.getByRole("heading", { name: "No code yet" }), + ).toBeInTheDocument(); + expect(screen.getByText(cloneUrl)).toBeInTheDocument(); + }); +}); diff --git a/app/(app)/[owner]/[repo]/blob/[branch]/[...path]/page.tsx b/app/(app)/[owner]/[repo]/blob/[branch]/[...path]/page.tsx index d756fbe3..5943c04c 100644 --- a/app/(app)/[owner]/[repo]/blob/[branch]/[...path]/page.tsx +++ b/app/(app)/[owner]/[repo]/blob/[branch]/[...path]/page.tsx @@ -1,6 +1,8 @@ import Link from "next/link"; import { notFound } from "next/navigation"; +import { Suspense } from "react"; import BlobViewer from "@/components/repo/BlobViewer"; +import { Skeleton } from "@/components/ui/skeleton"; import { apiClient, decodeBase64Content, @@ -24,15 +26,17 @@ function isBinaryContent(data: ContentResponse): boolean { return data.content == null; } -export default async function BlobPage({ - params, +async function BlobViewerContent({ + owner, + repo, + branch, + decodedPath, }: { - params: Promise<{ owner: string; repo: string; branch: string; path: string[] }>; + owner: string; + repo: string; + branch: string; + decodedPath: string; }) { - const { owner, repo, branch: rawBranch, path: rawPathSegments } = await params; - const branch = decodeURIComponent(rawBranch); - const decodedPath = decodePathSegments(rawPathSegments ?? []).join("/"); - let contentData: ContentResponse; try { contentData = await apiClient.getContents( @@ -49,10 +53,87 @@ export default async function BlobPage({ if (contentData.type !== "file") notFound(); const downloadUrl = contentData.download_url ?? ""; - const pathParts = decodedPath.split("/"); const binary = isBinaryContent(contentData); const truncated = contentData.truncated === true; + return ( + <> + {downloadUrl && ( +
+ + Raw + + + Download + +
+ )} + + {truncated ? ( +
+ This file is too large to display.{" "} + {downloadUrl && ( + + Download file + + )} +
+ ) : binary ? ( +
+ Binary file not shown. + {downloadUrl && ( + <> + {" "} + + Download file + + + )} +
+ ) : ( + + )} + + ); +} + +export default async function BlobPage({ + params, +}: { + params: Promise<{ owner: string; repo: string; branch: string; path: string[] }>; +}) { + const { owner, repo, branch: rawBranch, path: rawPathSegments } = await params; + const branch = decodeURIComponent(rawBranch); + const decodedPath = decodePathSegments(rawPathSegments ?? []).join("/"); + const pathParts = decodedPath.split("/"); + return (
@@ -106,70 +187,24 @@ export default async function BlobPage({ ); })} - - {downloadUrl && ( - - )}
- {truncated ? ( -
- This file is too large to display.{" "} - {downloadUrl && ( - - Download file - - )} -
- ) : binary ? ( -
- Binary file not shown. - {downloadUrl && ( - <> - {" "} - - Download file - - - )} -
- ) : ( - + {[...Array(8)].map((_, i) => ( + + ))} + + } + > + - )} + diff --git a/app/(app)/[owner]/[repo]/commits/page.tsx b/app/(app)/[owner]/[repo]/commits/page.tsx index 4d4171c7..a8a3f369 100644 --- a/app/(app)/[owner]/[repo]/commits/page.tsx +++ b/app/(app)/[owner]/[repo]/commits/page.tsx @@ -1,5 +1,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; +import { Suspense } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; import { apiClient, isApiError, @@ -51,6 +53,103 @@ function buildPageHref( return `/${owner}/${repo}/commits?${params.toString()}`; } +async function CommitList({ + owner, + repo, + branch, + page, + refParam, +}: { + owner: string; + repo: string; + branch: string; + page: number; + refParam?: string; +}) { + let commits: CommitListItem[]; + let links: Record; + try { + const result = await apiClient.getCommits( + owner, + repo, + branch, + page, + ); + commits = result.commits; + links = result.links; + } catch (err) { + if (isApiError(err) && err.status === 404) { + commits = []; + links = {}; + } else { + throw err; + } + } + + const prevPage = links.prev ? pageFromLinkUrl(links.prev) : null; + const nextPage = links.next ? pageFromLinkUrl(links.next) : null; + + return ( + <> + {commits.length === 0 ? ( +

No commits found.

+ ) : ( +
    + {commits.map((item) => { + const message = item.commit.message.split("\n")[0]; + const authorName = item.author?.login ?? item.commit.author.name; + return ( +
  • + +
    +

    + {message} +

    +

    + {authorName} committed {formatDate(item.commit.author.date)} +

    +
    + + {item.sha.slice(0, 7)} + + +
  • + ); + })} +
+ )} + + {(links.prev || links.next) && ( +
+ {links.prev && prevPage ? ( + + ← Previous + + ) : ( + + )} + {links.next && nextPage ? ( + + Next → + + ) : ( + + )} +
+ )} + + ); +} + export default async function CommitsPage({ params, searchParams, @@ -83,29 +182,6 @@ export default async function CommitsPage({ repoData.default_branch ?? "main", ); - let commits: CommitListItem[]; - let links: Record; - try { - const result = await apiClient.getCommits( - owner, - repo, - branch, - page, - ); - commits = result.commits; - links = result.links; - } catch (err) { - if (isApiError(err) && err.status === 404) { - commits = []; - links = {}; - } else { - throw err; - } - } - - const prevPage = links.prev ? pageFromLinkUrl(links.prev) : null; - const nextPage = links.next ? pageFromLinkUrl(links.next) : null; - return (
@@ -160,61 +236,23 @@ export default async function CommitsPage({ Commit history
- {commits.length === 0 ? ( -

No commits found.

- ) : ( -
    - {commits.map((item) => { - const message = item.commit.message.split("\n")[0]; - const authorName = item.author?.login ?? item.commit.author.name; - return ( -
  • - -
    -

    - {message} -

    -

    - {authorName} committed {formatDate(item.commit.author.date)} -

    -
    - - {item.sha.slice(0, 7)} - - -
  • - ); - })} -
- )} - - {(links.prev || links.next) && ( -
- {links.prev && prevPage ? ( - - ← Previous - - ) : ( - - )} - {links.next && nextPage ? ( - - Next → - - ) : ( - - )} -
- )} + + {[...Array(5)].map((_, i) => ( + + ))} + + } + > + + diff --git a/app/(app)/[owner]/[repo]/layout.tsx b/app/(app)/[owner]/[repo]/layout.tsx new file mode 100644 index 00000000..717398b5 --- /dev/null +++ b/app/(app)/[owner]/[repo]/layout.tsx @@ -0,0 +1,9 @@ +import ErrorBoundary from "@/components/ErrorBoundary"; + +export default function RepoLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/app/(app)/[owner]/[repo]/page.tsx b/app/(app)/[owner]/[repo]/page.tsx index b9e63b64..91d1a9b9 100644 --- a/app/(app)/[owner]/[repo]/page.tsx +++ b/app/(app)/[owner]/[repo]/page.tsx @@ -1,8 +1,11 @@ import Link from "next/link"; import { notFound } from "next/navigation"; +import { Suspense } from "react"; import { RepoRefSelector } from "@/components/repo/BranchSelector"; import CloneUrlCopy from "@/components/repo/CloneUrlCopy"; +import EmptyRepoState from "@/components/repo/EmptyRepoState"; import FileTree, { type TreeEntry } from "@/components/repo/FileTree"; +import { Skeleton } from "@/components/ui/skeleton"; import { apiClient, decodeBase64Content, @@ -18,6 +21,7 @@ interface RepoMetadata { private: boolean; visibility?: string; default_branch: string; + clone_url?: string; stargazers_count: number; watchers_count: number; forks_count: number; @@ -62,6 +66,83 @@ function mapContents(items: ContentItem[]): TreeEntry[] { })); } +function RepoPageSkeleton() { + return ( +
+ + + +
+ ); +} + +async function RepoFileTree({ + owner, + repo, + branch, + cloneUrl, +}: { + owner: string; + repo: string; + branch: string; + cloneUrl: string; +}) { + let contentsRaw: ContentItem[] | ContentItem | null = null; + let contentsNotFound = false; + try { + contentsRaw = await apiClient.getContents( + owner, + repo, + "", + branch, + ); + } catch (err) { + if (isApiError(err) && err.status === 404) { + contentsNotFound = true; + } else { + throw err; + } + } + + let commitsEmpty = false; + try { + const result = await apiClient.getCommits<{ sha: string }[]>( + owner, + repo, + branch, + 1, + ); + commitsEmpty = result.commits.length === 0; + } catch (err) { + if (isApiError(err) && err.status === 404) { + commitsEmpty = true; + } else { + throw err; + } + } + + if (contentsNotFound || commitsEmpty) { + return ; + } + + const contents: ContentItem[] = Array.isArray(contentsRaw) + ? contentsRaw + : contentsRaw + ? [contentsRaw] + : []; + const entries = mapContents(contents); + + return ( + + ); +} + async function fetchReadme( owner: string, repo: string, @@ -110,26 +191,12 @@ export default async function RepoPage({ const branches = branchesRaw.length > 0 ? branchesRaw : [{ name: metadata.default_branch ?? "main" }]; const branch = resolveBranchRef(refParam, branches, metadata.default_branch ?? "main"); - let contentsRaw: ContentItem[] | ContentItem | null = null; - try { - contentsRaw = await apiClient.getContents( - owner, - repo, - "", - branch, - ); - } catch (err) { - if (!isApiError(err) || err.status !== 404) throw err; - } + const cloneUrl = + metadata.clone_url ?? + `${process.env.NEXT_PUBLIC_API_URL ?? ""}/${owner}/${repo}.git`; const [readmeRaw] = await Promise.all([fetchReadme(owner, repo, branch)]); - const contents: ContentItem[] = Array.isArray(contentsRaw) - ? contentsRaw - : contentsRaw - ? [contentsRaw] - : []; - const entries = mapContents(contents); const readmeHtml = readmeRaw ? renderMarkdown(readmeRaw) : null; return ( @@ -263,13 +330,14 @@ export default async function RepoPage({ - + }> + + {readmeHtml && ( diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx new file mode 100644 index 00000000..ff30d57a --- /dev/null +++ b/components/ErrorBoundary.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Component, type ErrorInfo, type ReactNode } from "react"; + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + message: string; +} + +export default class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, message: "" }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, message: error.message }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error(error, errorInfo); + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+

{this.state.message}

+ +
+
+ ); + } + + return this.props.children; + } +} diff --git a/components/repo/EmptyRepoState.tsx b/components/repo/EmptyRepoState.tsx new file mode 100644 index 00000000..f6daf1db --- /dev/null +++ b/components/repo/EmptyRepoState.tsx @@ -0,0 +1,32 @@ +interface EmptyRepoStateProps { + cloneUrl: string; +} + +export default function EmptyRepoState({ cloneUrl }: EmptyRepoStateProps) { + return ( +
+

No code yet

+

+ Get started by cloning this repository and pushing your first commit. +

+ + {cloneUrl} + +
    +
  1. + git init +
  2. +
  3. + + git remote add origin {cloneUrl} + +
  4. +
  5. + + git push -u origin main + +
  6. +
+
+ ); +}