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
- Added file suggestions as a suggestion type. ([#88](https://github.com/sourcebot-dev/sourcebot/pull/88)) - 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 ## [2.5.0] - 2024-11-22

View file

@ -56,8 +56,8 @@ interface RepositoryBadgeProps {
const RepositoryBadge = ({ const RepositoryBadge = ({
repo repo
}: RepositoryBadgeProps) => { }: RepositoryBadgeProps) => {
const { repoIcon, repoName, repoLink } = (() => { const { repoIcon, displayName, repoLink } = (() => {
const info = getRepoCodeHostInfo(repo.Name); const info = getRepoCodeHostInfo(repo);
if (info) { if (info) {
return { return {
@ -66,14 +66,14 @@ const RepositoryBadge = ({
alt={info.costHostName} alt={info.costHostName}
className={`w-4 h-4 ${info.iconClassName}`} className={`w-4 h-4 ${info.iconClassName}`}
/>, />,
repoName: info.repoName, displayName: info.displayName,
repoLink: info.repoLink, repoLink: info.repoLink,
} }
} }
return { return {
repoIcon: <FileIcon className="w-4 h-4" />, repoIcon: <FileIcon className="w-4 h-4" />,
repoName: repo.Name, displayName: repo.Name,
repoLink: undefined, repoLink: undefined,
} }
})(); })();
@ -91,7 +91,7 @@ const RepositoryBadge = ({
> >
{repoIcon} {repoIcon}
<span className="text-sm font-mono"> <span className="text-sm font-mono">
{repoName} {displayName}
</span> </span>
</div> </div>
) )

View file

@ -1,7 +1,6 @@
'use client'; 'use client';
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getRepoCodeHostInfo } from "@/lib/utils";
import { Column, ColumnDef } from "@tanstack/react-table" import { Column, ColumnDef } from "@tanstack/react-table"
import { ArrowUpDown } from "lucide-react" import { ArrowUpDown } from "lucide-react"
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
@ -19,6 +18,7 @@ export type RepositoryColumnInfo = {
lastIndexed: string; lastIndexed: string;
latestCommit: string; latestCommit: string;
commitUrlTemplate: string; commitUrlTemplate: string;
url: string;
} }
export const columns: ColumnDef<RepositoryColumnInfo>[] = [ export const columns: ColumnDef<RepositoryColumnInfo>[] = [
@ -27,14 +27,16 @@ export const columns: ColumnDef<RepositoryColumnInfo>[] = [
header: "Name", header: "Name",
cell: ({ row }) => { cell: ({ row }) => {
const repo = row.original; 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 ( return (
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<span <span
className={info?.repoLink ? "cursor-pointer text-blue-500 hover:underline": ""} className={!isRemoteRepo ? "cursor-pointer text-blue-500 hover:underline": ""}
onClick={() => { onClick={() => {
if (info?.repoLink) { if (!isRemoteRepo) {
window.open(info.repoLink, "_blank"); window.open(url, "_blank");
} }
}} }}
> >

View file

@ -26,6 +26,7 @@ export const RepositoryTable = async () => {
latestCommit: repo.Repository.LatestCommitDate, latestCommit: repo.Repository.LatestCommitDate,
indexedFiles: repo.Stats.Documents, indexedFiles: repo.Stats.Documents,
commitUrlTemplate: repo.Repository.CommitURLTemplate, commitUrlTemplate: repo.Repository.CommitURLTemplate,
url: repo.Repository.URL,
} }
}).sort((a, b) => { }).sort((a, b) => {
return new Date(b.lastIndexed).getTime() - new Date(a.lastIndexed).getTime(); return new Date(b.lastIndexed).getTime() - new Date(a.lastIndexed).getTime();

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { fetchFileSource } from "@/app/api/(client)/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 { useQuery } from "@tanstack/react-query";
import { CodePreview, CodePreviewFile } from "./codePreview"; import { CodePreview, CodePreviewFile } from "./codePreview";
import { SearchResultFile } from "@/lib/types"; import { SearchResultFile } from "@/lib/types";
@ -11,6 +11,7 @@ interface CodePreviewPanelProps {
onClose: () => void; onClose: () => void;
selectedMatchIndex: number; selectedMatchIndex: number;
onSelectedMatchIndexChange: (index: number) => void; onSelectedMatchIndexChange: (index: number) => void;
repoUrlTemplates: Record<string, string>;
} }
export const CodePreviewPanel = ({ export const CodePreviewPanel = ({
@ -18,6 +19,7 @@ export const CodePreviewPanel = ({
onClose, onClose,
selectedMatchIndex, selectedMatchIndex,
onSelectedMatchIndexChange, onSelectedMatchIndexChange,
repoUrlTemplates,
}: CodePreviewPanelProps) => { }: CodePreviewPanelProps) => {
const { data: file } = useQuery({ const { data: file } = useQuery({
@ -37,8 +39,15 @@ export const CodePreviewPanel = ({
branch, branch,
}) })
.then(({ source }) => { .then(({ source }) => {
// @todo : refector this to use the templates provided by zoekt. const link = (() => {
const link = getCodeHostFilePreviewLink(fileMatch.Repository, fileMatch.FileName, branch); const template = repoUrlTemplates[fileMatch.Repository];
if (!template) {
return undefined;
}
return template
.replace("{{.Version}}", branch ?? "HEAD")
.replace("{{.Path}}", fileMatch.FileName);
})();
const decodedSource = base64Decode(source); const decodedSource = base64Decode(source);

View file

@ -2,16 +2,13 @@
import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"; import { QuestionMarkCircledIcon } from "@radix-ui/react-icons";
import clsx from "clsx"; import clsx from "clsx";
import Image from "next/image";
export type Entry = { export type Entry = {
key: string; key: string;
displayName: string; displayName: string;
count: number; count: number;
isSelected: boolean; isSelected: boolean;
icon?: string; Icon?: React.ReactNode;
iconAltText?: string;
iconClassName?: string;
} }
interface EntryProps { interface EntryProps {
@ -22,11 +19,9 @@ interface EntryProps {
export const Entry = ({ export const Entry = ({
entry: { entry: {
isSelected, isSelected,
icon,
iconAltText,
iconClassName,
displayName, displayName,
count, count,
Icon,
}, },
onClicked, onClicked,
}: EntryProps) => { }: EntryProps) => {
@ -42,13 +37,7 @@ export const Entry = ({
onClick={() => onClicked()} onClick={() => onClicked()}
> >
<div className="flex flex-row items-center gap-1"> <div className="flex flex-row items-center gap-1">
{icon ? ( {Icon ? Icon : (
<Image
src={icon}
alt={iconAltText ?? ''}
className={`w-4 h-4 flex-shrink-0 ${iconClassName}`}
/>
) : (
<QuestionMarkCircledIcon className="w-4 h-4 flex-shrink-0" /> <QuestionMarkCircledIcon className="w-4 h-4 flex-shrink-0" />
)} )}
<p className="text-wrap">{displayName}</p> <p className="text-wrap">{displayName}</p>

View file

@ -1,20 +1,24 @@
'use client'; 'use client';
import { SearchResultFile } from "@/lib/types"; import { Repository, SearchResultFile } from "@/lib/types";
import { getRepoCodeHostInfo } from "@/lib/utils"; import { cn, getRepoCodeHostInfo } from "@/lib/utils";
import { SetStateAction, useCallback, useEffect, useState } from "react"; import { SetStateAction, useCallback, useEffect, useState } from "react";
import { Entry } from "./entry"; import { Entry } from "./entry";
import { Filter } from "./filter"; import { Filter } from "./filter";
import { getLanguageIcon } from "./languageIcons"; import { getLanguageIcon } from "./languageIcons";
import Image from "next/image";
import { LaptopIcon, QuestionMarkCircledIcon } from "@radix-ui/react-icons";
interface FilePanelProps { interface FilePanelProps {
matches: SearchResultFile[]; matches: SearchResultFile[];
onFilterChanged: (filteredMatches: SearchResultFile[]) => void, onFilterChanged: (filteredMatches: SearchResultFile[]) => void,
repoMetadata: Record<string, Repository>;
} }
export const FilterPanel = ({ export const FilterPanel = ({
matches, matches,
onFilterChanged, onFilterChanged,
repoMetadata,
}: FilePanelProps) => { }: FilePanelProps) => {
const [repos, setRepos] = useState<Record<string, Entry>>({}); const [repos, setRepos] = useState<Record<string, Entry>>({});
const [languages, setLanguages] = useState<Record<string, Entry>>({}); const [languages, setLanguages] = useState<Record<string, Entry>>({});
@ -24,15 +28,24 @@ export const FilterPanel = ({
"Repository", "Repository",
matches, matches,
(key) => { (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 { return {
key, key,
displayName: info?.repoName ?? key, displayName: info?.displayName ?? key,
count: 0, count: 0,
isSelected: false, isSelected: false,
icon: info?.icon, Icon,
iconAltText: info?.costHostName,
iconClassName: info?.iconClassName,
}; };
} }
); );
@ -45,12 +58,23 @@ export const FilterPanel = ({
"Language", "Language",
matches, matches,
(key) => { (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 { return {
key, key,
displayName: key, displayName: key,
count: 0, count: 0,
isSelected: false, isSelected: false,
icon: getLanguageIcon(key), Icon: Icon,
} satisfies Entry; } satisfies Entry;
} }
) )
@ -85,10 +109,10 @@ export const FilterPanel = ({
); );
const filteredMatches = matches.filter((match) => const filteredMatches = matches.filter((match) =>
( (
(selectedRepos.size === 0 ? true : selectedRepos.has(match.Repository)) && (selectedRepos.size === 0 ? true : selectedRepos.has(match.Repository)) &&
(selectedLanguages.size === 0 ? true : selectedLanguages.has(match.Language)) (selectedLanguages.size === 0 ? true : selectedLanguages.has(match.Language))
) )
); );
onFilterChanged(filteredMatches); onFilterChanged(filteredMatches);

View file

@ -3,10 +3,10 @@
import { getRepoCodeHostInfo } from "@/lib/utils"; import { getRepoCodeHostInfo } from "@/lib/utils";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import Image from "next/image"; 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 clsx from "clsx";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { SearchResultFile } from "@/lib/types"; import { Repository, SearchResultFile } from "@/lib/types";
import { FileMatch } from "./fileMatch"; import { FileMatch } from "./fileMatch";
export const MAX_MATCHES_TO_PREVIEW = 3; export const MAX_MATCHES_TO_PREVIEW = 3;
@ -18,6 +18,7 @@ interface FileMatchContainerProps {
showAllMatches: boolean; showAllMatches: boolean;
onShowAllMatchesButtonClicked: () => void; onShowAllMatchesButtonClicked: () => void;
isBranchFilteringEnabled: boolean; isBranchFilteringEnabled: boolean;
repoMetadata: Record<string, Repository>;
} }
export const FileMatchContainer = ({ export const FileMatchContainer = ({
@ -27,6 +28,7 @@ export const FileMatchContainer = ({
showAllMatches, showAllMatches,
onShowAllMatchesButtonClicked, onShowAllMatchesButtonClicked,
isBranchFilteringEnabled, isBranchFilteringEnabled,
repoMetadata,
}: FileMatchContainerProps) => { }: FileMatchContainerProps) => {
const matchCount = useMemo(() => { const matchCount = useMemo(() => {
@ -59,11 +61,13 @@ export const FileMatchContainer = ({
return null; return null;
}, [matches]); }, [matches]);
const { repoIcon, repoName, repoLink } = useMemo(() => { const { repoIcon, displayName, repoLink } = useMemo(() => {
const info = getRepoCodeHostInfo(file.Repository); const repo: Repository | undefined = repoMetadata[file.Repository];
const info = getRepoCodeHostInfo(repo);
if (info) { if (info) {
return { return {
repoName: info.repoName, displayName: info.displayName,
repoLink: info.repoLink, repoLink: info.repoLink,
repoIcon: <Image repoIcon: <Image
src={info.icon} src={info.icon}
@ -74,11 +78,11 @@ export const FileMatchContainer = ({
} }
return { return {
repoName: file.Repository, displayName: file.Repository,
repoLink: undefined, repoLink: undefined,
repoIcon: <FileIcon className="w-4 h-4" /> repoIcon: <LaptopIcon className="w-4 h-4" />
} }
}, [file]); }, [file.Repository, repoMetadata]);
const isMoreContentButtonVisible = useMemo(() => { const isMoreContentButtonVisible = useMemo(() => {
return matchCount > MAX_MATCHES_TO_PREVIEW; return matchCount > MAX_MATCHES_TO_PREVIEW;
@ -122,7 +126,7 @@ export const FileMatchContainer = ({
} }
}} }}
> >
{repoName} {displayName}
</span> </span>
{isBranchFilteringEnabled && branches.length > 0 && ( {isBranchFilteringEnabled && branches.length > 0 && (
<span <span

View file

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

View file

@ -8,7 +8,7 @@ import {
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
import { SearchQueryParams, SearchResultFile } from "@/lib/types"; import { Repository, SearchQueryParams, SearchResultFile } from "@/lib/types";
import { createPathWithQueryParams } from "@/lib/utils"; import { createPathWithQueryParams } from "@/lib/utils";
import { SymbolIcon } from "@radix-ui/react-icons"; import { SymbolIcon } from "@radix-ui/react-icons";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@ -17,7 +17,7 @@ import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import logoDark from "../../../public/sb_logo_dark.png"; import logoDark from "../../../public/sb_logo_dark.png";
import logoLight from "../../../public/sb_logo_light.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 { SearchBar } from "../components/searchBar";
import { SettingsDropdown } from "../components/settingsDropdown"; import { SettingsDropdown } from "../components/settingsDropdown";
import { CodePreviewPanel } from "./components/codePreviewPanel"; import { CodePreviewPanel } from "./components/codePreviewPanel";
@ -45,6 +45,26 @@ export default function SearchPage() {
refetchOnWindowFocus: false, 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(() => { useEffect(() => {
if (!searchResponse) { if (!searchResponse) {
return; return;
@ -77,13 +97,14 @@ export default function SearchPage() {
}); });
}, [captureEvent, searchResponse]); }, [captureEvent, searchResponse]);
const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled } = useMemo(() => { const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled, repoUrlTemplates } = useMemo(() => {
if (!searchResponse) { if (!searchResponse) {
return { return {
fileMatches: [], fileMatches: [],
searchDurationMs: 0, searchDurationMs: 0,
totalMatchCount: 0, totalMatchCount: 0,
isBranchFilteringEnabled: false, isBranchFilteringEnabled: false,
repoUrlTemplates: {},
}; };
} }
@ -108,6 +129,7 @@ export default function SearchPage() {
searchDurationMs: Math.round(searchResponse.Result.Duration / 1000000), searchDurationMs: Math.round(searchResponse.Result.Duration / 1000000),
totalMatchCount: searchResponse.Result.MatchCount, totalMatchCount: searchResponse.Result.MatchCount,
isBranchFilteringEnabled, isBranchFilteringEnabled,
repoUrlTemplates: searchResponse.Result.RepoURLs,
} }
}, [searchResponse]); }, [searchResponse]);
@ -202,6 +224,8 @@ export default function SearchPage() {
isMoreResultsButtonVisible={isMoreResultsButtonVisible} isMoreResultsButtonVisible={isMoreResultsButtonVisible}
onLoadMoreResults={onLoadMoreResults} onLoadMoreResults={onLoadMoreResults}
isBranchFilteringEnabled={isBranchFilteringEnabled} isBranchFilteringEnabled={isBranchFilteringEnabled}
repoUrlTemplates={repoUrlTemplates}
repoMetadata={repoMetadata ?? {}}
/> />
)} )}
</div> </div>
@ -213,6 +237,8 @@ interface PanelGroupProps {
isMoreResultsButtonVisible?: boolean; isMoreResultsButtonVisible?: boolean;
onLoadMoreResults: () => void; onLoadMoreResults: () => void;
isBranchFilteringEnabled: boolean; isBranchFilteringEnabled: boolean;
repoUrlTemplates: Record<string, string>;
repoMetadata: Record<string, Repository>;
} }
const PanelGroup = ({ const PanelGroup = ({
@ -220,6 +246,8 @@ const PanelGroup = ({
isMoreResultsButtonVisible, isMoreResultsButtonVisible,
onLoadMoreResults, onLoadMoreResults,
isBranchFilteringEnabled, isBranchFilteringEnabled,
repoUrlTemplates,
repoMetadata,
}: PanelGroupProps) => { }: PanelGroupProps) => {
const [selectedMatchIndex, setSelectedMatchIndex] = useState(0); const [selectedMatchIndex, setSelectedMatchIndex] = useState(0);
const [selectedFile, setSelectedFile] = useState<SearchResultFile | undefined>(undefined); const [selectedFile, setSelectedFile] = useState<SearchResultFile | undefined>(undefined);
@ -254,6 +282,7 @@ const PanelGroup = ({
<FilterPanel <FilterPanel
matches={fileMatches} matches={fileMatches}
onFilterChanged={onFilterChanged} onFilterChanged={onFilterChanged}
repoMetadata={repoMetadata}
/> />
</ResizablePanel> </ResizablePanel>
<ResizableHandle <ResizableHandle
@ -278,6 +307,7 @@ const PanelGroup = ({
isLoadMoreButtonVisible={!!isMoreResultsButtonVisible} isLoadMoreButtonVisible={!!isMoreResultsButtonVisible}
onLoadMoreButtonClicked={onLoadMoreResults} onLoadMoreButtonClicked={onLoadMoreResults}
isBranchFilteringEnabled={isBranchFilteringEnabled} isBranchFilteringEnabled={isBranchFilteringEnabled}
repoMetadata={repoMetadata}
/> />
) : ( ) : (
<div className="flex flex-col items-center justify-center h-full"> <div className="flex flex-col items-center justify-center h-full">
@ -302,6 +332,7 @@ const PanelGroup = ({
onClose={() => setSelectedFile(undefined)} onClose={() => setSelectedFile(undefined)}
selectedMatchIndex={selectedMatchIndex} selectedMatchIndex={selectedMatchIndex}
onSelectedMatchIndexChange={setSelectedMatchIndex} onSelectedMatchIndexChange={setSelectedMatchIndex}
repoUrlTemplates={repoUrlTemplates}
/> />
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>

View file

@ -68,6 +68,7 @@ export const zoektSearchResponseSchema = z.object({
// Set if `whole` is true. // Set if `whole` is true.
Content: z.string().optional(), Content: z.string().optional(),
})).nullable(), })).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 gitlabLogo from "../../public/gitlab.svg";
import giteaLogo from "../../public/gitea.svg"; import giteaLogo from "../../public/gitea.svg";
import { ServiceError } from "./serviceError"; import { ServiceError } from "./serviceError";
import { Repository } from "./types";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@ -31,64 +32,53 @@ export const createPathWithQueryParams = (path: string, ...queryParams: [string,
type CodeHostInfo = { type CodeHostInfo = {
type: "github" | "gitlab" | "gitea"; type: "github" | "gitlab" | "gitea";
repoName: string; displayName: string;
costHostName: string; costHostName: string;
repoLink: string; repoLink: string;
icon: string; icon: string;
iconClassName?: string; iconClassName?: string;
} }
export const getRepoCodeHostInfo = (repoName: string): CodeHostInfo | undefined => { export const getRepoCodeHostInfo = (repo?: Repository): CodeHostInfo | undefined => {
if (repoName.startsWith("github.com")) { if (!repo) {
return { return undefined;
type: "github",
repoName: repoName.substring("github.com/".length),
costHostName: "GitHub",
repoLink: `https://${repoName}`,
icon: githubLogo,
iconClassName: "dark:invert",
}
} }
if (repoName.startsWith("gitlab.com")) { const hostType = repo.RawConfig ? repo.RawConfig['web-url-type'] : undefined;
return { if (!hostType) {
type: "gitlab", return undefined;
repoName: repoName.substring("gitlab.com/".length),
costHostName: "GitLab",
repoLink: `https://${repoName}`,
icon: gitlabLogo,
}
} }
if (repoName.startsWith("gitea.com")) { const url = new URL(repo.URL);
return { const displayName = url.pathname.slice(1);
type: "gitea",
repoName: repoName.substring("gitea.com/".length), switch (hostType) {
costHostName: "Gitea", case 'github':
repoLink: `https://${repoName}`, return {
icon: giteaLogo, 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,
}
} }
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 => { export const isServiceError = (data: unknown): data is ServiceError => {