diff --git a/CHANGELOG.md b/CHANGELOG.md
index 295ca08c8..b22036132 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- Added commit history viewer to code browser. [#1150](https://github.com/sourcebot-dev/sourcebot/pull/1150)
+- Added `/api/commits/authors` to the public API to allow fetching a list of authors for a given path and revision. [#1150](https://github.com/sourcebot-dev/sourcebot/pull/1150)
+
## [4.16.15] - 2026-04-23
### Changed
diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json
index 547b6d5aa..2c95f79ca 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": {
@@ -1731,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"
},
@@ -1761,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"
},
@@ -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/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/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/commitRow.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx
new file mode 100644
index 000000000..623a59a1d
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx
@@ -0,0 +1,105 @@
+'use client';
+
+import { useCallback, useMemo, useState } from "react";
+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, GitObjectPathType } from "@/features/git";
+import { getBrowsePath } from "../../hooks/utils";
+import { formatAuthorsText, getCommitAuthors } from "../../components/commitAuthors";
+import {
+ AuthorsAvatarGroup,
+ CommitActionLink,
+ CommitBody,
+ CommitBodyToggle,
+} from "../../components/commitParts";
+
+interface CommitRowProps {
+ commit: Commit;
+ repoName: string;
+ path: string;
+ pathType: GitObjectPathType;
+}
+
+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 isBlobPath = pathType === 'blob';
+
+ 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(async () => {
+ await 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}
+
+
+ {isBlobPath && (
+ }
+ />
+ )}
+ }
+ />
+
+
+ {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..a01962ff4
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitsPagination.tsx
@@ -0,0 +1,62 @@
+import Link from "next/link";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface CommitsPaginationProps {
+ page: number;
+ perPage: number;
+ totalCount: number;
+ extraParams?: Record;
+}
+
+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;
+
+ 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..adefd7077
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx
@@ -0,0 +1,176 @@
+import { format } from "date-fns";
+import { GitCommitHorizontal } from "lucide-react";
+import Link from "next/link";
+import { getRepoInfoByName } from "@/actions";
+import { PathHeader } from "@/app/(app)/components/pathHeader";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { getPathType, listCommitAuthors, listCommits } from "@/features/git";
+import { isServiceError } from "@/lib/utils";
+import { AuthorFilter } from "./authorFilter";
+import { dedupeCommitAuthorsByEmail, escapeGitBreLiteral } from "../../components/commitAuthors";
+import { CommitRow } from "./commitRow";
+import { CommitsPagination } from "./commitsPagination";
+import { DateFilter } from "./dateFilter";
+import { getBrowsePath } from "../../hooks/utils";
+
+interface CommitsPanelProps {
+ path: string;
+ repoName: string;
+ 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, 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, pathTypeResponse] = 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,
+ }),
+ getRepoInfoByName(repoName),
+ listCommitAuthors({
+ repo: repoName,
+ path: path || undefined,
+ ref: revisionName,
+ maxCount: AUTHORS_PER_PAGE,
+ skip: 0,
+ }),
+ getPathType({
+ repo: repoName,
+ ref: revisionName,
+ path,
+ }),
+ ]);
+
+ if (isServiceError(commitsResponse)) {
+ return Error loading commits: {commitsResponse.message}
;
+ }
+ if (isServiceError(repoInfoResponse)) {
+ return Error loading repo info: {repoInfoResponse.message}
;
+ }
+ if (isServiceError(authorsResponse)) {
+ return 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;
+ const isLastPage = page * COMMITS_PER_PAGE >= totalCount;
+ const hasFilters = Boolean(author || since || until);
+ const isEmpty = commits.length === 0;
+ const clearFiltersHref = getBrowsePath({
+ repoName,
+ revisionName,
+ path,
+ pathType: 'commits',
+ });
+
+ 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 (
+
+
+
+
+ {isEmpty ? (
+ hasFilters ? (
+
+
No commits match these filters
+
+
+ ) : (
+
+ No commits found
+
+ )
+ ) : (
+ <>
+ {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]/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 df0174432..9274e7735 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";
@@ -39,6 +40,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}`;
+ }
}
}
@@ -68,10 +75,16 @@ interface BrowsePageProps {
params: Promise<{
path: string[];
}>;
+ searchParams: Promise<{
+ page?: string;
+ author?: string;
+ since?: string;
+ until?: 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,
@@ -80,6 +93,11 @@ 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);
+ const author = searchParams.author || undefined;
+ const since = searchParams.since || undefined;
+ const until = searchParams.until || undefined;
+
return (
+ ) : pathType === 'commits' ? (
+
) : (
{
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,97 @@ 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 +169,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/components/commitAuthors.ts b/packages/web/src/app/(app)/browse/components/commitAuthors.ts
new file mode 100644
index 000000000..6a327095d
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/components/commitAuthors.ts
@@ -0,0 +1,80 @@
+import type { Commit, CommitAuthor } 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;
+ });
+};
+
+/**
+ * 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;
+ }
+ 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/components/commitParts.tsx b/packages/web/src/app/(app)/browse/components/commitParts.tsx
new file mode 100644
index 000000000..bc7033acb
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/components/commitParts.tsx
@@ -0,0 +1,89 @@
+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";
+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) => (
+
+);
+
+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..dc6ef318a
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/components/historyPanel.tsx
@@ -0,0 +1,118 @@
+'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, pathType } = 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..721640efb
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/components/historyRow.tsx
@@ -0,0 +1,79 @@
+'use client';
+
+import { useMemo } from "react";
+import { formatDistanceToNow } from "date-fns";
+import { Code, FileCode } from "lucide-react";
+import type { Commit } from "@/features/git";
+import { BrowsePathType, getBrowsePath } from "../hooks/utils";
+import { formatAuthorsText, getCommitAuthors } from "./commitAuthors";
+import { AuthorsAvatarGroup, CommitActionLink } from "./commitParts";
+
+interface HistoryRowProps {
+ commit: Commit;
+ repoName: string;
+ path: string;
+ pathType: BrowsePathType;
+}
+
+export const HistoryRow = ({ commit, repoName, path, pathType }: HistoryRowProps) => {
+ const shortSha = commit.hash.slice(0, 7);
+ const relativeDate = formatDistanceToNow(new Date(commit.date), { addSuffix: true });
+ const isBlobPath = pathType === 'blob';
+
+ 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}
+
+
+ {isBlobPath && (
+ }
+ />
+ )}
+ }
+ />
+
+
+ );
+};
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/(app)/browse/hooks/utils.ts b/packages/web/src/app/(app)/browse/hooks/utils.ts
index e48782098..afa1feb49 100644
--- a/packages/web/src/app/(app)/browse/hooks/utils.ts
+++ b/packages/web/src/app/(app)/browse/hooks/utils.ts
@@ -10,19 +10,21 @@ export type BrowseHighlightRange = {
end: { lineNumber: number; };
}
+export type BrowsePathType = 'blob' | 'tree' | 'commits';
+
export interface GetBrowsePathProps {
repoName: string;
revisionName?: string;
path: string;
- pathType: 'blob' | 'tree';
+ pathType: BrowsePathType;
highlightRange?: BrowseHighlightRange;
setBrowseState?: Partial;
}
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)),
@@ -50,18 +61,28 @@ export const getBrowseParamsFromPathParam = (pathParam: string) => {
}
})();
- if (pathType === 'blob' && path === '') {
+ // Normalize parsed paths the same way URL generation does, so URLs that
+ // happen to contain a leading slash (e.g. legacy bookmarks with `%2F`)
+ // don't leak `/foo` into git log args.
+ const normalizedPath = path.replace(/^\/+/, '');
+
+ if (pathType === 'blob' && normalizedPath === '') {
throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain a path for blob type`);
}
return {
repoName,
revisionName,
- path,
+ path: normalizedPath,
pathType,
}
};
+// Repo-relative paths shouldn't have leading slashes — `git log -- /foo` (or
+// just `--`) treats them as absolute filesystem paths. Repo root and `/`
+// both map to the empty path.
+const normalizeRepoPath = (path: string): string => path.replace(/^\/+/, '');
+
export const getBrowsePath = ({
repoName, revisionName, path, pathType, highlightRange, setBrowseState,
}: GetBrowsePathProps) => {
@@ -81,7 +102,7 @@ export const getBrowsePath = ({
params.set(SET_BROWSE_STATE_QUERY_PARAM, JSON.stringify(setBrowseState));
}
- const encodedPath = encodeURIComponent(path);
+ const encodedPath = encodeURIComponent(normalizeRepoPath(path));
const browsePath = `/browse/${repoName}${revisionName ? `@${revisionName}` : ''}/-/${pathType}/${encodedPath}${params.size > 0 ? `?${params.toString()}` : ''}`;
return browsePath;
};
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/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/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,
+}
diff --git a/packages/web/src/components/ui/calendar.tsx b/packages/web/src/components/ui/calendar.tsx
new file mode 100644
index 000000000..a623682da
--- /dev/null
+++ b/packages/web/src/components/ui/calendar.tsx
@@ -0,0 +1,213 @@
+"use client"
+
+import * as React from "react"
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from "lucide-react"
+import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { Button, buttonVariants } from "@/components/ui/button"
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps
& {
+ 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 (
+