diff --git a/actions/repositories.ts b/actions/repositories.ts index bc11bb9..afc0985 100644 --- a/actions/repositories.ts +++ b/actions/repositories.ts @@ -4,7 +4,8 @@ import { db } from "@/db"; import { repositories, users, stars } from "@/db/schema"; import { getSession } from "@/lib/session"; import { eq, and, desc, count, sql } from "drizzle-orm"; -import { revalidatePath, unstable_cache } from "next/cache"; +import { revalidatePath } from "next/cache"; +import { cacheLife, cacheTag } from "next/cache"; import git from "isomorphic-git"; import { createR2Fs, getRepoPrefix } from "@/lib/r2-fs"; @@ -244,6 +245,10 @@ export async function getRepoFileTree(owner: string, repoName: string, branch: s } export async function getRepoFile(owner: string, repoName: string, branch: string, filePath: string) { + "use cache"; + cacheTag(`repo:${owner}/${repoName}`, `file:${owner}/${repoName}:${branch}:${filePath}`); + cacheLife("hours"); + const user = await db.query.users.findFirst({ where: eq(users.username, owner), }); @@ -422,6 +427,10 @@ export async function updateRepository(repoId: string, data: { name?: string; de } export async function getRepoBranches(owner: string, repoName: string) { + "use cache"; + cacheTag(`repo:${owner}/${repoName}`, `branches:${owner}/${repoName}`); + cacheLife("hours"); + const user = await db.query.users.findFirst({ where: eq(users.username, owner), }); @@ -506,6 +515,10 @@ export async function getRepoCommitCount(owner: string, repoName: string, branch } export async function getPublicRepositories(sortBy: "stars" | "updated" | "created" = "updated", limit: number = 20, offset: number = 0) { + "use cache"; + cacheTag("public-repos", `public-repos:${sortBy}:${offset}`); + cacheLife("minutes"); + const allRepos = await db .select({ id: repositories.id, @@ -552,6 +565,16 @@ export async function getPublicRepositories(sortBy: "stars" | "updated" | "creat }; } +export async function getUserProfile(username: string) { + "use cache"; + cacheTag(`user-profile:${username}`); + cacheLife("hours"); + + return db.query.users.findFirst({ + where: eq(users.username, username), + }); +} + export type FileEntry = { name: string; type: "blob" | "tree"; @@ -649,23 +672,26 @@ async function fetchCommitCount(userId: string, repoName: string, defaultBranch: } } -const getCachedFileTree = (owner: string, repoName: string, userId: string, defaultBranch: string) => - unstable_cache(() => fetchFileTree(userId, repoName, defaultBranch), [`file-tree`, owner, repoName], { - tags: [`repo:${owner}/${repoName}`], - revalidate: 3600, - })(); +async function getCachedFileTree(owner: string, repoName: string, userId: string, defaultBranch: string) { + "use cache"; + cacheTag(`repo:${owner}/${repoName}`, `file-tree:${owner}/${repoName}`); + cacheLife("hours"); + return fetchFileTree(userId, repoName, defaultBranch); +} -const getCachedReadme = (owner: string, repoName: string, userId: string, readmeOid: string) => - unstable_cache(() => fetchReadme(userId, repoName, readmeOid), [`readme`, owner, repoName, readmeOid], { - tags: [`repo:${owner}/${repoName}`], - revalidate: 3600, - })(); +async function getCachedReadme(owner: string, repoName: string, userId: string, readmeOid: string) { + "use cache"; + cacheTag(`repo:${owner}/${repoName}`, `readme:${owner}/${repoName}`); + cacheLife("hours"); + return fetchReadme(userId, repoName, readmeOid); +} -const getCachedCommitCount = (owner: string, repoName: string, userId: string, defaultBranch: string) => - unstable_cache(() => fetchCommitCount(userId, repoName, defaultBranch), [`commit-count`, owner, repoName], { - tags: [`repo:${owner}/${repoName}`], - revalidate: 3600, - })(); +async function getCachedCommitCount(owner: string, repoName: string, userId: string, defaultBranch: string) { + "use cache"; + cacheTag(`repo:${owner}/${repoName}`, `commit-count:${owner}/${repoName}`); + cacheLife("hours"); + return fetchCommitCount(userId, repoName, defaultBranch); +} export async function getRepoPageData(owner: string, repoName: string) { const [user, session] = await Promise.all([db.query.users.findFirst({ where: eq(users.username, owner) }), getSession()]); @@ -723,6 +749,10 @@ export async function getRepoCommitCountCached(owner: string, repoName: string) } export async function getUserStarredRepos(username: string) { + "use cache"; + cacheTag(`user-starred:${username}`); + cacheLife("minutes"); + const user = await db.query.users.findFirst({ where: eq(users.username, username), }); diff --git a/app/(main)/[username]/[repo]/blob/[...path]/page.tsx b/app/(main)/[username]/[repo]/blob/[...path]/page.tsx index d236a61..4f10bc2 100644 --- a/app/(main)/[username]/[repo]/blob/[...path]/page.tsx +++ b/app/(main)/[username]/[repo]/blob/[...path]/page.tsx @@ -1,7 +1,9 @@ import { Suspense } from "react"; import { notFound } from "next/navigation"; +import { connection } from "next/server"; import Link from "next/link"; import { getRepository, getRepoFile, getRepoBranches } from "@/actions/repositories"; +import { ChunkedCodeViewer } from "@/components/chunked-code-viewer"; import { CodeViewer } from "@/components/code-viewer"; import { BranchSelector } from "@/components/branch-selector"; import { Badge } from "@/components/ui/badge"; @@ -28,12 +30,15 @@ const LANGUAGE_MAP: Record = { zsh: "bash", }; +const SMALL_FILE_THRESHOLD = 50 * 1024; + function getLanguage(filename: string): string { const ext = filename.split(".").pop()?.toLowerCase() || ""; return LANGUAGE_MAP[ext] || "text"; } async function FileContent({ username, repoName, branch, filePath }: { username: string; repoName: string; branch: string; filePath: string }) { + await connection(); const file = await getRepoFile(username, repoName, branch, filePath); if (!file) { @@ -42,6 +47,21 @@ async function FileContent({ username, repoName, branch, filePath }: { username: const fileName = filePath.split("/").pop() || ""; const language = getLanguage(fileName); + const fileSize = new TextEncoder().encode(file.content).length; + + if (fileSize > SMALL_FILE_THRESHOLD) { + return ( + + ); + } return ; } diff --git a/app/(main)/[username]/[repo]/page.tsx b/app/(main)/[username]/[repo]/page.tsx index 36f0be1..9bf95f8 100644 --- a/app/(main)/[username]/[repo]/page.tsx +++ b/app/(main)/[username]/[repo]/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from "react"; import { notFound } from "next/navigation"; +import { connection } from "next/server"; import { getRepoPageData, getRepoReadme, getRepoCommitCountCached } from "@/actions/repositories"; import { FileTree } from "@/components/file-tree"; import { CodeViewer } from "@/components/code-viewer"; @@ -13,6 +14,7 @@ import Link from "next/link"; import { getPublicServerUrl } from "@/lib/utils"; async function CommitCount({ username, repoName, branch }: { username: string; repoName: string; branch: string }) { + await connection(); const commitCount = await getRepoCommitCountCached(username, repoName); if (commitCount === 0) return null; @@ -30,6 +32,7 @@ async function CommitCount({ username, repoName, branch }: { username: string; r } async function ReadmeSection({ username, repoName, readmeOid }: { username: string; repoName: string; readmeOid: string }) { + await connection(); const content = await getRepoReadme(username, repoName, readmeOid); if (!content) return null; diff --git a/app/(main)/[username]/page.tsx b/app/(main)/[username]/page.tsx index 4930cef..23c200d 100644 --- a/app/(main)/[username]/page.tsx +++ b/app/(main)/[username]/page.tsx @@ -1,9 +1,7 @@ import { Suspense } from "react"; import { notFound } from "next/navigation"; -import { db } from "@/db"; -import { users } from "@/db/schema"; -import { eq } from "drizzle-orm"; -import { getUserRepositoriesWithStars, getUserStarredRepos } from "@/actions/repositories"; +import { connection } from "next/server"; +import { getUserRepositoriesWithStars, getUserStarredRepos, getUserProfile } from "@/actions/repositories"; import { RepoList } from "@/components/repo-list"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -13,6 +11,7 @@ import Link from "next/link"; import { GithubIcon, XIcon, LinkedInIcon } from "@/components/icons"; async function RepositoriesTab({ username }: { username: string }) { + await connection(); const repos = await getUserRepositoriesWithStars(username); if (repos.length === 0) { @@ -29,6 +28,7 @@ async function RepositoriesTab({ username }: { username: string }) { } async function StarredTab({ username }: { username: string }) { + await connection(); const repos = await getUserStarredRepos(username); if (repos.length === 0) { @@ -65,9 +65,7 @@ export default async function ProfilePage({ params, searchParams }: { params: Pr const { username } = await params; const { tab } = await searchParams; - const user = await db.query.users.findFirst({ - where: eq(users.username, username), - }); + const user = await getUserProfile(username); if (!user) { notFound(); diff --git a/app/(main)/explore/page.tsx b/app/(main)/explore/page.tsx index 641d406..f530b31 100644 --- a/app/(main)/explore/page.tsx +++ b/app/(main)/explore/page.tsx @@ -1,8 +1,10 @@ +import { Suspense } from "react"; +import { connection } from "next/server"; import Link from "next/link"; import { getPublicRepositories } from "@/actions/repositories"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Star, GitBranch, ChevronLeft, ChevronRight, Compass, Clock, Flame, Sparkles } from "lucide-react"; +import { Star, GitBranch, ChevronLeft, ChevronRight, Compass, Clock, Flame, Sparkles, Loader2 } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; const SORT_OPTIONS = [ @@ -11,14 +13,103 @@ const SORT_OPTIONS = [ { value: "created", label: "Newest", icon: Sparkles }, ] as const; +async function RepoGrid({ sortBy, page, perPage }: { sortBy: "stars" | "updated" | "created"; page: number; perPage: number }) { + await connection(); + const offset = (page - 1) * perPage; + const { repos, hasMore } = await getPublicRepositories(sortBy, perPage, offset); + + if (repos.length === 0) { + return ( +
+ +

No repositories yet

+

Be the first to create a public repository!

+
+ ); + } + + return ( + <> +
+ {repos.map((repo) => ( +
+
+ + + {repo.owner.name?.charAt(0).toUpperCase() || "U"} + +
+
+ + {repo.owner.username} + + / + + {repo.name} + +
+ {repo.description &&

{repo.description}

} +
+
+ + {repo.starCount} +
+ Updated {formatDistanceToNow(new Date(repo.updatedAt), { addSuffix: true })} +
+
+
+
+ ))} +
+ + {(page > 1 || hasMore) && ( +
+ + Page {page} + +
+ )} + + ); +} + +function RepoGridSkeleton() { + return ( +
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + export default async function ExplorePage({ searchParams }: { searchParams: Promise<{ sort?: string; page?: string }> }) { const { sort: sortParam, page: pageParam } = await searchParams; const sortBy = (["stars", "updated", "created"].includes(sortParam || "") ? sortParam : "stars") as "stars" | "updated" | "created"; const page = parseInt(pageParam || "1", 10); const perPage = 20; - const offset = (page - 1) * perPage; - - const { repos, hasMore } = await getPublicRepositories(sortBy, perPage, offset); return (
@@ -41,63 +132,9 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom ))}
- {repos.length === 0 ? ( -
- -

No repositories yet

-

Be the first to create a public repository!

-
- ) : ( -
- {repos.map((repo) => ( -
-
- - - {repo.owner.name?.charAt(0).toUpperCase() || "U"} - -
-
- - {repo.owner.username} - - / - - {repo.name} - -
- {repo.description &&

{repo.description}

} -
-
- - {repo.starCount} -
- Updated {formatDistanceToNow(new Date(repo.updatedAt), { addSuffix: true })} -
-
-
-
- ))} -
- )} - - {(page > 1 || hasMore) && ( -
- - Page {page} - -
- )} + }> + +
); } diff --git a/app/api/file/[...path]/route.ts b/app/api/file/[...path]/route.ts new file mode 100644 index 0000000..9d44511 --- /dev/null +++ b/app/api/file/[...path]/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/db"; +import { users, repositories } from "@/db/schema"; +import { eq, and } from "drizzle-orm"; +import { getSession } from "@/lib/session"; +import git from "isomorphic-git"; +import { createR2Fs, getRepoPrefix } from "@/lib/r2-fs"; + +const CHUNK_SIZE = 64 * 1024; + +export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + const { path } = await params; + + if (path.length < 4) { + return NextResponse.json({ error: "Invalid path" }, { status: 400 }); + } + + const [username, repoName, branch, ...fileParts] = path; + const filePath = fileParts.join("/"); + + const user = await db.query.users.findFirst({ + where: eq(users.username, username), + }); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const repo = await db.query.repositories.findFirst({ + where: and(eq(repositories.ownerId, user.id), eq(repositories.name, repoName)), + }); + + if (!repo) { + return NextResponse.json({ error: "Repository not found" }, { status: 404 }); + } + + if (repo.visibility === "private") { + const session = await getSession(); + if (!session?.user || session.user.id !== repo.ownerId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + } + + const repoPrefix = getRepoPrefix(user.id, `${repoName}.git`); + const fs = createR2Fs(repoPrefix); + + try { + const commits = await git.log({ + fs, + gitdir: "/", + ref: branch, + depth: 1, + }); + + if (commits.length === 0) { + return NextResponse.json({ error: "Branch not found" }, { status: 404 }); + } + + const commitOid = commits[0].oid; + const parts = filePath.split("/").filter(Boolean); + const fileName = parts.pop()!; + + let currentTree = (await git.readTree({ fs, gitdir: "/", oid: commitOid })).tree; + + for (const part of parts) { + const entry = currentTree.find((e) => e.path === part && e.type === "tree"); + if (!entry) { + return NextResponse.json({ error: "Path not found" }, { status: 404 }); + } + currentTree = (await git.readTree({ fs, gitdir: "/", oid: entry.oid })).tree; + } + + const fileEntry = currentTree.find((e) => e.path === fileName && e.type === "blob"); + if (!fileEntry) { + return NextResponse.json({ error: "File not found" }, { status: 404 }); + } + + const { blob } = await git.readBlob({ + fs, + gitdir: "/", + oid: fileEntry.oid, + }); + + const rangeHeader = request.headers.get("range"); + + if (rangeHeader) { + const match = rangeHeader.match(/bytes=(\d+)-(\d*)/); + if (match) { + const start = parseInt(match[1], 10); + const end = match[2] ? parseInt(match[2], 10) : Math.min(start + CHUNK_SIZE - 1, blob.length - 1); + const chunk = blob.slice(start, end + 1); + + return new NextResponse(chunk, { + status: 206, + headers: { + "Content-Range": `bytes ${start}-${end}/${blob.length}`, + "Accept-Ranges": "bytes", + "Content-Length": chunk.length.toString(), + "Content-Type": "application/octet-stream", + }, + }); + } + } + + const stream = new ReadableStream({ + start(controller) { + let offset = 0; + const push = () => { + if (offset >= blob.length) { + controller.close(); + return; + } + const chunk = blob.slice(offset, offset + CHUNK_SIZE); + controller.enqueue(chunk); + offset += CHUNK_SIZE; + setTimeout(push, 0); + }; + push(); + }, + }); + + return new NextResponse(stream, { + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": blob.length.toString(), + "Accept-Ranges": "bytes", + "X-Total-Size": blob.length.toString(), + }, + }); + } catch (err) { + console.error("File streaming error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + diff --git a/components/chunked-code-viewer.tsx b/components/chunked-code-viewer.tsx new file mode 100644 index 0000000..61c83a5 --- /dev/null +++ b/components/chunked-code-viewer.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { CodeViewer } from "./code-viewer"; +import { Loader2 } from "lucide-react"; + +const CHUNK_SIZE = 64 * 1024; +const LARGE_FILE_THRESHOLD = 100 * 1024; + +interface ChunkedCodeViewerProps { + username: string; + repoName: string; + branch: string; + filePath: string; + language: string; + initialContent?: string; + totalSize?: number; +} + +export function ChunkedCodeViewer({ username, repoName, branch, filePath, language, initialContent, totalSize }: ChunkedCodeViewerProps) { + const [content, setContent] = useState(initialContent || ""); + const [loading, setLoading] = useState(!initialContent); + const [progress, setProgress] = useState(initialContent ? 100 : 0); + const [error, setError] = useState(null); + + const loadFile = useCallback(async () => { + if (initialContent) return; + + setLoading(true); + setError(null); + + try { + const response = await fetch(`/api/file/${username}/${repoName}/${branch}/${filePath}`); + + if (!response.ok) { + throw new Error("Failed to load file"); + } + + const size = parseInt(response.headers.get("X-Total-Size") || "0", 10); + + if (size < LARGE_FILE_THRESHOLD || !response.body) { + const text = await response.text(); + setContent(text); + setProgress(100); + setLoading(false); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let result = ""; + let loaded = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + result += decoder.decode(value, { stream: true }); + loaded += value.length; + setProgress(Math.min(Math.round((loaded / size) * 100), 100)); + setContent(result); + } + + result += decoder.decode(); + setContent(result); + setProgress(100); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load file"); + } finally { + setLoading(false); + } + }, [username, repoName, branch, filePath, initialContent]); + + useEffect(() => { + loadFile(); + }, [loadFile]); + + if (error) { + return ( +
+

{error}

+
+ ); + } + + return ( +
+ {loading && progress < 100 && ( +
+
+
+
+
+ + {progress}% +
+
+ )} + {content ? ( + + ) : ( +
+ +
+ )} +
+ ); +} diff --git a/next.config.ts b/next.config.ts index 0bf7d54..1a8bfa5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,8 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ experimental: { + useCache: true, serverActions: { bodySizeLimit: "1mb", },