Icon & link support for self-hosted repositories (#93)

This commit is contained in:
Brendan Kellam 2024-11-26 21:49:41 -08:00 committed by GitHub
parent 01f4329d3e
commit d6544086e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 154 additions and 99 deletions

View file

@ -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

View file

@ -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: <FileIcon className="w-4 h-4" />,
repoName: repo.Name,
displayName: repo.Name,
repoLink: undefined,
}
})();
@ -91,7 +91,7 @@ const RepositoryBadge = ({
>
{repoIcon}
<span className="text-sm font-mono">
{repoName}
{displayName}
</span>
</div>
)

View file

@ -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<RepositoryColumnInfo>[] = [
@ -27,14 +27,16 @@ export const columns: ColumnDef<RepositoryColumnInfo>[] = [
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 (
<div className="flex flex-row items-center gap-2">
<span
className={info?.repoLink ? "cursor-pointer text-blue-500 hover:underline": ""}
className={!isRemoteRepo ? "cursor-pointer text-blue-500 hover:underline": ""}
onClick={() => {
if (info?.repoLink) {
window.open(info.repoLink, "_blank");
if (!isRemoteRepo) {
window.open(url, "_blank");
}
}}
>

View file

@ -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();

View file

@ -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<string, string>;
}
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);

View file

@ -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()}
>
<div className="flex flex-row items-center gap-1">
{icon ? (
<Image
src={icon}
alt={iconAltText ?? ''}
className={`w-4 h-4 flex-shrink-0 ${iconClassName}`}
/>
) : (
{Icon ? Icon : (
<QuestionMarkCircledIcon className="w-4 h-4 flex-shrink-0" />
)}
<p className="text-wrap">{displayName}</p>

View file

@ -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<string, Repository>;
}
export const FilterPanel = ({
matches,
onFilterChanged,
repoMetadata,
}: FilePanelProps) => {
const [repos, setRepos] = useState<Record<string, Entry>>({});
const [languages, setLanguages] = useState<Record<string, Entry>>({});
@ -24,15 +28,24 @@ export const FilterPanel = ({
"Repository",
matches,
(key) => {
const info = getRepoCodeHostInfo(key);
const repo: Repository | undefined = repoMetadata[key];
const info = getRepoCodeHostInfo(repo);
const Icon = info ? (
<Image
src={info.icon}
alt={info.costHostName}
className={cn('w-4 h-4 flex-shrink-0', info.iconClassName)}
/>
) : (
<LaptopIcon className="w-4 h-4 flex-shrink-0" />
);
return {
key,
displayName: info?.repoName ?? key,
displayName: info?.displayName ?? key,
count: 0,
isSelected: false,
icon: info?.icon,
iconAltText: info?.costHostName,
iconClassName: info?.iconClassName,
Icon,
};
}
);
@ -45,12 +58,23 @@ export const FilterPanel = ({
"Language",
matches,
(key) => {
const iconSrc = getLanguageIcon(key);
const Icon = iconSrc ? (
<Image
src={iconSrc}
alt={key}
className="w-4 h-4 flex-shrink-0"
/>
) : (
<QuestionMarkCircledIcon className="w-4 h-4 flex-shrink-0" />
);
return {
key,
displayName: key,
count: 0,
isSelected: false,
icon: getLanguageIcon(key),
Icon: Icon,
} satisfies Entry;
}
)

View file

@ -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<string, Repository>;
}
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: <Image
src={info.icon}
@ -74,11 +78,11 @@ export const FileMatchContainer = ({
}
return {
repoName: file.Repository,
displayName: file.Repository,
repoLink: undefined,
repoIcon: <FileIcon className="w-4 h-4" />
repoIcon: <LaptopIcon className="w-4 h-4" />
}
}, [file]);
}, [file.Repository, repoMetadata]);
const isMoreContentButtonVisible = useMemo(() => {
return matchCount > MAX_MATCHES_TO_PREVIEW;
@ -122,7 +126,7 @@ export const FileMatchContainer = ({
}
}}
>
{repoName}
{displayName}
</span>
{isBranchFilteringEnabled && branches.length > 0 && (
<span

View file

@ -1,6 +1,6 @@
'use client';
import { SearchResultFile } from "@/lib/types";
import { Repository, SearchResultFile } from "@/lib/types";
import { FileMatchContainer, MAX_MATCHES_TO_PREVIEW } from "./fileMatchContainer";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
@ -12,6 +12,7 @@ interface SearchResultsPanelProps {
isLoadMoreButtonVisible: boolean;
onLoadMoreButtonClicked: () => void;
isBranchFilteringEnabled: boolean;
repoMetadata: Record<string, Repository>;
}
const ESTIMATED_LINE_HEIGHT_PX = 20;
@ -25,6 +26,7 @@ export const SearchResultsPanel = ({
isLoadMoreButtonVisible,
onLoadMoreButtonClicked,
isBranchFilteringEnabled,
repoMetadata,
}: SearchResultsPanelProps) => {
const parentRef = useRef<HTMLDivElement>(null);
const [showAllMatchesStates, setShowAllMatchesStates] = useState(Array(fileMatches.length).fill(false));
@ -148,6 +150,7 @@ export const SearchResultsPanel = ({
onShowAllMatchesButtonClicked(virtualRow.index);
}}
isBranchFilteringEnabled={isBranchFilteringEnabled}
repoMetadata={repoMetadata}
/>
</div>
))}

View file

@ -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<string, Repository> =>
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 ?? {}}
/>
)}
</div>
@ -213,6 +237,8 @@ interface PanelGroupProps {
isMoreResultsButtonVisible?: boolean;
onLoadMoreResults: () => void;
isBranchFilteringEnabled: boolean;
repoUrlTemplates: Record<string, string>;
repoMetadata: Record<string, Repository>;
}
const PanelGroup = ({
@ -220,6 +246,8 @@ const PanelGroup = ({
isMoreResultsButtonVisible,
onLoadMoreResults,
isBranchFilteringEnabled,
repoUrlTemplates,
repoMetadata,
}: PanelGroupProps) => {
const [selectedMatchIndex, setSelectedMatchIndex] = useState(0);
const [selectedFile, setSelectedFile] = useState<SearchResultFile | undefined>(undefined);
@ -254,6 +282,7 @@ const PanelGroup = ({
<FilterPanel
matches={fileMatches}
onFilterChanged={onFilterChanged}
repoMetadata={repoMetadata}
/>
</ResizablePanel>
<ResizableHandle
@ -278,6 +307,7 @@ const PanelGroup = ({
isLoadMoreButtonVisible={!!isMoreResultsButtonVisible}
onLoadMoreButtonClicked={onLoadMoreResults}
isBranchFilteringEnabled={isBranchFilteringEnabled}
repoMetadata={repoMetadata}
/>
) : (
<div className="flex flex-col items-center justify-center h-full">
@ -302,6 +332,7 @@ const PanelGroup = ({
onClose={() => setSelectedFile(undefined)}
selectedMatchIndex={selectedMatchIndex}
onSelectedMatchIndexChange={setSelectedMatchIndex}
repoUrlTemplates={repoUrlTemplates}
/>
</ResizablePanel>
</ResizablePanelGroup>

View file

@ -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()),
}),
});

View file

@ -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")) {
export const getRepoCodeHostInfo = (repo?: Repository): CodeHostInfo | undefined => {
if (!repo) {
return undefined;
}
const hostType = repo.RawConfig ? repo.RawConfig['web-url-type'] : undefined;
if (!hostType) {
return undefined;
}
const url = new URL(repo.URL);
const displayName = url.pathname.slice(1);
switch (hostType) {
case 'github':
return {
type: "github",
repoName: repoName.substring("github.com/".length),
displayName: displayName,
costHostName: "GitHub",
repoLink: `https://${repoName}`,
repoLink: repo.URL,
icon: githubLogo,
iconClassName: "dark:invert",
}
}
if (repoName.startsWith("gitlab.com")) {
case 'gitlab':
return {
type: "gitlab",
repoName: repoName.substring("gitlab.com/".length),
displayName: displayName,
costHostName: "GitLab",
repoLink: `https://${repoName}`,
repoLink: repo.URL,
icon: gitlabLogo,
}
}
if (repoName.startsWith("gitea.com")) {
case 'gitea':
return {
type: "gitea",
repoName: repoName.substring("gitea.com/".length),
displayName: displayName,
costHostName: "Gitea",
repoLink: `https://${repoName}`,
repoLink: repo.URL,
icon: giteaLogo,
}
}
return undefined;
}
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}`;
}
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 => {