From ca582b8435aeabc6cb618f14559c9ec690c4f568 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Fri, 24 Apr 2026 12:01:19 -0700 Subject: [PATCH 01/11] feat(web): show latest commit header in file browser Adds a GitHub-style commit row below the path header on the file browse view, showing the author, message, short SHA, and relative date for the most recent commit that touched the file. A no-op History button is included as a placeholder for the upcoming file-history view. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[...path]/components/codePreviewPanel.tsx | 19 +++++++- .../[...path]/components/commitHeader.tsx | 46 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/commitHeader.tsx diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel.tsx index 6f5725783..9b5b47265 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel.tsx @@ -4,7 +4,8 @@ import { Separator } from "@/components/ui/separator"; import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils"; import Image from "next/image"; import { PureCodePreviewPanel } from "./pureCodePreviewPanel"; -import { getFileSource } from '@/features/git'; +import { CommitHeader } from "./commitHeader"; +import { getFileSource, listCommits } from '@/features/git'; interface CodePreviewPanelProps { path: string; @@ -13,13 +14,19 @@ interface CodePreviewPanelProps { } export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePreviewPanelProps) => { - const [fileSourceResponse, repoInfoResponse] = await Promise.all([ + const [fileSourceResponse, repoInfoResponse, commitsResponse] = await Promise.all([ getFileSource({ path, repo: repoName, ref: revisionName, }, { source: 'sourcebot-web-client' }), getRepoInfoByName(repoName), + listCommits({ + repo: repoName, + path, + ref: revisionName, + maxCount: 1, + }), ]); if (isServiceError(fileSourceResponse)) { @@ -30,6 +37,8 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePre return
Error loading repo info: {repoInfoResponse.message}
} + const latestCommit = !isServiceError(commitsResponse) ? commitsResponse.commits[0] : undefined; + const codeHostInfo = getCodeHostInfoForRepo({ codeHostType: repoInfoResponse.codeHostType, name: repoInfoResponse.name, @@ -74,6 +83,12 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePre )} + {latestCommit && ( + <> + + + + )} { + const shortSha = commit.hash.slice(0, 7); + const relativeDate = formatDistanceToNow(new Date(commit.date), { addSuffix: true }); + + return ( +
+
+ + + {commit.authorName} + + + {commit.message} + +
+
+
+ + {shortSha} + + · + + {relativeDate} + +
+ +
+
+ ); +}; From ccab91024add1a3b611c6c46721a62e3d1b1855a Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Fri, 24 Apr 2026 12:46:53 -0700 Subject: [PATCH 02/11] feat(web): support co-authors and commit body in commit header Parse Co-authored-by trailers from the commit body and display an AvatarGroup with the combined author list, falling back to an overflow count when more than two. Add a toggle button next to the commit message to reveal the full commit body inline. Upgrades the Avatar ui component to include AvatarGroup, AvatarGroupCount, and AvatarBadge primitives. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[...path]/components/commitHeader.tsx | 135 ++++++++++++---- packages/web/src/components/ui/avatar.tsx | 145 ++++++++++++------ 2 files changed, 211 insertions(+), 69 deletions(-) diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitHeader.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitHeader.tsx index 4092cc2eb..fd1fc2ec6 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/commitHeader.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitHeader.tsx @@ -1,6 +1,12 @@ +'use client'; + +import { useMemo, useState } from "react"; import { formatDistanceToNow } from "date-fns"; -import { History } from "lucide-react"; +import { History, MoreHorizontal } from "lucide-react"; +import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; +import { Toggle } from "@/components/ui/toggle"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { UserAvatar } from "@/components/userAvatar"; import type { Commit } from "@/features/git"; @@ -8,39 +14,116 @@ interface CommitHeaderProps { commit: Commit; } +type Author = { name: string; email: string }; + +const parseCoAuthors = (body: string): Author[] => { + const coAuthors: Author[] = []; + const regex = /^co-authored-by:\s*(.+?)\s*<(.+?)>\s*$/gim; + let match: RegExpExecArray | null; + while ((match = regex.exec(body)) !== null) { + coAuthors.push({ name: match[1].trim(), email: match[2].trim() }); + } + return coAuthors; +}; + +const formatAuthorsText = (authors: Author[]): string => { + if (authors.length === 1) { + return authors[0].name; + } + if (authors.length === 2) { + return `${authors[0].name} and ${authors[1].name}`; + } + const others = authors.length - 2; + return `${authors[0].name}, ${authors[1].name}, and ${others} other${others > 1 ? "s" : ""}`; +}; + export const CommitHeader = ({ commit }: CommitHeaderProps) => { + const [isBodyExpanded, setIsBodyExpanded] = useState(false); const shortSha = commit.hash.slice(0, 7); const relativeDate = formatDistanceToNow(new Date(commit.date), { addSuffix: true }); + const hasBody = commit.body.trim().length > 0; + + const authors = useMemo(() => { + const all: Author[] = [ + { name: commit.authorName, email: commit.authorEmail }, + ...parseCoAuthors(commit.body), + ]; + const seen = new Set(); + return all.filter((a) => { + const key = a.email.toLowerCase(); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + }, [commit.authorName, commit.authorEmail, commit.body]); + + const displayedAvatars = authors.slice(0, 2); + const overflowCount = Math.max(0, authors.length - 2); return ( -
-
- - - {commit.authorName} - - - {commit.message} - -
-
-
- - {shortSha} + <> +
+
+ + {displayedAvatars.map((a) => ( + + ))} + {overflowCount > 0 && ( + + +{overflowCount} + + )} + + + {formatAuthorsText(authors)} - · - - {relativeDate} + + {commit.message} + {hasBody && ( + + + + + + + Open commit details + + )} +
+
+
+ + {shortSha} + + · + + {relativeDate} + +
+
-
-
+ {hasBody && isBodyExpanded && ( +
+
+                        {commit.body.trim()}
+                    
+
+ )} + ); }; diff --git a/packages/web/src/components/ui/avatar.tsx b/packages/web/src/components/ui/avatar.tsx index 51e507ba9..da186ed39 100644 --- a/packages/web/src/components/ui/avatar.tsx +++ b/packages/web/src/components/ui/avatar.tsx @@ -5,46 +5,105 @@ import * as AvatarPrimitive from "@radix-ui/react-avatar" import { cn } from "@/lib/utils" -const Avatar = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -Avatar.displayName = AvatarPrimitive.Root.displayName - -const AvatarImage = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AvatarImage.displayName = AvatarPrimitive.Image.displayName - -const AvatarFallback = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName - -export { Avatar, AvatarImage, AvatarFallback } +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarBadge, + AvatarGroup, + AvatarGroupCount, +} From 45cc8ed43377de0da19c1b2a5bfd3bb730bd211b Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Fri, 24 Apr 2026 13:03:30 -0700 Subject: [PATCH 03/11] feat(web): add commits pathType and wire History button Extend the browse URL scheme to support /-/commits/ as a third pathType alongside blob and tree. Empty paths are permitted so the same route can later serve repo-level history. The History button in the commit header now links to this route via getBrowsePath. The page renders a placeholder for the commits pathType; the actual commit list panel will follow. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[...path]/components/codePreviewPanel.tsx | 7 +++++- .../[...path]/components/commitHeader.tsx | 22 +++++++++++++++---- .../src/app/(app)/browse/[...path]/page.tsx | 10 +++++++++ .../web/src/app/(app)/browse/hooks/utils.ts | 21 +++++++++++++----- 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel.tsx index 9b5b47265..632b852a2 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel.tsx @@ -85,7 +85,12 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePre {latestCommit && ( <> - + )} diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitHeader.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitHeader.tsx index fd1fc2ec6..5188c36de 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/commitHeader.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitHeader.tsx @@ -3,15 +3,20 @@ import { useMemo, useState } from "react"; import { formatDistanceToNow } from "date-fns"; import { History, MoreHorizontal } from "lucide-react"; +import Link from "next/link"; import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Toggle } from "@/components/ui/toggle"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { UserAvatar } from "@/components/userAvatar"; import type { Commit } from "@/features/git"; +import { getBrowsePath } from "../../hooks/utils"; interface CommitHeaderProps { commit: Commit; + repoName: string; + path: string; + revisionName?: string; } type Author = { name: string; email: string }; @@ -37,12 +42,19 @@ const formatAuthorsText = (authors: Author[]): string => { return `${authors[0].name}, ${authors[1].name}, and ${others} other${others > 1 ? "s" : ""}`; }; -export const CommitHeader = ({ commit }: CommitHeaderProps) => { +export const CommitHeader = ({ commit, repoName, path, revisionName }: CommitHeaderProps) => { const [isBodyExpanded, setIsBodyExpanded] = useState(false); const shortSha = commit.hash.slice(0, 7); const relativeDate = formatDistanceToNow(new Date(commit.date), { addSuffix: true }); const hasBody = commit.body.trim().length > 0; + const historyHref = getBrowsePath({ + repoName, + revisionName, + path, + pathType: 'commits', + }); + const authors = useMemo(() => { const all: Author[] = [ { name: commit.authorName, email: commit.authorEmail }, @@ -111,9 +123,11 @@ export const CommitHeader = ({ commit }: CommitHeaderProps) => { {relativeDate}
-
diff --git a/packages/web/src/app/(app)/browse/[...path]/page.tsx b/packages/web/src/app/(app)/browse/[...path]/page.tsx index df0174432..c00f4c5ac 100644 --- a/packages/web/src/app/(app)/browse/[...path]/page.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/page.tsx @@ -39,6 +39,12 @@ const parsePathForTitle = (path: string[]): string => { const directoryPath = filePath.endsWith('/') ? filePath : `${filePath}/`; return `${directoryPath} - ${repoAndRevision}`; } + case 'commits': { + if (filePath === '' || filePath === '/') { + return `History - ${repoAndRevision}`; + } + return `History: ${filePath} - ${repoAndRevision}`; + } } } @@ -94,6 +100,10 @@ export default async function BrowsePage(props: BrowsePageProps) { repoName={repoName} revisionName={revisionName} /> + ) : pathType === 'commits' ? ( +
+ Commit history view coming soon. +
) : ( ; } export const getBrowseParamsFromPathParam = (pathParam: string) => { - const sentinelIndex = pathParam.search(/\/-\/(tree|blob)/); + const sentinelIndex = pathParam.search(/\/-\/(tree|blob|commits)/); if (sentinelIndex === -1) { - throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain "/-/(tree|blob)/" pattern`); + throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain "/-/(tree|blob|commits)/" pattern`); } const repoAndRevisionPart = decodeURIComponent(pathParam.substring(0, sentinelIndex)); @@ -31,9 +33,13 @@ export const getBrowseParamsFromPathParam = (pathParam: string) => { const repoName = lastAtIndex === -1 ? repoAndRevisionPart : repoAndRevisionPart.substring(0, lastAtIndex); const revisionName = lastAtIndex === -1 ? undefined : repoAndRevisionPart.substring(lastAtIndex + 1); - const { path, pathType } = ((): { path: string, pathType: 'tree' | 'blob' } => { + const { path, pathType } = ((): { path: string, pathType: BrowsePathType } => { const path = pathParam.substring(sentinelIndex + '/-/'.length); - const pathType = path.startsWith('tree') ? 'tree' : 'blob'; + const pathType: BrowsePathType = path.startsWith('tree') + ? 'tree' + : path.startsWith('commits') + ? 'commits' + : 'blob'; // @note: decodedURIComponent is needed here incase the path contains a space. switch (pathType) { @@ -42,6 +48,11 @@ export const getBrowseParamsFromPathParam = (pathParam: string) => { path: decodeURIComponent(path.startsWith('tree/') ? path.substring('tree/'.length) : path.substring('tree'.length)), pathType, }; + case 'commits': + return { + path: decodeURIComponent(path.startsWith('commits/') ? path.substring('commits/'.length) : path.substring('commits'.length)), + pathType, + }; case 'blob': return { path: decodeURIComponent(path.startsWith('blob/') ? path.substring('blob/'.length) : path.substring('blob'.length)), From ec43d8db19462fc9d18143e3d14981955992601f Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Fri, 24 Apr 2026 13:46:35 -0700 Subject: [PATCH 04/11] feat(web): add commit history view Render a paginated commit history list when pathType is commits. The view reuses PathHeader for the "History for / " subheader, groups commits by local date with sticky section headers, and shows Previous/Next links driven by a page query param. Each row renders the commit message, co-authors, short SHA, copy action, view-code-at-commit, and view-repo-at-commit links. When on the last page the list ends with an "End of commit history" marker. Refactor the author parsing, avatar group, body toggle, and body panel out of commitHeader into commitAuthors.ts and commitParts.tsx so the new CommitRow can reuse them. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[...path]/components/commitAuthors.ts | 40 +++++++ .../[...path]/components/commitHeader.tsx | 90 ++------------ .../[...path]/components/commitParts.tsx | 67 +++++++++++ .../browse/[...path]/components/commitRow.tsx | 112 ++++++++++++++++++ .../components/commitsPagination.tsx | 48 ++++++++ .../[...path]/components/commitsPanel.tsx | 103 ++++++++++++++++ .../src/app/(app)/browse/[...path]/page.tsx | 17 ++- 7 files changed, 396 insertions(+), 81 deletions(-) create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/commitAuthors.ts create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/commitParts.tsx create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/commitsPagination.tsx create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitAuthors.ts b/packages/web/src/app/(app)/browse/[...path]/components/commitAuthors.ts new file mode 100644 index 000000000..dd1df1576 --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitAuthors.ts @@ -0,0 +1,40 @@ +import type { Commit } from "@/features/git"; + +export type Author = { name: string; email: string }; + +export const parseCoAuthors = (body: string): Author[] => { + const coAuthors: Author[] = []; + const regex = /^co-authored-by:\s*(.+?)\s*<(.+?)>\s*$/gim; + let match: RegExpExecArray | null; + while ((match = regex.exec(body)) !== null) { + coAuthors.push({ name: match[1].trim(), email: match[2].trim() }); + } + return coAuthors; +}; + +export const getCommitAuthors = (commit: Commit): Author[] => { + const all: Author[] = [ + { name: commit.authorName, email: commit.authorEmail }, + ...parseCoAuthors(commit.body), + ]; + const seen = new Set(); + return all.filter((a) => { + const key = a.email.toLowerCase(); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +}; + +export const formatAuthorsText = (authors: Author[]): string => { + if (authors.length === 1) { + return authors[0].name; + } + if (authors.length === 2) { + return `${authors[0].name} and ${authors[1].name}`; + } + const others = authors.length - 2; + return `${authors[0].name}, ${authors[1].name}, and ${others} other${others > 1 ? "s" : ""}`; +}; diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitHeader.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitHeader.tsx index 5188c36de..bb23fc90e 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/commitHeader.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitHeader.tsx @@ -2,15 +2,13 @@ import { useMemo, useState } from "react"; import { formatDistanceToNow } from "date-fns"; -import { History, MoreHorizontal } from "lucide-react"; +import { History } from "lucide-react"; import Link from "next/link"; -import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; -import { Toggle } from "@/components/ui/toggle"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { UserAvatar } from "@/components/userAvatar"; import type { Commit } from "@/features/git"; import { getBrowsePath } from "../../hooks/utils"; +import { formatAuthorsText, getCommitAuthors } from "./commitAuthors"; +import { AuthorsAvatarGroup, CommitBody, CommitBodyToggle } from "./commitParts"; interface CommitHeaderProps { commit: Commit; @@ -19,29 +17,6 @@ interface CommitHeaderProps { revisionName?: string; } -type Author = { name: string; email: string }; - -const parseCoAuthors = (body: string): Author[] => { - const coAuthors: Author[] = []; - const regex = /^co-authored-by:\s*(.+?)\s*<(.+?)>\s*$/gim; - let match: RegExpExecArray | null; - while ((match = regex.exec(body)) !== null) { - coAuthors.push({ name: match[1].trim(), email: match[2].trim() }); - } - return coAuthors; -}; - -const formatAuthorsText = (authors: Author[]): string => { - if (authors.length === 1) { - return authors[0].name; - } - if (authors.length === 2) { - return `${authors[0].name} and ${authors[1].name}`; - } - const others = authors.length - 2; - return `${authors[0].name}, ${authors[1].name}, and ${others} other${others > 1 ? "s" : ""}`; -}; - export const CommitHeader = ({ commit, repoName, path, revisionName }: CommitHeaderProps) => { const [isBodyExpanded, setIsBodyExpanded] = useState(false); const shortSha = commit.hash.slice(0, 7); @@ -55,43 +30,16 @@ export const CommitHeader = ({ commit, repoName, path, revisionName }: CommitHea pathType: 'commits', }); - const authors = useMemo(() => { - const all: Author[] = [ - { name: commit.authorName, email: commit.authorEmail }, - ...parseCoAuthors(commit.body), - ]; - const seen = new Set(); - return all.filter((a) => { - const key = a.email.toLowerCase(); - if (seen.has(key)) { - return false; - } - seen.add(key); - return true; - }); - }, [commit.authorName, commit.authorEmail, commit.body]); - - const displayedAvatars = authors.slice(0, 2); - const overflowCount = Math.max(0, authors.length - 2); + const authors = useMemo( + () => getCommitAuthors(commit), + [commit], + ); return ( <>
- - {displayedAvatars.map((a) => ( - - ))} - {overflowCount > 0 && ( - - +{overflowCount} - - )} - + {formatAuthorsText(authors)} @@ -99,18 +47,10 @@ export const CommitHeader = ({ commit, repoName, path, revisionName }: CommitHea {commit.message} {hasBody && ( - - - - - - - Open commit details - + )}
@@ -132,11 +72,7 @@ export const CommitHeader = ({ commit, repoName, path, revisionName }: CommitHea
{hasBody && isBodyExpanded && ( -
-
-                        {commit.body.trim()}
-                    
-
+ )} ); diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitParts.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitParts.tsx new file mode 100644 index 000000000..9810b6a59 --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitParts.tsx @@ -0,0 +1,67 @@ +import { MoreHorizontal } from "lucide-react"; +import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar"; +import { Toggle } from "@/components/ui/toggle"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { UserAvatar } from "@/components/userAvatar"; +import { cn } from "@/lib/utils"; +import type { Author } from "./commitAuthors"; + +interface AuthorsAvatarGroupProps { + authors: Author[]; + className?: string; +} + +export const AuthorsAvatarGroup = ({ authors, className }: AuthorsAvatarGroupProps) => { + const displayed = authors.slice(0, 2); + const overflow = Math.max(0, authors.length - 2); + + return ( + + {displayed.map((a) => ( + + ))} + {overflow > 0 && ( + + +{overflow} + + )} + + ); +}; + +interface CommitBodyToggleProps { + pressed: boolean; + onPressedChange: (pressed: boolean) => void; +} + +export const CommitBodyToggle = ({ pressed, onPressedChange }: CommitBodyToggleProps) => ( + + + + + + + Open commit details + +); + +interface CommitBodyProps { + body: string; + className?: string; +} + +export const CommitBody = ({ body, className }: CommitBodyProps) => ( +
+
+            {body.trim()}
+        
+
+); diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx new file mode 100644 index 000000000..2b2565597 --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useCallback, useMemo, useState } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { Code, FileCode } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { CopyIconButton } from "@/app/(app)/components/copyIconButton"; +import { useToast } from "@/components/hooks/use-toast"; +import type { Commit } from "@/features/git"; +import { getBrowsePath } from "../../hooks/utils"; +import { formatAuthorsText, getCommitAuthors } from "./commitAuthors"; +import { AuthorsAvatarGroup, CommitBody, CommitBodyToggle } from "./commitParts"; + +interface CommitRowProps { + commit: Commit; + repoName: string; + path: string; +} + +export const CommitRow = ({ commit, repoName, path }: CommitRowProps) => { + const [isBodyExpanded, setIsBodyExpanded] = useState(false); + const { toast } = useToast(); + + const shortSha = commit.hash.slice(0, 7); + const relativeDate = formatDistanceToNow(new Date(commit.date), { addSuffix: true }); + const hasBody = commit.body.trim().length > 0; + const hasFilePath = path !== '' && path !== '/'; + + const authors = useMemo( + () => getCommitAuthors(commit), + [commit], + ); + + const viewFileAtCommitHref = getBrowsePath({ + repoName, + revisionName: commit.hash, + path, + pathType: 'blob', + }); + + const viewRepoAtCommitHref = getBrowsePath({ + repoName, + revisionName: commit.hash, + path: '', + pathType: 'tree', + }); + + const onCopySha = useCallback(() => { + navigator.clipboard.writeText(commit.hash); + toast({ description: "✅ Copied commit SHA to clipboard" }); + return true; + }, [commit.hash, toast]); + + return ( + <> +
+
+
+ + {commit.message} + + {hasBody && ( + + )} +
+
+ + + {formatAuthorsText(authors)} authored {relativeDate} + +
+
+
+ + {shortSha} + + + {hasFilePath && ( + + + + + View code at this commit + + )} + + + + + View repository at this commit + +
+
+ {hasBody && isBodyExpanded && ( + + )} + + ); +}; diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitsPagination.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitsPagination.tsx new file mode 100644 index 000000000..37b768b7d --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitsPagination.tsx @@ -0,0 +1,48 @@ +import Link from "next/link"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface CommitsPaginationProps { + page: number; + perPage: number; + totalCount: number; +} + +export const CommitsPagination = ({ page, perPage, totalCount }: CommitsPaginationProps) => { + const hasPrev = page > 1; + const hasNext = page * perPage < totalCount; + + if (!hasPrev && !hasNext) { + return null; + } + + const linkClass = "flex flex-row items-center gap-1 text-sm text-primary hover:underline"; + const disabledClass = "flex flex-row items-center gap-1 text-sm text-muted-foreground cursor-not-allowed"; + + return ( +
+ {hasPrev ? ( + + + Previous + + ) : ( + + + Previous + + )} + {hasNext ? ( + + Next + + + ) : ( + + Next + + + )} +
+ ); +}; diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx new file mode 100644 index 000000000..ce1919873 --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx @@ -0,0 +1,103 @@ +import { format } from "date-fns"; +import { GitCommitHorizontal } from "lucide-react"; +import { getRepoInfoByName } from "@/actions"; +import { PathHeader } from "@/app/(app)/components/pathHeader"; +import { Separator } from "@/components/ui/separator"; +import { listCommits } from "@/features/git"; +import { isServiceError } from "@/lib/utils"; +import { CommitRow } from "./commitRow"; +import { CommitsPagination } from "./commitsPagination"; + +interface CommitsPanelProps { + path: string; + repoName: string; + revisionName?: string; + page: number; +} + +const PER_PAGE = 35; + +export const CommitsPanel = async ({ path, repoName, revisionName, page }: CommitsPanelProps) => { + const skip = (page - 1) * PER_PAGE; + + const [commitsResponse, repoInfoResponse] = await Promise.all([ + listCommits({ + repo: repoName, + path: path || undefined, + ref: revisionName, + maxCount: PER_PAGE, + skip, + }), + getRepoInfoByName(repoName), + ]); + + if (isServiceError(commitsResponse)) { + return
Error loading commits: {commitsResponse.message}
; + } + if (isServiceError(repoInfoResponse)) { + return
Error loading repo info: {repoInfoResponse.message}
; + } + + const { commits, totalCount } = commitsResponse; + const isLastPage = page * PER_PAGE >= totalCount; + + const groups = new Map(); + for (const commit of commits) { + const date = new Date(commit.date); + const key = format(date, "yyyy-MM-dd"); + const label = `Commits on ${format(date, "MMM d, yyyy")}`; + const existing = groups.get(key); + if (existing) { + existing.commits.push(commit); + } else { + groups.set(key, { label, commits: [commit] }); + } + } + + return ( +
+
+ History for + +
+ +
+ {Array.from(groups.values()).map((group) => ( +
+
+ + {group.label} +
+ {group.commits.map((commit) => ( + + ))} +
+ ))} + {isLastPage && ( +
+ End of commit history +
+ )} + +
+
+ ); +}; diff --git a/packages/web/src/app/(app)/browse/[...path]/page.tsx b/packages/web/src/app/(app)/browse/[...path]/page.tsx index c00f4c5ac..94c01b758 100644 --- a/packages/web/src/app/(app)/browse/[...path]/page.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/page.tsx @@ -1,6 +1,7 @@ import { Suspense } from "react"; import { getBrowseParamsFromPathParam } from "../hooks/utils"; import { CodePreviewPanel } from "./components/codePreviewPanel"; +import { CommitsPanel } from "./components/commitsPanel"; import { Loader2 } from "lucide-react"; import { TreePreviewPanel } from "./components/treePreviewPanel"; import { Metadata } from "next"; @@ -74,10 +75,13 @@ interface BrowsePageProps { params: Promise<{ path: string[]; }>; + searchParams: Promise<{ + page?: string; + }>; } export default async function BrowsePage(props: BrowsePageProps) { - const params = await props.params; + const [params, searchParams] = await Promise.all([props.params, props.searchParams]); const { path: _rawPath, @@ -86,6 +90,8 @@ export default async function BrowsePage(props: BrowsePageProps) { const rawPath = _rawPath.join('/'); const { repoName, revisionName, path, pathType } = getBrowseParamsFromPathParam(rawPath); + const page = Math.max(1, parseInt(searchParams.page ?? '1', 10) || 1); + return (
) : pathType === 'commits' ? ( -
- Commit history view coming soon. -
+ ) : ( Date: Fri, 24 Apr 2026 15:14:35 -0700 Subject: [PATCH 05/11] feat(web): add GET /api/commits/authors endpoint List unique commit authors scoped to a ref and optional path, sorted by commit count descending. Backed by git shortlog -sne for a native walk that emits one line per author rather than per commit, which keeps the response small even for files with long histories. The route is exposed in the public API under the Git tag. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sourcebot-public.openapi.json | 147 ++++++++++++++++++ docs/docs.json | 1 + .../app/api/(server)/commits/authors/route.ts | 53 +++++++ packages/web/src/features/git/index.ts | 1 + .../src/features/git/listCommitAuthorsApi.ts | 108 +++++++++++++ packages/web/src/features/git/schemas.ts | 14 ++ packages/web/src/openapi/publicApiDocument.ts | 33 ++++ packages/web/src/openapi/publicApiSchemas.ts | 5 + 8 files changed, 362 insertions(+) create mode 100644 packages/web/src/app/api/(server)/commits/authors/route.ts create mode 100644 packages/web/src/features/git/listCommitAuthorsApi.ts diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index 547b6d5aa..20a49da8b 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -965,6 +965,32 @@ "parents" ] }, + "PublicCommitAuthor": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "commitCount": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "name", + "email", + "commitCount" + ] + }, + "PublicListCommitAuthorsResponse": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicCommitAuthor" + } + }, "PublicEeUser": { "type": "object", "properties": { @@ -1944,6 +1970,127 @@ } } }, + "/api/commits/authors": { + "get": { + "operationId": "listCommitAuthors", + "tags": [ + "Git" + ], + "summary": "List commit authors", + "description": "Returns a paginated list of unique authors who committed in a repository, sorted by commit count descending. Optionally scoped to a file path.", + "parameters": [ + { + "schema": { + "type": "string", + "description": "The fully-qualified repository name." + }, + "required": true, + "description": "The fully-qualified repository name.", + "name": "repo", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "The git ref (branch, tag, or commit SHA) to list authors from. Defaults to `HEAD`." + }, + "required": false, + "description": "The git ref (branch, tag, or commit SHA) to list authors from. Defaults to `HEAD`.", + "name": "ref", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Restrict authors to those who touched this file path." + }, + "required": false, + "description": "Restrict authors to those who touched this file path.", + "name": "path", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 1 + }, + "required": false, + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "maximum": 100, + "default": 50 + }, + "required": false, + "name": "perPage", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Paginated commit author list.", + "headers": { + "X-Total-Count": { + "description": "Total number of unique authors matching the query across all pages.", + "schema": { + "type": "integer" + } + }, + "Link": { + "description": "Pagination links formatted per RFC 8288.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicListCommitAuthorsResponse" + } + } + } + }, + "400": { + "description": "Invalid query parameters or git ref.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "404": { + "description": "Repository not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "500": { + "description": "Unexpected failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + } + } + } + }, "/api/ee/user": { "get": { "operationId": "getUser", diff --git a/docs/docs.json b/docs/docs.json index 50e9e7990..7dbe333ed 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -175,6 +175,7 @@ "GET /api/commit", "GET /api/diff", "GET /api/commits", + "GET /api/commits/authors", "GET /api/source", "POST /api/tree" ] diff --git a/packages/web/src/app/api/(server)/commits/authors/route.ts b/packages/web/src/app/api/(server)/commits/authors/route.ts new file mode 100644 index 000000000..a090f5cb7 --- /dev/null +++ b/packages/web/src/app/api/(server)/commits/authors/route.ts @@ -0,0 +1,53 @@ +import { listCommitAuthors } from "@/features/git"; +import { listCommitAuthorsQueryParamsSchema } from "@/features/git/schemas"; +import { apiHandler } from "@/lib/apiHandler"; +import { buildLinkHeader } from "@/lib/pagination"; +import { serviceErrorResponse, queryParamsSchemaValidationError } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { NextRequest } from "next/server"; + +export const GET = apiHandler(async (request: NextRequest): Promise => { + const rawParams = Object.fromEntries( + Object.keys(listCommitAuthorsQueryParamsSchema.shape).map(key => [ + key, + request.nextUrl.searchParams.get(key) ?? undefined, + ]), + ); + const parsed = listCommitAuthorsQueryParamsSchema.safeParse(rawParams); + + if (!parsed.success) { + return serviceErrorResponse( + queryParamsSchemaValidationError(parsed.error), + ); + } + + const { page, perPage, ...searchParams } = parsed.data; + const skip = (page - 1) * perPage; + + const result = await listCommitAuthors({ ...searchParams, maxCount: perPage, skip }); + + if (isServiceError(result)) { + return serviceErrorResponse(result); + } + + const { authors, totalCount } = result; + + const headers = new Headers({ 'Content-Type': 'application/json' }); + headers.set('X-Total-Count', totalCount.toString()); + + const linkHeader = buildLinkHeader(request, { + page, + perPage, + totalCount, + extraParams: { + repo: searchParams.repo, + ...(searchParams.ref ? { ref: searchParams.ref } : {}), + ...(searchParams.path ? { path: searchParams.path } : {}), + }, + }); + if (linkHeader) { + headers.set('Link', linkHeader); + } + + return new Response(JSON.stringify(authors), { status: 200, headers }); +}); diff --git a/packages/web/src/features/git/index.ts b/packages/web/src/features/git/index.ts index 579d81017..05c07e151 100644 --- a/packages/web/src/features/git/index.ts +++ b/packages/web/src/features/git/index.ts @@ -5,4 +5,5 @@ export * from './getFolderContentsApi'; export * from './getTreeApi'; export * from './getFileSourceApi'; export * from './listCommitsApi'; +export * from './listCommitAuthorsApi'; export * from './types'; \ No newline at end of file diff --git a/packages/web/src/features/git/listCommitAuthorsApi.ts b/packages/web/src/features/git/listCommitAuthorsApi.ts new file mode 100644 index 000000000..bfcba39ca --- /dev/null +++ b/packages/web/src/features/git/listCommitAuthorsApi.ts @@ -0,0 +1,108 @@ +import { sew } from "@/middleware/sew"; +import { invalidGitRef, notFound, ServiceError, unexpectedError } from '@/lib/serviceError'; +import { withOptionalAuth } from '@/middleware/withAuth'; +import { getRepoPath } from '@sourcebot/shared'; +import { z } from 'zod'; +import { simpleGit } from 'simple-git'; +import { commitAuthorSchema } from './schemas'; +import { isGitRefValid } from './utils'; + +export type CommitAuthor = z.infer; + +export type ListCommitAuthorsResponse = { + authors: CommitAuthor[]; + totalCount: number; +}; + +type ListCommitAuthorsRequest = { + repo: string; + ref?: string; + path?: string; + maxCount?: number; + skip?: number; +}; + +/** + * List unique authors who committed in a repository, optionally scoped + * to a file path. Returns authors sorted by commit count (descending), + * deduped by lowercased email. + */ +export const listCommitAuthors = async ({ + repo: repoName, + ref = 'HEAD', + path, + maxCount = 50, + skip = 0, +}: ListCommitAuthorsRequest): Promise => sew(() => + withOptionalAuth(async ({ org, prisma }) => { + const repo = await prisma.repo.findFirst({ + where: { + name: repoName, + orgId: org.id, + }, + }); + + if (!repo) { + return notFound(`Repository "${repoName}" not found.`); + } + + if (!isGitRefValid(ref)) { + return invalidGitRef(ref); + } + + const { path: repoPath } = getRepoPath(repo); + const git = simpleGit().cwd(repoPath); + + try { + const args = ['shortlog', '-sne', ref]; + if (path) { + args.push('--', path); + } + + const output = await git.raw(args); + const lines = output.split('\n').filter(Boolean); + + // shortlog output: " \t <>" — already sorted + // by commit count descending (-n) and deduped by author. + const lineRegex = /^\s*(\d+)\s+(.+?)\s+<(.+?)>\s*$/; + const all: CommitAuthor[] = []; + for (const line of lines) { + const match = line.match(lineRegex); + if (!match) { + continue; + } + all.push({ + name: match[2], + email: match[3], + commitCount: parseInt(match[1], 10), + }); + } + + const totalCount = all.length; + const authors = all.slice(skip, skip + maxCount); + + return { authors, totalCount }; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + + if (errorMessage.includes('not a git repository')) { + return unexpectedError( + `Invalid git repository at ${repoPath}. ` + + `The directory exists but is not a valid git repository.`, + ); + } + + if (errorMessage.includes('ambiguous argument')) { + return unexpectedError(`Invalid git reference: ${ref}`); + } + + if (error instanceof Error) { + throw new Error( + `Failed to list commit authors in repository ${repoName}: ${error.message}`, + ); + } + throw new Error( + `Failed to list commit authors in repository ${repoName}: ${errorMessage}`, + ); + } + })); diff --git a/packages/web/src/features/git/schemas.ts b/packages/web/src/features/git/schemas.ts index 5247527ca..02b7a7271 100644 --- a/packages/web/src/features/git/schemas.ts +++ b/packages/web/src/features/git/schemas.ts @@ -95,3 +95,17 @@ export const getCommitQueryParamsSchema = z.object({ export const commitDetailSchema = commitSchema.extend({ parents: z.array(z.string()).describe('The parent commit SHAs.'), }); + +export const listCommitAuthorsQueryParamsSchema = z.object({ + repo: z.string().describe('The fully-qualified repository name.'), + ref: z.string().optional().describe('The git ref (branch, tag, or commit SHA) to list authors from. Defaults to `HEAD`.'), + path: z.string().optional().describe('Restrict authors to those who touched this file path.'), + page: z.coerce.number().int().positive().default(1), + perPage: z.coerce.number().int().positive().max(100).default(50), +}); + +export const commitAuthorSchema = z.object({ + name: z.string(), + email: z.string(), + commitCount: z.number().int().nonnegative(), +}); diff --git a/packages/web/src/openapi/publicApiDocument.ts b/packages/web/src/openapi/publicApiDocument.ts index 147c43c88..61b2c1c2c 100644 --- a/packages/web/src/openapi/publicApiDocument.ts +++ b/packages/web/src/openapi/publicApiDocument.ts @@ -18,6 +18,8 @@ import { publicHealthResponseSchema, publicCommitDetailSchema, publicGetCommitQuerySchema, + publicListCommitAuthorsQuerySchema, + publicListCommitAuthorsResponseSchema, publicListCommitsQuerySchema, publicListCommitsResponseSchema, publicListReposQueryParamsSchema, @@ -362,6 +364,37 @@ export function createPublicOpenApiDocument(version: string) { }, }); + registry.registerPath({ + method: 'get', + path: '/api/commits/authors', + operationId: 'listCommitAuthors', + tags: [gitTag.name], + summary: 'List commit authors', + description: 'Returns a paginated list of unique authors who committed in a repository, sorted by commit count descending. Optionally scoped to a file path.', + request: { + query: publicListCommitAuthorsQuerySchema, + }, + responses: { + 200: { + description: 'Paginated commit author list.', + headers: { + 'X-Total-Count': { + description: 'Total number of unique authors matching the query across all pages.', + schema: { type: 'integer' }, + }, + Link: { + description: 'Pagination links formatted per RFC 8288.', + schema: { type: 'string' }, + }, + }, + content: jsonContent(publicListCommitAuthorsResponseSchema), + }, + 400: errorJson('Invalid query parameters or git ref.'), + 404: errorJson('Repository not found.'), + 500: errorJson('Unexpected failure.'), + }, + }); + // EE: User Management registry.registerPath({ method: 'get', diff --git a/packages/web/src/openapi/publicApiSchemas.ts b/packages/web/src/openapi/publicApiSchemas.ts index dae94afc1..daea50e39 100644 --- a/packages/web/src/openapi/publicApiSchemas.ts +++ b/packages/web/src/openapi/publicApiSchemas.ts @@ -5,6 +5,7 @@ import { findRelatedSymbolsResponseSchema, } from '../features/codeNav/types.js'; import { + commitAuthorSchema, commitDetailSchema, commitSchema, fileSourceRequestSchema, @@ -13,6 +14,7 @@ import { getDiffRequestSchema, getDiffResponseSchema, getTreeRequestSchema, + listCommitAuthorsQueryParamsSchema, listCommitsQueryParamsSchema, } from '../features/git/schemas.js'; import { @@ -50,6 +52,9 @@ export const publicCommitSchema = commitSchema.openapi('PublicCommit'); export const publicListCommitsResponseSchema = z.array(publicCommitSchema).openapi('PublicListCommitsResponse'); export const publicGetCommitQuerySchema = getCommitQueryParamsSchema.openapi('PublicGetCommitQuery'); export const publicCommitDetailSchema = commitDetailSchema.openapi('PublicCommitDetail'); +export const publicListCommitAuthorsQuerySchema = listCommitAuthorsQueryParamsSchema.openapi('PublicListCommitAuthorsQuery'); +export const publicCommitAuthorSchema = commitAuthorSchema.openapi('PublicCommitAuthor'); +export const publicListCommitAuthorsResponseSchema = z.array(publicCommitAuthorSchema).openapi('PublicListCommitAuthorsResponse'); export const publicHealthResponseSchema = z.object({ status: z.enum(['ok']), From 2001211358c8aeb21a3bd2838cebbf4f314c87dd Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Fri, 24 Apr 2026 16:03:37 -0700 Subject: [PATCH 06/11] feat(web): add author filter to commit history view The history subheader gets an "All users" dropdown that filters commits by author. The top 100 authors (by commit count) are fetched via listCommitAuthors and shown in a Popover + cmdk Command list with a search input, checkmark on the selected author, and a "View commits for all users" footer to clear. A "Filter on author " row appears at the top whenever search is non-empty, acting as an escape valve for authors outside the top 100. The filter survives pagination by threading the author query param through CommitsPagination. Duplicate entries from the same email (git shortlog groups by full author string, so name-variant spellings split into multiple rows) are collapsed client-side with the name variant having the most commits winning as canonical. Document listCommits's --author and --grep as POSIX BRE regex and move the literal-escape responsibility onto the caller; CommitsPanel escapes the selected author via a BRE-safe helper before passing to git. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sourcebot-public.openapi.json | 8 +- .../[...path]/components/authorFilter.tsx | 169 ++++++++++++++++++ .../[...path]/components/commitAuthors.ts | 42 ++++- .../components/commitsPagination.tsx | 20 ++- .../[...path]/components/commitsPanel.tsx | 60 ++++--- .../src/app/(app)/browse/[...path]/page.tsx | 3 + .../src/features/git/listCommitsApi.test.ts | 17 ++ .../web/src/features/git/listCommitsApi.ts | 3 + packages/web/src/features/git/schemas.ts | 4 +- 9 files changed, 296 insertions(+), 30 deletions(-) create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/authorFilter.tsx diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index 20a49da8b..2c95f79ca 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -1757,10 +1757,10 @@ { "schema": { "type": "string", - "description": "Filter commits by message content (case-insensitive)." + "description": "Filter commits by message content (case-insensitive). Interpreted as a POSIX BRE regex — escape metacharacters for literal matching." }, "required": false, - "description": "Filter commits by message content (case-insensitive).", + "description": "Filter commits by message content (case-insensitive). Interpreted as a POSIX BRE regex — escape metacharacters for literal matching.", "name": "query", "in": "query" }, @@ -1787,10 +1787,10 @@ { "schema": { "type": "string", - "description": "Filter commits by author name or email (case-insensitive)." + "description": "Filter commits by author name or email (case-insensitive). Interpreted as a POSIX BRE regex — escape metacharacters for literal matching." }, "required": false, - "description": "Filter commits by author name or email (case-insensitive).", + "description": "Filter commits by author name or email (case-insensitive). Interpreted as a POSIX BRE regex — escape metacharacters for literal matching.", "name": "author", "in": "query" }, diff --git a/packages/web/src/app/(app)/browse/[...path]/components/authorFilter.tsx b/packages/web/src/app/(app)/browse/[...path]/components/authorFilter.tsx new file mode 100644 index 000000000..ec49c96f2 --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/authorFilter.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter, usePathname, useSearchParams } from "next/navigation"; +import { Check, ChevronDown, Users } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { UserAvatar } from "@/components/userAvatar"; +import { cn } from "@/lib/utils"; +import type { CommitAuthor } from "@/features/git"; + +interface AuthorFilterProps { + authors: CommitAuthor[]; + selectedAuthor?: string; +} + +export const AuthorFilter = ({ authors, selectedAuthor }: AuthorFilterProps) => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(''); + + // Reset the search input when the popover (re)opens, so stale text from a + // prior session doesn't appear. Intentionally does NOT fire on close — + // mid-close re-renders race with Radix's close animation and cause the + // flash-open-then-close behavior. + useEffect(() => { + if (isOpen) { + setSearch(''); + } + }, [isOpen]); + + const selectedAuthorDisplay = useMemo(() => { + if (!selectedAuthor) { + return undefined; + } + const key = selectedAuthor.toLowerCase(); + return authors.find((a) => a.email.toLowerCase() === key); + }, [authors, selectedAuthor]); + + const filteredAuthors = useMemo(() => { + const term = search.trim().toLowerCase(); + if (term.length === 0) { + return authors; + } + return authors.filter( + (a) => + a.name.toLowerCase().includes(term) || + a.email.toLowerCase().includes(term), + ); + }, [authors, search]); + + const navigateWithAuthor = useCallback((author: string | null) => { + const params = new URLSearchParams(searchParams); + if (author === null) { + params.delete('author'); + } else { + params.set('author', author); + } + params.delete('page'); + const query = params.toString(); + // Close the popover before kicking off navigation so the close render + // commits cleanly; the search reset is deferred to the next open. + setIsOpen(false); + router.push(`${pathname}${query ? `?${query}` : ''}`); + }, [pathname, router, searchParams]); + + const buttonLabel = selectedAuthor + ? selectedAuthorDisplay?.name ?? selectedAuthor + : 'All users'; + + return ( + + + + + + + + + {search.trim().length > 0 && ( + navigateWithAuthor(search.trim())} + className="cursor-pointer" + > + + Filter on author {search.trim()} + + + )} + {filteredAuthors.map((a) => { + const isSelected = + !!selectedAuthor && + a.email.toLowerCase() === selectedAuthor.toLowerCase(); + return ( + navigateWithAuthor(a.email)} + className="cursor-pointer" + > + + + {a.name} + + ); + })} + + {selectedAuthor && ( + <> + +
+ navigateWithAuthor(null)} + className="cursor-pointer justify-center text-primary" + > + View commits for all users + +
+ + )} +
+
+
+ ); +}; diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitAuthors.ts b/packages/web/src/app/(app)/browse/[...path]/components/commitAuthors.ts index dd1df1576..6a327095d 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/commitAuthors.ts +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitAuthors.ts @@ -1,4 +1,4 @@ -import type { Commit } from "@/features/git"; +import type { Commit, CommitAuthor } from "@/features/git"; export type Author = { name: string; email: string }; @@ -28,6 +28,46 @@ export const getCommitAuthors = (commit: Commit): Author[] => { }); }; +/** + * Collapses rows with the same lowercased email into a single entry. + * git shortlog groups by full author string (name + email), so one person + * who committed under multiple name spellings appears as multiple rows. + * The canonical name picked is the one with the most commits; counts are + * summed. Result is resorted by commitCount descending. + */ +export const dedupeCommitAuthorsByEmail = (authors: CommitAuthor[]): CommitAuthor[] => { + type Accum = CommitAuthor & { bestNameCount: number }; + const byEmail = new Map(); + for (const a of authors) { + const key = a.email.toLowerCase(); + const existing = byEmail.get(key); + if (!existing) { + byEmail.set(key, { ...a, bestNameCount: a.commitCount }); + } else { + existing.commitCount += a.commitCount; + if (a.commitCount > existing.bestNameCount) { + existing.name = a.name; + existing.email = a.email; + existing.bestNameCount = a.commitCount; + } + } + } + return Array.from(byEmail.values()) + .map(({ name, email, commitCount }) => ({ name, email, commitCount })) + .sort((a, b) => b.commitCount - a.commitCount); +}; + +/** + * Escapes a literal string so it matches verbatim under git's default regex + * (POSIX BRE with GNU extensions, used by `git log --author` and `--grep`). + * + * BRE treats `. [ ] ^ $ *` as metacharacters; `+ ? | ( ) { }` are literal + * in BRE (their `\` forms are the GNU extensions with meta meaning), so we + * do NOT escape them here. + */ +export const escapeGitBreLiteral = (s: string): string => + s.replace(/[.[\]^$*\\]/g, '\\$&'); + export const formatAuthorsText = (authors: Author[]): string => { if (authors.length === 1) { return authors[0].name; diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitsPagination.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitsPagination.tsx index 37b768b7d..a01962ff4 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/commitsPagination.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitsPagination.tsx @@ -6,9 +6,23 @@ interface CommitsPaginationProps { page: number; perPage: number; totalCount: number; + extraParams?: Record; } -export const CommitsPagination = ({ page, perPage, totalCount }: CommitsPaginationProps) => { +const buildHref = (page: number, extraParams?: Record) => { + const params = new URLSearchParams(); + params.set('page', String(page)); + if (extraParams) { + for (const [key, value] of Object.entries(extraParams)) { + if (value !== undefined && value !== '') { + params.set(key, value); + } + } + } + return `?${params.toString()}`; +}; + +export const CommitsPagination = ({ page, perPage, totalCount, extraParams }: CommitsPaginationProps) => { const hasPrev = page > 1; const hasNext = page * perPage < totalCount; @@ -22,7 +36,7 @@ export const CommitsPagination = ({ page, perPage, totalCount }: CommitsPaginati return (
{hasPrev ? ( - + Previous @@ -33,7 +47,7 @@ export const CommitsPagination = ({ page, perPage, totalCount }: CommitsPaginati )} {hasNext ? ( - + Next diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx index ce1919873..7aaab3f35 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx @@ -3,8 +3,10 @@ import { GitCommitHorizontal } from "lucide-react"; import { getRepoInfoByName } from "@/actions"; import { PathHeader } from "@/app/(app)/components/pathHeader"; import { Separator } from "@/components/ui/separator"; -import { listCommits } from "@/features/git"; +import { listCommitAuthors, listCommits } from "@/features/git"; import { isServiceError } from "@/lib/utils"; +import { AuthorFilter } from "./authorFilter"; +import { dedupeCommitAuthorsByEmail, escapeGitBreLiteral } from "./commitAuthors"; import { CommitRow } from "./commitRow"; import { CommitsPagination } from "./commitsPagination"; @@ -13,22 +15,32 @@ interface CommitsPanelProps { repoName: string; revisionName?: string; page: number; + author?: string; } -const PER_PAGE = 35; +const COMMITS_PER_PAGE = 35; +const AUTHORS_PER_PAGE = 100; -export const CommitsPanel = async ({ path, repoName, revisionName, page }: CommitsPanelProps) => { - const skip = (page - 1) * PER_PAGE; +export const CommitsPanel = async ({ path, repoName, revisionName, page, author }: CommitsPanelProps) => { + const skip = (page - 1) * COMMITS_PER_PAGE; - const [commitsResponse, repoInfoResponse] = await Promise.all([ + const [commitsResponse, repoInfoResponse, authorsResponse] = await Promise.all([ listCommits({ repo: repoName, path: path || undefined, ref: revisionName, - maxCount: PER_PAGE, + author: author ? escapeGitBreLiteral(author) : undefined, + maxCount: COMMITS_PER_PAGE, skip, }), getRepoInfoByName(repoName), + listCommitAuthors({ + repo: repoName, + path: path || undefined, + ref: revisionName, + maxCount: AUTHORS_PER_PAGE, + skip: 0, + }), ]); if (isServiceError(commitsResponse)) { @@ -37,9 +49,13 @@ export const CommitsPanel = async ({ path, repoName, revisionName, page }: Commi if (isServiceError(repoInfoResponse)) { return
Error loading repo info: {repoInfoResponse.message}
; } + if (isServiceError(authorsResponse)) { + return
Error loading commit authors: {authorsResponse.message}
; + } + const authors = dedupeCommitAuthorsByEmail(authorsResponse.authors); const { commits, totalCount } = commitsResponse; - const isLastPage = page * PER_PAGE >= totalCount; + const isLastPage = page * COMMITS_PER_PAGE >= totalCount; const groups = new Map(); for (const commit of commits) { @@ -56,18 +72,21 @@ export const CommitsPanel = async ({ path, repoName, revisionName, page }: Commi return (
-
- History for - +
+
+ History for + +
+
@@ -94,8 +113,9 @@ export const CommitsPanel = async ({ path, repoName, revisionName, page }: Commi )}
diff --git a/packages/web/src/app/(app)/browse/[...path]/page.tsx b/packages/web/src/app/(app)/browse/[...path]/page.tsx index 94c01b758..6e7d305f5 100644 --- a/packages/web/src/app/(app)/browse/[...path]/page.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/page.tsx @@ -77,6 +77,7 @@ interface BrowsePageProps { }>; searchParams: Promise<{ page?: string; + author?: string; }>; } @@ -91,6 +92,7 @@ export default async function BrowsePage(props: BrowsePageProps) { const { repoName, revisionName, path, pathType } = getBrowseParamsFromPathParam(rawPath); const page = Math.max(1, parseInt(searchParams.page ?? '1', 10) || 1); + const author = searchParams.author || undefined; return (
@@ -112,6 +114,7 @@ export default async function BrowsePage(props: BrowsePageProps) { repoName={repoName} revisionName={revisionName} page={page} + author={author} /> ) : ( { ); }); + it('should pass author through as a regex pattern verbatim', async () => { + // Caller is responsible for escaping literal inputs. Here we pass a + // pre-escaped BRE pattern and assert it reaches git log unchanged. + await listCommits({ + repo: 'github.com/test/repo', + author: '49699333+dependabot\\[bot\\]@users\\.noreply\\.github\\.com', + }); + + expect(mockGitLog).toHaveBeenCalledWith( + expect.arrayContaining([ + '--author=49699333+dependabot\\[bot\\]@users\\.noreply\\.github\\.com', + '--regexp-ignore-case', + 'HEAD', + ]) + ); + }); + it('should add --grep and --regexp-ignore-case when query is provided', async () => { await listCommits({ repo: 'github.com/test/repo', diff --git a/packages/web/src/features/git/listCommitsApi.ts b/packages/web/src/features/git/listCommitsApi.ts index 362f8c78b..3e72d0dc7 100644 --- a/packages/web/src/features/git/listCommitsApi.ts +++ b/packages/web/src/features/git/listCommitsApi.ts @@ -76,6 +76,9 @@ export const listCommits = async ({ const git = simpleGit().cwd(repoPath); try { + // --author and --grep are interpreted as git's default regex (POSIX BRE + // with GNU extensions). Callers are responsible for escaping metacharacters + // when they want literal matching. const sharedOptions: Record = { ...(gitSince ? { '--since': gitSince } : {}), ...(gitUntil ? { '--until': gitUntil } : {}), diff --git a/packages/web/src/features/git/schemas.ts b/packages/web/src/features/git/schemas.ts index 02b7a7271..e7cb5d70a 100644 --- a/packages/web/src/features/git/schemas.ts +++ b/packages/web/src/features/git/schemas.ts @@ -67,10 +67,10 @@ export const getDiffResponseSchema = z.object({ export const listCommitsQueryParamsSchema = z.object({ repo: z.string().describe('The fully-qualified repository name.'), - query: z.string().optional().describe('Filter commits by message content (case-insensitive).'), + query: z.string().optional().describe('Filter commits by message content (case-insensitive). Interpreted as a POSIX BRE regex — escape metacharacters for literal matching.'), since: z.string().optional().describe('Return commits after this date. Accepts ISO 8601 or relative formats (e.g. `30 days ago`).'), until: z.string().optional().describe('Return commits before this date. Accepts ISO 8601 or relative formats.'), - author: z.string().optional().describe('Filter commits by author name or email (case-insensitive).'), + author: z.string().optional().describe('Filter commits by author name or email (case-insensitive). Interpreted as a POSIX BRE regex — escape metacharacters for literal matching.'), ref: z.string().optional().describe('The git ref (branch, tag, or commit SHA) to list commits from. Defaults to `HEAD`.'), path: z.string().optional().describe('Restrict commits to those that touch this file path.'), page: z.coerce.number().int().positive().default(1), From d8db3990e3598552ed3d6603bdfd927c46aaadd7 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Fri, 24 Apr 2026 16:42:11 -0700 Subject: [PATCH 07/11] feat(web): add date range filter to commit history view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a date range dropdown next to the author filter using shadcn's range Calendar in a Popover. URL state is `?since=YYYY-MM-DD&until=YYYY-MM-DD` so ranges are shareable. Two clicks are required to form a range even when one is already selected — the component tracks an in-progress draft locally and intercepts react-day-picker v9's "adjust" behavior that would otherwise commit a new range in a single click. Single-day ranges require two clicks of the same date. The upper bound is made inclusive by appending end-of-day time before passing to git log. `since` also gets explicit midnight time to sidestep git's approxidate parser, which silently mishandles some bare YYYY-MM-DD forms. Future dates are disabled. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/web/package.json | 3 +- .../[...path]/components/commitsPanel.tsx | 21 +- .../[...path]/components/dateFilter.tsx | 224 ++++++++++++++++++ .../src/app/(app)/browse/[...path]/page.tsx | 6 + packages/web/src/components/ui/calendar.tsx | 213 +++++++++++++++++ yarn.lock | 55 ++++- 6 files changed, 516 insertions(+), 6 deletions(-) create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/dateFilter.tsx create mode 100644 packages/web/src/components/ui/calendar.tsx diff --git a/packages/web/package.json b/packages/web/package.json index de7f19aed..ed9e39c51 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -81,7 +81,7 @@ "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.2", @@ -166,6 +166,7 @@ "pretty-bytes": "^6.1.1", "psl": "^1.15.0", "react": "19.2.4", + "react-day-picker": "^9.14.0", "react-device-detect": "^2.2.3", "react-dom": "19.2.4", "react-hook-form": "^7.53.0", diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx index 7aaab3f35..66ab7f4af 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx @@ -9,6 +9,7 @@ import { AuthorFilter } from "./authorFilter"; import { dedupeCommitAuthorsByEmail, escapeGitBreLiteral } from "./commitAuthors"; import { CommitRow } from "./commitRow"; import { CommitsPagination } from "./commitsPagination"; +import { DateFilter } from "./dateFilter"; interface CommitsPanelProps { path: string; @@ -16,20 +17,31 @@ interface CommitsPanelProps { revisionName?: string; page: number; author?: string; + since?: string; + until?: string; } const COMMITS_PER_PAGE = 35; const AUTHORS_PER_PAGE = 100; -export const CommitsPanel = async ({ path, repoName, revisionName, page, author }: CommitsPanelProps) => { +export const CommitsPanel = async ({ path, repoName, revisionName, page, author, since, until }: CommitsPanelProps) => { const skip = (page - 1) * COMMITS_PER_PAGE; + // The URL stores dates as YYYY-MM-DD. Always pass explicit timestamps to + // git: the bare-date form triggers approxidate quirks (returning 0 commits + // in some cases), and bare `--until=YYYY-MM-DD` would also exclude commits + // made on that day since it resolves to midnight at the start. + const sinceForGit = since ? `${since}T00:00:00` : undefined; + const untilForGit = until ? `${until}T23:59:59` : undefined; + const [commitsResponse, repoInfoResponse, authorsResponse] = await Promise.all([ listCommits({ repo: repoName, path: path || undefined, ref: revisionName, author: author ? escapeGitBreLiteral(author) : undefined, + since: sinceForGit, + until: untilForGit, maxCount: COMMITS_PER_PAGE, skip, }), @@ -86,7 +98,10 @@ export const CommitsPanel = async ({ path, repoName, revisionName, page, author revisionName={revisionName} />
- +
+ + +
@@ -115,7 +130,7 @@ export const CommitsPanel = async ({ path, repoName, revisionName, page, author page={page} perPage={COMMITS_PER_PAGE} totalCount={totalCount} - extraParams={{ author }} + extraParams={{ author, since, until }} />
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/dateFilter.tsx b/packages/web/src/app/(app)/browse/[...path]/components/dateFilter.tsx new file mode 100644 index 000000000..ccb312512 --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/dateFilter.tsx @@ -0,0 +1,224 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter, usePathname, useSearchParams } from "next/navigation"; +import { Calendar as CalendarIcon, ChevronDown } from "lucide-react"; +import { format } from "date-fns"; +import type { DateRange } from "react-day-picker"; + +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +interface DateFilterProps { + since?: string; + until?: string; +} + +// Parse 'YYYY-MM-DD' as a date in the local calendar (not UTC midnight). +const parseLocalDate = (s: string): Date | undefined => { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); + if (!match) { + return undefined; + } + return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])); +}; + +const formatLocalDate = (d: Date): string => { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +}; + +const formatLabel = (from: Date | undefined, to: Date | undefined): string => { + if (!from && !to) { + return 'All time'; + } + const currentYear = new Date().getFullYear(); + const fmt = (d: Date) => + d.getFullYear() === currentYear ? format(d, 'MMM d') : format(d, 'MMM d, yyyy'); + + if (from && to) { + if (formatLocalDate(from) === formatLocalDate(to)) { + return fmt(from); + } + return `${fmt(from)} - ${fmt(to)}`; + } + return fmt((from ?? to) as Date); +}; + +export const DateFilter = ({ since, until }: DateFilterProps) => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const [isOpen, setIsOpen] = useState(false); + const [timeZone, setTimeZone] = useState(undefined); + + const fromDate = useMemo(() => (since ? parseLocalDate(since) : undefined), [since]); + const toDate = useMemo(() => (until ? parseLocalDate(until) : undefined), [until]); + + const [month, setMonth] = useState(fromDate); + + useEffect(() => { + setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone); + }, []); + + const selectedRange: DateRange | undefined = useMemo(() => { + if (!fromDate && !toDate) { + return undefined; + } + return { from: fromDate, to: toDate }; + }, [fromDate, toDate]); + + // Track in-progress selection locally so DayPicker can distinguish between + // a first click (`{from, to: undefined}`) and a completed range. Without + // this, controlling `selected` directly off the URL would make every click + // look like a fresh range start. + const [draftRange, setDraftRange] = useState(selectedRange); + + // Sync the draft with the URL whenever the popover is (re)opened, so a + // half-finished selection from a previous session doesn't carry over. + useEffect(() => { + if (isOpen) { + setDraftRange(selectedRange); + } + }, [isOpen, selectedRange]); + + const navigateWithRange = useCallback( + (from: Date | undefined, to: Date | undefined) => { + const params = new URLSearchParams(searchParams); + if (from) { + params.set('since', formatLocalDate(from)); + } else { + params.delete('since'); + } + if (to) { + params.set('until', formatLocalDate(to)); + } else { + params.delete('until'); + } + params.delete('page'); + const query = params.toString(); + setIsOpen(false); + router.push(`${pathname}${query ? `?${query}` : ''}`); + }, + [pathname, router, searchParams], + ); + + const onSelect = useCallback( + (selected: DateRange | undefined) => { + const draftWasComplete = Boolean(draftRange?.from && draftRange.to); + const draftWasPartial = Boolean(draftRange?.from && !draftRange.to); + + // When a complete range already exists, react-day-picker v9 adjusts + // the existing range based on where the click lands (moving one + // endpoint), producing a new complete range in a single click. We + // require two clicks to form a new range, so intercept that case: + // infer the clicked date and demote to a partial range. + if (draftWasComplete && selected?.from && selected.to) { + const prevFromTime = draftRange!.from!.getTime(); + const prevToTime = draftRange!.to!.getTime(); + const fromIsNew = + selected.from.getTime() !== prevFromTime && + selected.from.getTime() !== prevToTime; + const toIsNew = + selected.to.getTime() !== prevFromTime && + selected.to.getTime() !== prevToTime; + // Prefer whichever endpoint is actually new; fall back to + // `from` if both happen to match (shouldn't happen in practice). + const clickedDate = fromIsNew + ? selected.from + : toIsNew + ? selected.to + : selected.from; + setDraftRange({ from: clickedDate, to: undefined }); + return; + } + + // react-day-picker v9 can also return a complete single-day range + // (`{from: D, to: D}`) on a single click when there was no prior + // partial selection. Demote that to a partial range too. + const isSingleDay = + selected?.from && + selected.to && + selected.from.getTime() === selected.to.getTime(); + + if (isSingleDay && !draftWasPartial) { + setDraftRange({ from: selected!.from, to: undefined }); + return; + } + + setDraftRange(selected); + if (selected?.from && selected.to) { + navigateWithRange(selected.from, selected.to); + } + }, + [draftRange, navigateWithRange], + ); + + const onClear = useCallback(() => { + navigateWithRange(undefined, undefined); + }, [navigateWithRange]); + + const onToday = useCallback(() => { + setMonth(new Date()); + }, []); + + const label = formatLabel(fromDate, toDate); + const hasFilter = Boolean(fromDate || toDate); + + return ( + + + + + + +
+ + +
+
+
+ ); +}; diff --git a/packages/web/src/app/(app)/browse/[...path]/page.tsx b/packages/web/src/app/(app)/browse/[...path]/page.tsx index 6e7d305f5..9274e7735 100644 --- a/packages/web/src/app/(app)/browse/[...path]/page.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/page.tsx @@ -78,6 +78,8 @@ interface BrowsePageProps { searchParams: Promise<{ page?: string; author?: string; + since?: string; + until?: string; }>; } @@ -93,6 +95,8 @@ export default async function BrowsePage(props: BrowsePageProps) { const page = Math.max(1, parseInt(searchParams.page ?? '1', 10) || 1); const author = searchParams.author || undefined; + const since = searchParams.since || undefined; + const until = searchParams.until || undefined; return (
@@ -115,6 +119,8 @@ export default async function BrowsePage(props: BrowsePageProps) { revisionName={revisionName} page={page} author={author} + since={since} + until={until} /> ) : ( & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "bg-popover absolute inset-0 opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-sm" + : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-[--cell-size] select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-muted-foreground select-none text-[0.8rem]", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md", + defaultClassNames.day + ), + range_start: cn( + "bg-accent rounded-l-md", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end), + today: cn( + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( +
- {latestCommit && ( - <> - - - - )} { - const [isBodyExpanded, setIsBodyExpanded] = useState(false); - const shortSha = commit.hash.slice(0, 7); - const relativeDate = formatDistanceToNow(new Date(commit.date), { addSuffix: true }); - const hasBody = commit.body.trim().length > 0; - - const historyHref = getBrowsePath({ - repoName, - revisionName, - path, - pathType: 'commits', - }); - - const authors = useMemo( - () => getCommitAuthors(commit), - [commit], - ); - - return ( - <> -
-
- - - {formatAuthorsText(authors)} - - - {commit.message} - - {hasBody && ( - - )} -
-
-
- - {shortSha} - - · - - {relativeDate} - -
- -
-
- {hasBody && isBodyExpanded && ( - - )} - - ); -}; diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx index 2b2565597..c647f81cd 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx @@ -3,15 +3,17 @@ import { useCallback, useMemo, useState } from "react"; import { formatDistanceToNow } from "date-fns"; import { Code, FileCode } from "lucide-react"; -import Link from "next/link"; -import { Button } from "@/components/ui/button"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { CopyIconButton } from "@/app/(app)/components/copyIconButton"; import { useToast } from "@/components/hooks/use-toast"; import type { Commit } from "@/features/git"; import { getBrowsePath } from "../../hooks/utils"; -import { formatAuthorsText, getCommitAuthors } from "./commitAuthors"; -import { AuthorsAvatarGroup, CommitBody, CommitBodyToggle } from "./commitParts"; +import { formatAuthorsText, getCommitAuthors } from "../../components/commitAuthors"; +import { + AuthorsAvatarGroup, + CommitActionLink, + CommitBody, + CommitBodyToggle, +} from "../../components/commitParts"; interface CommitRowProps { commit: Commit; @@ -81,27 +83,17 @@ export const CommitRow = ({ commit, repoName, path }: CommitRowProps) => { {hasFilePath && ( - - - - - View code at this commit - + } + /> )} - - - - - View repository at this commit - + } + />
{hasBody && isBodyExpanded && ( diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx index 66ab7f4af..9a866fa4f 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx @@ -6,7 +6,7 @@ import { Separator } from "@/components/ui/separator"; import { listCommitAuthors, listCommits } from "@/features/git"; import { isServiceError } from "@/lib/utils"; import { AuthorFilter } from "./authorFilter"; -import { dedupeCommitAuthorsByEmail, escapeGitBreLiteral } from "./commitAuthors"; +import { dedupeCommitAuthorsByEmail, escapeGitBreLiteral } from "../../components/commitAuthors"; import { CommitRow } from "./commitRow"; import { CommitsPagination } from "./commitsPagination"; import { DateFilter } from "./dateFilter"; diff --git a/packages/web/src/app/(app)/browse/browseStateProvider.tsx b/packages/web/src/app/(app)/browse/browseStateProvider.tsx index a3dea45b4..fc18c0a86 100644 --- a/packages/web/src/app/(app)/browse/browseStateProvider.tsx +++ b/packages/web/src/app/(app)/browse/browseStateProvider.tsx @@ -14,6 +14,7 @@ export interface BrowseState { isFileTreePanelCollapsed: boolean; isFileSearchOpen: boolean; activeExploreMenuTab: "references" | "definitions"; + activeBottomPanelTab: "explore" | "history"; bottomPanelSize: number; } @@ -23,6 +24,7 @@ const defaultState: BrowseState = { isFileTreePanelCollapsed: false, isFileSearchOpen: false, activeExploreMenuTab: "references", + activeBottomPanelTab: "history", bottomPanelSize: 35, }; diff --git a/packages/web/src/app/(app)/browse/components/bottomPanel.tsx b/packages/web/src/app/(app)/browse/components/bottomPanel.tsx index b8004aa51..3a8e1f64a 100644 --- a/packages/web/src/app/(app)/browse/components/bottomPanel.tsx +++ b/packages/web/src/app/(app)/browse/components/bottomPanel.tsx @@ -4,16 +4,24 @@ import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; import { Button } from "@/components/ui/button"; import { ResizablePanel } from "@/components/ui/resizable"; import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsList } from "@/components/ui/tabs"; +import { LowProfileTabsTrigger } from "@/components/ui/tab-switcher"; import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { FaChevronDown } from "react-icons/fa"; import { VscReferences, VscSymbolMisc } from "react-icons/vsc"; import { ImperativePanelHandle } from "react-resizable-panels"; import { useBrowseState } from "../hooks/useBrowseState"; +import { useBrowseParams } from "../hooks/useBrowseParams"; +import { getBrowsePath } from "../hooks/utils"; import { ExploreMenu } from "@/ee/features/codeNav/components/exploreMenu"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { ExternalLink, History } from "lucide-react"; +import type { BrowseState } from "../browseStateProvider"; +import { HistoryPanel } from "./historyPanel"; +import { LatestCommitInfo } from "./latestCommitInfo"; export const BOTTOM_PANEL_MIN_SIZE = 35; export const BOTTOM_PANEL_MAX_SIZE = 65; @@ -23,16 +31,26 @@ interface BottomPanelProps { order: number; } +type BottomPanelTab = BrowseState["activeBottomPanelTab"]; + export const BottomPanel = ({ order }: BottomPanelProps) => { const panelRef = useRef(null); const hasCodeNavEntitlement = useHasEntitlement("code-nav"); const router = useRouter(); const { - state: { selectedSymbolInfo, isBottomPanelCollapsed, bottomPanelSize }, + state: { selectedSymbolInfo, isBottomPanelCollapsed, bottomPanelSize, activeBottomPanelTab }, updateBrowseState, } = useBrowseState(); + const { repoName, revisionName, path } = useBrowseParams(); + const fullHistoryHref = getBrowsePath({ + repoName, + revisionName, + path, + pathType: 'commits', + }); + useEffect(() => { if (isBottomPanelCollapsed) { panelRef.current?.collapse(); @@ -41,45 +59,94 @@ export const BottomPanel = ({ order }: BottomPanelProps) => { } }, [isBottomPanelCollapsed]); + const onTabClick = useCallback((tab: BottomPanelTab) => { + if (isBottomPanelCollapsed) { + updateBrowseState({ isBottomPanelCollapsed: false, activeBottomPanelTab: tab }); + return; + } + if (activeBottomPanelTab === tab) { + updateBrowseState({ isBottomPanelCollapsed: true }); + return; + } + updateBrowseState({ activeBottomPanelTab: tab }); + }, [isBottomPanelCollapsed, activeBottomPanelTab, updateBrowseState]); + useHotkeys("shift+mod+e", (event) => { event.preventDefault(); - updateBrowseState({ isBottomPanelCollapsed: !isBottomPanelCollapsed }); + onTabClick("explore"); }, { enableOnFormTags: true, enableOnContentEditable: true, description: "Open Explore Panel", }); + useHotkeys("shift+mod+h", (event) => { + event.preventDefault(); + onTabClick("history"); + }, { + enableOnFormTags: true, + enableOnContentEditable: true, + description: "Open History Panel", + }); + + // Empty value when collapsed so neither tab shows the active underline. + const tabsValue = isBottomPanelCollapsed ? "" : activeBottomPanelTab; + return ( <> -
-
- -
+
+ + + onTabClick("history")} + className="text-foreground" + > + + + History + + + + onTabClick("explore")} + className="text-foreground" + > + + + Explore + + + + + - {!isBottomPanelCollapsed && ( - + {isBottomPanelCollapsed ? ( +
+ +
+ ) : ( +
+ {activeBottomPanelTab === "history" && ( + + )} + +
)}
@@ -99,40 +166,43 @@ export const BottomPanel = ({ order }: BottomPanelProps) => { order={order} id={"bottom-panel"} > - {!hasCodeNavEntitlement ? ( -
- -

- Code navigation is not enabled for router.push(`/settings/license`)}>your plan. -

- - - Learn more - -
- ) : !selectedSymbolInfo ? ( -
- -

No symbol selected

- - Learn more - -
+ {activeBottomPanelTab === "explore" ? ( + !hasCodeNavEntitlement ? ( +
+ +

+ Code navigation is not enabled for router.push(`/settings/license`)}>your plan. +

+ + + Learn more + +
+ ) : !selectedSymbolInfo ? ( +
+ +

No symbol selected

+ + Learn more + +
+ ) : ( + + ) ) : ( - + )} ) } - diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitAuthors.ts b/packages/web/src/app/(app)/browse/components/commitAuthors.ts similarity index 100% rename from packages/web/src/app/(app)/browse/[...path]/components/commitAuthors.ts rename to packages/web/src/app/(app)/browse/components/commitAuthors.ts diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitParts.tsx b/packages/web/src/app/(app)/browse/components/commitParts.tsx similarity index 75% rename from packages/web/src/app/(app)/browse/[...path]/components/commitParts.tsx rename to packages/web/src/app/(app)/browse/components/commitParts.tsx index 9810b6a59..bc7033acb 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/commitParts.tsx +++ b/packages/web/src/app/(app)/browse/components/commitParts.tsx @@ -1,5 +1,8 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; import { MoreHorizontal } from "lucide-react"; import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; import { Toggle } from "@/components/ui/toggle"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { UserAvatar } from "@/components/userAvatar"; @@ -65,3 +68,22 @@ export const CommitBody = ({ body, className }: CommitBodyProps) => (
); + +interface CommitActionLinkProps { + href: string; + label: string; + icon: ReactNode; +} + +export const CommitActionLink = ({ href, label, icon }: CommitActionLinkProps) => ( + + + + + {label} + +); diff --git a/packages/web/src/app/(app)/browse/components/historyPanel.tsx b/packages/web/src/app/(app)/browse/components/historyPanel.tsx new file mode 100644 index 000000000..3a2e2f927 --- /dev/null +++ b/packages/web/src/app/(app)/browse/components/historyPanel.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useEffect, useRef } from "react"; +import { Loader2 } from "lucide-react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { listCommits } from "@/app/api/(client)/client"; +import { isServiceError } from "@/lib/utils"; +import type { ListCommitsResponse } from "@/features/git"; +import { useBrowseParams } from "../hooks/useBrowseParams"; +import { HistoryRow } from "./historyRow"; + +const PER_PAGE = 25; + +type CommitsPage = ListCommitsResponse & { page: number }; + +export const HistoryPanel = () => { + const { repoName, revisionName, path } = useBrowseParams(); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + status, + error, + } = useInfiniteQuery({ + queryKey: ['historyPanelCommits', repoName, revisionName ?? null, path], + queryFn: async ({ pageParam }) => { + const page = pageParam as number; + const result = await listCommits({ + repo: repoName, + ref: revisionName, + path: path || undefined, + page, + perPage: PER_PAGE, + }); + if (isServiceError(result)) { + throw new Error(result.message); + } + return { ...result, page }; + }, + initialPageParam: 1, + getNextPageParam: (lastPage) => { + const seenSoFar = lastPage.page * PER_PAGE; + return seenSoFar < lastPage.totalCount ? lastPage.page + 1 : undefined; + }, + }); + + const sentinelRef = useRef(null); + + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel || !hasNextPage) { + return; + } + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { rootMargin: '100px' }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const allCommits = data?.pages.flatMap((p) => p.commits) ?? []; + + return ( +
+
+ {status === 'pending' && ( +
+ +
+ )} + {status === 'error' && ( +
+ Failed to load commit history + {error instanceof Error && ( + {error.message} + )} +
+ )} + {status === 'success' && allCommits.length === 0 && ( +
+ No commits found +
+ )} + {status === 'success' && allCommits.map((commit) => ( + + ))} + {hasNextPage && ( +
+ {isFetchingNextPage && ( + + )} +
+ )} + {status === 'success' && !hasNextPage && allCommits.length > 0 && ( +
+ End of commit history +
+ )} +
+
+ ); +}; diff --git a/packages/web/src/app/(app)/browse/components/historyRow.tsx b/packages/web/src/app/(app)/browse/components/historyRow.tsx new file mode 100644 index 000000000..fb29af587 --- /dev/null +++ b/packages/web/src/app/(app)/browse/components/historyRow.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { useMemo } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { Code, FileCode } from "lucide-react"; +import type { Commit } from "@/features/git"; +import { getBrowsePath } from "../hooks/utils"; +import { formatAuthorsText, getCommitAuthors } from "./commitAuthors"; +import { AuthorsAvatarGroup, CommitActionLink } from "./commitParts"; + +interface HistoryRowProps { + commit: Commit; + repoName: string; + path: string; +} + +export const HistoryRow = ({ commit, repoName, path }: HistoryRowProps) => { + const shortSha = commit.hash.slice(0, 7); + const relativeDate = formatDistanceToNow(new Date(commit.date), { addSuffix: true }); + const hasFilePath = path !== '' && path !== '/'; + + const authors = useMemo(() => getCommitAuthors(commit), [commit]); + + const viewCodeHref = getBrowsePath({ + repoName, + revisionName: commit.hash, + path, + pathType: 'blob', + }); + + const viewRepoHref = getBrowsePath({ + repoName, + revisionName: commit.hash, + path: '', + pathType: 'tree', + }); + + return ( +
+ + {shortSha} + + + {commit.message} + + + a.name).join(", ")} + > + {formatAuthorsText(authors)} + + + {relativeDate} + +
+ {hasFilePath && ( + } + /> + )} + } + /> +
+
+ ); +}; diff --git a/packages/web/src/app/(app)/browse/components/latestCommitInfo.tsx b/packages/web/src/app/(app)/browse/components/latestCommitInfo.tsx new file mode 100644 index 000000000..f34c096c1 --- /dev/null +++ b/packages/web/src/app/(app)/browse/components/latestCommitInfo.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { useMemo } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { useQuery } from "@tanstack/react-query"; +import { listCommits } from "@/app/api/(client)/client"; +import { isServiceError } from "@/lib/utils"; +import { useBrowseParams } from "../hooks/useBrowseParams"; +import { formatAuthorsText, getCommitAuthors } from "./commitAuthors"; +import { AuthorsAvatarGroup } from "./commitParts"; + +export const LatestCommitInfo = () => { + const { repoName, revisionName, path } = useBrowseParams(); + + const { data: commit } = useQuery({ + queryKey: ['latestCommitInfo', repoName, revisionName ?? null, path], + queryFn: async () => { + const result = await listCommits({ + repo: repoName, + ref: revisionName, + path: path || undefined, + page: 1, + perPage: 1, + }); + if (isServiceError(result)) { + throw new Error(result.message); + } + return result.commits[0] ?? null; + }, + }); + + const authors = useMemo( + () => (commit ? getCommitAuthors(commit) : []), + [commit], + ); + + if (!commit) { + return null; + } + + const relativeDate = formatDistanceToNow(new Date(commit.date), { addSuffix: true }); + + return ( +
+ + a.name).join(", ")}> + {formatAuthorsText(authors)} + + + {commit.message} + + + {relativeDate} + +
+ ); +}; diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index e56aac18a..93dd269ee 100644 --- a/packages/web/src/app/api/(client)/client.ts +++ b/packages/web/src/app/api/(client)/client.ts @@ -12,12 +12,15 @@ import { FindRelatedSymbolsResponse, } from "@/features/codeNav/types"; import { + Commit, GetFilesRequest, GetFilesResponse, GetTreeRequest, GetTreeResponse, FileSourceRequest, FileSourceResponse, + ListCommitsQueryParams, + ListCommitsResponse, } from "@/features/git"; import type { PermissionSyncStatusResponse } from "../(server)/ee/permissionSyncStatus/api"; import type { AccountSyncStatusResponse } from "../(server)/ee/accountPermissionSyncJobStatus/api"; @@ -120,6 +123,30 @@ export const getTree = async (body: GetTreeRequest): Promise => { + const url = new URL("/api/commits", window.location.origin); + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== '') { + url.searchParams.set(key, value.toString()); + } + } + + const response = await fetch(url, { + method: "GET", + headers: { + "X-Sourcebot-Client-Source": "sourcebot-web-client", + }, + }); + + const result = await response.json(); + if (isServiceError(result)) { + return result; + } + + const totalCount = parseInt(response.headers.get('X-Total-Count') ?? '0', 10); + return { commits: result as Commit[], totalCount }; +} + export const getFiles = async (body: GetFilesRequest): Promise => { const result = await fetch("/api/files", { method: "POST", diff --git a/packages/web/src/components/ui/tab-switcher.tsx b/packages/web/src/components/ui/tab-switcher.tsx index a1797ae9e..4998c1206 100644 --- a/packages/web/src/components/ui/tab-switcher.tsx +++ b/packages/web/src/components/ui/tab-switcher.tsx @@ -2,6 +2,7 @@ import { useRouter } from "next/navigation" import { TabsList, TabsTrigger } from "@/components/ui/tabs" +import { cn } from "@/lib/utils" import { ReactNode } from "react" interface TabSwitcherProps { @@ -37,14 +38,18 @@ interface LowProfileTabsTrigger { value: string children: React.ReactNode onClick?: () => void + className?: string } - - export function LowProfileTabsTrigger({ value, children, onClick }: LowProfileTabsTrigger) { + + export function LowProfileTabsTrigger({ value, children, onClick, className }: LowProfileTabsTrigger) { return ( {children} diff --git a/packages/web/src/features/git/listCommitsApi.ts b/packages/web/src/features/git/listCommitsApi.ts index 3e72d0dc7..682692ab9 100644 --- a/packages/web/src/features/git/listCommitsApi.ts +++ b/packages/web/src/features/git/listCommitsApi.ts @@ -15,6 +15,18 @@ export type ListCommitsResponse = { totalCount: number; }; +export type ListCommitsQueryParams = { + repo: string; + query?: string; + since?: string; + until?: string; + author?: string; + ref?: string; + path?: string; + page?: number; + perPage?: number; +}; + type ListCommitsRequest = { repo: string; query?: string; From aa58cd38ef2bb4f998f69a3bb681f8077397914f Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sat, 25 Apr 2026 01:34:43 -0400 Subject: [PATCH 10/11] fix(web): resolve path type for the commits view and tighten path handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commits view URL pattern (/-/commits/) doesn't carry whether the path is a file or a folder, so PathHeader was rendering folder paths with the file-icon last-segment treatment. Add a small getPathType helper backed by `git cat-file -t :` and use it to pick the correct PathHeader behaviour. CommitRow and HistoryRow now gate the "view code at this commit" action on the same blob check so the file-only link doesn't appear on folder rows. Also fix a related path-handling bug: PathHeader's repo-name link called getBrowsePath with `path: '/'`, which encoded as `%2F` and parsed back as `path: '/'` — that leaked through as `git log -- /`, which git correctly rejects with "outside repository". Strip leading slashes both when generating and when parsing browse URLs so any path that comes through as a literal `/` resolves to the repo root. When clicking "View full history" the bottom panel now collapses on the same click, so the new full-view page renders without the panel still hovering on top of it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../browse/[...path]/components/commitRow.tsx | 9 +- .../[...path]/components/commitsPanel.tsx | 14 +++- .../(app)/browse/components/bottomPanel.tsx | 5 +- .../(app)/browse/components/historyPanel.tsx | 3 +- .../(app)/browse/components/historyRow.tsx | 9 +- .../web/src/app/(app)/browse/hooks/utils.ts | 16 +++- .../web/src/features/git/getPathTypeApi.ts | 84 +++++++++++++++++++ packages/web/src/features/git/index.ts | 1 + 8 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 packages/web/src/features/git/getPathTypeApi.ts diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx index c647f81cd..70d5d9903 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx @@ -5,7 +5,7 @@ import { formatDistanceToNow } from "date-fns"; import { Code, FileCode } from "lucide-react"; import { CopyIconButton } from "@/app/(app)/components/copyIconButton"; import { useToast } from "@/components/hooks/use-toast"; -import type { Commit } from "@/features/git"; +import type { Commit, GitObjectPathType } from "@/features/git"; import { getBrowsePath } from "../../hooks/utils"; import { formatAuthorsText, getCommitAuthors } from "../../components/commitAuthors"; import { @@ -19,16 +19,17 @@ interface CommitRowProps { commit: Commit; repoName: string; path: string; + pathType: GitObjectPathType; } -export const CommitRow = ({ commit, repoName, path }: CommitRowProps) => { +export const CommitRow = ({ commit, repoName, path, pathType }: CommitRowProps) => { const [isBodyExpanded, setIsBodyExpanded] = useState(false); const { toast } = useToast(); const shortSha = commit.hash.slice(0, 7); const relativeDate = formatDistanceToNow(new Date(commit.date), { addSuffix: true }); const hasBody = commit.body.trim().length > 0; - const hasFilePath = path !== '' && path !== '/'; + const isBlobPath = pathType === 'blob'; const authors = useMemo( () => getCommitAuthors(commit), @@ -82,7 +83,7 @@ export const CommitRow = ({ commit, repoName, path }: CommitRowProps) => { {shortSha} - {hasFilePath && ( + {isBlobPath && ( Error loading commit authors: {authorsResponse.message}
; } + // Fall back to 'blob' if the lookup fails so PathHeader still renders. + const headerPathType = isServiceError(pathTypeResponse) ? 'blob' : pathTypeResponse; const authors = dedupeCommitAuthorsByEmail(authorsResponse.authors); const { commits, totalCount } = commitsResponse; @@ -89,6 +96,7 @@ export const CommitsPanel = async ({ path, repoName, revisionName, page, author, History for
@@ -117,6 +126,7 @@ export const CommitsPanel = async ({ path, repoName, revisionName, page, author, commit={commit} repoName={repoName} path={path} + pathType={headerPathType} /> ))}
diff --git a/packages/web/src/app/(app)/browse/components/bottomPanel.tsx b/packages/web/src/app/(app)/browse/components/bottomPanel.tsx index 3a8e1f64a..56ae56b29 100644 --- a/packages/web/src/app/(app)/browse/components/bottomPanel.tsx +++ b/packages/web/src/app/(app)/browse/components/bottomPanel.tsx @@ -130,7 +130,10 @@ export const BottomPanel = ({ order }: BottomPanelProps) => {
{activeBottomPanelTab === "history" && (
- {group.commits.map((commit) => ( - + ) : ( +
+ No commits found +
+ ) + ) : ( + <> + {Array.from(groups.values()).map((group) => ( +
+
+ + {group.label} +
+ {group.commits.map((commit) => ( + + ))} +
))} - - ))} - {isLastPage && ( -
- End of commit history -
+ {isLastPage && ( +
+ End of commit history +
+ )} + + )} - ); diff --git a/packages/web/src/app/(app)/browse/components/bottomPanel.tsx b/packages/web/src/app/(app)/browse/components/bottomPanel.tsx index 56ae56b29..b68276014 100644 --- a/packages/web/src/app/(app)/browse/components/bottomPanel.tsx +++ b/packages/web/src/app/(app)/browse/components/bottomPanel.tsx @@ -18,7 +18,7 @@ import { getBrowsePath } from "../hooks/utils"; import { ExploreMenu } from "@/ee/features/codeNav/components/exploreMenu"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { ExternalLink, History } from "lucide-react"; +import { History } from "lucide-react"; import type { BrowseState } from "../browseStateProvider"; import { HistoryPanel } from "./historyPanel"; import { LatestCommitInfo } from "./latestCommitInfo"; @@ -134,7 +134,7 @@ export const BottomPanel = ({ order }: BottomPanelProps) => { href={fullHistoryHref} onClick={() => updateBrowseState({ isBottomPanelCollapsed: true })} > - + View full history