diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 96d59b5..1bcfa6f 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -7,8 +7,8 @@ }, "scripts": { "predev": "node ../../scripts/link-worktree-dev-vars.mjs", - "dev": "vite dev --port 3000", - "build": "NODE_OPTIONS='--max-old-space-size=4096' vite build", + "dev": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite dev --port 3000", + "build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build", "preview": "vite preview", "test": "vitest run", "format": "biome format", @@ -63,6 +63,7 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.1.4", + "cross-env": "^10.1.0", "drizzle-kit": "^0.31.10", "jsdom": "^28.1.0", "react-scan": "^0.5.3", diff --git a/apps/dashboard/src/components/layouts/dashboard-tabs.tsx b/apps/dashboard/src/components/layouts/dashboard-tabs.tsx index 3886ce4..965f91e 100644 --- a/apps/dashboard/src/components/layouts/dashboard-tabs.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-tabs.tsx @@ -36,6 +36,7 @@ const tabIconMap = { review: ReviewsIcon, repo: ArchiveIcon, commit: GitCommitIcon, + commits: GitCommitIcon, actions: ActionsIcon, } as const; @@ -300,7 +301,9 @@ function ScrollActiveTabIntoView({ useEffect(() => { const container = scrollRef.current; if (!container) return; - const activeTab = container.querySelector(".active"); + const activeTab = container.querySelector( + "[data-active='true']", + ); if (!activeTab) return; const { left: cLeft, right: cRight } = container.getBoundingClientRect(); @@ -334,9 +337,11 @@ const DetailTab = memo(function DetailTab({ onContextMenu: () => void; routerRef: React.RefObject>; }) { + const pathname = useRouterState({ select: (s) => s.location.pathname }); const preloadTab = () => { void preloadRouteOnce(routerRef.current, tab.url); }; + const isActive = isTabActive(tab, pathname); return ( {tab.avatarUrl ? ( ); }); + +function isTabActive(tab: Tab, pathname: string) { + if (tab.type === "repo") { + const repoPath = `/${tab.repo}`; + return ( + pathname === repoPath || + pathname === `${repoPath}/` || + pathname.startsWith(`${repoPath}/tree/`) || + pathname.startsWith(`${repoPath}/blob/`) + ); + } + + return normalizePath(pathname) === normalizePath(tab.url); +} + +function normalizePath(path: string) { + if (path.length > 1 && path.endsWith("/")) return path.slice(0, -1); + return path; +} diff --git a/apps/dashboard/src/components/navigation/command-palette.tsx b/apps/dashboard/src/components/navigation/command-palette.tsx index 1a7b729..af200bf 100644 --- a/apps/dashboard/src/components/navigation/command-palette.tsx +++ b/apps/dashboard/src/components/navigation/command-palette.tsx @@ -90,7 +90,7 @@ export function CommandPalette() { value={search} onValueChange={setSearch} /> - + {getEmptyMessage( search, @@ -98,12 +98,17 @@ export function CommandPalette() { )} {Array.from(groups.entries()).map(([groupName, groupItems]) => ( - + {groupItems.map((item) => ( handleSelect(item)} + className="rounded-none !px-4" > {item.icon && ( - +
- +
- +
{formatRelativeTime(commit.date)} +
); diff --git a/apps/dashboard/src/components/repo/commits-link.tsx b/apps/dashboard/src/components/repo/commits-link.tsx new file mode 100644 index 0000000..b08e4ca --- /dev/null +++ b/apps/dashboard/src/components/repo/commits-link.tsx @@ -0,0 +1,45 @@ +import { GitCommitIcon } from "@diffkit/icons"; +import { cn } from "@diffkit/ui/lib/utils"; +import { Link } from "@tanstack/react-router"; +import type { ReactNode } from "react"; + +const commitsLinkClassName = + "-my-1 -mr-1 flex items-center gap-1 rounded-md px-2 py-1.5 font-medium text-foreground transition-colors hover:bg-surface-2"; + +export function buildCommitsLinkSplat(currentRef: string, path?: string) { + return path ? `${currentRef}/${path}` : currentRef; +} + +export function CommitsLink({ + owner, + repo, + currentRef, + path, + children = "History", + "aria-label": ariaLabel = "View commits", + className, +}: { + owner: string; + repo: string; + currentRef: string; + path?: string; + children?: ReactNode; + "aria-label"?: string; + className?: string; +}) { + return ( + + + {children} + + ); +} diff --git a/apps/dashboard/src/components/repo/folder-view.tsx b/apps/dashboard/src/components/repo/folder-view.tsx index 07510fe..8c0b282 100644 --- a/apps/dashboard/src/components/repo/folder-view.tsx +++ b/apps/dashboard/src/components/repo/folder-view.tsx @@ -56,6 +56,8 @@ export function FolderView({ scope={scope} defaultBranch={repo.defaultBranch} defaultBranchTip={repo.latestCommit} + path={currentPath} + historyLabel="History" />
{entries.map((entry, index) => ( diff --git a/apps/dashboard/src/components/repo/latest-commit-bar.tsx b/apps/dashboard/src/components/repo/latest-commit-bar.tsx index 9d056b9..5665efc 100644 --- a/apps/dashboard/src/components/repo/latest-commit-bar.tsx +++ b/apps/dashboard/src/components/repo/latest-commit-bar.tsx @@ -10,9 +10,11 @@ import { Link } from "@tanstack/react-router"; import { formatRelativeTime } from "#/lib/format-relative-time"; import { type GitHubQueryScope, + githubFileLastCommitQueryOptions, githubRefHeadCommitQueryOptions, } from "#/lib/github.query"; import type { RepoOverview } from "#/lib/github.types"; +import { CommitsLink } from "./commits-link"; export function LatestCommitBar({ owner, @@ -21,6 +23,8 @@ export function LatestCommitBar({ scope, defaultBranch, defaultBranchTip, + path, + historyLabel = "Commits", }: { owner: string; repoName: string; @@ -28,20 +32,35 @@ export function LatestCommitBar({ scope: GitHubQueryScope; defaultBranch: string; defaultBranchTip: RepoOverview["latestCommit"]; + path?: string; + historyLabel?: string; }) { - const tipQuery = useQuery({ + const pathCommitQuery = useQuery({ + ...githubFileLastCommitQueryOptions(scope, { + owner, + repo: repoName, + ref, + path: path ?? "", + }), + enabled: !!path, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + const refCommitQuery = useQuery({ ...githubRefHeadCommitQueryOptions(scope, { owner, repo: repoName, ref, }), + enabled: !path, placeholderData: - ref === defaultBranch && defaultBranchTip != null + !path && ref === defaultBranch && defaultBranchTip != null ? defaultBranchTip : undefined, refetchOnMount: false, refetchOnWindowFocus: false, }); + const tipQuery = path ? pathCommitQuery : refCommitQuery; const commit = tipQuery.data; @@ -95,6 +114,9 @@ export function LatestCommitBar({ {formatRelativeTime(commit.date)} + + {historyLabel} +
); diff --git a/apps/dashboard/src/components/repo/repo-commits-list.tsx b/apps/dashboard/src/components/repo/repo-commits-list.tsx new file mode 100644 index 0000000..38bf301 --- /dev/null +++ b/apps/dashboard/src/components/repo/repo-commits-list.tsx @@ -0,0 +1,402 @@ +import { GitBranchIcon, GitCommitIcon } from "@diffkit/icons"; +import { Button } from "@diffkit/ui/components/button"; +import { Skeleton } from "@diffkit/ui/components/skeleton"; +import { Spinner } from "@diffkit/ui/components/spinner"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@diffkit/ui/components/tooltip"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { formatRelativeTime } from "#/lib/format-relative-time"; +import { + type GitHubQueryScope, + githubRepoCommitsQueryOptions, + githubRepoOverviewQueryOptions, +} from "#/lib/github.query"; +import type { RepoCommitSummary } from "#/lib/github.types"; +import { useRegisterTab } from "#/lib/use-register-tab"; +import { BranchSelector } from "./code-explorer-toolbar"; + +const COMMITS_PAGE_SIZE = 30; +const skeletonRows = [ + "commit-0", + "commit-1", + "commit-2", + "commit-3", + "commit-4", +]; + +const commitDateFormatter = new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + year: "numeric", +}); + +export function RepoCommitsPage({ + owner, + repo, + currentRef, + currentPath, + scope, +}: { + owner: string; + repo: string; + currentRef: string; + currentPath: string; + scope: GitHubQueryScope; +}) { + const navigate = useNavigate(); + const loadMoreRef = useRef(null); + const overviewQuery = useQuery( + githubRepoOverviewQueryOptions(scope, { + owner, + repo, + }), + ); + const commitsQuery = useInfiniteQuery( + githubRepoCommitsQueryOptions(scope, { + owner, + repo, + ref: currentRef, + ...(currentPath ? { path: currentPath } : {}), + perPage: COMMITS_PAGE_SIZE, + }), + ); + + const commits = useMemo( + () => commitsQuery.data?.pages.flatMap((page) => page.commits) ?? [], + [commitsQuery.data], + ); + const groups = useMemo(() => groupCommitsByDay(commits), [commits]); + const repoData = overviewQuery.data; + const pathSegments = currentPath ? currentPath.split("/") : []; + + const pathBreadcrumbs = pathSegments.map((segment, index) => ({ + segment, + path: pathSegments.slice(0, index + 1).join("/"), + })); + + const handleBranchChange = useCallback( + (branch: string) => { + if (branch === currentRef) return; + void navigate({ + to: "/$owner/$repo/commits/$", + params: { + owner, + repo, + _splat: currentPath ? `${branch}/${currentPath}` : branch, + }, + }); + }, + [currentPath, currentRef, navigate, owner, repo], + ); + + useRegisterTab( + repoData + ? { + type: "commits", + title: currentPath + ? `${currentPath.split("/").pop()} commits` + : `${repoData.name} commits`, + url: currentPath + ? `/${owner}/${repo}/commits/${currentRef}/${currentPath}` + : `/${owner}/${repo}/commits/${currentRef}`, + repo: `${owner}/${repo}`, + iconColor: "text-muted-foreground", + tabId: `commits:${owner}/${repo}`, + } + : null, + ); + + useEffect(() => { + const target = loadMoreRef.current; + if (!target) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if ( + entry?.isIntersecting && + commitsQuery.hasNextPage && + !commitsQuery.isFetchingNextPage && + !commitsQuery.isFetchNextPageError + ) { + void commitsQuery.fetchNextPage(); + } + }, + { rootMargin: "500px 0px" }, + ); + + observer.observe(target); + return () => observer.disconnect(); + }, [ + commitsQuery.hasNextPage, + commitsQuery.isFetchingNextPage, + commitsQuery.isFetchNextPageError, + commitsQuery.fetchNextPage, + ]); + + if (overviewQuery.error) throw overviewQuery.error; + if (commitsQuery.isError && !commitsQuery.isFetchNextPageError) { + throw commitsQuery.error; + } + + return ( +
+
+
+
+
+ +
+
+

+ + {owner} + + / + + {repo} + + {pathBreadcrumbs.map((breadcrumb) => ( + + / + + {breadcrumb.segment} + + + ))} +

+

+ {currentPath + ? "Commits that touched this path." + : "Commits on this repository."} +

+
+
+
+ {repoData ? ( + + ) : ( +
+ + + {currentRef} + +
+ )} +
+
+ +
+ {commitsQuery.isPending ? ( + + ) : commits.length === 0 ? ( +
+ No commits found for this ref. +
+ ) : ( +
    + {groups.map((group) => ( +
  1. +
    + Commits on {group.label} +
    +
      + {group.commits.map((commit) => ( + + ))} +
    +
  2. + ))} +
+ )} +
+ +
+ {commitsQuery.isFetchingNextPage ? ( +
+ +
+ ) : commitsQuery.isFetchNextPageError ? ( +
+

+ Couldn't load more commits. +

+ +
+ ) : commitsQuery.hasNextPage ? ( + + ) : commits.length > 0 ? ( + + End of commit history + + ) : null} +
+
+
+ ); +} + +function CommitRow({ + commit, + owner, + repo, +}: { + commit: RepoCommitSummary; + owner: string; + repo: string; +}) { + const firstLine = commit.message.split("\n")[0] || commit.sha; + const authorName = commit.author?.login ?? commit.authorName ?? "Unknown"; + const shortSha = commit.sha.slice(0, 7); + + return ( +
  • +
    + {commit.author ? ( + + {commit.author.login} + + ) : ( +
    + +
    + )} +
    + + {firstLine} + +

    + {authorName}{" "} + committed{" "} + {commit.date ? formatRelativeTime(commit.date) : "recently"} +

    +
    +
    +
    + + + + {shortSha} + + + + {commit.sha} + + +
    +
  • + ); +} + +function RepoCommitsListSkeleton() { + return ( + <> +
    + +
    + + + ); +} + +function RepoCommitsRowsSkeleton() { + return ( +
    + {skeletonRows.map((key) => ( +
    +
    + +
    + + +
    +
    + +
    + ))} +
    + ); +} + +function groupCommitsByDay(commits: RepoCommitSummary[]) { + const groups = new Map(); + + for (const commit of commits) { + const label = formatCommitDay(commit.date); + const group = groups.get(label) ?? []; + group.push(commit); + groups.set(label, group); + } + + return Array.from(groups.entries()).map(([label, groupedCommits]) => ({ + label, + commits: groupedCommits, + })); +} + +function formatCommitDay(date: string) { + const parsed = new Date(date); + if (Number.isNaN(parsed.getTime())) return "recent history"; + return commitDateFormatter.format(parsed); +} diff --git a/apps/dashboard/src/lib/command-palette/use-command-items.ts b/apps/dashboard/src/lib/command-palette/use-command-items.ts index 97486ba..2b15745 100644 --- a/apps/dashboard/src/lib/command-palette/use-command-items.ts +++ b/apps/dashboard/src/lib/command-palette/use-command-items.ts @@ -222,6 +222,25 @@ export function getCommandSearchItems( const items: CommandItem[] = []; + for (const repo of result.repos) { + items.push({ + id: `repo:${repo.id}`, + label: repo.fullName, + group: "Repositories", + icon: CodeIcon, + keywords: [repo.name, repo.owner, repo.language ?? ""].filter(Boolean), + action: { + type: "navigate", + to: `/${repo.owner}/${repo.name}`, + }, + meta: { + language: repo.language, + stars: repo.stars, + updatedAt: repo.updatedAt ?? undefined, + }, + }); + } + for (const pr of result.pulls) { const prState = getPrIcon(pr); items.push({ diff --git a/apps/dashboard/src/lib/github-revalidation.ts b/apps/dashboard/src/lib/github-revalidation.ts index 5b27147..b6a0d51 100644 --- a/apps/dashboard/src/lib/github-revalidation.ts +++ b/apps/dashboard/src/lib/github-revalidation.ts @@ -439,7 +439,7 @@ export function getGitHubWebhookRevalidationSignalKeys( export function getGitHubRevalidationSignalKeysForTab(tab: Tab) { const [owner, repo] = tab.repo.split("/"); - if (tab.type === "repo") { + if (tab.type === "repo" || tab.type === "commits") { return [ githubRevalidationSignalKeys.repoCode({ owner, diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index d62063e..f69ddb1 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -49,6 +49,7 @@ import type { RepoCollaborator, RepoCommitDetail, RepoCommitInput, + RepoCommitsPage, RepoContributorsResult, RepoOverview, RepoParticipationStats, @@ -467,9 +468,15 @@ type AuthenticatedUserRepo = Awaited< type ListForUserRepo = Awaited< ReturnType >["data"][number]; +type GetRepoItem = Awaited< + ReturnType +>["data"]; +type SearchRepoItem = Awaited< + ReturnType +>["data"]["items"][number]; function mapGithubRestRepoToUserRepoSummary( - repo: AuthenticatedUserRepo | ListForUserRepo, + repo: AuthenticatedUserRepo | ListForUserRepo | GetRepoItem, ): UserRepoSummary { const repoId = repo.id; if (typeof repoId !== "number") { @@ -499,6 +506,33 @@ function mapGithubRestRepoToUserRepoSummary( }; } +function mapGithubSearchRepoToUserRepoSummary( + repo: SearchRepoItem, +): UserRepoSummary { + const visibility: UserRepoSummary["visibility"] = + repo.visibility === "internal" + ? "internal" + : repo.visibility === "private" || repo.private + ? "private" + : "public"; + + return { + id: repo.id ?? 0, + name: repo.name ?? "", + fullName: repo.full_name ?? "", + description: repo.description ?? null, + stars: repo.stargazers_count ?? 0, + forks: repo.forks_count ?? 0, + language: repo.language ?? null, + updatedAt: repo.updated_at ?? null, + createdAt: repo.created_at ?? null, + isPrivate: Boolean(repo.private), + visibility, + url: repo.html_url ?? "", + owner: repo.owner?.login ?? "", + }; +} + type RepoPullDetail = Awaited< ReturnType >["data"]; @@ -923,6 +957,7 @@ function normalizeCommandPaletteSearchQuery(query: string) { function emptyCommandPaletteSearchResult(): CommandPaletteSearchResult { return { + repos: [], pulls: [], issues: [], }; @@ -1992,7 +2027,7 @@ async function getInstallationAccessIndex( const { installations, installationsAvailable, appUserOctokit } = await getGitHubAppUserInstallations(context.session.user.id); - if (!installationsAvailable) { + if (!installationsAvailable || !appUserOctokit) { debug( "installation-access", "app-user token unavailable, index not available (fail-open)", @@ -5746,41 +5781,61 @@ export const searchCommandPaletteGitHub = createServerFn({ method: "GET" }) const login = viewer.login; const perPage = clampCommandSearchPerPage(data.perPage); - const [pullItems, issueItems, accessIndex] = await Promise.all([ - safeCommandPaletteSearch({ - label: "pull requests", - fallback: [] as SearchItem[], - task: async (signal) => { - const response = - await context.octokit.rest.search.issuesAndPullRequests({ - q: `${query} is:pr involves:${login} archived:false`, - per_page: perPage, - sort: "updated", - order: "desc", - request: { signal }, - }); - return response.data.items; - }, - }), - safeCommandPaletteSearch({ - label: "issues", - fallback: [] as SearchItem[], - task: async (signal) => { - const response = - await context.octokit.rest.search.issuesAndPullRequests({ - q: `${query} is:issue involves:${login} archived:false`, + const [repoItems, exactRepo, pullItems, issueItems, accessIndex] = + await Promise.all([ + safeCommandPaletteSearch({ + label: "repositories", + fallback: [] as SearchRepoItem[], + task: async (signal) => { + const response = await context.octokit.rest.search.repos({ + q: `${query} archived:false`, per_page: perPage, sort: "updated", order: "desc", request: { signal }, }); - return response.data.items; - }, - }), - getInstallationAccessIndex(context), - ]); + return response.data.items; + }, + }), + fetchCommandPaletteExactRepo(context, query), + safeCommandPaletteSearch({ + label: "pull requests", + fallback: [] as SearchItem[], + task: async (signal) => { + const response = + await context.octokit.rest.search.issuesAndPullRequests({ + q: `${query} is:pr involves:${login} archived:false`, + per_page: perPage, + sort: "updated", + order: "desc", + request: { signal }, + }); + return response.data.items; + }, + }), + safeCommandPaletteSearch({ + label: "issues", + fallback: [] as SearchItem[], + task: async (signal) => { + const response = + await context.octokit.rest.search.issuesAndPullRequests({ + q: `${query} is:issue involves:${login} archived:false`, + per_page: perPage, + sort: "updated", + order: "desc", + request: { signal }, + }); + return response.data.items; + }, + }), + getInstallationAccessIndex(context), + ]); return { + repos: mergeCommandPaletteRepos([ + ...(exactRepo ? [exactRepo] : []), + ...repoItems.map(mapGithubSearchRepoToUserRepoSummary), + ]), pulls: filterItemsByInstallationAccess( mapPullSearchItems(pullItems), accessIndex, @@ -5792,6 +5847,42 @@ export const searchCommandPaletteGitHub = createServerFn({ method: "GET" }) }; }); +async function fetchCommandPaletteExactRepo( + context: GitHubContext, + query: string, +): Promise { + const match = query.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/); + if (!match) { + return null; + } + + return safeCommandPaletteSearch({ + label: "exact repository", + fallback: null, + task: async (signal) => { + const response = await context.octokit.rest.repos.get({ + owner: match[1], + repo: match[2], + request: { signal }, + }); + return mapGithubRestRepoToUserRepoSummary(response.data); + }, + }); +} + +function mergeCommandPaletteRepos(repos: UserRepoSummary[]): UserRepoSummary[] { + const reposByFullName = new Map(); + + for (const repo of repos) { + const key = repo.fullName.toLowerCase(); + if (!reposByFullName.has(key)) { + reposByFullName.set(key, repo); + } + } + + return [...reposByFullName.values()]; +} + function filterItemsByInstallationAccess< T extends { repository: RepositoryRef }, >(items: T[], accessIndex: GitHubInstallationAccessIndex): T[] { @@ -9748,6 +9839,78 @@ export const getRefHeadCommit = createServerFn({ method: "GET" }) }).catch(() => null); }); +type RepoCommitsInput = { + owner: string; + repo: string; + ref: string; + path?: string; + page?: number; + perPage?: number; +}; + +export const getRepoCommits = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContextForRepository(data); + if (!context) return { commits: [], nextPage: null }; + + const perPage = Math.min(Math.max(data.perPage ?? 15, 1), 30); + const page = Math.max(data.page ?? 1, 1); + + try { + return await getCachedGitHubRequest< + Awaited< + ReturnType + >["data"], + RepoCommitsPage + >({ + context, + resource: "repo.commits.v1", + params: { ...data, page, perPage }, + freshForMs: githubCachePolicy.detail.staleTimeMs, + signalKeys: [githubRevalidationSignalKeys.repoCode(data)], + namespaceKeys: [githubRevalidationSignalKeys.repoCode(data)], + cacheMode: "split", + request: (headers) => + context.octokit.rest.repos.listCommits({ + owner: data.owner, + repo: data.repo, + sha: data.ref, + ...(data.path ? { path: data.path } : {}), + page, + per_page: perPage, + headers, + }), + mapData: (commits) => ({ + commits: commits.map((commit) => ({ + sha: commit.sha, + message: commit.commit.message, + date: + commit.commit.committer?.date ?? commit.commit.author?.date ?? "", + authorName: + commit.commit.author?.name ?? + commit.commit.committer?.name ?? + null, + author: commit.author + ? { + login: commit.author.login, + avatarUrl: commit.author.avatar_url, + url: commit.author.html_url, + type: commit.author.type, + } + : null, + })), + nextPage: commits.length === perPage ? page + 1 : null, + }), + }); + } catch (error) { + if (error instanceof RequestError && error.status === 404) { + return { commits: [], nextPage: null }; + } + throw error; + } + }); + // --------------------------------------------------------------------------- // Batch tree entry commits (single GraphQL query for all entries in a dir) // --------------------------------------------------------------------------- diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index d2dfc08..c6aebd5 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -30,6 +30,7 @@ import { getRepoBranches, getRepoCollaborators, getRepoCommit, + getRepoCommits, getRepoContributors, getRepoDiscussions, getRepoFileContent, @@ -293,6 +294,16 @@ export const githubQueryKeys = { scope: GitHubQueryScope, input: { owner: string; repo: string; ref: string }, ) => ["github", scope.userId, "repo", "refHeadCommit", input] as const, + commits: ( + scope: GitHubQueryScope, + input: { + owner: string; + repo: string; + ref: string; + path?: string; + perPage?: number; + }, + ) => ["github", scope.userId, "repo", "commits", input] as const, treeEntryCommits: ( scope: GitHubQueryScope, input: { owner: string; repo: string; ref: string; dirPath: string }, @@ -942,6 +953,28 @@ export function githubRefHeadCommitQueryOptions( }); } +export function githubRepoCommitsQueryOptions( + scope: GitHubQueryScope, + input: { + owner: string; + repo: string; + ref: string; + path?: string; + perPage?: number; + }, +) { + return infiniteQueryOptions({ + queryKey: githubQueryKeys.repo.commits(scope, input), + queryFn: ({ pageParam }) => + getRepoCommits({ data: { ...input, page: pageParam } }), + initialPageParam: 1, + getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined, + staleTime: githubCachePolicy.detail.staleTimeMs, + gcTime: githubCachePolicy.detail.gcTimeMs, + meta: tabPersistedMeta, + }); +} + export function githubTreeEntryCommitsQueryOptions( scope: GitHubQueryScope, input: { diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index f108c3f..b62027e 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -148,6 +148,7 @@ export type MyIssuesResult = { }; export type CommandPaletteSearchResult = { + repos: UserRepoSummary[]; pulls: PullSummary[]; issues: IssueSummary[]; }; @@ -676,6 +677,19 @@ export type FileLastCommit = { author: GitHubActor | null; }; +export type RepoCommitSummary = { + sha: string; + message: string; + date: string; + authorName: string | null; + author: GitHubActor | null; +}; + +export type RepoCommitsPage = { + commits: RepoCommitSummary[]; + nextPage: number | null; +}; + export type RepoBranch = { name: string; isProtected: boolean; diff --git a/apps/dashboard/src/lib/protected-auth-cache.ts b/apps/dashboard/src/lib/protected-auth-cache.ts index ca83a83..a181800 100644 --- a/apps/dashboard/src/lib/protected-auth-cache.ts +++ b/apps/dashboard/src/lib/protected-auth-cache.ts @@ -39,4 +39,4 @@ export function clearProtectedRouteCachedAuth(): void { return; } cachedAuth = null; -} \ No newline at end of file +} diff --git a/apps/dashboard/src/lib/query-client.tsx b/apps/dashboard/src/lib/query-client.tsx index 9a0fb62..6b8d30a 100644 --- a/apps/dashboard/src/lib/query-client.tsx +++ b/apps/dashboard/src/lib/query-client.tsx @@ -135,6 +135,18 @@ function matchesTabQuery(queryKey: readonly unknown[], tab: Tab) { ); } + if (tab.type === "commits") { + return ( + resourceType === "repo" && + (resourceName === "overview" || + resourceName === "branches" || + resourceName === "commits") && + isRepoQueryKeyInput(input) && + input.owner === owner && + input.repo === repo + ); + } + return ( resourceType === "issues" && (resourceName === "page" || diff --git a/apps/dashboard/src/lib/tab-store.ts b/apps/dashboard/src/lib/tab-store.ts index f37795f..c812273 100644 --- a/apps/dashboard/src/lib/tab-store.ts +++ b/apps/dashboard/src/lib/tab-store.ts @@ -6,6 +6,7 @@ export type TabType = | "review" | "repo" | "commit" + | "commits" | "actions"; export interface Tab { @@ -30,6 +31,7 @@ const VALID_TAB_TYPES = { review: true, repo: true, commit: true, + commits: true, actions: true, } satisfies Record; diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index c44b43a..456d553 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -39,6 +39,7 @@ import { Route as ProtectedOwnerRepoPullPullIdRouteImport } from './routes/_prot import { Route as ProtectedOwnerRepoIssuesNewRouteImport } from './routes/_protected/$owner/$repo/issues.new' import { Route as ProtectedOwnerRepoIssuesIssueIdRouteImport } from './routes/_protected/$owner/$repo/issues.$issueId' import { Route as ProtectedOwnerRepoCompareSplatRouteImport } from './routes/_protected/$owner/$repo/compare.$' +import { Route as ProtectedOwnerRepoCommitsSplatRouteImport } from './routes/_protected/$owner/$repo/commits.$' import { Route as ProtectedOwnerRepoCommitShaRouteImport } from './routes/_protected/$owner/$repo/commit.$sha' import { Route as ProtectedOwnerRepoBlobSplatRouteImport } from './routes/_protected/$owner/$repo/blob.$' import { Route as ProtectedOwnerRepoActionsRunsRunIdRouteImport } from './routes/_protected/$owner/$repo/actions.runs.$runId' @@ -202,6 +203,12 @@ const ProtectedOwnerRepoCompareSplatRoute = path: '/$owner/$repo/compare/$', getParentRoute: () => ProtectedRoute, } as any) +const ProtectedOwnerRepoCommitsSplatRoute = + ProtectedOwnerRepoCommitsSplatRouteImport.update({ + id: '/$owner/$repo/commits/$', + path: '/$owner/$repo/commits/$', + getParentRoute: () => ProtectedRoute, + } as any) const ProtectedOwnerRepoCommitShaRoute = ProtectedOwnerRepoCommitShaRouteImport.update({ id: '/$owner/$repo/commit/$sha', @@ -251,6 +258,7 @@ export interface FileRoutesByFullPath { '/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute '/$owner/$repo/blob/$': typeof ProtectedOwnerRepoBlobSplatRoute '/$owner/$repo/commit/$sha': typeof ProtectedOwnerRepoCommitShaRoute + '/$owner/$repo/commits/$': typeof ProtectedOwnerRepoCommitsSplatRoute '/$owner/$repo/compare/$': typeof ProtectedOwnerRepoCompareSplatRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute @@ -285,6 +293,7 @@ export interface FileRoutesByTo { '/$owner/$repo': typeof ProtectedOwnerRepoIndexRoute '/$owner/$repo/blob/$': typeof ProtectedOwnerRepoBlobSplatRoute '/$owner/$repo/commit/$sha': typeof ProtectedOwnerRepoCommitShaRoute + '/$owner/$repo/commits/$': typeof ProtectedOwnerRepoCommitsSplatRoute '/$owner/$repo/compare/$': typeof ProtectedOwnerRepoCompareSplatRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute @@ -322,6 +331,7 @@ export interface FileRoutesById { '/_protected/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute '/_protected/$owner/$repo/blob/$': typeof ProtectedOwnerRepoBlobSplatRoute '/_protected/$owner/$repo/commit/$sha': typeof ProtectedOwnerRepoCommitShaRoute + '/_protected/$owner/$repo/commits/$': typeof ProtectedOwnerRepoCommitsSplatRoute '/_protected/$owner/$repo/compare/$': typeof ProtectedOwnerRepoCompareSplatRoute '/_protected/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/_protected/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute @@ -359,6 +369,7 @@ export interface FileRouteTypes { | '/$owner/$repo/' | '/$owner/$repo/blob/$' | '/$owner/$repo/commit/$sha' + | '/$owner/$repo/commits/$' | '/$owner/$repo/compare/$' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/issues/new' @@ -393,6 +404,7 @@ export interface FileRouteTypes { | '/$owner/$repo' | '/$owner/$repo/blob/$' | '/$owner/$repo/commit/$sha' + | '/$owner/$repo/commits/$' | '/$owner/$repo/compare/$' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/issues/new' @@ -429,6 +441,7 @@ export interface FileRouteTypes { | '/_protected/$owner/$repo/' | '/_protected/$owner/$repo/blob/$' | '/_protected/$owner/$repo/commit/$sha' + | '/_protected/$owner/$repo/commits/$' | '/_protected/$owner/$repo/compare/$' | '/_protected/$owner/$repo/issues/$issueId' | '/_protected/$owner/$repo/issues/new' @@ -666,6 +679,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedOwnerRepoCompareSplatRouteImport parentRoute: typeof ProtectedRoute } + '/_protected/$owner/$repo/commits/$': { + id: '/_protected/$owner/$repo/commits/$' + path: '/$owner/$repo/commits/$' + fullPath: '/$owner/$repo/commits/$' + preLoaderRoute: typeof ProtectedOwnerRepoCommitsSplatRouteImport + parentRoute: typeof ProtectedRoute + } '/_protected/$owner/$repo/commit/$sha': { id: '/_protected/$owner/$repo/commit/$sha' path: '/$owner/$repo/commit/$sha' @@ -723,6 +743,7 @@ interface ProtectedRouteChildren { ProtectedOwnerRepoIndexRoute: typeof ProtectedOwnerRepoIndexRoute ProtectedOwnerRepoBlobSplatRoute: typeof ProtectedOwnerRepoBlobSplatRoute ProtectedOwnerRepoCommitShaRoute: typeof ProtectedOwnerRepoCommitShaRoute + ProtectedOwnerRepoCommitsSplatRoute: typeof ProtectedOwnerRepoCommitsSplatRoute ProtectedOwnerRepoCompareSplatRoute: typeof ProtectedOwnerRepoCompareSplatRoute ProtectedOwnerRepoIssuesIssueIdRoute: typeof ProtectedOwnerRepoIssuesIssueIdRoute ProtectedOwnerRepoIssuesNewRoute: typeof ProtectedOwnerRepoIssuesNewRoute @@ -748,6 +769,7 @@ const ProtectedRouteChildren: ProtectedRouteChildren = { ProtectedOwnerRepoIndexRoute: ProtectedOwnerRepoIndexRoute, ProtectedOwnerRepoBlobSplatRoute: ProtectedOwnerRepoBlobSplatRoute, ProtectedOwnerRepoCommitShaRoute: ProtectedOwnerRepoCommitShaRoute, + ProtectedOwnerRepoCommitsSplatRoute: ProtectedOwnerRepoCommitsSplatRoute, ProtectedOwnerRepoCompareSplatRoute: ProtectedOwnerRepoCompareSplatRoute, ProtectedOwnerRepoIssuesIssueIdRoute: ProtectedOwnerRepoIssuesIssueIdRoute, ProtectedOwnerRepoIssuesNewRoute: ProtectedOwnerRepoIssuesNewRoute, diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/commits.$.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/commits.$.tsx new file mode 100644 index 0000000..139d279 --- /dev/null +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/commits.$.tsx @@ -0,0 +1,72 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { RepoCommitsPage } from "#/components/repo/repo-commits-list"; +import { + githubRepoBranchesQueryOptions, + githubRepoCommitsQueryOptions, + githubRepoOverviewQueryOptions, +} from "#/lib/github.query"; +import { parseRepoRef } from "#/lib/parse-repo-ref"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; + +export const Route = createFileRoute("/_protected/$owner/$repo/commits/$")({ + ssr: false, + loader: async ({ context, params }) => { + const scope = { userId: context.user.id }; + const splat = params._splat ?? ""; + const overviewOptions = githubRepoOverviewQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + }); + const branchesOptions = githubRepoBranchesQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + }); + + const [overview, branches] = await Promise.all([ + context.queryClient.ensureQueryData(overviewOptions), + context.queryClient.ensureQueryData(branchesOptions), + ]); + const { ref, path } = parseRepoRef(splat, { + branches: branches ?? undefined, + defaultBranch: overview?.defaultBranch, + }); + + void context.queryClient.prefetchInfiniteQuery( + githubRepoCommitsQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + ref, + ...(path ? { path } : {}), + perPage: 30, + }), + ); + + return { ref, path }; + }, + head: ({ match, params }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle(`Commits ยท ${params.owner}/${params.repo}`), + description: `View commit history for ${params.owner}/${params.repo}.`, + robots: "noindex", + }), + component: CommitsRoute, +}); + +function CommitsRoute() { + const { user } = Route.useRouteContext(); + const { owner, repo } = Route.useParams(); + const { ref, path } = Route.useLoaderData(); + const scope = useMemo(() => ({ userId: user.id }), [user.id]); + + return ( + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58dd2a3..02565be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,9 @@ importers: '@vitejs/plugin-react': specifier: ^5.1.4 version: 5.2.0(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + cross-env: + specifier: ^10.1.0 + version: 10.1.0 drizzle-kit: specifier: ^0.31.10 version: 0.31.10 @@ -706,6 +709,9 @@ packages: '@emnapi/runtime@1.9.2': resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -3414,6 +3420,11 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -5564,6 +5575,8 @@ snapshots: tslib: 2.8.1 optional: true + '@epic-web/invariant@1.0.0': {} + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -7987,6 +8000,11 @@ snapshots: cookie@1.1.1: {} + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1