From a361d8c6ec1f8f37bf5c10046657cffad51f823c Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sun, 12 Apr 2026 15:37:08 -0400 Subject: [PATCH] Add repository overview page at // with code explorer, markdown preview, and sidebar --- .../src/components/layouts/dashboard-tabs.tsx | 24 +- .../components/repo/code-explorer-toolbar.tsx | 296 ++++++++++++++++++ .../src/components/repo/file-tree.tsx | 68 ++++ .../src/components/repo/latest-commit-bar.tsx | 55 ++++ .../src/components/repo/repo-header.tsx | 45 +++ .../components/repo/repo-markdown-files.tsx | 183 +++++++++++ .../components/repo/repo-overview-page.tsx | 118 +++++++ .../repo/repo-overview-skeleton.tsx | 66 ++++ .../src/components/repo/repo-sidebar.tsx | 143 +++++++++ apps/dashboard/src/lib/github-cache-policy.ts | 4 + apps/dashboard/src/lib/github-revalidation.ts | 22 ++ apps/dashboard/src/lib/github.functions.ts | 275 ++++++++++++++++ apps/dashboard/src/lib/github.query.ts | 98 ++++++ apps/dashboard/src/lib/github.types.ts | 55 ++++ apps/dashboard/src/lib/tab-store.ts | 5 +- .../src/lib/use-github-revalidation.ts | 86 ++++- apps/dashboard/src/lib/use-register-tab.ts | 11 +- apps/dashboard/src/routeTree.gen.ts | 21 ++ .../routes/_protected/$owner/$repo/index.tsx | 103 ++++++ packages/icons/src/index.ts | 4 +- packages/ui/src/components/command.tsx | 2 +- packages/ui/src/components/tabs.tsx | 10 +- 22 files changed, 1673 insertions(+), 21 deletions(-) create mode 100644 apps/dashboard/src/components/repo/code-explorer-toolbar.tsx create mode 100644 apps/dashboard/src/components/repo/file-tree.tsx create mode 100644 apps/dashboard/src/components/repo/latest-commit-bar.tsx create mode 100644 apps/dashboard/src/components/repo/repo-header.tsx create mode 100644 apps/dashboard/src/components/repo/repo-markdown-files.tsx create mode 100644 apps/dashboard/src/components/repo/repo-overview-page.tsx create mode 100644 apps/dashboard/src/components/repo/repo-overview-skeleton.tsx create mode 100644 apps/dashboard/src/components/repo/repo-sidebar.tsx create mode 100644 apps/dashboard/src/routes/_protected/$owner/$repo/index.tsx diff --git a/apps/dashboard/src/components/layouts/dashboard-tabs.tsx b/apps/dashboard/src/components/layouts/dashboard-tabs.tsx index 1f1924a..6e3b1e5 100644 --- a/apps/dashboard/src/components/layouts/dashboard-tabs.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-tabs.tsx @@ -1,4 +1,5 @@ import { + ArchiveIcon, ChevronRightIcon, CloseIcon, GitPullRequestIcon, @@ -28,6 +29,7 @@ const tabIconMap = { pull: GitPullRequestIcon, issue: IssuesIcon, review: ReviewsIcon, + repo: ArchiveIcon, } as const; function useScrollShadows(tabCount: number) { @@ -262,11 +264,19 @@ const DetailTab = memo(function DetailTab({ activeProps={{ className: "active" }} className="group relative flex h-8 shrink-0 items-center gap-1.5 rounded-md px-3 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-surface-1 hover:text-foreground [&.active]:bg-surface-1 [&.active]:text-foreground" > - + {tab.avatarUrl ? ( + + ) : ( + + )} {tab.title} {tab.type === "review" ? ( @@ -277,11 +287,11 @@ const DetailTab = memo(function DetailTab({ -{tab.deletions} )} - ) : ( + ) : tab.number != null ? ( #{tab.number} - )} + ) : null} {/* Mobile: inline close button in flow — oversized touch target */} + + + + + + + {branchesQuery.isLoading ? "Loading…" : "No branches found."} + + {/* Default branch always first */} + { + onBranchChange(value); + setOpen(false); + }} + className={cn( + repo.defaultBranch === currentRef && + "font-medium text-foreground", + )} + > + + {repo.defaultBranch} + + default + + + {branchesQuery.data + ?.filter((b) => b.name !== repo.defaultBranch) + .map((branch) => ( + { + onBranchChange(value); + setOpen(false); + }} + className={cn( + branch.name === currentRef && "font-medium text-foreground", + )} + > + + {branch.name} + + ))} + + +
+ + {repo.branchCount} branch{repo.branchCount !== 1 ? "es" : ""} + {repo.tagCount > 0 && ( + <> + {" · "} + {repo.tagCount} tag{repo.tagCount !== 1 ? "s" : ""} + + )} + +
+
+ + ); +} + +function CodePopover({ repo }: { repo: RepoOverview }) { + const [copied, setCopied] = useState(false); + const httpsUrl = `https://github.com/${repo.fullName}.git`; + const sshUrl = `git@github.com:${repo.fullName}.git`; + const cliCommand = `gh repo clone ${repo.fullName}`; + const zipUrl = `https://github.com/${repo.fullName}/archive/refs/heads/${repo.defaultBranch}.zip`; + + const handleCopy = useCallback((text: string) => { + void navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, []); + + return ( + + + + + +
+ Clone {repo.fullName} +
+ + + + + HTTPS + + + SSH + + + CLI + + + + + handleCopy(httpsUrl)} + /> + +

+ Clone using the web URL. +

+
+ + handleCopy(sshUrl)} + /> + +

+ Use a password-protected SSH key. +

+
+ + handleCopy(cliCommand)} + /> +

+ Work fast with the official CLI. +

+
+
+ + +
+
+ ); +} + +function CloneInput({ + value, + copied, + onCopy, +}: { + value: string; + copied: boolean; + onCopy: () => void; +}) { + return ( +
+ + {value} + + +
+ ); +} diff --git a/apps/dashboard/src/components/repo/file-tree.tsx b/apps/dashboard/src/components/repo/file-tree.tsx new file mode 100644 index 0000000..d2a221d --- /dev/null +++ b/apps/dashboard/src/components/repo/file-tree.tsx @@ -0,0 +1,68 @@ +import { FileIcon, FolderIcon } from "@diffkit/icons"; +import { cn } from "@diffkit/ui/lib/utils"; +import { formatRelativeTime } from "#/lib/format-relative-time"; +import type { RepoTreeEntry } from "#/lib/github.types"; + +export function FileTree({ entries }: { entries: RepoTreeEntry[] }) { + return ( +
+ {entries.map((entry, index) => ( + + ))} +
+ ); +} + +function FileTreeRow({ + entry, + isLast, +}: { + entry: RepoTreeEntry; + isLast: boolean; +}) { + const Icon = entry.type === "dir" ? FolderIcon : FileIcon; + + return ( +
+
+ + + {entry.name} + +
+ + {entry.lastCommit?.message ?? ""} + + + {entry.lastCommit?.date + ? formatRelativeTime(entry.lastCommit.date) + : ""} + +
+ ); +} diff --git a/apps/dashboard/src/components/repo/latest-commit-bar.tsx b/apps/dashboard/src/components/repo/latest-commit-bar.tsx new file mode 100644 index 0000000..965e3f1 --- /dev/null +++ b/apps/dashboard/src/components/repo/latest-commit-bar.tsx @@ -0,0 +1,55 @@ +import { GitCommitIcon } from "@diffkit/icons"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@diffkit/ui/components/tooltip"; +import { formatRelativeTime } from "#/lib/format-relative-time"; +import type { RepoOverview } from "#/lib/github.types"; + +export function LatestCommitBar({ repo }: { repo: RepoOverview }) { + const commit = repo.latestCommit; + if (!commit) return null; + + const shortSha = commit.sha.slice(0, 7); + const firstLine = commit.message.split("\n")[0]; + + return ( +
+ {commit.author && ( + {commit.author.login} + )} + {commit.author?.login ?? "Unknown"} + + + + {firstLine} + + + {firstLine.length > 60 && ( + + {firstLine} + + )} + +
+ + + + + {shortSha} + + + + {commit.sha} + + + {formatRelativeTime(commit.date)} +
+
+ ); +} diff --git a/apps/dashboard/src/components/repo/repo-header.tsx b/apps/dashboard/src/components/repo/repo-header.tsx new file mode 100644 index 0000000..e22b825 --- /dev/null +++ b/apps/dashboard/src/components/repo/repo-header.tsx @@ -0,0 +1,45 @@ +import { ArchiveIcon } from "@diffkit/icons"; +import { Badge } from "@diffkit/ui/components/badge"; +import { cn } from "@diffkit/ui/lib/utils"; +import { Link } from "@tanstack/react-router"; +import { useState } from "react"; +import type { RepoOverview } from "#/lib/github.types"; + +export function RepoHeader({ repo }: { repo: RepoOverview }) { + const [imgError, setImgError] = useState(false); + + return ( +
+ {repo.ownerAvatarUrl && !imgError ? ( + {repo.owner} setImgError(true)} + /> + ) : ( + + )} +
+ + {repo.owner} + + / + {repo.name} +
+ + {repo.isPrivate ? "Private" : "Public"} + +
+ ); +} diff --git a/apps/dashboard/src/components/repo/repo-markdown-files.tsx b/apps/dashboard/src/components/repo/repo-markdown-files.tsx new file mode 100644 index 0000000..8c460c9 --- /dev/null +++ b/apps/dashboard/src/components/repo/repo-markdown-files.tsx @@ -0,0 +1,183 @@ +import { Skeleton } from "@diffkit/ui/components/skeleton"; +import { cn } from "@diffkit/ui/lib/utils"; +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { lazy, Suspense, useEffect, useMemo, useState } from "react"; +import { + type GitHubQueryScope, + githubRepoFileContentQueryOptions, +} from "#/lib/github.query"; +import type { RepoTreeEntry } from "#/lib/github.types"; + +const Markdown = lazy(() => + import("@diffkit/ui/components/markdown").then((mod) => ({ + default: mod.Markdown, + })), +); + +const KNOWN_MD_FILES = [ + "README.md", + "readme.md", + "README", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", + "LICENSE", + "LICENSE.md", + "LICENSE.txt", + "SECURITY.md", + "CHANGELOG.md", +]; + +const TAB_LABELS: Record = { + "README.md": "README", + "readme.md": "README", + README: "README", + "CODE_OF_CONDUCT.md": "Code of conduct", + "CONTRIBUTING.md": "Contributing", + LICENSE: "License", + "LICENSE.md": "License", + "LICENSE.txt": "License", + "SECURITY.md": "Security", + "CHANGELOG.md": "Changelog", +}; + +export function RepoMarkdownFiles({ + entries, + owner, + repo, + currentRef, + scope, +}: { + entries: RepoTreeEntry[]; + owner: string; + repo: string; + currentRef: string; + scope: GitHubQueryScope; +}) { + const mdFiles = useMemo(() => { + const fileNames = new Set(entries.map((e) => e.name)); + return KNOWN_MD_FILES.filter((name) => fileNames.has(name)); + }, [entries]); + + // Prefetch all MD files in background so tab switches are instant + const queryClient = useQueryClient(); + useEffect(() => { + for (const file of mdFiles) { + void queryClient.prefetchQuery( + githubRepoFileContentQueryOptions(scope, { + owner, + repo, + ref: currentRef, + path: file, + }), + ); + } + }, [queryClient, mdFiles, scope, owner, repo, currentRef]); + + const [activeTab, setActiveTab] = useState(null); + const selectedFile = activeTab ?? mdFiles[0] ?? null; + + if (mdFiles.length === 0) return null; + + return ( +
+ {/* Tab bar */} +
+ {mdFiles.map((file) => ( + + ))} +
+ + {/* Content */} + {selectedFile && ( +
+ +
+ )} +
+ ); +} + +function MarkdownFileContent({ + owner, + repo, + path, + currentRef, + scope, +}: { + owner: string; + repo: string; + path: string; + currentRef: string; + scope: GitHubQueryScope; +}) { + const contentQuery = useQuery({ + ...githubRepoFileContentQueryOptions(scope, { + owner, + repo, + ref: currentRef, + path, + }), + placeholderData: keepPreviousData, + }); + + if (contentQuery.isLoading) { + return ( +
+ + + + + + +
+ ); + } + + if (!contentQuery.data) { + return ( +
+ Unable to load file content. +
+ ); + } + + return ( +
+ + + + +
+ } + > + + {contentQuery.data} + + + + ); +} diff --git a/apps/dashboard/src/components/repo/repo-overview-page.tsx b/apps/dashboard/src/components/repo/repo-overview-page.tsx new file mode 100644 index 0000000..ef4147b --- /dev/null +++ b/apps/dashboard/src/components/repo/repo-overview-page.tsx @@ -0,0 +1,118 @@ +import { useQuery } from "@tanstack/react-query"; +import { getRouteApi } from "@tanstack/react-router"; +import { useState } from "react"; +import { + githubRepoOverviewQueryOptions, + githubRepoTreeQueryOptions, +} from "#/lib/github.query"; +import { useHasMounted } from "#/lib/use-has-mounted"; +import { useRegisterTab } from "#/lib/use-register-tab"; +import { CodeExplorerToolbar } from "./code-explorer-toolbar"; +import { FileTree } from "./file-tree"; +import { LatestCommitBar } from "./latest-commit-bar"; +import { RepoHeader } from "./repo-header"; +import { RepoMarkdownFiles } from "./repo-markdown-files"; +import { RepoOverviewSkeleton } from "./repo-overview-skeleton"; +import { RepoSidebar } from "./repo-sidebar"; + +const routeApi = getRouteApi("/_protected/$owner/$repo/"); + +export function RepoOverviewPage() { + const { user } = routeApi.useRouteContext(); + const { owner, repo } = routeApi.useParams(); + const scope = { userId: user.id }; + const hasMounted = useHasMounted(); + + const overviewQuery = useQuery({ + ...githubRepoOverviewQueryOptions(scope, { owner, repo }), + enabled: hasMounted, + }); + + const repoData = overviewQuery.data; + const [currentRef, setCurrentRef] = useState(null); + const activeRef = currentRef ?? repoData?.defaultBranch ?? "main"; + + useRegisterTab( + repoData + ? { + type: "repo", + title: `${owner}/${repoData.name}`, + url: `/${owner}/${repo}`, + repo: `${owner}/${repo}`, + iconColor: "text-muted-foreground", + avatarUrl: repoData.ownerAvatarUrl, + } + : null, + ); + + const treeQuery = useQuery({ + ...githubRepoTreeQueryOptions(scope, { + owner, + repo, + ref: activeRef, + path: "", + }), + enabled: hasMounted && !!repoData, + }); + + if (overviewQuery.error) throw overviewQuery.error; + if (!repoData) return ; + + return ( +
+
+
+ + + + +
+ + {treeQuery.data ? ( + + ) : ( + + )} +
+ + {treeQuery.data && ( + + )} +
+ + +
+
+ ); +} + +const skeletonRows = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7"]; + +function FileTreeSkeleton() { + return ( +
+ {skeletonRows.map((key) => ( +
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/apps/dashboard/src/components/repo/repo-overview-skeleton.tsx b/apps/dashboard/src/components/repo/repo-overview-skeleton.tsx new file mode 100644 index 0000000..866045a --- /dev/null +++ b/apps/dashboard/src/components/repo/repo-overview-skeleton.tsx @@ -0,0 +1,66 @@ +import { Skeleton } from "@diffkit/ui/components/skeleton"; + +export function RepoOverviewSkeleton() { + return ( +
+
+
+ {/* Header */} +
+ + + +
+ + {/* Toolbar */} +
+ + +
+ + {/* Commit bar + file rows */} +
+
+
+ + + + +
+
+
+ {Array.from({ length: 8 }, (_, i) => `skeleton-row-${i}`).map( + (key) => ( +
+ + + + +
+ ), + )} +
+
+
+ + {/* Sidebar skeleton */} + +
+
+ ); +} diff --git a/apps/dashboard/src/components/repo/repo-sidebar.tsx b/apps/dashboard/src/components/repo/repo-sidebar.tsx new file mode 100644 index 0000000..3d00ef5 --- /dev/null +++ b/apps/dashboard/src/components/repo/repo-sidebar.tsx @@ -0,0 +1,143 @@ +import { GitBranchIcon, GitForkIcon, StarIcon, ViewIcon } from "@diffkit/icons"; +import { Badge } from "@diffkit/ui/components/badge"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@diffkit/ui/components/tooltip"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { + DetailSidebar, + DetailSidebarRow, + DetailSidebarSection, +} from "#/components/details/detail-sidebar"; +import { + type GitHubQueryScope, + githubRepoContributorsQueryOptions, +} from "#/lib/github.query"; +import type { RepoOverview } from "#/lib/github.types"; +import { useHasMounted } from "#/lib/use-has-mounted"; + +function formatCount(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`; + return String(n); +} + +export function RepoSidebar({ + repo, + scope, +}: { + repo: RepoOverview; + scope: GitHubQueryScope; +}) { + return ( + + {/* About */} + {repo.description && ( + +

{repo.description}

+
+ )} + + {/* Topics */} + {repo.topics.length > 0 && ( + +
+ {repo.topics.map((topic) => ( + + {topic} + + ))} +
+
+ )} + + {/* Stats */} + + + {formatCount(repo.stars)} + + + {formatCount(repo.watchers)} + + + {formatCount(repo.forks)} + + + {repo.branchCount} + + + + {/* Details */} + + {repo.language && ( + {repo.language} + )} + {repo.license && ( + {repo.license} + )} + + + {/* Contributors */} + +
+ ); +} + +function ContributorsSection({ + repo, + scope, +}: { + repo: RepoOverview; + scope: GitHubQueryScope; +}) { + const hasMounted = useHasMounted(); + const contributorsQuery = useQuery({ + ...githubRepoContributorsQueryOptions(scope, { + owner: repo.owner, + repo: repo.name, + }), + enabled: hasMounted, + }); + + const data = contributorsQuery.data; + if (!data) return null; + + return ( + +
+
+ {data.contributors.map((c) => ( + + + + {c.login} + + + + {c.login} · {formatCount(c.contributions)} commits + + + ))} +
+
+ {data.totalCount > data.contributors.length && ( + + )} +
+ ); +} diff --git a/apps/dashboard/src/lib/github-cache-policy.ts b/apps/dashboard/src/lib/github-cache-policy.ts index 9c26ef5..64ad4db 100644 --- a/apps/dashboard/src/lib/github-cache-policy.ts +++ b/apps/dashboard/src/lib/github-cache-policy.ts @@ -31,4 +31,8 @@ export const githubCachePolicy = { staleTimeMs: 60 * 60 * 1000, gcTimeMs: 24 * 60 * 60 * 1000, }, + repoMeta: { + staleTimeMs: 30 * 60 * 1000, + gcTimeMs: 24 * 60 * 60 * 1000, + }, } as const; diff --git a/apps/dashboard/src/lib/github-revalidation.ts b/apps/dashboard/src/lib/github-revalidation.ts index c16ec74..3690df5 100644 --- a/apps/dashboard/src/lib/github-revalidation.ts +++ b/apps/dashboard/src/lib/github-revalidation.ts @@ -18,6 +18,8 @@ export const githubRevalidationSignalKeys = { `workflowRun:${input.owner}/${input.repo}#${input.runId}`, workflowJobEntity: (input: { owner: string; repo: string; jobId: number }) => `workflowJob:${input.owner}/${input.repo}#${input.jobId}`, + repoCode: (input: { owner: string; repo: string }) => + `repoCode:${input.owner}/${input.repo}`, } as const; export type GitHubRevalidationSignalRecord = { @@ -249,6 +251,15 @@ export function getGitHubWebhookRevalidationSignalKeys( ]; } + if (event === "push") { + return [ + githubRevalidationSignalKeys.repoCode({ + owner: repository.owner, + repo: repository.repo, + }), + ]; + } + if (event === "delete") { return [githubRevalidationSignalKeys.pullsMine]; } @@ -319,6 +330,17 @@ export function getGitHubWebhookRevalidationSignalKeys( export function getGitHubRevalidationSignalKeysForTab(tab: Tab) { const [owner, repo] = tab.repo.split("/"); + if (tab.type === "repo") { + return [ + githubRevalidationSignalKeys.repoCode({ + owner, + repo, + }), + ]; + } + + if (tab.number == null) return []; + if (tab.type === "pull" || tab.type === "review") { return [ githubRevalidationSignalKeys.pullEntity({ diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 4be0dd0..9e23e75 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -30,8 +30,13 @@ import type { PullReviewComment, PullStatus, PullSummary, + RepoBranch, RepoCollaborator, + RepoContributor, + RepoContributorsResult, + RepoOverview, RepositoryRef, + RepoTreeEntry, RequestReviewersInput, SetLabelsInput, SubmitReviewInput, @@ -4350,3 +4355,273 @@ export const getUserActivity = createServerFn({ method: "GET" }) return []; } }); + +// --------------------------------------------------------------------------- +// Repository overview +// --------------------------------------------------------------------------- + +type RepoOverviewInput = { + owner: string; + repo: string; +}; + +export const getRepoOverview = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) return null; + + const [repoRes, branchesRes, tagsRes, commitsRes] = await Promise.all([ + context.octokit.rest.repos.get({ + owner: data.owner, + repo: data.repo, + }), + context.octokit.rest.repos.listBranches({ + owner: data.owner, + repo: data.repo, + per_page: 1, + }), + context.octokit.rest.repos.listTags({ + owner: data.owner, + repo: data.repo, + per_page: 1, + }), + context.octokit.rest.repos.listCommits({ + owner: data.owner, + repo: data.repo, + per_page: 1, + }), + ]); + + const repo = repoRes.data; + + // Extract total counts from Link headers + const branchCount = + parseLinkHeaderLastPage(branchesRes.headers.link as string | undefined) ?? + branchesRes.data.length; + const tagCount = + parseLinkHeaderLastPage(tagsRes.headers.link as string | undefined) ?? + tagsRes.data.length; + + const latestCommit = commitsRes.data[0] + ? { + sha: commitsRes.data[0].sha, + message: commitsRes.data[0].commit.message, + date: + commitsRes.data[0].commit.committer?.date ?? + commitsRes.data[0].commit.author?.date ?? + "", + author: mapActor(commitsRes.data[0].author), + } + : null; + + return { + id: repo.id, + name: repo.name, + fullName: repo.full_name, + description: repo.description, + isPrivate: repo.private, + isFork: repo.fork, + defaultBranch: repo.default_branch, + stars: repo.stargazers_count, + forks: repo.forks_count, + watchers: repo.subscribers_count, + language: repo.language, + license: repo.license?.spdx_id ?? null, + topics: repo.topics ?? [], + url: repo.html_url, + owner: repo.owner.login, + ownerAvatarUrl: repo.owner.avatar_url, + branchCount, + tagCount, + latestCommit, + }; + }); + +function parseLinkHeaderLastPage(link: string | undefined): number | null { + if (!link) return null; + const match = link.match(/[&?]page=(\d+)[^>]*>;\s*rel="last"/); + return match ? Number(match[1]) : null; +} + +// --------------------------------------------------------------------------- +// Repository branches +// --------------------------------------------------------------------------- + +type RepoBranchesInput = { + owner: string; + repo: string; +}; + +export const getRepoBranches = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) return []; + + const res = await context.octokit.rest.repos.listBranches({ + owner: data.owner, + repo: data.repo, + per_page: 25, + }); + + return res.data.map((b) => ({ + name: b.name, + isProtected: b.protected, + })); + }); + +// --------------------------------------------------------------------------- +// Repository tree contents +// --------------------------------------------------------------------------- + +type RepoTreeInput = { + owner: string; + repo: string; + ref: string; + path: string; +}; + +export const getRepoTree = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) return []; + + const res = await context.octokit.rest.repos.getContent({ + owner: data.owner, + repo: data.repo, + path: data.path, + ref: data.ref, + }); + + if (!Array.isArray(res.data)) return []; + + const entries: RepoTreeEntry[] = res.data.map((item) => ({ + name: item.name, + type: + item.type === "dir" + ? "dir" + : item.type === "submodule" + ? "submodule" + : "file", + path: item.path, + sha: item.sha, + size: item.size ?? null, + lastCommit: null, + })); + + // Fetch last commit for each entry in parallel (capped at 15 to avoid rate limits) + const BATCH_SIZE = 15; + for (let i = 0; i < entries.length; i += BATCH_SIZE) { + const batch = entries.slice(i, i + BATCH_SIZE); + const commitResults = await Promise.all( + batch.map(async (entry) => { + try { + const commitRes = await context.octokit.rest.repos.listCommits({ + owner: data.owner, + repo: data.repo, + sha: data.ref, + path: entry.path, + per_page: 1, + }); + const commit = commitRes.data[0]; + if (commit) { + return { + message: commit.commit.message.split("\n")[0], + date: + commit.commit.committer?.date ?? + commit.commit.author?.date ?? + "", + }; + } + } catch { + // Ignore individual commit fetch failures + } + return null; + }), + ); + + for (let j = 0; j < batch.length; j++) { + batch[j].lastCommit = commitResults[j] ?? null; + } + } + + // Sort: dirs first, then files, alphabetically within each group + entries.sort((a, b) => { + if (a.type === "dir" && b.type !== "dir") return -1; + if (a.type !== "dir" && b.type === "dir") return 1; + return a.name.localeCompare(b.name); + }); + + return entries; + }); + +// --------------------------------------------------------------------------- +// Repository file content +// --------------------------------------------------------------------------- + +type RepoFileContentInput = { + owner: string; + repo: string; + path: string; + ref: string; +}; + +export const getRepoFileContent = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) return null; + + try { + const res = await context.octokit.rest.repos.getContent({ + owner: data.owner, + repo: data.repo, + path: data.path, + ref: data.ref, + }); + + if (Array.isArray(res.data) || !("content" in res.data)) return null; + return Buffer.from(res.data.content, "base64").toString("utf-8"); + } catch { + return null; + } + }); + +// --------------------------------------------------------------------------- +// Repository contributors +// --------------------------------------------------------------------------- + +type RepoContributorsInput = { + owner: string; + repo: string; +}; + +export const getRepoContributors = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) return { contributors: [], totalCount: 0 }; + + const res = await context.octokit.rest.repos.listContributors({ + owner: data.owner, + repo: data.repo, + per_page: 30, + anon: "false", + }); + + const totalCount = + parseLinkHeaderLastPage(res.headers.link as string | undefined) ?? + res.data.length; + + const contributors: RepoContributor[] = res.data + .filter((c): c is typeof c & { login: string } => !!c.login) + .map((c) => ({ + login: c.login, + avatarUrl: c.avatar_url ?? "", + contributions: c.contributions ?? 0, + })); + + return { contributors, totalCount }; + }); diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index 168ca40..5ffd4d8 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -20,8 +20,13 @@ import { getPullStatus, getPullsFromRepo, getPullsFromUser, + getRepoBranches, getRepoCollaborators, + getRepoContributors, + getRepoFileContent, getRepoLabels, + getRepoOverview, + getRepoTree, getTimelineEventPage, getUserActivity, getUserContributions, @@ -172,6 +177,28 @@ export const githubQueryKeys = { ["github", scope.userId, "pinnedRepos", username] as const, activity: (scope: GitHubQueryScope, username: string) => ["github", scope.userId, "activity", username] as const, + repo: { + overview: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, + ) => ["github", scope.userId, "repo", "overview", input] as const, + branches: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, + ) => ["github", scope.userId, "repo", "branches", input] as const, + tree: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string; ref: string; path: string }, + ) => ["github", scope.userId, "repo", "tree", input] as const, + fileContent: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string; ref: string; path: string }, + ) => ["github", scope.userId, "repo", "fileContent", input] as const, + contributors: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, + ) => ["github", scope.userId, "repo", "contributors", input] as const, + }, issues: { mine: (scope: GitHubQueryScope) => ["github", scope.userId, "issues", "mine"] as const, @@ -536,3 +563,74 @@ export function githubUserActivityQueryOptions( gcTime: githubCachePolicy.userActivity.gcTimeMs, }); } + +// --------------------------------------------------------------------------- +// Repository +// --------------------------------------------------------------------------- + +// Repo metadata — aggressively cached, revalidated via webhooks +export function githubRepoOverviewQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repo.overview(scope, input), + queryFn: () => getRepoOverview({ data: input }), + staleTime: githubCachePolicy.repoMeta.staleTimeMs, + gcTime: githubCachePolicy.repoMeta.gcTimeMs, + meta: persistedMeta, + }); +} + +export function githubRepoBranchesQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repo.branches(scope, input), + queryFn: () => getRepoBranches({ data: input }), + staleTime: githubCachePolicy.repoMeta.staleTimeMs, + gcTime: githubCachePolicy.repoMeta.gcTimeMs, + meta: persistedMeta, + }); +} + +export function githubRepoContributorsQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repo.contributors(scope, input), + queryFn: () => getRepoContributors({ data: input }), + staleTime: githubCachePolicy.repoMeta.staleTimeMs, + gcTime: githubCachePolicy.repoMeta.gcTimeMs, + meta: persistedMeta, + }); +} + +// Code content — shorter cache, will be webhook-revalidated on push +export function githubRepoTreeQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string; ref: string; path: string }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repo.tree(scope, input), + queryFn: () => getRepoTree({ data: input }), + staleTime: githubCachePolicy.detail.staleTimeMs, + gcTime: githubCachePolicy.detail.gcTimeMs, + meta: tabPersistedMeta, + }); +} + +export function githubRepoFileContentQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string; ref: string; path: string }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repo.fileContent(scope, input), + queryFn: () => getRepoFileContent({ data: input }), + staleTime: githubCachePolicy.repoMeta.staleTimeMs, + gcTime: githubCachePolicy.repoMeta.gcTimeMs, + meta: persistedMeta, + }); +} diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index 85a9c25..d968b67 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -389,6 +389,61 @@ export type PinnedRepo = { forks: number; }; +export type RepoOverview = { + id: number; + name: string; + fullName: string; + description: string | null; + isPrivate: boolean; + isFork: boolean; + defaultBranch: string; + stars: number; + forks: number; + watchers: number; + language: string | null; + license: string | null; + topics: string[]; + url: string; + owner: string; + ownerAvatarUrl: string; + branchCount: number; + tagCount: number; + latestCommit: { + sha: string; + message: string; + date: string; + author: GitHubActor | null; + } | null; +}; + +export type RepoTreeEntry = { + name: string; + type: "file" | "dir" | "submodule"; + path: string; + sha: string; + size: number | null; + lastCommit: { + message: string; + date: string; + } | null; +}; + +export type RepoBranch = { + name: string; + isProtected: boolean; +}; + +export type RepoContributor = { + login: string; + avatarUrl: string; + contributions: number; +}; + +export type RepoContributorsResult = { + contributors: RepoContributor[]; + totalCount: number; +}; + export type UserActivityEvent = { id: string; type: string; diff --git a/apps/dashboard/src/lib/tab-store.ts b/apps/dashboard/src/lib/tab-store.ts index dab5d37..d9bc313 100644 --- a/apps/dashboard/src/lib/tab-store.ts +++ b/apps/dashboard/src/lib/tab-store.ts @@ -1,15 +1,16 @@ import { useSyncExternalStore } from "react"; -export type TabType = "pull" | "issue" | "review"; +export type TabType = "pull" | "issue" | "review" | "repo"; export interface Tab { id: string; type: TabType; title: string; - number: number; + number?: number; url: string; repo: string; iconColor: string; + avatarUrl?: string; additions?: number; deletions?: number; } diff --git a/apps/dashboard/src/lib/use-github-revalidation.ts b/apps/dashboard/src/lib/use-github-revalidation.ts index 0ad39c4..15eb3fd 100644 --- a/apps/dashboard/src/lib/use-github-revalidation.ts +++ b/apps/dashboard/src/lib/use-github-revalidation.ts @@ -9,15 +9,34 @@ import { } from "./github-revalidation"; import { type Tab, useTabs } from "./tab-store"; +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object"; +} + const GITHUB_REVALIDATION_POLL_INTERVAL_MS = 10_000; const GITHUB_REVALIDATION_INITIAL_DELAY_MS = 5_000; +function getUniqueRepoKeys(tabs: Tab[]) { + const repos = new Set(); + for (const tab of tabs) { + repos.add(tab.repo); + } + return Array.from(repos); +} + function getUniqueSignalKeys(tabs: Tab[]) { return Array.from( new Set([ githubRevalidationSignalKeys.pullsMine, githubRevalidationSignalKeys.issuesMine, ...tabs.flatMap((tab) => getGitHubRevalidationSignalKeysForTab(tab)), + ...getUniqueRepoKeys(tabs).map((repo) => { + const [owner, name] = repo.split("/"); + return githubRevalidationSignalKeys.repoCode({ + owner, + repo: name, + }); + }), ]), ); } @@ -29,10 +48,12 @@ function getQueryUpdatedAt( return queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0; } +type NumberedTab = Tab & { number: number }; + async function invalidatePullTabQueries( queryClient: QueryClient, scope: GitHubQueryScope, - tab: Tab, + tab: NumberedTab, ) { const [owner, repo] = tab.repo.split("/"); const input = { owner, repo, pullNumber: tab.number }; @@ -63,7 +84,7 @@ async function invalidatePullTabQueries( async function invalidateIssueTabQueries( queryClient: QueryClient, scope: GitHubQueryScope, - tab: Tab, + tab: NumberedTab, ) { const [owner, repo] = tab.repo.split("/"); const input = { owner, repo, issueNumber: tab.number }; @@ -139,6 +160,52 @@ export function useGitHubRevalidation(userId: string) { }); } + // Invalidate repo code queries (tree, file content) on push events + for (const repoKey of getUniqueRepoKeys(tabs)) { + if (cancelled) break; + const [owner, repo] = repoKey.split("/"); + const signalKey = githubRevalidationSignalKeys.repoCode({ + owner, + repo, + }); + const updatedAt = signalsByKey.get(signalKey) ?? 0; + if (updatedAt === 0) continue; + + const matchesRepo = (query: { queryKey: readonly unknown[] }) => { + const key = query.queryKey; + const input = key[4]; + return ( + isRecord(input) && input.owner === owner && input.repo === repo + ); + }; + + const treeQueries = queryClient.getQueriesData({ + queryKey: ["github", scope.userId, "repo", "tree"], + predicate: matchesRepo, + }); + const fileQueries = queryClient.getQueriesData({ + queryKey: ["github", scope.userId, "repo", "fileContent"], + predicate: matchesRepo, + }); + + if (treeQueries.length > 0 || fileQueries.length > 0) { + debug("github-revalidation", "invalidating repo code queries", { + signalKey, + repoKey, + }); + await queryClient.invalidateQueries({ + queryKey: ["github", scope.userId, "repo", "tree"], + predicate: matchesRepo, + refetchType: "all", + }); + await queryClient.invalidateQueries({ + queryKey: ["github", scope.userId, "repo", "fileContent"], + predicate: matchesRepo, + refetchType: "all", + }); + } + } + // Invalidate tab queries serially to avoid a burst of concurrent // server function RPCs that can overwhelm the Worker. for (const tab of tabs) { @@ -150,19 +217,26 @@ export function useGitHubRevalidation(userId: string) { continue; } + // Repo tabs are revalidated via the repo code block above + if (tab.type === "repo" || tab.number == null) { + continue; + } + + const numberedTab = tab as NumberedTab; + if (tab.type === "pull" || tab.type === "review") { const [owner, repo] = tab.repo.split("/"); const comparisonKey = githubQueryKeys.pulls.page(scope, { owner, repo, - pullNumber: tab.number, + pullNumber: numberedTab.number, }); if (updatedAt > getQueryUpdatedAt(queryClient, comparisonKey)) { debug("github-revalidation", "invalidating pull tab queries", { signalKey, tabId: tab.id, }); - await invalidatePullTabQueries(queryClient, scope, tab); + await invalidatePullTabQueries(queryClient, scope, numberedTab); } continue; } @@ -171,14 +245,14 @@ export function useGitHubRevalidation(userId: string) { const comparisonKey = githubQueryKeys.issues.page(scope, { owner, repo, - issueNumber: tab.number, + issueNumber: numberedTab.number, }); if (updatedAt > getQueryUpdatedAt(queryClient, comparisonKey)) { debug("github-revalidation", "invalidating issue tab queries", { signalKey, tabId: tab.id, }); - await invalidateIssueTabQueries(queryClient, scope, tab); + await invalidateIssueTabQueries(queryClient, scope, numberedTab); } } } catch (error) { diff --git a/apps/dashboard/src/lib/use-register-tab.ts b/apps/dashboard/src/lib/use-register-tab.ts index 18edb80..6961b53 100644 --- a/apps/dashboard/src/lib/use-register-tab.ts +++ b/apps/dashboard/src/lib/use-register-tab.ts @@ -5,24 +5,30 @@ export function useRegisterTab( tab: { type: TabType; title: string | undefined; - number: number; + number?: number; url: string; repo: string; iconColor: string; + avatarUrl?: string; additions?: number; deletions?: number; } | null, ) { useEffect(() => { if (!tab?.title) return; + const id = + tab.number != null + ? `${tab.type}:${tab.repo}#${tab.number}` + : `${tab.type}:${tab.repo}`; addTab({ - id: `${tab.type}:${tab.repo}#${tab.number}`, + id, type: tab.type, title: tab.title, number: tab.number, url: tab.url, repo: tab.repo, iconColor: tab.iconColor, + avatarUrl: tab.avatarUrl, additions: tab.additions, deletions: tab.deletions, }); @@ -33,6 +39,7 @@ export function useRegisterTab( tab?.url, tab?.repo, tab?.iconColor, + tab?.avatarUrl, tab?.additions, tab?.deletions, ]); diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index e43cbbc..b63791b 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -24,6 +24,7 @@ import { Route as ProtectedOwnerIndexRouteImport } from './routes/_protected/$ow import { Route as ApiWebhooksGithubRouteImport } from './routes/api/webhooks/github' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ProtectedSettingsShortcutsRouteImport } from './routes/_protected/settings/shortcuts' +import { Route as ProtectedOwnerRepoIndexRouteImport } from './routes/_protected/$owner/$repo/index' import { Route as ApiGithubAppCallbackRouteImport } from './routes/api/github/app/callback' import { Route as ApiGithubAppAuthorizeRouteImport } from './routes/api/github/app/authorize' import { Route as ProtectedOwnerRepoReviewPullIdRouteImport } from './routes/_protected/$owner/$repo/review.$pullId' @@ -105,6 +106,11 @@ const ProtectedSettingsShortcutsRoute = path: '/shortcuts', getParentRoute: () => ProtectedSettingsRoute, } as any) +const ProtectedOwnerRepoIndexRoute = ProtectedOwnerRepoIndexRouteImport.update({ + id: '/$owner/$repo/', + path: '/$owner/$repo/', + getParentRoute: () => ProtectedRoute, +} as any) const ApiGithubAppCallbackRoute = ApiGithubAppCallbackRouteImport.update({ id: '/api/github/app/callback', path: '/api/github/app/callback', @@ -151,6 +157,7 @@ export interface FileRoutesByFullPath { '/settings/': typeof ProtectedSettingsIndexRoute '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute '/api/github/app/callback': typeof ApiGithubAppCallbackRoute + '/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute '/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute @@ -171,6 +178,7 @@ export interface FileRoutesByTo { '/settings': typeof ProtectedSettingsIndexRoute '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute '/api/github/app/callback': typeof ApiGithubAppCallbackRoute + '/$owner/$repo': typeof ProtectedOwnerRepoIndexRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute '/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute @@ -194,6 +202,7 @@ export interface FileRoutesById { '/_protected/settings/': typeof ProtectedSettingsIndexRoute '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute '/api/github/app/callback': typeof ApiGithubAppCallbackRoute + '/_protected/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute '/_protected/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/_protected/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute '/_protected/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute @@ -217,6 +226,7 @@ export interface FileRouteTypes { | '/settings/' | '/api/github/app/authorize' | '/api/github/app/callback' + | '/$owner/$repo/' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/pull/$pullId' | '/$owner/$repo/review/$pullId' @@ -237,6 +247,7 @@ export interface FileRouteTypes { | '/settings' | '/api/github/app/authorize' | '/api/github/app/callback' + | '/$owner/$repo' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/pull/$pullId' | '/$owner/$repo/review/$pullId' @@ -259,6 +270,7 @@ export interface FileRouteTypes { | '/_protected/settings/' | '/api/github/app/authorize' | '/api/github/app/callback' + | '/_protected/$owner/$repo/' | '/_protected/$owner/$repo/issues/$issueId' | '/_protected/$owner/$repo/pull/$pullId' | '/_protected/$owner/$repo/review/$pullId' @@ -383,6 +395,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedSettingsShortcutsRouteImport parentRoute: typeof ProtectedSettingsRoute } + '/_protected/$owner/$repo/': { + id: '/_protected/$owner/$repo/' + path: '/$owner/$repo' + fullPath: '/$owner/$repo/' + preLoaderRoute: typeof ProtectedOwnerRepoIndexRouteImport + parentRoute: typeof ProtectedRoute + } '/api/github/app/callback': { id: '/api/github/app/callback' path: '/api/github/app/callback' @@ -441,6 +460,7 @@ interface ProtectedRouteChildren { ProtectedSettingsRoute: typeof ProtectedSettingsRouteWithChildren ProtectedIndexRoute: typeof ProtectedIndexRoute ProtectedOwnerIndexRoute: typeof ProtectedOwnerIndexRoute + ProtectedOwnerRepoIndexRoute: typeof ProtectedOwnerRepoIndexRoute ProtectedOwnerRepoIssuesIssueIdRoute: typeof ProtectedOwnerRepoIssuesIssueIdRoute ProtectedOwnerRepoPullPullIdRoute: typeof ProtectedOwnerRepoPullPullIdRoute ProtectedOwnerRepoReviewPullIdRoute: typeof ProtectedOwnerRepoReviewPullIdRoute @@ -453,6 +473,7 @@ const ProtectedRouteChildren: ProtectedRouteChildren = { ProtectedSettingsRoute: ProtectedSettingsRouteWithChildren, ProtectedIndexRoute: ProtectedIndexRoute, ProtectedOwnerIndexRoute: ProtectedOwnerIndexRoute, + ProtectedOwnerRepoIndexRoute: ProtectedOwnerRepoIndexRoute, ProtectedOwnerRepoIssuesIssueIdRoute: ProtectedOwnerRepoIssuesIssueIdRoute, ProtectedOwnerRepoPullPullIdRoute: ProtectedOwnerRepoPullPullIdRoute, ProtectedOwnerRepoReviewPullIdRoute: ProtectedOwnerRepoReviewPullIdRoute, diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/index.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/index.tsx new file mode 100644 index 0000000..4897a14 --- /dev/null +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/index.tsx @@ -0,0 +1,103 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { RepoOverviewPage } from "#/components/repo/repo-overview-page"; +import type { GitHubQueryScope } from "#/lib/github.query"; +import { + githubRepoFileContentQueryOptions, + githubRepoOverviewQueryOptions, + githubRepoTreeQueryOptions, + githubViewerQueryOptions, +} from "#/lib/github.query"; +import type { RepoTreeEntry } from "#/lib/github.types"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; + +const KNOWN_MD_FILES = new Set([ + "README.md", + "readme.md", + "README", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", + "LICENSE", + "LICENSE.md", + "LICENSE.txt", + "SECURITY.md", + "CHANGELOG.md", +]); + +function prefetchMdFiles( + queryClient: QueryClient, + scope: GitHubQueryScope, + params: { owner: string; repo: string }, + ref: string, + entries: RepoTreeEntry[], +) { + for (const entry of entries) { + if (KNOWN_MD_FILES.has(entry.name)) { + void queryClient.prefetchQuery( + githubRepoFileContentQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + ref, + path: entry.name, + }), + ); + } + } +} + +export const Route = createFileRoute("/_protected/$owner/$repo/")({ + ssr: false, + loader: ({ context, params }) => { + const scope = { userId: context.user.id }; + const overviewOptions = githubRepoOverviewQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + }); + + // Clean up broken cache entries + const cachedData = context.queryClient.getQueryData( + overviewOptions.queryKey, + ); + if (cachedData !== undefined && !cachedData) { + context.queryClient.removeQueries({ + queryKey: overviewOptions.queryKey, + exact: true, + }); + } + + // Never block navigation — fire prefetches and let the component + // show cached data instantly or a skeleton while loading. + void context.queryClient.prefetchQuery(overviewOptions); + void context.queryClient.prefetchQuery(githubViewerQueryOptions(scope)); + + // If overview is cached, prefetch tree + MD files immediately + const cachedOverview = context.queryClient.getQueryData( + overviewOptions.queryKey, + ); + if (cachedOverview) { + const ref = cachedOverview.defaultBranch ?? "main"; + const treeOptions = githubRepoTreeQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + ref, + path: "", + }); + + void context.queryClient.prefetchQuery(treeOptions); + + // If tree is also cached, prefetch MD file contents + const cachedTree = context.queryClient.getQueryData(treeOptions.queryKey); + if (cachedTree) { + prefetchMdFiles(context.queryClient, scope, params, ref, cachedTree); + } + } + }, + head: ({ match, params }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle(`${params.owner}/${params.repo}`), + description: `Repository overview for ${params.owner}/${params.repo}.`, + robots: "noindex", + }), + component: RepoOverviewPage, +}); diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 499fe84..3a380e1 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -4,6 +4,7 @@ export { AddCircleHalfDotIcon as IssuesIcon, AlertCircleIcon, + ArchiveIcon, ArrangeIcon as SortIcon, ArrowDown01Icon as ChevronDownIcon, ArrowLeft01Icon as ChevronLeftIcon, @@ -19,13 +20,13 @@ export { CheckListIcon as ReviewsIcon, CircleIcon, Clock01Icon as ClockIcon, - CodeIcon, CommandIcon, Comment01Icon as CommentIcon, ComputerIcon as SystemIcon, Copy01Icon as CopyIcon, DashboardSquare01Icon as DashboardIcon, Delete01Icon, + Download02Icon as DownloadIcon, DragDropVerticalIcon as GripVerticalIcon, File02Icon as FileIcon, Folder01Icon as FolderIcon, @@ -54,6 +55,7 @@ export { Search01Icon as SearchIcon, Settings01Icon as SettingsIcon, SidebarLeftIcon as PanelLeftIcon, + SourceCodeIcon as CodeIcon, StarIcon, Sun01Icon as SunIcon, Tick02Icon as CheckIcon, diff --git a/packages/ui/src/components/command.tsx b/packages/ui/src/components/command.tsx index f34bb1a..e99115b 100644 --- a/packages/ui/src/components/command.tsx +++ b/packages/ui/src/components/command.tsx @@ -140,7 +140,7 @@ function CommandItem({