diff --git a/apps/dashboard/src/components/layouts/dashboard-tabs.tsx b/apps/dashboard/src/components/layouts/dashboard-tabs.tsx index 4307a77..468362c 100644 --- a/apps/dashboard/src/components/layouts/dashboard-tabs.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-tabs.tsx @@ -2,6 +2,7 @@ import { ArchiveIcon, ChevronRightIcon, CloseIcon, + GitCommitIcon, GitPullRequestIcon, IssuesIcon, Remove01Icon, @@ -33,6 +34,7 @@ const tabIconMap = { issue: IssuesIcon, review: ReviewsIcon, repo: ArchiveIcon, + commit: GitCommitIcon, } as const; function useScrollShadows(tabCount: number) { @@ -371,7 +373,7 @@ const DetailTab = memo(function DetailTab({ /> )} {tab.title} - {tab.type === "review" ? ( + {tab.type === "review" || tab.type === "commit" ? ( {tab.additions != null && ( +{tab.additions} diff --git a/apps/dashboard/src/components/pulls/review/review-file-diff-block.tsx b/apps/dashboard/src/components/pulls/review/review-file-diff-block.tsx index 683add5..c0b3abf 100644 --- a/apps/dashboard/src/components/pulls/review/review-file-diff-block.tsx +++ b/apps/dashboard/src/components/pulls/review/review-file-diff-block.tsx @@ -83,6 +83,7 @@ export const ReviewFileDiffBlock = memo(function ReviewFileDiffBlock({ file, diffStyle, isNearViewport, + readOnly = false, annotations, repliesByCommentId, owner, @@ -103,6 +104,7 @@ export const ReviewFileDiffBlock = memo(function ReviewFileDiffBlock({ file: PullFile; diffStyle: "unified" | "split"; isNearViewport: boolean; + readOnly?: boolean; annotations: DiffLineAnnotation[]; repliesByCommentId: ReadonlyMap; owner: string; @@ -131,6 +133,10 @@ export const ReviewFileDiffBlock = memo(function ReviewFileDiffBlock({ ); const allAnnotations = useMemo(() => { + if (readOnly) { + return [] as DiffLineAnnotation[]; + } + const result: DiffLineAnnotation[] = [...annotations]; for (const pending of pendingComments) { @@ -157,7 +163,7 @@ export const ReviewFileDiffBlock = memo(function ReviewFileDiffBlock({ } return result; - }, [annotations, pendingComments, activeCommentForm]); + }, [readOnly, annotations, pendingComments, activeCommentForm]); const useWordDiff = file.changes <= LARGE_PATCH_CHANGE_THRESHOLD && @@ -175,9 +181,9 @@ export const ReviewFileDiffBlock = memo(function ReviewFileDiffBlock({ hunkSeparators: "line-info" as const, overflow: "scroll" as const, disableFileHeader: true, - enableGutterUtility: true, - enableLineSelection: true, - onGutterUtilityClick: handleGutterUtilityClick, + enableGutterUtility: !readOnly, + enableLineSelection: !readOnly, + ...(readOnly ? {} : { onGutterUtilityClick: handleGutterUtilityClick }), unsafeCSS: [ `:host { color-scheme: ${isDark ? "dark" : "light"}; }`, `:host { --diffs-font-family: 'Geist Mono Variable', 'SF Mono', ui-monospace, 'Cascadia Code', monospace; }`, @@ -187,7 +193,7 @@ export const ReviewFileDiffBlock = memo(function ReviewFileDiffBlock({ `[data-diff] { border: 1px solid var(--border); border-top: 0; border-radius: 0 0 4px 4px; overflow: hidden; }`, ].join("\n"), }), - [diffStyle, handleGutterUtilityClick, isDark, useWordDiff], + [diffStyle, handleGutterUtilityClick, isDark, readOnly, useWordDiff], ); const patchString = useMemo(() => buildPatchString(file), [file]); @@ -236,6 +242,8 @@ export const ReviewFileDiffBlock = memo(function ReviewFileDiffBlock({ renderAnnotation={( annotation: DiffLineAnnotation, ) => { + if (readOnly) return null; + const data = annotation.metadata as | PendingComment | PullReviewComment diff --git a/apps/dashboard/src/components/pulls/review/review-page.tsx b/apps/dashboard/src/components/pulls/review/review-page.tsx index a9d75f0..2b5a928 100644 --- a/apps/dashboard/src/components/pulls/review/review-page.tsx +++ b/apps/dashboard/src/components/pulls/review/review-page.tsx @@ -719,7 +719,7 @@ const ReviewToolbar = memo(function ReviewToolbar({ // ReviewSidebar — owns file filter state, reads activeFile from store // --------------------------------------------------------------------------- -const ReviewSidebar = memo(function ReviewSidebar({ +export const ReviewSidebar = memo(function ReviewSidebar({ sidebarFiles, sidebarFileCount, activeFileStore, diff --git a/apps/dashboard/src/components/repo/code-file-view.tsx b/apps/dashboard/src/components/repo/code-file-view.tsx index 39d7bc3..9ebf429 100644 --- a/apps/dashboard/src/components/repo/code-file-view.tsx +++ b/apps/dashboard/src/components/repo/code-file-view.tsx @@ -15,6 +15,7 @@ import { } from "@diffkit/ui/components/tooltip"; import { cn } from "@diffkit/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; import { Suspense, use, useCallback, useMemo, useRef, useState } from "react"; import { formatRelativeTime } from "#/lib/format-relative-time"; import { @@ -216,7 +217,7 @@ export function CodeFileView({ const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${currentRef}/${path}`; return (
- +
- +
- +
)} {commit.author?.login ?? "Unknown"} - - - - {firstLine} - - - {firstLine.length > 60 && ( - - {firstLine} - - )} - + + {firstLine} +
diff --git a/apps/dashboard/src/components/repo/commit-diff-pane.tsx b/apps/dashboard/src/components/repo/commit-diff-pane.tsx new file mode 100644 index 0000000..d13f124 --- /dev/null +++ b/apps/dashboard/src/components/repo/commit-diff-pane.tsx @@ -0,0 +1,310 @@ +import type { SelectedLineRange } from "@pierre/diffs"; +import type { DiffLineAnnotation } from "@pierre/diffs/react"; +import { + forwardRef, + memo, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import { ReviewFileDiffBlock } from "#/components/pulls/review/review-file-diff-block"; +import type { + ActiveCommentForm, + PendingComment, +} from "#/components/pulls/review/review-types"; +import { encodeFileId } from "#/components/pulls/review/review-utils"; +import type { PullFile, PullReviewComment } from "#/lib/github.types"; + +const INITIAL_VISIBLE_COUNT = 12; +const LOAD_MORE_CHUNK = 12; +const SCROLL_TARGET_BUFFER = 6; +const EMPTY_ANNOTATIONS: DiffLineAnnotation[] = []; +const EMPTY_PENDING_COMMENTS: PendingComment[] = []; +const EMPTY_REPLIES = new Map(); +const NOOP_COMMENT_FORM: ActiveCommentForm | null = null; + +export type CommitDiffPaneHandle = { + scrollToFile: (filename: string) => void; +}; + +type CommitDiffPaneProps = { + files: PullFile[]; + diffStyle: "unified" | "split"; + onActiveFileChange: (filename: string) => void; +}; + +export const CommitDiffPane = memo( + forwardRef(function CommitDiffPane( + { files, diffStyle, onActiveFileChange }, + ref, + ) { + const diffPanelRef = useRef(null); + const loadMoreRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(() => + Math.min(files.length, INITIAL_VISIBLE_COUNT), + ); + const [scrollTarget, setScrollTarget] = useState(null); + + useEffect(() => { + setVisibleCount((previous) => + Math.min( + files.length, + Math.max(files.length === 0 ? 0 : INITIAL_VISIBLE_COUNT, previous), + ), + ); + }, [files.length]); + + const revealFile = useCallback( + (filename: string) => { + const targetIndex = files.findIndex( + (file) => file.filename === filename, + ); + if (targetIndex === -1) { + setScrollTarget(filename); + return; + } + + setScrollTarget(filename); + setVisibleCount((previous) => + Math.min( + files.length, + Math.max(previous, targetIndex + 1 + SCROLL_TARGET_BUFFER), + ), + ); + }, + [files], + ); + + const handleHash = useCallback(() => { + if (typeof window === "undefined") return; + + const hash = window.location.hash.slice(1); + if (!hash) return; + + setScrollTarget(hash); + }, []); + + useImperativeHandle( + ref, + () => ({ + scrollToFile: revealFile, + }), + [revealFile], + ); + + useEffect(() => { + handleHash(); + window.addEventListener("hashchange", handleHash); + + return () => window.removeEventListener("hashchange", handleHash); + }, [handleHash]); + + useEffect(() => { + if (!scrollTarget) return; + + const targetIndex = files.findIndex( + (file) => + file.filename === scrollTarget || + encodeFileId(file.filename) === scrollTarget, + ); + + if (targetIndex !== -1) { + const needed = targetIndex + 1 + SCROLL_TARGET_BUFFER; + if (needed > visibleCount) { + setVisibleCount(Math.min(files.length, needed)); + return; + } + } + + const frameId = requestAnimationFrame(() => { + const encodedId = encodeFileId(scrollTarget); + const element = + document.getElementById(encodedId) ?? + document.getElementById(scrollTarget); + if (!element) { + setScrollTarget(null); + return; + } + + element.scrollIntoView({ block: "start" }); + const filename = element.getAttribute("data-filename"); + if (filename) { + onActiveFileChange(filename); + const hash = `#${encodeFileId(filename)}`; + if (window.location.hash !== hash) { + history.replaceState(null, "", hash); + } + } + setScrollTarget(null); + }); + return () => cancelAnimationFrame(frameId); + }, [files, onActiveFileChange, scrollTarget, visibleCount]); + + useEffect(() => { + const panel = diffPanelRef.current; + const sentinel = loadMoreRef.current; + if (!panel || !sentinel || visibleCount >= files.length) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (!entries[0]?.isIntersecting) return; + + if (visibleCount < files.length) { + setVisibleCount((previous) => + Math.min(files.length, previous + LOAD_MORE_CHUNK), + ); + } + }, + { + root: panel, + rootMargin: "3000px 0px", + threshold: 0, + }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [files.length, visibleCount]); + + const visibleFiles = useMemo( + () => files.slice(0, visibleCount), + [files, visibleCount], + ); + + useEffect(() => { + const panel = diffPanelRef.current; + if (!panel || visibleFiles.length === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + + const filename = entry.target.getAttribute("data-filename"); + if (filename) { + onActiveFileChange(filename); + } + } + }, + { + root: panel, + rootMargin: "-10% 0px -80% 0px", + threshold: 0, + }, + ); + + for (const file of visibleFiles) { + const element = document.getElementById(encodeFileId(file.filename)); + if (element) observer.observe(element); + } + + return () => observer.disconnect(); + }, [onActiveFileChange, visibleFiles]); + + const [nearViewportFiles, setNearViewportFiles] = useState>( + () => new Set(), + ); + + useEffect(() => { + if (visibleFiles.length === 0 || nearViewportFiles.size > 0) return; + setNearViewportFiles( + new Set(visibleFiles.slice(0, 4).map((f) => f.filename)), + ); + }, [visibleFiles, nearViewportFiles.size]); + + useEffect(() => { + const panel = diffPanelRef.current; + if (!panel || visibleFiles.length === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + const newlyVisible: string[] = []; + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const filename = entry.target.getAttribute("data-filename"); + if (filename) { + newlyVisible.push(filename); + observer.unobserve(entry.target); + } + } + if (newlyVisible.length > 0) { + setNearViewportFiles((prev) => { + const next = new Set(prev); + for (const f of newlyVisible) next.add(f); + return next; + }); + } + }, + { + root: panel, + rootMargin: "1500px 0px", + threshold: 0, + }, + ); + + for (const file of visibleFiles) { + if (nearViewportFiles.has(file.filename)) continue; + const element = document.getElementById(encodeFileId(file.filename)); + if (element) observer.observe(element); + } + + return () => observer.disconnect(); + }, [visibleFiles, nearViewportFiles]); + + if (files.length === 0) { + return ( +
+ No files changed in this commit. +
+ ); + } + + const noopStartComment = ( + _filename: string, + _range: SelectedLineRange, + ) => {}; + const noopCancel = () => {}; + const noopAdd = (_comment: PendingComment) => {}; + const noopEdit = (_original: PendingComment, _newBody: string) => {}; + + return ( +
+
+ {visibleFiles.map((file) => ( + + ))} + + {visibleCount < files.length && ( +
+ )} +
+
+ ); + }), +); + +CommitDiffPane.displayName = "CommitDiffPane"; diff --git a/apps/dashboard/src/components/repo/commit-page.tsx b/apps/dashboard/src/components/repo/commit-page.tsx new file mode 100644 index 0000000..584fa12 --- /dev/null +++ b/apps/dashboard/src/components/repo/commit-page.tsx @@ -0,0 +1,320 @@ +import { FileIcon, GitCommitIcon, PanelLeftIcon } from "@diffkit/icons"; +import { + Drawer, + DrawerContent, + DrawerTitle, +} from "@diffkit/ui/components/drawer"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@diffkit/ui/components/resizable"; +import { cn } from "@diffkit/ui/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import { getRouteApi, Link } from "@tanstack/react-router"; +import { + lazy, + memo, + Suspense, + useCallback, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { createActiveFileStore } from "#/components/pulls/review/review-file-tree"; +import { ReviewSidebar } from "#/components/pulls/review/review-page"; +import { + type GitHubQueryScope, + githubRepoCommitQueryOptions, +} from "#/lib/github.query"; +import type { PullFileSummary, RepoCommitDetail } from "#/lib/github.types"; +import { useRegisterTab } from "#/lib/use-register-tab"; +import type { CommitDiffPaneHandle } from "./commit-diff-pane"; + +const routeApi = getRouteApi("/_protected/$owner/$repo/commit/$sha"); + +const MD_QUERY = "(min-width: 768px)"; +const mdSubscribe = (cb: () => void) => { + const mql = window.matchMedia(MD_QUERY); + mql.addEventListener("change", cb); + return () => mql.removeEventListener("change", cb); +}; +const getMdSnapshot = () => window.matchMedia(MD_QUERY).matches; +const getMdServerSnapshot = () => true; + +function useIsDesktop() { + return useSyncExternalStore(mdSubscribe, getMdSnapshot, getMdServerSnapshot); +} + +const commitDiffPaneImport = import("./commit-diff-pane"); +const CommitDiffPane = lazy(() => + commitDiffPaneImport.then((mod) => ({ default: mod.CommitDiffPane })), +); + +function CommitDiffPanePlaceholder() { + return
; +} + +export function CommitPage() { + const { user } = routeApi.useRouteContext(); + const { owner, repo, sha } = routeApi.useParams(); + const scope = useMemo( + () => ({ userId: user.id }) satisfies GitHubQueryScope, + [user.id], + ); + const isDesktop = useIsDesktop(); + const [diffStyle, setDiffStyle] = useState<"unified" | "split">("unified"); + const [fileSheetOpen, setFileSheetOpen] = useState(false); + const activeFileStore = useRef(createActiveFileStore(null)).current; + const diffPaneRef = useRef(null); + + const commitQuery = useQuery({ + ...githubRepoCommitQueryOptions(scope, { owner, repo, sha }), + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + const commit = commitQuery.data; + + const sidebarFiles = useMemo( + () => + commit?.files.map((f) => ({ + filename: f.filename, + status: f.status, + additions: f.additions, + deletions: f.deletions, + changes: f.changes, + previousFilename: f.previousFilename, + })) ?? [], + [commit?.files], + ); + + const diffStats = useMemo(() => { + if (!commit) return { totalAdditions: 0, totalDeletions: 0 }; + return commit.files.reduce( + (acc, file) => ({ + totalAdditions: acc.totalAdditions + file.additions, + totalDeletions: acc.totalDeletions + file.deletions, + }), + { totalAdditions: 0, totalDeletions: 0 }, + ); + }, [commit]); + + useRegisterTab( + commit + ? { + type: "commit", + title: commit.message.split("\n")[0], + url: `/${owner}/${repo}/commit/${sha}`, + repo: `${owner}/${repo}`, + iconColor: "text-muted-foreground", + tabId: `commit:${owner}/${repo}@${commit.sha}`, + additions: diffStats.totalAdditions, + deletions: diffStats.totalDeletions, + } + : null, + ); + + const handleActiveFileChange = useCallback( + (filename: string) => { + activeFileStore.set(filename); + }, + [activeFileStore], + ); + + const scrollToFile = useCallback((path: string) => { + diffPaneRef.current?.scrollToFile(path); + }, []); + + if (commitQuery.error) throw commitQuery.error; + + if (commitQuery.isPending) { + return ( +
+
+
+ ); + } + + if (!commit) { + return ( +
+

Commit not found or you do not have access.

+ + Back to repository + +
+ ); + } + + const sidebarFileCount = sidebarFiles.length; + + const diffContent = ( + }> + + + ); + + return ( +
+ setFileSheetOpen(true)} + isDesktop={isDesktop} + /> + + {isDesktop ? ( + + + + + + + + {diffContent} + + ) : ( + <> +
{diffContent}
+ + + Files + + + + + )} +
+ ); +} + +const CommitToolbar = memo(function CommitToolbar({ + owner, + repo, + commit, + sidebarFileCount, + diffStats, + diffStyle, + onSetDiffStyle, + onOpenFileSheet, + isDesktop, +}: { + owner: string; + repo: string; + commit: RepoCommitDetail; + sidebarFileCount: number; + diffStats: { totalAdditions: number; totalDeletions: number }; + diffStyle: "unified" | "split"; + onSetDiffStyle: (style: "unified" | "split") => void; + onOpenFileSheet: () => void; + isDesktop: boolean; +}) { + const shortSha = commit.sha.slice(0, 7); + const titleLine = commit.message.split("\n")[0]; + + return ( +
+ {!isDesktop && ( + + )} + + + {shortSha} + + +
+ +
+ + {titleLine} +
+ +
+
+ + + + {sidebarFileCount} + + + {" "} + {sidebarFileCount === 1 ? "file" : "files"} + + + + +{diffStats.totalAdditions} + + + -{diffStats.totalDeletions} + +
+ +
+ +
+ + +
+
+
+ ); +}); diff --git a/apps/dashboard/src/components/repo/folder-view.tsx b/apps/dashboard/src/components/repo/folder-view.tsx index 5f5c4e8..07510fe 100644 --- a/apps/dashboard/src/components/repo/folder-view.tsx +++ b/apps/dashboard/src/components/repo/folder-view.tsx @@ -49,7 +49,14 @@ export function FolderView({ return (
- +
{entries.map((entry, index) => ( -
+ {entry.name} -
- + +
{commit ? ( - commit.message.split("\n")[0] + + {commit.message.split("\n")[0]} + ) : isCommitLoading ? ( ) : null} - +
{commit?.date ? ( formatRelativeTime(commit.date) @@ -146,7 +161,7 @@ function FolderViewRow({ ) : null} - +
); } diff --git a/apps/dashboard/src/components/repo/latest-commit-bar.tsx b/apps/dashboard/src/components/repo/latest-commit-bar.tsx index f4b4fb0..9d056b9 100644 --- a/apps/dashboard/src/components/repo/latest-commit-bar.tsx +++ b/apps/dashboard/src/components/repo/latest-commit-bar.tsx @@ -1,14 +1,61 @@ import { GitCommitIcon } from "@diffkit/icons"; +import { Skeleton } from "@diffkit/ui/components/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@diffkit/ui/components/tooltip"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; import { formatRelativeTime } from "#/lib/format-relative-time"; +import { + type GitHubQueryScope, + githubRefHeadCommitQueryOptions, +} from "#/lib/github.query"; import type { RepoOverview } from "#/lib/github.types"; -export function LatestCommitBar({ repo }: { repo: RepoOverview }) { - const commit = repo.latestCommit; +export function LatestCommitBar({ + owner, + repoName, + ref, + scope, + defaultBranch, + defaultBranchTip, +}: { + owner: string; + repoName: string; + ref: string; + scope: GitHubQueryScope; + defaultBranch: string; + defaultBranchTip: RepoOverview["latestCommit"]; +}) { + const tipQuery = useQuery({ + ...githubRefHeadCommitQueryOptions(scope, { + owner, + repo: repoName, + ref, + }), + placeholderData: + ref === defaultBranch && defaultBranchTip != null + ? defaultBranchTip + : undefined, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + const commit = tipQuery.data; + + if (tipQuery.isPending && commit == null) { + return ( +
+ + + + +
+ ); + } + if (!commit) return null; const shortSha = commit.sha.slice(0, 7); @@ -24,18 +71,17 @@ export function LatestCommitBar({ repo }: { repo: RepoOverview }) { /> )} {commit.author?.login ?? "Unknown"} - - - - {firstLine} - - - {firstLine.length > 60 && ( - - {firstLine} - - )} - + + {firstLine} +
diff --git a/apps/dashboard/src/components/repo/repo-overview-page.tsx b/apps/dashboard/src/components/repo/repo-overview-page.tsx index fbc6018..f97c244 100644 --- a/apps/dashboard/src/components/repo/repo-overview-page.tsx +++ b/apps/dashboard/src/components/repo/repo-overview-page.tsx @@ -75,7 +75,14 @@ export function RepoOverviewPage() { />
- + {treeQuery.data ? ( { + const repoCodeKey = githubRevalidationSignalKeys.repoCode({ + owner: data.owner, + repo: data.repo, + }); + + return getCachedGitHubRequest({ + context, + resource: "repos.commit", + params: data, + freshForMs: githubCachePolicy.detail.staleTimeMs, + signalKeys: [repoCodeKey], + namespaceKeys: [repoCodeKey], + cacheMode: "split", + request: (headers, signal) => + context.octokit.rest.repos.getCommit({ + owner: data.owner, + repo: data.repo, + ref: data.sha, + headers, + request: { signal }, + }), + mapData: (commit) => ({ + sha: commit.sha, + message: commit.commit.message ?? "", + date: + commit.commit.author?.date ?? + commit.commit.committer?.date ?? + new Date().toISOString(), + author: commit.author + ? { + login: commit.author.login, + avatarUrl: commit.author.avatar_url, + url: commit.author.html_url, + type: commit.author.type ?? "User", + } + : null, + files: (commit.files ?? []).map((file) => ({ + sha: file.sha, + filename: file.filename, + status: file.status as PullFile["status"], + additions: file.additions, + deletions: file.deletions, + changes: file.changes, + patch: file.patch ?? null, + previousFilename: file.previous_filename ?? null, + })), + }), + }); +} + +export const getRepoCommit = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContextForRepository(data); + if (!context) { + return null; + } + + try { + return await getRepoCommitResult(context, data); + } catch (error) { + if ( + error instanceof RequestError && + (error.status === 404 || error.status === 403) + ) { + return null; + } + throw error; + } + }); + async function getPullReviewCommentsResult( context: GitHubContext, data: PullFromRepoInput, @@ -8294,6 +8371,59 @@ export const getFileLastCommit = createServerFn({ method: "GET" }) }).catch(() => null); }); +type RefHeadCommitInput = { + owner: string; + repo: string; + ref: string; +}; + +/** Tip commit for a branch/tag/SHA (first page of `listCommits` for `ref`, no path filter). */ +export const getRefHeadCommit = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContextForRepository(data); + if (!context) return null; + + return getCachedGitHubRequest< + Awaited>["data"], + FileLastCommit | null + >({ + context, + resource: "repo.refHeadCommit.v1", + params: data, + 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, + per_page: 1, + headers, + }), + mapData: (commits) => { + const commit = commits[0]; + if (!commit) return null; + return { + sha: commit.sha, + message: commit.commit.message, + date: + commit.commit.committer?.date ?? commit.commit.author?.date ?? "", + author: commit.author + ? { + login: commit.author.login, + avatarUrl: commit.author.avatar_url, + url: commit.author.html_url, + type: commit.author.type, + } + : null, + }; + }, + }).catch(() => null); + }); + // --------------------------------------------------------------------------- // 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 4439ad8..f5df9b3 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -23,8 +23,10 @@ import { getPullStatus, getPullsFromRepo, getPullsFromUser, + getRefHeadCommit, getRepoBranches, getRepoCollaborators, + getRepoCommit, getRepoContributors, getRepoDiscussions, getRepoFileContent, @@ -214,6 +216,14 @@ export const githubQueryKeys = { scope: GitHubQueryScope, input: { owner: string; repo: string; ref: string; path: string }, ) => ["github", scope.userId, "repo", "fileLastCommit", input] as const, + commit: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string; sha: string }, + ) => ["github", scope.userId, "repo", "commit", input] as const, + refHeadCommit: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string; ref: string }, + ) => ["github", scope.userId, "repo", "refHeadCommit", input] as const, treeEntryCommits: ( scope: GitHubQueryScope, input: { owner: string; repo: string; ref: string; dirPath: string }, @@ -742,6 +752,32 @@ export function githubFileLastCommitQueryOptions( }); } +export function githubRepoCommitQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string; sha: string }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repo.commit(scope, input), + queryFn: () => getRepoCommit({ data: input }), + staleTime: githubCachePolicy.detail.staleTimeMs, + gcTime: githubCachePolicy.detail.gcTimeMs, + meta: tabPersistedMeta, + }); +} + +export function githubRefHeadCommitQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string; ref: string }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repo.refHeadCommit(scope, input), + queryFn: () => getRefHeadCommit({ data: input }), + 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 ac83f17..5e50b3a 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -340,6 +340,20 @@ export type PullFilesPage = { nextPage: number | null; }; +export type RepoCommitInput = { + owner: string; + repo: string; + sha: string; +}; + +export type RepoCommitDetail = { + sha: string; + message: string; + date: string; + author: GitHubActor | null; + files: PullFile[]; +}; + export type PullReviewComment = { id: number; nodeId: string; diff --git a/apps/dashboard/src/lib/tab-store.ts b/apps/dashboard/src/lib/tab-store.ts index ba5fe8e..c2f6463 100644 --- a/apps/dashboard/src/lib/tab-store.ts +++ b/apps/dashboard/src/lib/tab-store.ts @@ -1,6 +1,6 @@ import { useSyncExternalStore } from "react"; -export type TabType = "pull" | "issue" | "review" | "repo"; +export type TabType = "pull" | "issue" | "review" | "repo" | "commit"; export interface Tab { id: string; @@ -23,6 +23,7 @@ const VALID_TAB_TYPES = { issue: true, review: true, repo: true, + commit: true, } satisfies Record; function isValidTabType(type: unknown): type is TabType { diff --git a/apps/dashboard/src/lib/use-register-tab.ts b/apps/dashboard/src/lib/use-register-tab.ts index fd03974..f5e7c16 100644 --- a/apps/dashboard/src/lib/use-register-tab.ts +++ b/apps/dashboard/src/lib/use-register-tab.ts @@ -13,14 +13,16 @@ export function useRegisterTab( additions?: number; deletions?: number; merged?: boolean; + tabId?: string; } | null, ) { useEffect(() => { if (!tab?.title) return; const id = - tab.number != null + tab.tabId ?? + (tab.number != null ? `${tab.type}:${tab.repo}#${tab.number}` - : `${tab.type}:${tab.repo}`; + : `${tab.type}:${tab.repo}`); addTab({ id, type: tab.type, @@ -45,5 +47,6 @@ export function useRegisterTab( tab?.additions, tab?.deletions, tab?.merged, + tab?.tabId, ]); } diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index cd0b61b..6c453d5 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -37,6 +37,7 @@ import { Route as ProtectedOwnerRepoReviewPullIdRouteImport } from './routes/_pr import { Route as ProtectedOwnerRepoPullPullIdRouteImport } from './routes/_protected/$owner/$repo/pull.$pullId' 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 ProtectedOwnerRepoCommitShaRouteImport } from './routes/_protected/$owner/$repo/commit.$sha' import { Route as ProtectedOwnerRepoBlobSplatRouteImport } from './routes/_protected/$owner/$repo/blob.$' const TermsRoute = TermsRouteImport.update({ @@ -185,6 +186,12 @@ const ProtectedOwnerRepoIssuesIssueIdRoute = path: '/$owner/$repo/issues/$issueId', getParentRoute: () => ProtectedRoute, } as any) +const ProtectedOwnerRepoCommitShaRoute = + ProtectedOwnerRepoCommitShaRouteImport.update({ + id: '/$owner/$repo/commit/$sha', + path: '/$owner/$repo/commit/$sha', + getParentRoute: () => ProtectedRoute, + } as any) const ProtectedOwnerRepoBlobSplatRoute = ProtectedOwnerRepoBlobSplatRouteImport.update({ id: '/$owner/$repo/blob/$', @@ -215,6 +222,7 @@ export interface FileRoutesByFullPath { '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute '/$owner/$repo/blob/$': typeof ProtectedOwnerRepoBlobSplatRoute + '/$owner/$repo/commit/$sha': typeof ProtectedOwnerRepoCommitShaRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute @@ -244,6 +252,7 @@ export interface FileRoutesByTo { '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/$owner/$repo': typeof ProtectedOwnerRepoIndexRoute '/$owner/$repo/blob/$': typeof ProtectedOwnerRepoBlobSplatRoute + '/$owner/$repo/commit/$sha': typeof ProtectedOwnerRepoCommitShaRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute @@ -276,6 +285,7 @@ export interface FileRoutesById { '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/_protected/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute '/_protected/$owner/$repo/blob/$': typeof ProtectedOwnerRepoBlobSplatRoute + '/_protected/$owner/$repo/commit/$sha': typeof ProtectedOwnerRepoCommitShaRoute '/_protected/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/_protected/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute '/_protected/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute @@ -308,6 +318,7 @@ export interface FileRouteTypes { | '/api/github/app/callback' | '/$owner/$repo/' | '/$owner/$repo/blob/$' + | '/$owner/$repo/commit/$sha' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/issues/new' | '/$owner/$repo/pull/$pullId' @@ -337,6 +348,7 @@ export interface FileRouteTypes { | '/api/github/app/callback' | '/$owner/$repo' | '/$owner/$repo/blob/$' + | '/$owner/$repo/commit/$sha' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/issues/new' | '/$owner/$repo/pull/$pullId' @@ -368,6 +380,7 @@ export interface FileRouteTypes { | '/api/github/app/callback' | '/_protected/$owner/$repo/' | '/_protected/$owner/$repo/blob/$' + | '/_protected/$owner/$repo/commit/$sha' | '/_protected/$owner/$repo/issues/$issueId' | '/_protected/$owner/$repo/issues/new' | '/_protected/$owner/$repo/pull/$pullId' @@ -587,6 +600,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedOwnerRepoIssuesIssueIdRouteImport parentRoute: typeof ProtectedRoute } + '/_protected/$owner/$repo/commit/$sha': { + id: '/_protected/$owner/$repo/commit/$sha' + path: '/$owner/$repo/commit/$sha' + fullPath: '/$owner/$repo/commit/$sha' + preLoaderRoute: typeof ProtectedOwnerRepoCommitShaRouteImport + parentRoute: typeof ProtectedRoute + } '/_protected/$owner/$repo/blob/$': { id: '/_protected/$owner/$repo/blob/$' path: '/$owner/$repo/blob/$' @@ -622,6 +642,7 @@ interface ProtectedRouteChildren { ProtectedOwnerRepoPullsRoute: typeof ProtectedOwnerRepoPullsRoute ProtectedOwnerRepoIndexRoute: typeof ProtectedOwnerRepoIndexRoute ProtectedOwnerRepoBlobSplatRoute: typeof ProtectedOwnerRepoBlobSplatRoute + ProtectedOwnerRepoCommitShaRoute: typeof ProtectedOwnerRepoCommitShaRoute ProtectedOwnerRepoIssuesIssueIdRoute: typeof ProtectedOwnerRepoIssuesIssueIdRoute ProtectedOwnerRepoIssuesNewRoute: typeof ProtectedOwnerRepoIssuesNewRoute ProtectedOwnerRepoPullPullIdRoute: typeof ProtectedOwnerRepoPullPullIdRoute @@ -642,6 +663,7 @@ const ProtectedRouteChildren: ProtectedRouteChildren = { ProtectedOwnerRepoPullsRoute: ProtectedOwnerRepoPullsRoute, ProtectedOwnerRepoIndexRoute: ProtectedOwnerRepoIndexRoute, ProtectedOwnerRepoBlobSplatRoute: ProtectedOwnerRepoBlobSplatRoute, + ProtectedOwnerRepoCommitShaRoute: ProtectedOwnerRepoCommitShaRoute, ProtectedOwnerRepoIssuesIssueIdRoute: ProtectedOwnerRepoIssuesIssueIdRoute, ProtectedOwnerRepoIssuesNewRoute: ProtectedOwnerRepoIssuesNewRoute, ProtectedOwnerRepoPullPullIdRoute: ProtectedOwnerRepoPullPullIdRoute, diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/commit.$sha.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/commit.$sha.tsx new file mode 100644 index 0000000..86aa0d2 --- /dev/null +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/commit.$sha.tsx @@ -0,0 +1,26 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { CommitPage } from "#/components/repo/commit-page"; +import { githubRepoCommitQueryOptions } from "#/lib/github.query"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; + +export const Route = createFileRoute("/_protected/$owner/$repo/commit/$sha")({ + ssr: false, + loader: ({ context, params }) => { + const scope = { userId: context.user.id }; + const input = { owner: params.owner, repo: params.repo, sha: params.sha }; + void context.queryClient.prefetchQuery( + githubRepoCommitQueryOptions(scope, input), + ); + return {}; + }, + head: ({ match, params }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle( + `Commit ${params.sha.slice(0, 7)} · ${params.owner}/${params.repo}`, + ), + description: `View commit ${params.sha} in ${params.owner}/${params.repo}.`, + robots: "noindex", + }), + component: CommitPage, +});