diff --git a/CHANGELOG.md b/CHANGELOG.md index 51866112..c552db5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed issue where parenthesis in query params were not being encoded, resulting in a poor experience when embedding links in Markdown. [#674](https://github.com/sourcebot-dev/sourcebot/pull/674) - Gitlab clone respects host protocol setting in environment variable. [#676](https://github.com/sourcebot-dev/sourcebot/pull/676) +- Fixed performance issues with `/repos` page. [#677](https://github.com/sourcebot-dev/sourcebot/pull/677) ## [4.10.3] - 2025-12-12 diff --git a/packages/backend/src/repoIndexManager.ts b/packages/backend/src/repoIndexManager.ts index 5a576d05..9362c42a 100644 --- a/packages/backend/src/repoIndexManager.ts +++ b/packages/backend/src/repoIndexManager.ts @@ -258,6 +258,11 @@ export class RepoIndexManager { }, data: { status: RepoIndexingJobStatus.IN_PROGRESS, + repo: { + update: { + latestIndexingJobStatus: RepoIndexingJobStatus.IN_PROGRESS, + } + } }, select: { type: true, @@ -462,6 +467,11 @@ export class RepoIndexManager { data: { status: RepoIndexingJobStatus.COMPLETED, completedAt: new Date(), + repo: { + update: { + latestIndexingJobStatus: RepoIndexingJobStatus.COMPLETED, + } + } }, include: { repo: true, @@ -522,6 +532,11 @@ export class RepoIndexManager { status: RepoIndexingJobStatus.FAILED, completedAt: new Date(), errorMessage: job.failedReason, + repo: { + update: { + latestIndexingJobStatus: RepoIndexingJobStatus.FAILED, + } + } }, select: { repo: true } }); @@ -550,6 +565,11 @@ export class RepoIndexManager { status: RepoIndexingJobStatus.FAILED, completedAt: new Date(), errorMessage: 'Job stalled', + repo: { + update: { + latestIndexingJobStatus: RepoIndexingJobStatus.FAILED, + } + } }, select: { repo: true, type: true } }); @@ -572,6 +592,11 @@ export class RepoIndexManager { status: RepoIndexingJobStatus.FAILED, completedAt: new Date(), errorMessage: 'Job timed out', + repo: { + update: { + latestIndexingJobStatus: RepoIndexingJobStatus.FAILED, + } + } }, select: { repo: true } }); diff --git a/packages/db/prisma/migrations/20251218171324_add_latest_indexing_job_status/migration.sql b/packages/db/prisma/migrations/20251218171324_add_latest_indexing_job_status/migration.sql new file mode 100644 index 00000000..c5181ab5 --- /dev/null +++ b/packages/db/prisma/migrations/20251218171324_add_latest_indexing_job_status/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Repo" ADD COLUMN "latestIndexingJobStatus" "RepoIndexingJobStatus"; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 95460852..c700e242 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -66,6 +66,7 @@ model Repo { jobs RepoIndexingJob[] indexedAt DateTime? /// When the repo was last indexed successfully. indexedCommitHash String? /// The commit hash of the last indexed commit (on HEAD). + latestIndexingJobStatus RepoIndexingJobStatus? /// The status of the latest indexing job. external_id String /// The id of the repo in the external service external_codeHostType CodeHostType /// The type of the external service (e.g., github, gitlab, etc.) diff --git a/packages/db/tools/scripts/inject-repo-data.ts b/packages/db/tools/scripts/inject-repo-data.ts index bb427cbe..609bcdcc 100644 --- a/packages/db/tools/scripts/inject-repo-data.ts +++ b/packages/db/tools/scripts/inject-repo-data.ts @@ -1,7 +1,9 @@ import { Script } from "../scriptRunner"; import { PrismaClient } from "../../dist"; -const NUM_REPOS = 100000; +const NUM_REPOS = 1000; +const NUM_INDEXING_JOBS_PER_REPO = 10000; +const NUM_PERMISSION_JOBS_PER_REPO = 10000; export const injectRepoData: Script = { run: async (prisma: PrismaClient) => { @@ -32,30 +34,60 @@ export const injectRepoData: Script = { }); - console.log(`Creating ${NUM_REPOS} repos...`); + console.log(`Creating ${NUM_REPOS} repos...`); - for (let i = 0; i < NUM_REPOS; i++) { - await prisma.repo.create({ - data: { - name: `test-repo-${i}`, - isFork: false, - isArchived: false, - metadata: {}, - cloneUrl: `https://github.com/test-org/test-repo-${i}`, - webUrl: `https://github.com/test-org/test-repo-${i}`, - orgId, - external_id: `test-repo-${i}`, - external_codeHostType: 'github', - external_codeHostUrl: 'https://github.com', - connections: { - create: { - connectionId: connection.id, - } + const statuses = ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED'] as const; + const indexingJobTypes = ['INDEX', 'CLEANUP'] as const; + + for (let i = 0; i < NUM_REPOS; i++) { + const repo = await prisma.repo.create({ + data: { + name: `test-repo-${i}`, + isFork: false, + isArchived: false, + metadata: {}, + cloneUrl: `https://github.com/test-org/test-repo-${i}`, + webUrl: `https://github.com/test-org/test-repo-${i}`, + orgId, + external_id: `test-repo-${i}`, + external_codeHostType: 'github', + external_codeHostUrl: 'https://github.com', + connections: { + create: { + connectionId: connection.id, } } + } + }); + + for (let j = 0; j < NUM_PERMISSION_JOBS_PER_REPO; j++) { + const status = statuses[Math.floor(Math.random() * statuses.length)]; + await prisma.repoPermissionSyncJob.create({ + data: { + repoId: repo.id, + status, + completedAt: status === 'COMPLETED' || status === 'FAILED' ? new Date() : null, + errorMessage: status === 'FAILED' ? 'Mock error message' : null + } }); } - console.log(`Created ${NUM_REPOS} repos.`); + for (let j = 0; j < NUM_INDEXING_JOBS_PER_REPO; j++) { + const status = statuses[Math.floor(Math.random() * statuses.length)]; + const type = indexingJobTypes[Math.floor(Math.random() * indexingJobTypes.length)]; + await prisma.repoIndexingJob.create({ + data: { + repoId: repo.id, + type, + status, + completedAt: status === 'COMPLETED' || status === 'FAILED' ? new Date() : null, + errorMessage: status === 'FAILED' ? 'Mock indexing error' : null, + metadata: {} + } + }); + } + } + + console.log(`Created ${NUM_REPOS} repos with associated jobs.`); } }; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/repos/components/reposTable.tsx b/packages/web/src/app/[domain]/repos/components/reposTable.tsx index 1dec5c97..8e01dee5 100644 --- a/packages/web/src/app/[domain]/repos/components/reposTable.tsx +++ b/packages/web/src/app/[domain]/repos/components/reposTable.tsx @@ -10,35 +10,31 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { Input } from "@/components/ui/input" +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" import { cn, getCodeHostCommitUrl, getCodeHostIcon, getCodeHostInfoForRepo, getRepoImageSrc } from "@/lib/utils" import { type ColumnDef, - type ColumnFiltersState, - type SortingState, type VisibilityState, flexRender, getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, useReactTable, } from "@tanstack/react-table" import { cva } from "class-variance-authority" -import { ArrowUpDown, ExternalLink, MoreHorizontal, RefreshCwIcon } from "lucide-react" +import { ArrowDown, ArrowUp, ArrowUpDown, ExternalLink, Loader2, MoreHorizontal, RefreshCwIcon } from "lucide-react" import Image from "next/image" import Link from "next/link" -import { useMemo, useState } from "react" +import { useEffect, useRef, useState } from "react" import { getBrowsePath } from "../../browse/hooks/utils" -import { useRouter } from "next/navigation" +import { useRouter, useSearchParams, usePathname } from "next/navigation" import { useToast } from "@/components/hooks/use-toast"; import { DisplayDate } from "../../components/DisplayDate" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { NotificationDot } from "../../components/notificationDot" import { CodeHostType } from "@sourcebot/db" +import { useHotkeys } from "react-hotkeys-hook" // @see: https://v0.app/chat/repo-indexing-status-uhjdDim8OUS @@ -84,15 +80,29 @@ const getStatusBadge = (status: Repo["latestJobStatus"]) => { return {labels[status]} } -export const columns: ColumnDef[] = [ +interface ColumnsContext { + onSortChange: (sortBy: string) => void; + currentSortBy?: string; + currentSortOrder: string; +} + +export const getColumns = (context: ColumnsContext): ColumnDef[] => [ { accessorKey: "displayName", size: 400, - header: ({ column }) => { + header: () => { + const isActive = context.currentSortBy === 'displayName'; + const Icon = isActive + ? (context.currentSortOrder === 'asc' ? ArrowUp : ArrowDown) + : ArrowUpDown; + return ( - ) }, @@ -159,14 +169,19 @@ export const columns: ColumnDef[] = [ { accessorKey: "indexedAt", size: 200, - header: ({ column }) => { + header: () => { + const isActive = context.currentSortBy === 'indexedAt'; + const Icon = isActive + ? (context.currentSortOrder === 'asc' ? ArrowUp : ArrowDown) + : ArrowUpDown; + return ( ) }, @@ -271,46 +286,118 @@ export const columns: ColumnDef[] = [ }, ] -export const ReposTable = ({ data }: { data: Repo[] }) => { - const [sorting, setSorting] = useState([]) - const [columnFilters, setColumnFilters] = useState([]) +interface ReposTableProps { + data: Repo[]; + currentPage: number; + pageSize: number; + totalCount: number; + initialSearch: string; + initialStatus: string; + initialSortBy?: string; + initialSortOrder: string; + stats: { + numCompleted: number + numFailed: number + numPending: number + numInProgress: number + numNoJobs: number + } +} + +export const ReposTable = ({ + data, + currentPage, + pageSize, + totalCount, + initialSearch, + initialStatus, + initialSortBy, + initialSortOrder, + stats, +}: ReposTableProps) => { const [columnVisibility, setColumnVisibility] = useState({}) const [rowSelection, setRowSelection] = useState({}) + const [searchValue, setSearchValue] = useState(initialSearch) + const [isPendingSearch, setIsPendingSearch] = useState(false) + const searchInputRef = useRef(null) const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); const { toast } = useToast(); - const { - numCompleted, - numInProgress, - numPending, - numFailed, - numNoJobs, - } = useMemo(() => { - return { - numCompleted: data.filter((repo) => repo.latestJobStatus === "COMPLETED").length, - numInProgress: data.filter((repo) => repo.latestJobStatus === "IN_PROGRESS").length, - numPending: data.filter((repo) => repo.latestJobStatus === "PENDING").length, - numFailed: data.filter((repo) => repo.latestJobStatus === "FAILED").length, - numNoJobs: data.filter((repo) => repo.latestJobStatus === null).length, + // Focus search box when '/' is pressed + useHotkeys('/', (event) => { + event.preventDefault(); + searchInputRef.current?.focus(); + }); + + // Debounced search effect - only runs when searchValue changes + useEffect(() => { + setIsPendingSearch(true); + const timer = setTimeout(() => { + const params = new URLSearchParams(searchParams.toString()); + if (searchValue) { + params.set('search', searchValue); + } else { + params.delete('search'); + } + params.set('page', '1'); // Reset to page 1 on search + router.replace(`${pathname}?${params.toString()}`); + setIsPendingSearch(false); + }, 300); + + return () => { + clearTimeout(timer); + setIsPendingSearch(false); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchValue]); + + const updateStatusFilter = (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value === 'all') { + params.delete('status'); + } else { + params.set('status', value); + } + params.set('page', '1'); // Reset to page 1 on filter change + router.replace(`${pathname}?${params.toString()}`); + }; + + const handleSortChange = (sortBy: string) => { + const params = new URLSearchParams(searchParams.toString()); + + // Toggle sort order if clicking the same column + if (initialSortBy === sortBy) { + const newOrder = initialSortOrder === 'asc' ? 'desc' : 'asc'; + params.set('sortOrder', newOrder); + } else { + // Default to ascending when changing columns + params.set('sortBy', sortBy); + params.set('sortOrder', 'asc'); } - }, [data]); + + params.set('page', '1'); // Reset to page 1 on sort change + router.replace(`${pathname}?${params.toString()}`); + }; + + const totalPages = Math.ceil(totalCount / pageSize); + + const columns = getColumns({ + onSortChange: handleSortChange, + currentSortBy: initialSortBy, + currentSortOrder: initialSortOrder, + }); const table = useReactTable({ data, columns, - onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: setRowSelection, columnResizeMode: 'onChange', enableColumnResizing: false, state: { - sorting, - columnFilters, columnVisibility, rowSelection, }, @@ -319,28 +406,34 @@ export const ReposTable = ({ data }: { data: Repo[] }) => { return (
- table.getColumn("displayName")?.setFilterValue(event.target.value)} - className="max-w-sm" - /> + + setSearchValue(event.target.value)} + className="ring-0" + /> + {isPendingSearch && ( + + + + )} +
- {table.getFilteredRowModel().rows.length} {data.length > 1 ? 'repositories' : 'repository'} total + {totalCount} {totalCount !== 1 ? 'repositories' : 'repository'} total + {totalPages > 1 && ` • Page ${currentPage} of ${totalPages}`}
-
diff --git a/packages/web/src/app/[domain]/repos/page.tsx b/packages/web/src/app/[domain]/repos/page.tsx index 79c4497b..476228dd 100644 --- a/packages/web/src/app/[domain]/repos/page.tsx +++ b/packages/web/src/app/[domain]/repos/page.tsx @@ -3,30 +3,49 @@ import { ServiceErrorException } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { withOptionalAuthV2 } from "@/withAuthV2"; import { ReposTable } from "./components/reposTable"; -import { RepoIndexingJobStatus } from "@sourcebot/db"; +import { RepoIndexingJobStatus, Prisma } from "@sourcebot/db"; +import z from "zod"; -export default async function ReposPage() { +interface ReposPageProps { + searchParams: Promise<{ + page?: string; + pageSize?: string; + search?: string; + status?: string; + sortBy?: string; + sortOrder?: string; + }>; +} + +export default async function ReposPage({ searchParams }: ReposPageProps) { + const params = await searchParams; + + // Parse pagination parameters with defaults + const page = z.number().int().positive().safeParse(params.page).data ?? 1; + const pageSize = z.number().int().positive().safeParse(params.pageSize).data ?? 5; - const _repos = await getReposWithLatestJob(); - if (isServiceError(_repos)) { - throw new ServiceErrorException(_repos); + // Parse filter parameters + const search = z.string().optional().safeParse(params.search).data ?? ''; + const status = z.enum(['all', 'none', 'COMPLETED', 'IN_PROGRESS', 'PENDING', 'FAILED']).safeParse(params.status).data ?? 'all'; + const sortBy = z.enum(['displayName', 'indexedAt']).safeParse(params.sortBy).data ?? undefined; + const sortOrder = z.enum(['asc', 'desc']).safeParse(params.sortOrder).data ?? 'asc'; + + // Calculate skip for pagination + const skip = (page - 1) * pageSize; + + const _result = await getRepos({ + skip, + take: pageSize, + search, + status, + sortBy, + sortOrder, + }); + if (isServiceError(_result)) { + throw new ServiceErrorException(_result); } - const repos = _repos - .map((repo) => ({ - ...repo, - latestJobStatus: repo.jobs.length > 0 ? repo.jobs[0].status : null, - isFirstTimeIndex: repo.indexedAt === null && repo.jobs.filter((job) => job.status === RepoIndexingJobStatus.PENDING || job.status === RepoIndexingJobStatus.IN_PROGRESS).length > 0, - })) - .sort((a, b) => { - if (a.isFirstTimeIndex && !b.isFirstTimeIndex) { - return -1; - } - if (!a.isFirstTimeIndex && b.isFirstTimeIndex) { - return 1; - } - return a.name.localeCompare(b.name); - }); + const { repos, totalCount, stats } = _result; return ( <> @@ -34,42 +53,129 @@ export default async function ReposPage() {

Repositories

View and manage your code repositories and their indexing status.

- ({ - id: repo.id, - name: repo.name, - displayName: repo.displayName ?? repo.name, - isArchived: repo.isArchived, - isPublic: repo.isPublic, - indexedAt: repo.indexedAt, - createdAt: repo.createdAt, - webUrl: repo.webUrl, - imageUrl: repo.imageUrl, - latestJobStatus: repo.latestJobStatus, - isFirstTimeIndex: repo.isFirstTimeIndex, - codeHostType: repo.external_codeHostType, - indexedCommitHash: repo.indexedCommitHash, - }))} /> + ({ + id: repo.id, + name: repo.name, + displayName: repo.displayName ?? repo.name, + isArchived: repo.isArchived, + isPublic: repo.isPublic, + indexedAt: repo.indexedAt, + createdAt: repo.createdAt, + webUrl: repo.webUrl, + imageUrl: repo.imageUrl, + latestJobStatus: repo.latestIndexingJobStatus, + isFirstTimeIndex: repo.indexedAt === null, + codeHostType: repo.external_codeHostType, + indexedCommitHash: repo.indexedCommitHash, + }))} + currentPage={page} + pageSize={pageSize} + totalCount={totalCount} + initialSearch={search} + initialStatus={status} + initialSortBy={sortBy} + initialSortOrder={sortOrder} + stats={stats} + /> ) } -const getReposWithLatestJob = async () => sew(() => - withOptionalAuthV2(async ({ prisma, org }) => { +interface GetReposParams { + skip: number; + take: number; + search: string; + status: 'all' | 'none' | 'COMPLETED' | 'IN_PROGRESS' | 'PENDING' | 'FAILED'; + sortBy?: 'displayName' | 'indexedAt'; + sortOrder: 'asc' | 'desc'; +} + +const getRepos = async ({ skip, take, search, status, sortBy, sortOrder }: GetReposParams) => sew(() => + withOptionalAuthV2(async ({ prisma }) => { + const whereClause: Prisma.RepoWhereInput = { + ...(search ? { + displayName: { contains: search, mode: 'insensitive' }, + } : {}), + latestIndexingJobStatus: + status === 'all' ? undefined : + status === 'none' ? null : + status + }; + + // Build orderBy clause based on sortBy and sortOrder + const orderByClause: Prisma.RepoOrderByWithRelationInput = {}; + + if (sortBy === 'displayName') { + orderByClause.displayName = sortOrder === 'asc' ? 'asc' : 'desc'; + } else if (sortBy === 'indexedAt') { + orderByClause.indexedAt = sortOrder === 'asc' ? 'asc' : 'desc'; + } else { + // Default to displayName asc + orderByClause.displayName = 'asc'; + } + const repos = await prisma.repo.findMany({ - include: { - jobs: { - orderBy: { - createdAt: 'desc' - }, - take: 1 + skip, + take, + where: whereClause, + orderBy: orderByClause, + }); + + // Calculate total count using the filtered where clause + const totalCount = await prisma.repo.count({ + where: whereClause + }); + + // Status stats + const [ + numCompleted, + numFailed, + numPending, + numInProgress, + numNoJobs + ] = await Promise.all([ + prisma.repo.count({ + where: { + ...whereClause, + latestIndexingJobStatus: RepoIndexingJobStatus.COMPLETED, + } + }), + prisma.repo.count({ + where: { + ...whereClause, + latestIndexingJobStatus: RepoIndexingJobStatus.FAILED, } - }, - orderBy: { - name: 'asc' - }, - where: { - orgId: org.id, + }), + prisma.repo.count({ + where: { + ...whereClause, + latestIndexingJobStatus: RepoIndexingJobStatus.PENDING, + } + }), + prisma.repo.count({ + where: { + ...whereClause, + latestIndexingJobStatus: RepoIndexingJobStatus.IN_PROGRESS, + } + }), + prisma.repo.count({ + where: { + ...whereClause, + latestIndexingJobStatus: null, + } + }), + ]) + + return { + repos, + totalCount, + stats: { + numCompleted, + numFailed, + numPending, + numInProgress, + numNoJobs, } - }); - return repos; + }; })); \ No newline at end of file diff --git a/packages/web/src/components/ui/input-group.tsx b/packages/web/src/components/ui/input-group.tsx new file mode 100644 index 00000000..44acef38 --- /dev/null +++ b/packages/web/src/components/ui/input-group.tsx @@ -0,0 +1,170 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
textarea]:h-auto", + + // Variants based on alignment. + "has-[>[data-align=inline-start]]:[&>input]:pl-2", + "has-[>[data-align=inline-end]]:[&>input]:pr-2", + "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", + "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", + + // Focus state. + "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]", + + // Error state. + "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", + + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", + { + variants: { + align: { + "inline-start": + "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", + "inline-end": + "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", + "block-start": + "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5", + "block-end": + "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return + } + e.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +const inputGroupButtonVariants = cva( + "text-sm shadow-none flex gap-2 items-center", + { + variants: { + size: { + xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2", + sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size"> & + VariantProps) { + return ( +