diff --git a/package.json b/package.json index 7e4b0476..e9bdc94d 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,13 @@ "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-navigation-menu": "^1.2.0", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@replit/codemirror-vim": "^6.2.1", "@tanstack/react-query": "^5.53.3", + "@tanstack/react-table": "^8.20.5", "@uiw/react-codemirror": "^4.23.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/public/sb_logo_dark_small.png b/public/sb_logo_dark_small.png new file mode 100644 index 00000000..8aae8a55 Binary files /dev/null and b/public/sb_logo_dark_small.png differ diff --git a/public/sb_logo_light_small.png b/public/sb_logo_light_small.png new file mode 100644 index 00000000..90704ff3 Binary files /dev/null and b/public/sb_logo_light_small.png differ diff --git a/src/app/api/(server)/repos/route.ts b/src/app/api/(server)/repos/route.ts new file mode 100644 index 00000000..def073c1 --- /dev/null +++ b/src/app/api/(server)/repos/route.ts @@ -0,0 +1,8 @@ +'use server'; + +import { listRepositories } from "@/lib/server/searchService"; + +export const GET = async () => { + const response = await listRepositories(); + return Response.json(response); +} \ No newline at end of file diff --git a/src/app/navigationMenu.tsx b/src/app/navigationMenu.tsx new file mode 100644 index 00000000..2f20319e --- /dev/null +++ b/src/app/navigationMenu.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { Button } from "@/components/ui/button"; +import { NavigationMenu as NavigationMenuBase, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu"; +import Link from "next/link"; +import { GitHubLogoIcon } from "@radix-ui/react-icons"; +import { SettingsDropdown } from "./settingsDropdown"; +import { Separator } from "@/components/ui/separator"; +import Image from "next/image"; +import logoDark from "../../public/sb_logo_dark_small.png"; +import logoLight from "../../public/sb_logo_light_small.png"; +import { useRouter } from "next/navigation"; + +const SOURCEBOT_GITHUB_URL = "https://github.com/TaqlaAI/sourcebot"; + +export const NavigationMenu = () => { + const router = useRouter(); + + return ( +
+
+
+
{ + router.push("/"); + }} + > + {"Sourcebot + {"Sourcebot +
+ + + + + + + Search + + + + + + + Repositories + + + + + +
+ +
+ + +
+
+ +
+ + + ) +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 6dd04265..32c7da01 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,32 +3,15 @@ import Image from "next/image"; import logoDark from "../../public/sb_logo_dark_large.png"; import logoLight from "../../public/sb_logo_light_large.png"; +import { NavigationMenu } from "./navigationMenu"; import { SearchBar } from "./searchBar"; -import { SettingsDropdown } from "./settingsDropdown"; -import { GitHubLogoIcon } from "@radix-ui/react-icons"; -import { Button } from "@/components/ui/button"; - -const SOURCEBOT_GITHUB_URL = "https://github.com/TaqlaAI/sourcebot"; export default function Home() { - return (
{/* TopBar */} -
-
- - -
-
+ +
[] = [ + { + accessorKey: "name", + header: "Name", + }, + { + accessorKey: "branches", + header: "Branches", + cell: ({ row }) => { + const branches = row.original.branches; + return ( +
+ {branches.map(({ name, version }, index) => { + const shortVersion = version.substring(0, 8); + return ( + + {name} + @ + { + const url = row.original.commitUrlTemplate.replace("{{.Version}}", version); + window.open(url, "_blank"); + }} + > + {shortVersion} + + + ) + })} +
+ ); + }, + }, + { + accessorKey: "shardCount", + header: ({ column }) => createSortHeader("Shard Count", column), + cell: ({ row }) => ( +
{row.original.shardCount}
+ ) + }, + { + accessorKey: "indexedFiles", + header: ({ column }) => createSortHeader("Indexed Files", column), + cell: ({ row }) => ( +
{row.original.indexedFiles}
+ ) + }, + { + accessorKey: "indexSizeBytes", + header: ({ column }) => createSortHeader("Index Size", column), + cell: ({ row }) => { + const size = getFormattedSizeString(row.original.indexSizeBytes); + return
{size}
; + } + }, + { + accessorKey: "repoSizeBytes", + header: ({ column }) => createSortHeader("Repository Size", column), + cell: ({ row }) => { + const size = getFormattedSizeString(row.original.repoSizeBytes); + return
{size}
; + } + }, + { + accessorKey: "lastIndexed", + header: ({ column }) => createSortHeader("Last Indexed", column), + cell: ({ row }) => { + const date = new Date(row.original.lastIndexed); + return date.toISOString(); + } + }, + { + accessorKey: "latestCommit", + header: ({ column }) => createSortHeader("Latest Commit", column), + cell: ({ row }) => { + const date = new Date(row.original.latestCommit); + return date.toISOString(); + } + } +] + +const getFormattedSizeString = (bytes: number): string => { + let size = bytes; + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)} ${units[unitIndex]}`; +} + +const createSortHeader = (name: string, column: Column) => { + return ( + + ) +} \ No newline at end of file diff --git a/src/app/repos/page.tsx b/src/app/repos/page.tsx new file mode 100644 index 00000000..b5b91be9 --- /dev/null +++ b/src/app/repos/page.tsx @@ -0,0 +1,43 @@ +import { NavigationMenu } from "../navigationMenu"; +import { DataTable } from "@/components/ui/data-table"; +import { columns, RepositoryColumnInfo } from "./columns"; +import { listRepositories } from "@/lib/server/searchService"; +import { isServiceError } from "@/lib/utils"; + +export default async function ReposPage() { + const _repos = await listRepositories(); + + if (isServiceError(_repos)) { + return
Error fetching repositories
; + } + const repos = _repos.List.Repos.map((repo): RepositoryColumnInfo => { + return { + name: repo.Repository.Name, + branches: repo.Repository.Branches.map((branch) => { + return { + name: branch.Name, + version: branch.Version, + } + }), + repoSizeBytes: repo.Stats.ContentBytes, + indexSizeBytes: repo.Stats.IndexBytes, + shardCount: repo.Stats.Shards, + lastIndexed: repo.IndexMetadata.IndexTime, + latestCommit: repo.Repository.LatestCommitDate, + indexedFiles: repo.Stats.Documents, + commitUrlTemplate: repo.Repository.CommitURLTemplate, + } + }); + + return ( +
+ + +
+ ) +} \ No newline at end of file diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx new file mode 100644 index 00000000..a328d833 --- /dev/null +++ b/src/components/ui/data-table.tsx @@ -0,0 +1,136 @@ +"use client" + +import { + ColumnDef, + ColumnFiltersState, + SortingState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import * as React from "react" + + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] + searchKey: string + searchPlaceholder?: string +} + +export function DataTable({ + columns, + data, + searchKey, + searchPlaceholder, +}: DataTableProps) { + const [sorting, setSorting] = React.useState([]) + const [columnFilters, setColumnFilters] = React.useState( + [] + ) + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + state: { + sorting, + columnFilters, + }, + }) + + return ( +
+
+ + table.getColumn(searchKey)?.setFilterValue(event.target.value) + } + className="max-w-sm" + /> +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+ + +
+
+ ) +} diff --git a/src/components/ui/navigation-menu.tsx b/src/components/ui/navigation-menu.tsx new file mode 100644 index 00000000..1419f566 --- /dev/null +++ b/src/components/ui/navigation-menu.tsx @@ -0,0 +1,128 @@ +import * as React from "react" +import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" +import { cva } from "class-variance-authority" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const NavigationMenu = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + +)) +NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName + +const NavigationMenuList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName + +const NavigationMenuItem = NavigationMenuPrimitive.Item + +const navigationMenuTriggerStyle = cva( + "group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50" +) + +const NavigationMenuTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children}{" "} + +)) +NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName + +const NavigationMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName + +const NavigationMenuLink = NavigationMenuPrimitive.Link + +const NavigationMenuViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ +
+)) +NavigationMenuViewport.displayName = + NavigationMenuPrimitive.Viewport.displayName + +const NavigationMenuIndicator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +
+ +)) +NavigationMenuIndicator.displayName = + NavigationMenuPrimitive.Indicator.displayName + +export { + navigationMenuTriggerStyle, + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuIndicator, + NavigationMenuViewport, +} diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 00000000..7f3502f8 --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,117 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index c96fed73..5da6ec65 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -17,11 +17,11 @@ export type SearchResultLocation = z.infer; // @see : https://github.com/TaqlaAI/zoekt/blob/main/api.go#L212 const locationSchema = z.object({ - // 0-based byte offset from the beginning of the file + // 0-based byte offset from the beginning of the file ByteOffset: z.number(), - // 1-based line number from the beginning of the file + // 1-based line number from the beginning of the file LineNumber: z.number(), - // 1-based column number (in runes) from the beginning of line + // 1-based column number (in runes) from the beginning of line Column: z.number(), }); @@ -66,4 +66,63 @@ export type FileSourceResponse = z.infer; export const fileSourceResponseSchema = z.object({ source: z.string(), -}); \ No newline at end of file +}); + + +export type ListRepositoriesResponse = z.infer; + +// @see : https://github.com/TaqlaAI/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L728 +export const statsSchema = z.object({ + Repos: z.number(), + Shards: z.number(), + Documents: z.number(), + IndexBytes: z.number(), + ContentBytes: z.number(), + NewLinesCount: z.number(), + DefaultBranchNewLinesCount: z.number(), + OtherBranchesNewLinesCount: z.number(), +}); + +// @see : https://github.com/TaqlaAI/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L716 +export const indexMetadataSchema = z.object({ + IndexFormatVersion: z.number(), + IndexFeatureVersion: z.number(), + IndexMinReaderVersion: z.number(), + IndexTime: z.string(), + PlainASCII: z.boolean(), + LanguageMap: z.record(z.string(), z.number()), + ZoektVersion: z.string(), + ID: z.string(), +}); + +// @see : https://github.com/TaqlaAI/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L555 +export const repositorySchema = z.object({ + Name: z.string(), + URL: z.string(), + Source: z.string(), + Branches: z.array(z.object({ + Name: z.string(), + Version: z.string(), + })), + CommitURLTemplate: z.string(), + FileURLTemplate: z.string(), + LineFragmentTemplate: z.string(), + RawConfig: z.record(z.string(), z.string()), + Rank: z.number(), + IndexOptions: z.string(), + HasSymbols: z.boolean(), + Tombstone: z.boolean(), + LatestCommitDate: z.string(), + FileTombstones: z.string().optional(), +}); + +export const listRepositoriesResponseSchema = z.object({ + List: z.object({ + Repos: z.array(z.object({ + Repository: repositorySchema, + IndexMetadata: indexMetadataSchema, + Stats: statsSchema, + })), + Stats: statsSchema, + }) +}); diff --git a/src/lib/server/searchService.ts b/src/lib/server/searchService.ts index 924363aa..330408ba 100644 --- a/src/lib/server/searchService.ts +++ b/src/lib/server/searchService.ts @@ -1,9 +1,9 @@ +import escapeStringRegexp from "escape-string-regexp"; import { SHARD_MAX_MATCH_COUNT, TOTAL_MAX_MATCH_COUNT } from "../environment"; -import { FileSourceRequest, FileSourceResponse, SearchRequest, SearchResponse, searchResponseSchema } from "../schemas"; -import { fileNotFound, invalidZoektResponse, schemaValidationError, ServiceError, unexpectedError } from "../serviceError"; +import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, listRepositoriesResponseSchema, SearchRequest, SearchResponse, searchResponseSchema } from "../schemas"; +import { fileNotFound, invalidZoektResponse, ServiceError, unexpectedError } from "../serviceError"; import { isServiceError } from "../utils"; import { zoektFetch } from "./zoektClient"; -import escapeStringRegexp from "escape-string-regexp"; export const search = async ({ query, numResults, whole }: SearchRequest): Promise => { const body = JSON.stringify({ @@ -63,4 +63,30 @@ export const getFileSource = async ({ fileName, repository }: FileSourceRequest) return { source } +} + +export const listRepositories = async (): Promise => { + const body = JSON.stringify({ + opts: { + Field: 0, + } + }); + const listResponse = await zoektFetch({ + path: "/api/list", + body, + method: "POST" + }); + + if (!listResponse.ok) { + return invalidZoektResponse(listResponse); + } + + const listBody = await listResponse.json(); + const parsedListResponse = listRepositoriesResponseSchema.safeParse(listBody); + if (!parsedListResponse.success) { + console.error(`Failed to parse zoekt response. Error: ${parsedListResponse.error}`); + return unexpectedError(`Something went wrong while parsing the response from zoekt`); + } + + return parsedListResponse.data; } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index ad3e17ff..bfca08f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -582,6 +582,26 @@ aria-hidden "^1.1.1" react-remove-scroll "2.5.7" +"@radix-ui/react-navigation-menu@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.0.tgz#884c9b9fd141cc5db257bd3f6bf3b84e349c6617" + integrity sha512-OQ8tcwAOR0DhPlSY3e4VMXeHiol7la4PPdJWhhwJiJA+NLX0SaCaonOkRnI3gCDHoZ7Fo7bb/G6q25fRM2Y+3Q== + dependencies: + "@radix-ui/primitive" "1.1.0" + "@radix-ui/react-collection" "1.1.0" + "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/react-context" "1.1.0" + "@radix-ui/react-direction" "1.1.0" + "@radix-ui/react-dismissable-layer" "1.1.0" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-presence" "1.1.0" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-use-previous" "1.1.0" + "@radix-ui/react-visually-hidden" "1.1.0" + "@radix-ui/react-popper@1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.0.tgz#a3e500193d144fe2d8f5d5e60e393d64111f2a7a" @@ -689,6 +709,11 @@ resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz#3c2c8ce04827b26a39e442ff4888d9212268bd27" integrity sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w== +"@radix-ui/react-use-previous@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz#d4dd37b05520f1d996a384eb469320c2ada8377c" + integrity sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og== + "@radix-ui/react-use-rect@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz#13b25b913bd3e3987cc9b073a1a164bb1cf47b88" @@ -703,6 +728,13 @@ dependencies: "@radix-ui/react-use-layout-effect" "1.1.0" +"@radix-ui/react-visually-hidden@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz#ad47a8572580f7034b3807c8e6740cd41038a5a2" + integrity sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ== + dependencies: + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/rect@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.0.tgz#f817d1d3265ac5415dadc67edab30ae196696438" @@ -743,6 +775,18 @@ dependencies: "@tanstack/query-core" "5.53.3" +"@tanstack/react-table@^8.20.5": + version "8.20.5" + resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.20.5.tgz#19987d101e1ea25ef5406dce4352cab3932449d8" + integrity sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA== + dependencies: + "@tanstack/table-core" "8.20.5" + +"@tanstack/table-core@8.20.5": + version "8.20.5" + resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.20.5.tgz#3974f0b090bed11243d4107283824167a395cf1d" + integrity sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg== + "@types/json5@^0.0.29": version "0.0.29" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"