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]/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 27cef3ab..212f1f76 100644 --- a/app/(app)/[owner]/[repo]/page.tsx +++ b/app/(app)/[owner]/[repo]/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; 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 { apiClient, @@ -18,6 +19,7 @@ interface RepoMetadata { private: boolean; visibility?: string; default_branch: string; + clone_url?: string; stargazers_count: number; watchers_count: number; forks_count: number; @@ -110,7 +112,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"); + const cloneUrl = + metadata.clone_url ?? + `${process.env.NEXT_PUBLIC_API_URL ?? ""}/${owner}/${repo}.git`; + let contentsRaw: ContentItem[] | ContentItem | null = null; + let contentsNotFound = false; try { contentsRaw = await apiClient.getContents( owner, @@ -119,7 +126,11 @@ export default async function RepoPage({ branch, ); } catch (err) { - if (!isApiError(err) || err.status !== 404) throw err; + if (isApiError(err) && err.status === 404) { + contentsNotFound = true; + } else { + throw err; + } } const [readmeRaw] = await Promise.all([fetchReadme(owner, repo, branch)]); @@ -130,6 +141,7 @@ export default async function RepoPage({ ? [contentsRaw] : []; const entries = mapContents(contents); + const isEmptyRepo = contentsNotFound || entries.length === 0; const readmeHtml = readmeRaw ? renderMarkdown(readmeRaw) : null; return ( @@ -269,12 +281,16 @@ export default async function RepoPage({ - + {isEmptyRepo ? ( + + ) : ( + + )} {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. +
+
+ ); +}