From 1631ed36e9deeff3b1e72f318fb6140dd8e337cb Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 16 Dec 2025 16:10:47 -0800 Subject: [PATCH 1/3] fix --- packages/db/tools/scripts/inject-repo-data.ts | 72 +++++--- .../[domain]/repos/components/reposTable.tsx | 151 +++++++++++----- packages/web/src/app/[domain]/repos/page.tsx | 139 +++++++++----- .../web/src/components/ui/input-group.tsx | 170 ++++++++++++++++++ 4 files changed, 423 insertions(+), 109 deletions(-) create mode 100644 packages/web/src/components/ui/input-group.tsx 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..2b9b67c5 100644 --- a/packages/web/src/app/[domain]/repos/components/reposTable.tsx +++ b/packages/web/src/app/[domain]/repos/components/reposTable.tsx @@ -10,35 +10,33 @@ 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 { 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 @@ -271,46 +269,87 @@ export const columns: ColumnDef[] = [ }, ] -export const ReposTable = ({ data }: { data: Repo[] }) => { +interface ReposTableProps { + data: Repo[]; + currentPage: number; + pageSize: number; + totalCount: number; + initialSearch: string; + initialStatus: string; +} + +export const ReposTable = ({ + data, + currentPage, + pageSize, + totalCount, + initialSearch, + initialStatus, +}: ReposTableProps) => { const [sorting, setSorting] = useState([]) - const [columnFilters, setColumnFilters] = useState([]) 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); } - }, [data]); + params.set('page', '1'); // Reset to page 1 on filter change + router.replace(`${pathname}?${params.toString()}`); + }; + + const totalPages = Math.ceil(totalCount / pageSize); 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 +358,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..9e17a5e0 100644 --- a/packages/web/src/app/[domain]/repos/page.tsx +++ b/packages/web/src/app/[domain]/repos/page.tsx @@ -3,30 +3,48 @@ 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"; -export default async function ReposPage() { +interface ReposPageProps { + searchParams: Promise<{ + page?: string; + pageSize?: string; + search?: string; + status?: string; + }>; +} + +export default async function ReposPage({ searchParams }: ReposPageProps) { + const params = await searchParams; + + // Parse pagination parameters with defaults + const page = parseInt(params.page ?? '1', 10); + const pageSize = parseInt(params.pageSize ?? '5', 10); + + // Parse filter parameters + const search = params.search ?? ''; + const status = params.status ?? 'all'; - const _repos = await getReposWithLatestJob(); - if (isServiceError(_repos)) { - throw new ServiceErrorException(_repos); + // Calculate skip for pagination + const skip = (page - 1) * pageSize; + + const _result = await getReposWithLatestJob({ + skip, + take: pageSize, + search, + status, + }); + 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: _repos, totalCount } = _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: { status: RepoIndexingJobStatus }) => job.status === RepoIndexingJobStatus.PENDING || job.status === RepoIndexingJobStatus.IN_PROGRESS).length > 0, + })); return ( <> @@ -34,28 +52,55 @@ 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.latestJobStatus, + isFirstTimeIndex: repo.isFirstTimeIndex, + codeHostType: repo.external_codeHostType, + indexedCommitHash: repo.indexedCommitHash, + }))} + currentPage={page} + pageSize={pageSize} + totalCount={totalCount} + initialSearch={search} + initialStatus={status} + /> ) } -const getReposWithLatestJob = async () => sew(() => +interface GetReposParams { + skip: number; + take: number; + search: string; + status: string; +} + +const getReposWithLatestJob = async ({ skip, take, search, status }: GetReposParams) => sew(() => withOptionalAuthV2(async ({ prisma, org }) => { + const whereClause: Prisma.RepoWhereInput = { + orgId: org.id, + ...(search && { + OR: [ + { name: { contains: search, mode: 'insensitive' } }, + { displayName: { contains: search, mode: 'insensitive' } } + ] + }), + }; + const repos = await prisma.repo.findMany({ + skip, + take, + where: whereClause, include: { jobs: { orderBy: { @@ -64,12 +109,20 @@ const getReposWithLatestJob = async () => sew(() => take: 1 } }, - orderBy: { - name: 'asc' - }, - where: { - orgId: org.id, - } + orderBy: [ + { indexedAt: 'asc' }, // null first (never indexed) + { name: 'asc' } // then alphabetically + ] + }); + + // Calculate total count using the filtered where clause + const totalCount = await prisma.repo.count({ + where: whereClause }); - return repos; + + + return { + repos, + totalCount + }; })); \ 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 ( +