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 ( +
+
+
+ History for + +
+
+ + +
+
+ +
+ {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) => ( +
+
+            {body.trim()}
+        
+
+); + +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 ( +