From a99154096505ac5e26da0d197e38ac50abf4c6af Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 18 Apr 2026 15:16:56 -0400 Subject: [PATCH] feat(dashboard): commit page, ref-aware latest commit, commit links Add repository commit view at /{owner}/{repo}/commit/{sha} with read-only diffs and file tree aligned with the PR review UX. Link commit subjects from the repo overview bar, blob header, and directory rows. Latest commit bar uses the selected ref tip (getRefHeadCommit) so it updates when switching branches. Includes commit tab type, read-only diff blocks, and GitHub API wiring for single-commit fetch and ref head resolution. --- .../src/components/layouts/dashboard-tabs.tsx | 4 +- .../pulls/review/review-file-diff-block.tsx | 18 +- .../components/pulls/review/review-page.tsx | 2 +- .../src/components/repo/code-file-view.tsx | 30 +- .../src/components/repo/commit-diff-pane.tsx | 310 +++++++++++++++++ .../src/components/repo/commit-page.tsx | 320 ++++++++++++++++++ .../src/components/repo/folder-view.tsx | 43 ++- .../src/components/repo/latest-commit-bar.tsx | 74 +++- .../components/repo/repo-overview-page.tsx | 9 +- apps/dashboard/src/lib/github.functions.ts | 130 +++++++ apps/dashboard/src/lib/github.query.ts | 36 ++ apps/dashboard/src/lib/github.types.ts | 14 + apps/dashboard/src/lib/tab-store.ts | 3 +- apps/dashboard/src/lib/use-register-tab.ts | 7 +- apps/dashboard/src/routeTree.gen.ts | 22 ++ .../_protected/$owner/$repo/commit.$sha.tsx | 26 ++ 16 files changed, 994 insertions(+), 54 deletions(-) create mode 100644 apps/dashboard/src/components/repo/commit-diff-pane.tsx create mode 100644 apps/dashboard/src/components/repo/commit-page.tsx create mode 100644 apps/dashboard/src/routes/_protected/$owner/$repo/commit.$sha.tsx 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, +});