From d6544086e74ac2c93e40e913e63a24d03caf831e Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 26 Nov 2024 21:49:41 -0800 Subject: [PATCH] Icon & link support for self-hosted repositories (#93) --- CHANGELOG.md | 1 + .../src/app/components/repositoryCarousel.tsx | 10 +-- packages/web/src/app/repos/columns.tsx | 12 +-- .../web/src/app/repos/repositoryTable.tsx | 1 + .../components/codePreviewPanel/index.tsx | 15 +++- .../search/components/filterPanel/entry.tsx | 17 +--- .../search/components/filterPanel/index.tsx | 50 ++++++++--- .../searchResultsPanel/fileMatchContainer.tsx | 22 +++-- .../components/searchResultsPanel/index.tsx | 5 +- packages/web/src/app/search/page.tsx | 37 ++++++++- packages/web/src/lib/schemas.ts | 1 + packages/web/src/lib/utils.ts | 82 ++++++++----------- 12 files changed, 154 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0118e8d..5a064699 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 ### Added - Added file suggestions as a suggestion type. ([#88](https://github.com/sourcebot-dev/sourcebot/pull/88)) +- Added icon and link support for self-hosted repositories. ([#93](https://github.com/sourcebot-dev/sourcebot/pull/93)) ## [2.5.0] - 2024-11-22 diff --git a/packages/web/src/app/components/repositoryCarousel.tsx b/packages/web/src/app/components/repositoryCarousel.tsx index 0969ff81..3d6fa8a2 100644 --- a/packages/web/src/app/components/repositoryCarousel.tsx +++ b/packages/web/src/app/components/repositoryCarousel.tsx @@ -56,8 +56,8 @@ interface RepositoryBadgeProps { const RepositoryBadge = ({ repo }: RepositoryBadgeProps) => { - const { repoIcon, repoName, repoLink } = (() => { - const info = getRepoCodeHostInfo(repo.Name); + const { repoIcon, displayName, repoLink } = (() => { + const info = getRepoCodeHostInfo(repo); if (info) { return { @@ -66,14 +66,14 @@ const RepositoryBadge = ({ alt={info.costHostName} className={`w-4 h-4 ${info.iconClassName}`} />, - repoName: info.repoName, + displayName: info.displayName, repoLink: info.repoLink, } } return { repoIcon: , - repoName: repo.Name, + displayName: repo.Name, repoLink: undefined, } })(); @@ -91,7 +91,7 @@ const RepositoryBadge = ({ > {repoIcon} - {repoName} + {displayName} ) diff --git a/packages/web/src/app/repos/columns.tsx b/packages/web/src/app/repos/columns.tsx index d9c78bf2..a239dce8 100644 --- a/packages/web/src/app/repos/columns.tsx +++ b/packages/web/src/app/repos/columns.tsx @@ -1,7 +1,6 @@ 'use client'; import { Button } from "@/components/ui/button"; -import { getRepoCodeHostInfo } from "@/lib/utils"; import { Column, ColumnDef } from "@tanstack/react-table" import { ArrowUpDown } from "lucide-react" import prettyBytes from "pretty-bytes"; @@ -19,6 +18,7 @@ export type RepositoryColumnInfo = { lastIndexed: string; latestCommit: string; commitUrlTemplate: string; + url: string; } export const columns: ColumnDef[] = [ @@ -27,14 +27,16 @@ export const columns: ColumnDef[] = [ header: "Name", cell: ({ row }) => { const repo = row.original; - const info = getRepoCodeHostInfo(repo.name); + const url = repo.url; + // local repositories will have a url of 0 length + const isRemoteRepo = url.length === 0; return (
{ - if (info?.repoLink) { - window.open(info.repoLink, "_blank"); + if (!isRemoteRepo) { + window.open(url, "_blank"); } }} > diff --git a/packages/web/src/app/repos/repositoryTable.tsx b/packages/web/src/app/repos/repositoryTable.tsx index 81ec5d11..b6a185a1 100644 --- a/packages/web/src/app/repos/repositoryTable.tsx +++ b/packages/web/src/app/repos/repositoryTable.tsx @@ -26,6 +26,7 @@ export const RepositoryTable = async () => { latestCommit: repo.Repository.LatestCommitDate, indexedFiles: repo.Stats.Documents, commitUrlTemplate: repo.Repository.CommitURLTemplate, + url: repo.Repository.URL, } }).sort((a, b) => { return new Date(b.lastIndexed).getTime() - new Date(a.lastIndexed).getTime(); diff --git a/packages/web/src/app/search/components/codePreviewPanel/index.tsx b/packages/web/src/app/search/components/codePreviewPanel/index.tsx index 2b6e1cc4..30ae2f43 100644 --- a/packages/web/src/app/search/components/codePreviewPanel/index.tsx +++ b/packages/web/src/app/search/components/codePreviewPanel/index.tsx @@ -1,7 +1,7 @@ 'use client'; import { fetchFileSource } from "@/app/api/(client)/client"; -import { getCodeHostFilePreviewLink, base64Decode } from "@/lib/utils"; +import { base64Decode } from "@/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { CodePreview, CodePreviewFile } from "./codePreview"; import { SearchResultFile } from "@/lib/types"; @@ -11,6 +11,7 @@ interface CodePreviewPanelProps { onClose: () => void; selectedMatchIndex: number; onSelectedMatchIndexChange: (index: number) => void; + repoUrlTemplates: Record; } export const CodePreviewPanel = ({ @@ -18,6 +19,7 @@ export const CodePreviewPanel = ({ onClose, selectedMatchIndex, onSelectedMatchIndexChange, + repoUrlTemplates, }: CodePreviewPanelProps) => { const { data: file } = useQuery({ @@ -37,8 +39,15 @@ export const CodePreviewPanel = ({ branch, }) .then(({ source }) => { - // @todo : refector this to use the templates provided by zoekt. - const link = getCodeHostFilePreviewLink(fileMatch.Repository, fileMatch.FileName, branch); + const link = (() => { + const template = repoUrlTemplates[fileMatch.Repository]; + if (!template) { + return undefined; + } + return template + .replace("{{.Version}}", branch ?? "HEAD") + .replace("{{.Path}}", fileMatch.FileName); + })(); const decodedSource = base64Decode(source); diff --git a/packages/web/src/app/search/components/filterPanel/entry.tsx b/packages/web/src/app/search/components/filterPanel/entry.tsx index ff67d4cf..bc6f7460 100644 --- a/packages/web/src/app/search/components/filterPanel/entry.tsx +++ b/packages/web/src/app/search/components/filterPanel/entry.tsx @@ -2,16 +2,13 @@ import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"; import clsx from "clsx"; -import Image from "next/image"; export type Entry = { key: string; displayName: string; count: number; isSelected: boolean; - icon?: string; - iconAltText?: string; - iconClassName?: string; + Icon?: React.ReactNode; } interface EntryProps { @@ -22,11 +19,9 @@ interface EntryProps { export const Entry = ({ entry: { isSelected, - icon, - iconAltText, - iconClassName, displayName, count, + Icon, }, onClicked, }: EntryProps) => { @@ -42,13 +37,7 @@ export const Entry = ({ onClick={() => onClicked()} >
- {icon ? ( - {iconAltText - ) : ( + {Icon ? Icon : ( )}

{displayName}

diff --git a/packages/web/src/app/search/components/filterPanel/index.tsx b/packages/web/src/app/search/components/filterPanel/index.tsx index 5f6edc96..f7caa224 100644 --- a/packages/web/src/app/search/components/filterPanel/index.tsx +++ b/packages/web/src/app/search/components/filterPanel/index.tsx @@ -1,20 +1,24 @@ 'use client'; -import { SearchResultFile } from "@/lib/types"; -import { getRepoCodeHostInfo } from "@/lib/utils"; +import { Repository, SearchResultFile } from "@/lib/types"; +import { cn, getRepoCodeHostInfo } from "@/lib/utils"; import { SetStateAction, useCallback, useEffect, useState } from "react"; import { Entry } from "./entry"; import { Filter } from "./filter"; import { getLanguageIcon } from "./languageIcons"; +import Image from "next/image"; +import { LaptopIcon, QuestionMarkCircledIcon } from "@radix-ui/react-icons"; interface FilePanelProps { matches: SearchResultFile[]; onFilterChanged: (filteredMatches: SearchResultFile[]) => void, + repoMetadata: Record; } export const FilterPanel = ({ matches, onFilterChanged, + repoMetadata, }: FilePanelProps) => { const [repos, setRepos] = useState>({}); const [languages, setLanguages] = useState>({}); @@ -24,19 +28,28 @@ export const FilterPanel = ({ "Repository", matches, (key) => { - const info = getRepoCodeHostInfo(key); + const repo: Repository | undefined = repoMetadata[key]; + const info = getRepoCodeHostInfo(repo); + const Icon = info ? ( + {info.costHostName} + ) : ( + + ); + return { key, - displayName: info?.repoName ?? key, + displayName: info?.displayName ?? key, count: 0, isSelected: false, - icon: info?.icon, - iconAltText: info?.costHostName, - iconClassName: info?.iconClassName, + Icon, }; } ); - + setRepos(_repos); }, [matches, setRepos]); @@ -45,12 +58,23 @@ export const FilterPanel = ({ "Language", matches, (key) => { + const iconSrc = getLanguageIcon(key); + const Icon = iconSrc ? ( + {key} + ) : ( + + ); + return { key, displayName: key, count: 0, isSelected: false, - icon: getLanguageIcon(key), + Icon: Icon, } satisfies Entry; } ) @@ -85,10 +109,10 @@ export const FilterPanel = ({ ); const filteredMatches = matches.filter((match) => - ( - (selectedRepos.size === 0 ? true : selectedRepos.has(match.Repository)) && - (selectedLanguages.size === 0 ? true : selectedLanguages.has(match.Language)) - ) + ( + (selectedRepos.size === 0 ? true : selectedRepos.has(match.Repository)) && + (selectedLanguages.size === 0 ? true : selectedLanguages.has(match.Language)) + ) ); onFilterChanged(filteredMatches); diff --git a/packages/web/src/app/search/components/searchResultsPanel/fileMatchContainer.tsx b/packages/web/src/app/search/components/searchResultsPanel/fileMatchContainer.tsx index f7c5c187..82d39c55 100644 --- a/packages/web/src/app/search/components/searchResultsPanel/fileMatchContainer.tsx +++ b/packages/web/src/app/search/components/searchResultsPanel/fileMatchContainer.tsx @@ -3,10 +3,10 @@ import { getRepoCodeHostInfo } from "@/lib/utils"; import { useCallback, useMemo } from "react"; import Image from "next/image"; -import { DoubleArrowDownIcon, DoubleArrowUpIcon, FileIcon } from "@radix-ui/react-icons"; +import { DoubleArrowDownIcon, DoubleArrowUpIcon, LaptopIcon } from "@radix-ui/react-icons"; import clsx from "clsx"; import { Separator } from "@/components/ui/separator"; -import { SearchResultFile } from "@/lib/types"; +import { Repository, SearchResultFile } from "@/lib/types"; import { FileMatch } from "./fileMatch"; export const MAX_MATCHES_TO_PREVIEW = 3; @@ -18,6 +18,7 @@ interface FileMatchContainerProps { showAllMatches: boolean; onShowAllMatchesButtonClicked: () => void; isBranchFilteringEnabled: boolean; + repoMetadata: Record; } export const FileMatchContainer = ({ @@ -27,6 +28,7 @@ export const FileMatchContainer = ({ showAllMatches, onShowAllMatchesButtonClicked, isBranchFilteringEnabled, + repoMetadata, }: FileMatchContainerProps) => { const matchCount = useMemo(() => { @@ -59,11 +61,13 @@ export const FileMatchContainer = ({ return null; }, [matches]); - const { repoIcon, repoName, repoLink } = useMemo(() => { - const info = getRepoCodeHostInfo(file.Repository); + const { repoIcon, displayName, repoLink } = useMemo(() => { + const repo: Repository | undefined = repoMetadata[file.Repository]; + const info = getRepoCodeHostInfo(repo); + if (info) { return { - repoName: info.repoName, + displayName: info.displayName, repoLink: info.repoLink, repoIcon: + repoIcon: } - }, [file]); + }, [file.Repository, repoMetadata]); const isMoreContentButtonVisible = useMemo(() => { return matchCount > MAX_MATCHES_TO_PREVIEW; @@ -122,7 +126,7 @@ export const FileMatchContainer = ({ } }} > - {repoName} + {displayName} {isBranchFilteringEnabled && branches.length > 0 && ( void; isBranchFilteringEnabled: boolean; + repoMetadata: Record; } const ESTIMATED_LINE_HEIGHT_PX = 20; @@ -25,6 +26,7 @@ export const SearchResultsPanel = ({ isLoadMoreButtonVisible, onLoadMoreButtonClicked, isBranchFilteringEnabled, + repoMetadata, }: SearchResultsPanelProps) => { const parentRef = useRef(null); const [showAllMatchesStates, setShowAllMatchesStates] = useState(Array(fileMatches.length).fill(false)); @@ -148,6 +150,7 @@ export const SearchResultsPanel = ({ onShowAllMatchesButtonClicked(virtualRow.index); }} isBranchFilteringEnabled={isBranchFilteringEnabled} + repoMetadata={repoMetadata} />
))} diff --git a/packages/web/src/app/search/page.tsx b/packages/web/src/app/search/page.tsx index 3dcbe366..5eb8ad5a 100644 --- a/packages/web/src/app/search/page.tsx +++ b/packages/web/src/app/search/page.tsx @@ -8,7 +8,7 @@ import { import { Separator } from "@/components/ui/separator"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; -import { SearchQueryParams, SearchResultFile } from "@/lib/types"; +import { Repository, SearchQueryParams, SearchResultFile } from "@/lib/types"; import { createPathWithQueryParams } from "@/lib/utils"; import { SymbolIcon } from "@radix-ui/react-icons"; import { useQuery } from "@tanstack/react-query"; @@ -17,7 +17,7 @@ import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import logoDark from "../../../public/sb_logo_dark.png"; import logoLight from "../../../public/sb_logo_light.png"; -import { search } from "../api/(client)/client"; +import { getRepos, search } from "../api/(client)/client"; import { SearchBar } from "../components/searchBar"; import { SettingsDropdown } from "../components/settingsDropdown"; import { CodePreviewPanel } from "./components/codePreviewPanel"; @@ -45,6 +45,26 @@ export default function SearchPage() { refetchOnWindowFocus: false, }); + // Use the /api/repos endpoint to get a useful list of + // repository metadata (like host type, repo name, etc.) + // Convert this into a map of repo name to repo metadata + // for easy lookup. + const { data: repoMetadata } = useQuery({ + queryKey: ["repos"], + queryFn: () => getRepos(), + select: (data): Record => + data.List.Repos + .map(r => r.Repository) + .reduce( + (acc, repo) => ({ + ...acc, + [repo.Name]: repo, + }), + {}, + ), + refetchOnWindowFocus: false, + }); + useEffect(() => { if (!searchResponse) { return; @@ -77,13 +97,14 @@ export default function SearchPage() { }); }, [captureEvent, searchResponse]); - const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled } = useMemo(() => { + const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled, repoUrlTemplates } = useMemo(() => { if (!searchResponse) { return { fileMatches: [], searchDurationMs: 0, totalMatchCount: 0, isBranchFilteringEnabled: false, + repoUrlTemplates: {}, }; } @@ -108,6 +129,7 @@ export default function SearchPage() { searchDurationMs: Math.round(searchResponse.Result.Duration / 1000000), totalMatchCount: searchResponse.Result.MatchCount, isBranchFilteringEnabled, + repoUrlTemplates: searchResponse.Result.RepoURLs, } }, [searchResponse]); @@ -202,6 +224,8 @@ export default function SearchPage() { isMoreResultsButtonVisible={isMoreResultsButtonVisible} onLoadMoreResults={onLoadMoreResults} isBranchFilteringEnabled={isBranchFilteringEnabled} + repoUrlTemplates={repoUrlTemplates} + repoMetadata={repoMetadata ?? {}} /> )}
@@ -213,6 +237,8 @@ interface PanelGroupProps { isMoreResultsButtonVisible?: boolean; onLoadMoreResults: () => void; isBranchFilteringEnabled: boolean; + repoUrlTemplates: Record; + repoMetadata: Record; } const PanelGroup = ({ @@ -220,6 +246,8 @@ const PanelGroup = ({ isMoreResultsButtonVisible, onLoadMoreResults, isBranchFilteringEnabled, + repoUrlTemplates, + repoMetadata, }: PanelGroupProps) => { const [selectedMatchIndex, setSelectedMatchIndex] = useState(0); const [selectedFile, setSelectedFile] = useState(undefined); @@ -254,6 +282,7 @@ const PanelGroup = ({ ) : (
@@ -302,6 +332,7 @@ const PanelGroup = ({ onClose={() => setSelectedFile(undefined)} selectedMatchIndex={selectedMatchIndex} onSelectedMatchIndexChange={setSelectedMatchIndex} + repoUrlTemplates={repoUrlTemplates} /> diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index f401b765..9fcb2b0d 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -68,6 +68,7 @@ export const zoektSearchResponseSchema = z.object({ // Set if `whole` is true. Content: z.string().optional(), })).nullable(), + RepoURLs: z.record(z.string(), z.string()), }), }); diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index c2a46df3..3e6211bb 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -4,6 +4,7 @@ import githubLogo from "../../public/github.svg"; import gitlabLogo from "../../public/gitlab.svg"; import giteaLogo from "../../public/gitea.svg"; import { ServiceError } from "./serviceError"; +import { Repository } from "./types"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -31,64 +32,53 @@ export const createPathWithQueryParams = (path: string, ...queryParams: [string, type CodeHostInfo = { type: "github" | "gitlab" | "gitea"; - repoName: string; + displayName: string; costHostName: string; repoLink: string; icon: string; iconClassName?: string; } -export const getRepoCodeHostInfo = (repoName: string): CodeHostInfo | undefined => { - if (repoName.startsWith("github.com")) { - return { - type: "github", - repoName: repoName.substring("github.com/".length), - costHostName: "GitHub", - repoLink: `https://${repoName}`, - icon: githubLogo, - iconClassName: "dark:invert", - } - } - - if (repoName.startsWith("gitlab.com")) { - return { - type: "gitlab", - repoName: repoName.substring("gitlab.com/".length), - costHostName: "GitLab", - repoLink: `https://${repoName}`, - icon: gitlabLogo, - } +export const getRepoCodeHostInfo = (repo?: Repository): CodeHostInfo | undefined => { + if (!repo) { + return undefined; } - if (repoName.startsWith("gitea.com")) { - return { - type: "gitea", - repoName: repoName.substring("gitea.com/".length), - costHostName: "Gitea", - repoLink: `https://${repoName}`, - icon: giteaLogo, - } + const hostType = repo.RawConfig ? repo.RawConfig['web-url-type'] : undefined; + if (!hostType) { + return undefined; } - return undefined; -} + const url = new URL(repo.URL); + const displayName = url.pathname.slice(1); -export const getCodeHostFilePreviewLink = (repoName: string, filePath: string, branch: string = "HEAD"): string | undefined => { - const info = getRepoCodeHostInfo(repoName); - - if (info?.type === "github") { - return `${info.repoLink}/blob/${branch}/${filePath}`; + switch (hostType) { + case 'github': + return { + type: "github", + displayName: displayName, + costHostName: "GitHub", + repoLink: repo.URL, + icon: githubLogo, + iconClassName: "dark:invert", + } + case 'gitlab': + return { + type: "gitlab", + displayName: displayName, + costHostName: "GitLab", + repoLink: repo.URL, + icon: gitlabLogo, + } + case 'gitea': + return { + type: "gitea", + displayName: displayName, + costHostName: "Gitea", + repoLink: repo.URL, + icon: giteaLogo, + } } - - if (info?.type === "gitlab") { - return `${info.repoLink}/-/blob/${branch}/${filePath}`; - } - - if (info?.type === "gitea") { - return `${info.repoLink}/src/branch/${branch}/${filePath}`; - } - - return undefined; } export const isServiceError = (data: unknown): data is ServiceError => {