mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-14 05:15:19 +00:00
Various improvements and optimizations on the web side
This commit is contained in:
parent
5fe554e7da
commit
c1467bcd82
39 changed files with 1200 additions and 1558 deletions
|
|
@ -10,7 +10,7 @@ import { prisma } from "@/prisma";
|
|||
import { render } from "@react-email/components";
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { decrypt, encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto";
|
||||
import { ApiKey, ConnectionSyncStatus, Org, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||
import { ApiKey, ConnectionSyncStatus, Org, OrgRole, Prisma, RepoIndexingStatus, RepoJobStatus, RepoJobType, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||
import { createLogger } from "@sourcebot/logger";
|
||||
import { azuredevopsSchema } from "@sourcebot/schemas/v3/azuredevops.schema";
|
||||
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
|
||||
|
|
@ -638,22 +638,20 @@ export const getConnectionInfo = async (connectionId: number, domain: string) =>
|
|||
}
|
||||
})));
|
||||
|
||||
export const getRepos = async (filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() =>
|
||||
export const getRepos = async ({
|
||||
where,
|
||||
take,
|
||||
}: {
|
||||
where?: Prisma.RepoWhereInput,
|
||||
take?: number
|
||||
} = {}) => sew(() =>
|
||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||
const repos = await prisma.repo.findMany({
|
||||
where: {
|
||||
orgId: org.id,
|
||||
...(filter.status ? {
|
||||
repoIndexingStatus: { in: filter.status }
|
||||
} : {}),
|
||||
...(filter.connectionId ? {
|
||||
connections: {
|
||||
some: {
|
||||
connectionId: filter.connectionId
|
||||
}
|
||||
}
|
||||
} : {}),
|
||||
}
|
||||
...where,
|
||||
},
|
||||
take,
|
||||
});
|
||||
|
||||
return repos.map((repo) => repositoryQuerySchema.parse({
|
||||
|
|
@ -669,6 +667,60 @@ export const getRepos = async (filter: { status?: RepoIndexingStatus[], connecti
|
|||
}))
|
||||
}));
|
||||
|
||||
/**
|
||||
* Returns a set of aggregated stats about the repos in the org
|
||||
*/
|
||||
export const getReposStats = async () => sew(() =>
|
||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||
const [
|
||||
// Total number of repos.
|
||||
numberOfRepos,
|
||||
// Number of repos with their first time indexing jobs either
|
||||
// pending or in progress.
|
||||
numberOfReposWithFirstTimeIndexingJobsInProgress,
|
||||
// Number of repos that have been indexed at least once.
|
||||
numberOfReposWithIndex,
|
||||
] = await Promise.all([
|
||||
prisma.repo.count({
|
||||
where: {
|
||||
orgId: org.id,
|
||||
}
|
||||
}),
|
||||
prisma.repo.count({
|
||||
where: {
|
||||
orgId: org.id,
|
||||
jobs: {
|
||||
some: {
|
||||
type: RepoJobType.INDEX,
|
||||
status: {
|
||||
in: [
|
||||
RepoJobStatus.PENDING,
|
||||
RepoJobStatus.IN_PROGRESS,
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
indexedAt: null,
|
||||
}
|
||||
}),
|
||||
prisma.repo.count({
|
||||
where: {
|
||||
orgId: org.id,
|
||||
NOT: {
|
||||
indexedAt: null,
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
numberOfRepos,
|
||||
numberOfReposWithFirstTimeIndexingJobsInProgress,
|
||||
numberOfReposWithIndex,
|
||||
};
|
||||
})
|
||||
)
|
||||
|
||||
export const getRepoInfoByName = async (repoName: string) => sew(() =>
|
||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||
// @note: repo names are represented by their remote url
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useRef } from "react";
|
||||
import { FileTreeItem } from "@/features/fileTree/actions";
|
||||
import { FileTreeItemComponent } from "@/features/fileTree/components/fileTreeItemComponent";
|
||||
import { getBrowsePath } from "../../hooks/useBrowseNavigation";
|
||||
import { getBrowsePath } from "../../hooks/utils";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useBrowseParams } from "../../hooks/useBrowseParams";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
import { useRouter } from "next/navigation";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { useCallback } from "react";
|
||||
import { BrowseState, SET_BROWSE_STATE_QUERY_PARAM } from "../browseStateProvider";
|
||||
import { BrowseState } from "../browseStateProvider";
|
||||
import { getBrowsePath } from "./utils";
|
||||
|
||||
export type BrowseHighlightRange = {
|
||||
start: { lineNumber: number; column: number; };
|
||||
|
|
@ -25,37 +26,6 @@ export interface GetBrowsePathProps {
|
|||
domain: string;
|
||||
}
|
||||
|
||||
export const getBrowsePath = ({
|
||||
repoName,
|
||||
revisionName = 'HEAD',
|
||||
path,
|
||||
pathType,
|
||||
highlightRange,
|
||||
setBrowseState,
|
||||
domain,
|
||||
}: GetBrowsePathProps) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (highlightRange) {
|
||||
const { start, end } = highlightRange;
|
||||
|
||||
if ('column' in start && 'column' in end) {
|
||||
params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber}:${start.column},${end.lineNumber}:${end.column}`);
|
||||
} else {
|
||||
params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber},${end.lineNumber}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (setBrowseState) {
|
||||
params.set(SET_BROWSE_STATE_QUERY_PARAM, JSON.stringify(setBrowseState));
|
||||
}
|
||||
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const browsePath = `/${domain}/browse/${repoName}@${revisionName}/-/${pathType}/${encodedPath}${params.size > 0 ? `?${params.toString()}` : ''}`;
|
||||
return browsePath;
|
||||
}
|
||||
|
||||
|
||||
export const useBrowseNavigation = () => {
|
||||
const router = useRouter();
|
||||
const domain = useDomain();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { getBrowsePath, GetBrowsePathProps } from "./useBrowseNavigation";
|
||||
import { GetBrowsePathProps } from "./useBrowseNavigation";
|
||||
import { getBrowsePath } from "./utils";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
|
||||
export const useBrowsePath = ({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { SET_BROWSE_STATE_QUERY_PARAM } from "../browseStateProvider";
|
||||
import { GetBrowsePathProps, HIGHLIGHT_RANGE_QUERY_PARAM } from "./useBrowseNavigation";
|
||||
|
||||
export const getBrowseParamsFromPathParam = (pathParam: string) => {
|
||||
const sentinelIndex = pathParam.search(/\/-\/(tree|blob)/);
|
||||
|
|
@ -7,7 +9,7 @@ export const getBrowseParamsFromPathParam = (pathParam: string) => {
|
|||
|
||||
const repoAndRevisionPart = decodeURIComponent(pathParam.substring(0, sentinelIndex));
|
||||
const lastAtIndex = repoAndRevisionPart.lastIndexOf('@');
|
||||
|
||||
|
||||
const repoName = lastAtIndex === -1 ? repoAndRevisionPart : repoAndRevisionPart.substring(0, lastAtIndex);
|
||||
const revisionName = lastAtIndex === -1 ? undefined : repoAndRevisionPart.substring(lastAtIndex + 1);
|
||||
|
||||
|
|
@ -40,4 +42,28 @@ export const getBrowseParamsFromPathParam = (pathParam: string) => {
|
|||
path,
|
||||
pathType,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getBrowsePath = ({
|
||||
repoName, revisionName = 'HEAD', path, pathType, highlightRange, setBrowseState, domain,
|
||||
}: GetBrowsePathProps) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (highlightRange) {
|
||||
const { start, end } = highlightRange;
|
||||
|
||||
if ('column' in start && 'column' in end) {
|
||||
params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber}:${start.column},${end.lineNumber}:${end.column}`);
|
||||
} else {
|
||||
params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber},${end.lineNumber}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (setBrowseState) {
|
||||
params.set(SET_BROWSE_STATE_QUERY_PARAM, JSON.stringify(setBrowseState));
|
||||
}
|
||||
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const browsePath = `/${domain}/browse/${repoName}@${revisionName}/-/${pathType}/${encodedPath}${params.size > 0 ? `?${params.toString()}` : ''}`;
|
||||
return browsePath;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,13 +11,13 @@ import { cn, getCodeHostIcon } from "@/lib/utils";
|
|||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard";
|
||||
|
||||
interface AskSourcebotDemoCardsProps {
|
||||
interface DemoCards {
|
||||
demoExamples: DemoExamples;
|
||||
}
|
||||
|
||||
export const AskSourcebotDemoCards = ({
|
||||
export const DemoCards = ({
|
||||
demoExamples,
|
||||
}: AskSourcebotDemoCardsProps) => {
|
||||
}: DemoCards) => {
|
||||
const captureEvent = useCaptureEvent();
|
||||
const [selectedFilterSearchScope, setSelectedFilterSearchScope] = useState<number | null>(null);
|
||||
|
||||
|
|
@ -6,47 +6,24 @@ import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolba
|
|||
import { LanguageModelInfo, SearchScope } from "@/features/chat/types";
|
||||
import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread";
|
||||
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
||||
import { useCallback, useState } from "react";
|
||||
import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar";
|
||||
import { useState } from "react";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
import { DemoExamples } from "@/types";
|
||||
import { AskSourcebotDemoCards } from "./askSourcebotDemoCards";
|
||||
import { AgenticSearchTutorialDialog } from "./agenticSearchTutorialDialog";
|
||||
import { setAgenticSearchTutorialDismissedCookie } from "@/actions";
|
||||
import { RepositorySnapshot } from "./repositorySnapshot";
|
||||
import { SearchModeSelector } from "../../components/searchModeSelector";
|
||||
|
||||
interface AgenticSearchProps {
|
||||
searchModeSelectorProps: SearchModeSelectorProps;
|
||||
interface LandingPageChatBox {
|
||||
languageModels: LanguageModelInfo[];
|
||||
repos: RepositoryQuery[];
|
||||
searchContexts: SearchContextQuery[];
|
||||
chatHistory: {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
name: string | null;
|
||||
}[];
|
||||
demoExamples: DemoExamples | undefined;
|
||||
isTutorialDismissed: boolean;
|
||||
}
|
||||
|
||||
export const AgenticSearch = ({
|
||||
searchModeSelectorProps,
|
||||
export const LandingPageChatBox = ({
|
||||
languageModels,
|
||||
repos,
|
||||
searchContexts,
|
||||
demoExamples,
|
||||
isTutorialDismissed,
|
||||
}: AgenticSearchProps) => {
|
||||
}: LandingPageChatBox) => {
|
||||
const { createNewChatThread, isLoading } = useCreateNewChatThread();
|
||||
const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage<SearchScope[]>("selectedSearchScopes", [], { initializeWithValue: false });
|
||||
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
|
||||
|
||||
const [isTutorialOpen, setIsTutorialOpen] = useState(!isTutorialDismissed);
|
||||
const onTutorialDismissed = useCallback(() => {
|
||||
setIsTutorialOpen(false);
|
||||
setAgenticSearchTutorialDismissedCookie(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full">
|
||||
<div className="mt-4 w-full border rounded-md shadow-sm max-w-[800px]">
|
||||
|
|
@ -74,34 +51,12 @@ export const AgenticSearch = ({
|
|||
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
||||
/>
|
||||
<SearchModeSelector
|
||||
{...searchModeSelectorProps}
|
||||
searchMode="agentic"
|
||||
className="ml-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<RepositorySnapshot
|
||||
repos={repos}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center w-fit gap-6">
|
||||
<Separator className="mt-5 w-[700px]" />
|
||||
</div>
|
||||
|
||||
{demoExamples && (
|
||||
<AskSourcebotDemoCards
|
||||
demoExamples={demoExamples}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isTutorialOpen && (
|
||||
<AgenticSearchTutorialDialog
|
||||
onClose={onTutorialDismissed}
|
||||
/>
|
||||
)}
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { ResizablePanel } from "@/components/ui/resizable";
|
||||
import { ChatBox } from "@/features/chat/components/chatBox";
|
||||
import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar";
|
||||
import { CustomSlateEditor } from "@/features/chat/customSlateEditor";
|
||||
import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread";
|
||||
import { LanguageModelInfo, SearchScope } from "@/features/chat/types";
|
||||
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Descendant } from "slate";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
|
||||
interface NewChatPanelProps {
|
||||
languageModels: LanguageModelInfo[];
|
||||
repos: RepositoryQuery[];
|
||||
searchContexts: SearchContextQuery[];
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const NewChatPanel = ({
|
||||
languageModels,
|
||||
repos,
|
||||
searchContexts,
|
||||
order,
|
||||
}: NewChatPanelProps) => {
|
||||
const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage<SearchScope[]>("selectedSearchScopes", [], { initializeWithValue: false });
|
||||
const { createNewChatThread, isLoading } = useCreateNewChatThread();
|
||||
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
|
||||
|
||||
const onSubmit = useCallback((children: Descendant[]) => {
|
||||
createNewChatThread(children, selectedSearchScopes);
|
||||
}, [createNewChatThread, selectedSearchScopes]);
|
||||
|
||||
|
||||
return (
|
||||
<ResizablePanel
|
||||
order={order}
|
||||
id="new-chat-panel"
|
||||
defaultSize={85}
|
||||
>
|
||||
<div className="flex flex-col h-full w-full items-center justify-start pt-[20vh]">
|
||||
<h2 className="text-4xl font-bold mb-8">What can I help you understand?</h2>
|
||||
<div className="border rounded-md w-full max-w-3xl mx-auto mb-8 shadow-sm">
|
||||
<CustomSlateEditor>
|
||||
<ChatBox
|
||||
onSubmit={onSubmit}
|
||||
className="min-h-[80px]"
|
||||
preferredSuggestionsBoxPlacement="bottom-start"
|
||||
isRedirecting={isLoading}
|
||||
languageModels={languageModels}
|
||||
selectedSearchScopes={selectedSearchScopes}
|
||||
searchContexts={searchContexts}
|
||||
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
||||
/>
|
||||
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
|
||||
<ChatBoxToolbar
|
||||
languageModels={languageModels}
|
||||
repos={repos}
|
||||
searchContexts={searchContexts}
|
||||
selectedSearchScopes={selectedSearchScopes}
|
||||
onSelectedSearchScopesChange={setSelectedSearchScopes}
|
||||
isContextSelectorOpen={isContextSelectorOpen}
|
||||
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
||||
/>
|
||||
</div>
|
||||
</CustomSlateEditor>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
"use client"
|
||||
|
||||
import { setAgenticSearchTutorialDismissedCookie } from "@/actions"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||
import { ModelProviderLogo } from "@/features/chat/components/chatBox/modelProviderLogo"
|
||||
import { cn } from "@/lib/utils"
|
||||
import mentionsDemo from "@/public/ask_sb_tutorial_at_mentions.png"
|
||||
|
|
@ -27,11 +28,9 @@ import {
|
|||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { useState } from "react"
|
||||
import { useCallback, useState } from "react"
|
||||
|
||||
|
||||
interface AgenticSearchTutorialDialogProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
|
||||
// Star button component that fetches GitHub star count
|
||||
|
|
@ -249,7 +248,17 @@ const tutorialSteps = [
|
|||
},
|
||||
]
|
||||
|
||||
export const AgenticSearchTutorialDialog = ({ onClose }: AgenticSearchTutorialDialogProps) => {
|
||||
interface TutorialDialogProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export const TutorialDialog = ({ isOpen: _isOpen }: TutorialDialogProps) => {
|
||||
const [isOpen, setIsOpen] = useState(_isOpen);
|
||||
const onClose = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setAgenticSearchTutorialDismissedCookie(true);
|
||||
}, []);
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
|
||||
const nextStep = () => {
|
||||
|
|
@ -269,11 +278,12 @@ export const AgenticSearchTutorialDialog = ({ onClose }: AgenticSearchTutorialDi
|
|||
const currentStepData = tutorialSteps[currentStep];
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[900px] p-0 flex flex-col h-[525px] overflow-hidden rounded-xl border-none bg-transparent"
|
||||
closeButtonClassName="text-white"
|
||||
>
|
||||
<DialogTitle className="sr-only">Ask Sourcebot tutorial</DialogTitle>
|
||||
<div className="relative flex h-full">
|
||||
{/* Left Column (Text Content & Navigation) */}
|
||||
<div className="flex-1 flex flex-col justify-between bg-background">
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME } from '@/lib/constants';
|
||||
import { NavigationGuardProvider } from 'next-navigation-guard';
|
||||
import { cookies } from 'next/headers';
|
||||
import { TutorialDialog } from './components/tutorialDialog';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default async function Layout({ children }: LayoutProps) {
|
||||
const isTutorialDismissed = (await cookies()).get(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME)?.value === "true";
|
||||
|
||||
return (
|
||||
// @note: we use a navigation guard here since we don't support resuming streams yet.
|
||||
|
|
@ -13,6 +17,7 @@ export default async function Layout({ children }: LayoutProps) {
|
|||
<div className="flex flex-col h-screen w-screen">
|
||||
{children}
|
||||
</div>
|
||||
<TutorialDialog isOpen={!isTutorialDismissed} />
|
||||
</NavigationGuardProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,17 @@
|
|||
import { getRepos, getSearchContexts } from "@/actions";
|
||||
import { getUserChatHistory, getConfiguredLanguageModelsInfo } from "@/features/chat/actions";
|
||||
import { getRepos, getReposStats, getSearchContexts } from "@/actions";
|
||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||
import { getConfiguredLanguageModelsInfo } from "@/features/chat/actions";
|
||||
import { CustomSlateEditor } from "@/features/chat/customSlateEditor";
|
||||
import { ServiceErrorException } from "@/lib/serviceError";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { NewChatPanel } from "./components/newChatPanel";
|
||||
import { TopBar } from "../components/topBar";
|
||||
import { ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { ChatSidePanel } from "./components/chatSidePanel";
|
||||
import { auth } from "@/auth";
|
||||
import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle";
|
||||
import { isServiceError, measure } from "@/lib/utils";
|
||||
import { LandingPageChatBox } from "./components/landingPageChatBox";
|
||||
import { RepositoryCarousel } from "../components/repositoryCarousel";
|
||||
import { NavigationMenu } from "../components/navigationMenu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DemoCards } from "./components/demoCards";
|
||||
import { env } from "@/env.mjs";
|
||||
import { loadJsonFile } from "@sourcebot/shared";
|
||||
import { DemoExamples, demoExamplesSchema } from "@/types";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
|
|
@ -18,47 +22,85 @@ interface PageProps {
|
|||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const languageModels = await getConfiguredLanguageModelsInfo();
|
||||
const repos = await getRepos();
|
||||
const searchContexts = await getSearchContexts(params.domain);
|
||||
const session = await auth();
|
||||
const chatHistory = session ? await getUserChatHistory(params.domain) : [];
|
||||
const allRepos = await getRepos();
|
||||
|
||||
if (isServiceError(chatHistory)) {
|
||||
throw new ServiceErrorException(chatHistory);
|
||||
}
|
||||
const carouselRepos = await getRepos({
|
||||
where: {
|
||||
indexedAt: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
|
||||
if (isServiceError(repos)) {
|
||||
throw new ServiceErrorException(repos);
|
||||
const repoStats = await getReposStats();
|
||||
|
||||
if (isServiceError(allRepos)) {
|
||||
throw new ServiceErrorException(allRepos);
|
||||
}
|
||||
|
||||
if (isServiceError(searchContexts)) {
|
||||
throw new ServiceErrorException(searchContexts);
|
||||
}
|
||||
|
||||
const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
|
||||
if (isServiceError(carouselRepos)) {
|
||||
throw new ServiceErrorException(carouselRepos);
|
||||
}
|
||||
|
||||
if (isServiceError(repoStats)) {
|
||||
throw new ServiceErrorException(repoStats);
|
||||
}
|
||||
|
||||
const demoExamples = env.SOURCEBOT_DEMO_EXAMPLES_PATH ? await (async () => {
|
||||
try {
|
||||
return (await measure(() => loadJsonFile<DemoExamples>(env.SOURCEBOT_DEMO_EXAMPLES_PATH!, demoExamplesSchema), 'loadExamplesJsonFile')).data;
|
||||
} catch (error) {
|
||||
console.error('Failed to load demo examples:', error);
|
||||
return undefined;
|
||||
}
|
||||
})() : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar
|
||||
<div className="flex flex-col items-center overflow-hidden min-h-screen">
|
||||
<NavigationMenu
|
||||
domain={params.domain}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
>
|
||||
<ChatSidePanel
|
||||
order={1}
|
||||
chatHistory={chatHistory}
|
||||
isAuthenticated={!!session}
|
||||
isCollapsedInitially={false}
|
||||
/>
|
||||
<AnimatedResizableHandle />
|
||||
<NewChatPanel
|
||||
languageModels={languageModels}
|
||||
searchContexts={searchContexts}
|
||||
repos={indexedRepos}
|
||||
order={2}
|
||||
/>
|
||||
</ResizablePanelGroup>
|
||||
</>
|
||||
|
||||
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
|
||||
<div className="max-h-44 w-auto">
|
||||
<SourcebotLogo
|
||||
className="h-18 md:h-40 w-auto"
|
||||
/>
|
||||
</div>
|
||||
<CustomSlateEditor>
|
||||
<LandingPageChatBox
|
||||
languageModels={languageModels}
|
||||
repos={allRepos}
|
||||
searchContexts={searchContexts}
|
||||
/>
|
||||
</CustomSlateEditor>
|
||||
|
||||
<div className="mt-8">
|
||||
<RepositoryCarousel
|
||||
numberOfReposWithIndex={repoStats.numberOfReposWithIndex}
|
||||
displayRepos={carouselRepos}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{demoExamples && (
|
||||
<>
|
||||
<div className="flex flex-col items-center w-fit gap-6">
|
||||
<Separator className="mt-5 w-[700px]" />
|
||||
</div>
|
||||
|
||||
<DemoCards
|
||||
demoExamples={demoExamples}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
||||
import { CircleXIcon } from "lucide-react";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { unwrapServiceError } from "@/lib/utils";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { env } from "@/env.mjs";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ConnectionSyncStatus, RepoIndexingStatus } from "@sourcebot/db";
|
||||
import { getConnections } from "@/actions";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getRepos } from "@/app/api/(client)/client";
|
||||
|
||||
export const ErrorNavIndicator = () => {
|
||||
const domain = useDomain();
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const { data: repos, isPending: isPendingRepos, isError: isErrorRepos } = useQuery({
|
||||
queryKey: ['repos', domain],
|
||||
queryFn: () => unwrapServiceError(getRepos()),
|
||||
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.FAILED),
|
||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
||||
});
|
||||
|
||||
const { data: connections, isPending: isPendingConnections, isError: isErrorConnections } = useQuery({
|
||||
queryKey: ['connections', domain],
|
||||
queryFn: () => unwrapServiceError(getConnections(domain)),
|
||||
select: (data) => data.filter(connection => connection.syncStatus === ConnectionSyncStatus.FAILED),
|
||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
||||
});
|
||||
|
||||
if (isPendingRepos || isErrorRepos || isPendingConnections || isErrorConnections) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (repos.length === 0 && connections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_error_nav_hover', {})}>
|
||||
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_error_nav_pressed', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-full text-red-700 dark:text-red-400 text-xs font-medium hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors cursor-pointer">
|
||||
<CircleXIcon className="h-4 w-4" />
|
||||
{repos.length + connections.length > 0 && (
|
||||
<span>{repos.length + connections.length}</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
{connections.length > 0 && (
|
||||
<div className="flex flex-col gap-4 pb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-500"></div>
|
||||
<h3 className="text-sm font-medium text-red-700 dark:text-red-400">Connection Sync Issues</h3>
|
||||
</div>
|
||||
<p className="text-sm text-red-600/90 dark:text-red-300/90 leading-relaxed">
|
||||
The following connections have failed to sync:
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<TooltipProvider>
|
||||
{connections
|
||||
.slice(0, 10)
|
||||
.map(connection => (
|
||||
<Link key={connection.name} href={`/${domain}/connections/${connection.id}`} onClick={() => captureEvent('wa_error_nav_job_pressed', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-red-50 dark:bg-red-900/20
|
||||
rounded-md text-sm text-red-700 dark:text-red-300
|
||||
border border-red-200/50 dark:border-red-800/50
|
||||
hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-medium truncate max-w-[200px]">{connection.name}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{connection.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
{connections.length > 10 && (
|
||||
<div className="text-sm text-red-600/90 dark:text-red-300/90 pl-3 pt-1">
|
||||
And {connections.length - 10} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{repos.length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-500"></div>
|
||||
<h3 className="text-sm font-medium text-red-700 dark:text-red-400">Repository Indexing Issues</h3>
|
||||
</div>
|
||||
<p className="text-sm text-red-600/90 dark:text-red-300/90 leading-relaxed">
|
||||
The following repositories failed to index:
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<TooltipProvider>
|
||||
{repos
|
||||
.slice(0, 10)
|
||||
.map(repo => (
|
||||
<div key={repo.repoId} className="flex items-center gap-2 px-3 py-2 bg-red-50 dark:bg-red-900/20
|
||||
rounded-md text-sm text-red-700 dark:text-red-300
|
||||
border border-red-200/50 dark:border-red-800/50
|
||||
hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-sm font-medium truncate max-w-[200px]">{repo.repoName}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{repo.repoName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
{repos.length > 10 && (
|
||||
<div className="text-sm text-red-600/90 dark:text-red-300/90 pl-3 pt-1">
|
||||
And {repos.length - 10} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||
import { LanguageModelInfo } from "@/features/chat/types";
|
||||
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { AgenticSearch } from "./agenticSearch";
|
||||
import { PreciseSearch } from "./preciseSearch";
|
||||
import { SearchMode } from "./toolbar";
|
||||
import { CustomSlateEditor } from "@/features/chat/customSlateEditor";
|
||||
import { setSearchModeCookie } from "@/actions";
|
||||
import { useCallback, useState } from "react";
|
||||
import { DemoExamples } from "@/types";
|
||||
|
||||
interface HomepageProps {
|
||||
initialRepos: RepositoryQuery[];
|
||||
searchContexts: SearchContextQuery[];
|
||||
languageModels: LanguageModelInfo[];
|
||||
chatHistory: {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
name: string | null;
|
||||
}[];
|
||||
initialSearchMode: SearchMode;
|
||||
demoExamples: DemoExamples | undefined;
|
||||
isAgenticSearchTutorialDismissed: boolean;
|
||||
}
|
||||
|
||||
|
||||
export const Homepage = ({
|
||||
initialRepos,
|
||||
searchContexts,
|
||||
languageModels,
|
||||
chatHistory,
|
||||
initialSearchMode,
|
||||
demoExamples,
|
||||
isAgenticSearchTutorialDismissed,
|
||||
}: HomepageProps) => {
|
||||
const [searchMode, setSearchMode] = useState<SearchMode>(initialSearchMode);
|
||||
const isAgenticSearchEnabled = languageModels.length > 0;
|
||||
|
||||
const onSearchModeChanged = useCallback(async (newMode: SearchMode) => {
|
||||
setSearchMode(newMode);
|
||||
await setSearchModeCookie(newMode);
|
||||
}, [setSearchMode]);
|
||||
|
||||
useHotkeys("mod+i", (e) => {
|
||||
e.preventDefault();
|
||||
onSearchModeChanged("agentic");
|
||||
}, {
|
||||
enableOnFormTags: true,
|
||||
enableOnContentEditable: true,
|
||||
description: "Switch to agentic search",
|
||||
});
|
||||
|
||||
useHotkeys("mod+p", (e) => {
|
||||
e.preventDefault();
|
||||
onSearchModeChanged("precise");
|
||||
}, {
|
||||
enableOnFormTags: true,
|
||||
enableOnContentEditable: true,
|
||||
description: "Switch to precise search",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
|
||||
<div className="max-h-44 w-auto">
|
||||
<SourcebotLogo
|
||||
className="h-18 md:h-40 w-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{searchMode === "precise" ? (
|
||||
<PreciseSearch
|
||||
initialRepos={initialRepos}
|
||||
searchModeSelectorProps={{
|
||||
searchMode: "precise",
|
||||
isAgenticSearchEnabled,
|
||||
onSearchModeChange: onSearchModeChanged,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CustomSlateEditor>
|
||||
<AgenticSearch
|
||||
searchModeSelectorProps={{
|
||||
searchMode: "agentic",
|
||||
isAgenticSearchEnabled,
|
||||
onSearchModeChange: onSearchModeChanged,
|
||||
}}
|
||||
languageModels={languageModels}
|
||||
repos={initialRepos}
|
||||
searchContexts={searchContexts}
|
||||
chatHistory={chatHistory}
|
||||
demoExamples={demoExamples}
|
||||
isTutorialDismissed={isAgenticSearchTutorialDismissed}
|
||||
/>
|
||||
</CustomSlateEditor>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SyntaxReferenceGuideHint } from "../syntaxReferenceGuideHint";
|
||||
import { RepositorySnapshot } from "./repositorySnapshot";
|
||||
import { RepositoryQuery } from "@/lib/types";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import Link from "next/link";
|
||||
import { SearchBar } from "../searchBar/searchBar";
|
||||
import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar";
|
||||
|
||||
interface PreciseSearchProps {
|
||||
initialRepos: RepositoryQuery[];
|
||||
searchModeSelectorProps: SearchModeSelectorProps;
|
||||
}
|
||||
|
||||
export const PreciseSearch = ({
|
||||
initialRepos,
|
||||
searchModeSelectorProps,
|
||||
}: PreciseSearchProps) => {
|
||||
const domain = useDomain();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 w-full max-w-[800px] border rounded-md shadow-sm">
|
||||
<SearchBar
|
||||
autoFocus={true}
|
||||
className="border-none pt-0.5 pb-0"
|
||||
/>
|
||||
<Separator />
|
||||
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
|
||||
<SearchModeSelector
|
||||
{...searchModeSelectorProps}
|
||||
className="ml-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<RepositorySnapshot
|
||||
repos={initialRepos}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-center w-fit gap-6">
|
||||
<Separator className="mt-5" />
|
||||
<span className="font-semibold">How to search</span>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<HowToSection
|
||||
title="Search in files or paths"
|
||||
>
|
||||
<QueryExample>
|
||||
<Query query="test todo" domain={domain}>test todo</Query> <QueryExplanation>(both test and todo)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="test or todo" domain={domain}>test <Highlight>or</Highlight> todo</Query> <QueryExplanation>(either test or todo)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query={`"exit boot"`} domain={domain}>{`"exit boot"`}</Query> <QueryExplanation>(exact match)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="TODO case:yes" domain={domain}>TODO <Highlight>case:</Highlight>yes</Query> <QueryExplanation>(case sensitive)</QueryExplanation>
|
||||
</QueryExample>
|
||||
</HowToSection>
|
||||
<HowToSection
|
||||
title="Filter results"
|
||||
>
|
||||
<QueryExample>
|
||||
<Query query="file:README setup" domain={domain}><Highlight>file:</Highlight>README setup</Query> <QueryExplanation>(by filename)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="repo:torvalds/linux test" domain={domain}><Highlight>repo:</Highlight>torvalds/linux test</Query> <QueryExplanation>(by repo)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="lang:typescript" domain={domain}><Highlight>lang:</Highlight>typescript</Query> <QueryExplanation>(by language)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="rev:HEAD" domain={domain}><Highlight>rev:</Highlight>HEAD</Query> <QueryExplanation>(by branch or tag)</QueryExplanation>
|
||||
</QueryExample>
|
||||
</HowToSection>
|
||||
<HowToSection
|
||||
title="Advanced"
|
||||
>
|
||||
<QueryExample>
|
||||
<Query query="file:\.py$" domain={domain}><Highlight>file:</Highlight>{`\\.py$`}</Query> <QueryExplanation>{`(files that end in ".py")`}</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="sym:main" domain={domain}><Highlight>sym:</Highlight>main</Query> <QueryExplanation>{`(symbols named "main")`}</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="todo -lang:c" domain={domain}>todo <Highlight>-lang:c</Highlight></Query> <QueryExplanation>(negate filter)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="content:README" domain={domain}><Highlight>content:</Highlight>README</Query> <QueryExplanation>(search content only)</QueryExplanation>
|
||||
</QueryExample>
|
||||
</HowToSection>
|
||||
</div>
|
||||
<SyntaxReferenceGuideHint />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="dark:text-gray-300 text-sm mb-2 underline">{title}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
const Highlight = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<span className="text-highlight">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const QueryExample = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<span className="text-sm font-mono">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const QueryExplanation = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<span className="text-gray-500 dark:text-gray-400 ml-3">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const Query = ({ query, domain, children }: { query: string, domain: string, children: React.ReactNode }) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/${domain}/search?query=${query}`}
|
||||
className="cursor-pointer hover:underline"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import Autoscroll from "embla-carousel-auto-scroll";
|
||||
import { getCodeHostInfoForRepo } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import { FileIcon } from "@radix-ui/react-icons";
|
||||
import clsx from "clsx";
|
||||
import { RepositoryQuery } from "@/lib/types";
|
||||
import { getBrowsePath } from "../../browse/hooks/useBrowseNavigation";
|
||||
import Link from "next/link";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
|
||||
interface RepositoryCarouselProps {
|
||||
repos: RepositoryQuery[];
|
||||
}
|
||||
|
||||
export const RepositoryCarousel = ({
|
||||
repos,
|
||||
}: RepositoryCarouselProps) => {
|
||||
return (
|
||||
<Carousel
|
||||
opts={{
|
||||
align: "start",
|
||||
loop: true,
|
||||
}}
|
||||
className="w-full max-w-lg"
|
||||
plugins={[
|
||||
Autoscroll({
|
||||
startDelay: 0,
|
||||
speed: 1,
|
||||
stopOnMouseEnter: true,
|
||||
stopOnInteraction: false,
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<CarouselContent>
|
||||
{repos.map((repo, index) => (
|
||||
<CarouselItem key={index} className="basis-auto">
|
||||
<RepositoryBadge
|
||||
key={index}
|
||||
repo={repo}
|
||||
/>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
)
|
||||
};
|
||||
|
||||
interface RepositoryBadgeProps {
|
||||
repo: RepositoryQuery;
|
||||
}
|
||||
|
||||
const RepositoryBadge = ({
|
||||
repo
|
||||
}: RepositoryBadgeProps) => {
|
||||
const domain = useDomain();
|
||||
const { repoIcon, displayName } = (() => {
|
||||
const info = getCodeHostInfoForRepo({
|
||||
codeHostType: repo.codeHostType,
|
||||
name: repo.repoName,
|
||||
displayName: repo.repoDisplayName,
|
||||
webUrl: repo.webUrl,
|
||||
});
|
||||
|
||||
if (info) {
|
||||
return {
|
||||
repoIcon: <Image
|
||||
src={info.icon}
|
||||
alt={info.codeHostName}
|
||||
className={`w-4 h-4 ${info.iconClassName}`}
|
||||
/>,
|
||||
displayName: info.displayName,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repoIcon: <FileIcon className="w-4 h-4" />,
|
||||
displayName: repo.repoName,
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={getBrowsePath({
|
||||
repoName: repo.repoName,
|
||||
path: '/',
|
||||
pathType: 'tree',
|
||||
domain
|
||||
})}
|
||||
|
||||
className={clsx("flex flex-row items-center gap-2 border rounded-md p-2 text-clip")}
|
||||
>
|
||||
{repoIcon}
|
||||
<span className="text-sm font-mono">
|
||||
{displayName}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { RepositoryCarousel } from "./repositoryCarousel";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { unwrapServiceError } from "@/lib/utils";
|
||||
import { getRepos } from "@/app/api/(client)/client";
|
||||
import { env } from "@/env.mjs";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import { RepoIndexingStatus } from "@sourcebot/db";
|
||||
import { SymbolIcon } from "@radix-ui/react-icons";
|
||||
import { RepositoryQuery } from "@/lib/types";
|
||||
import { captureEvent } from "@/hooks/useCaptureEvent";
|
||||
|
||||
interface RepositorySnapshotProps {
|
||||
repos: RepositoryQuery[];
|
||||
}
|
||||
|
||||
const MAX_REPOS_TO_DISPLAY_IN_CAROUSEL = 15;
|
||||
|
||||
export function RepositorySnapshot({
|
||||
repos: initialRepos,
|
||||
}: RepositorySnapshotProps) {
|
||||
const domain = useDomain();
|
||||
|
||||
const { data: repos, isPending, isError } = useQuery({
|
||||
queryKey: ['repos', domain],
|
||||
queryFn: () => unwrapServiceError(getRepos()),
|
||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
||||
placeholderData: initialRepos,
|
||||
});
|
||||
|
||||
if (isPending || isError || !repos) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<RepoSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Use `indexedAt` to determine if a repo has __ever__ been indexed.
|
||||
// The repo indexing status only tells us the repo's current indexing status.
|
||||
const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
|
||||
|
||||
// If there are no indexed repos...
|
||||
if (indexedRepos.length === 0) {
|
||||
|
||||
// ... show a loading state if repos are being indexed now
|
||||
if (repos.some((repo) => repo.repoIndexingStatus === RepoIndexingStatus.INDEXING || repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE)) {
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<SymbolIcon className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">indexing in progress...</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ... otherwise, show the empty state.
|
||||
} else {
|
||||
return (
|
||||
<EmptyRepoState />
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<span className="text-sm">
|
||||
{`${indexedRepos.length} `}
|
||||
<Link
|
||||
href={`${domain}/repos`}
|
||||
className="text-link hover:underline"
|
||||
>
|
||||
{indexedRepos.length > 1 ? 'repositories' : 'repository'}
|
||||
</Link>
|
||||
{` indexed`}
|
||||
</span>
|
||||
<RepositoryCarousel
|
||||
repos={indexedRepos.slice(0, MAX_REPOS_TO_DISPLAY_IN_CAROUSEL)}
|
||||
/>
|
||||
{process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === "demo" && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Interested in using Sourcebot on your code? Check out our{' '}
|
||||
<a
|
||||
href="https://docs.sourcebot.dev/docs/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
onClick={() => captureEvent('wa_demo_docs_link_pressed', {})}
|
||||
>
|
||||
docs
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyRepoState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<span className="text-sm">No repositories found</span>
|
||||
|
||||
<div className="w-full max-w-lg">
|
||||
<div className="flex flex-row items-center gap-2 border rounded-md p-4 justify-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<>
|
||||
Create a{" "}
|
||||
<Link href={`https://docs.sourcebot.dev/docs/connections/overview`} className="text-blue-500 hover:underline inline-flex items-center gap-1">
|
||||
connection
|
||||
</Link>{" "}
|
||||
to start indexing repositories
|
||||
</>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RepoSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{/* Skeleton for "Search X repositories" text */}
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<Skeleton className="h-4 w-14" /> {/* "Search X" */}
|
||||
<Skeleton className="h-4 w-24" /> {/* "repositories" */}
|
||||
</div>
|
||||
|
||||
{/* Skeleton for repository carousel */}
|
||||
<Carousel
|
||||
opts={{
|
||||
align: "start",
|
||||
loop: true,
|
||||
}}
|
||||
className="w-full max-w-lg"
|
||||
>
|
||||
<CarouselContent>
|
||||
{[1, 2, 3].map((_, index) => (
|
||||
<CarouselItem key={index} className="basis-auto">
|
||||
<div className="flex flex-row items-center gap-2 border rounded-md p-2">
|
||||
<Skeleton className="h-4 w-4 rounded-sm" /> {/* Icon */}
|
||||
<Skeleton className="h-4 w-32" /> {/* Repository name */}
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,21 +1,26 @@
|
|||
import { getRepos, getReposStats } from "@/actions";
|
||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||
import { auth } from "@/auth";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { Separator } from "@/components/ui/separator";
|
||||
import { SettingsDropdown } from "./settingsDropdown";
|
||||
import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons";
|
||||
import { redirect } from "next/navigation";
|
||||
import { OrgSelector } from "./orgSelector";
|
||||
import { ErrorNavIndicator } from "./errorNavIndicator";
|
||||
import { WarningNavIndicator } from "./warningNavIndicator";
|
||||
import { ProgressNavIndicator } from "./progressNavIndicator";
|
||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||
import { TrialNavIndicator } from "./trialNavIndicator";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
|
||||
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
||||
import { env } from "@/env.mjs";
|
||||
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
|
||||
import { auth } from "@/auth";
|
||||
import WhatsNewIndicator from "./whatsNewIndicator";
|
||||
import { ServiceErrorException } from "@/lib/serviceError";
|
||||
import { cn, getShortenedNumberDisplayString, isServiceError } from "@/lib/utils";
|
||||
import { DiscordLogoIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
|
||||
import { RepoJobStatus, RepoJobType } from "@sourcebot/db";
|
||||
import { BookMarkedIcon, CircleIcon, MessageCircleIcon, SearchIcon, SettingsIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { OrgSelector } from "../orgSelector";
|
||||
import { SettingsDropdown } from "../settingsDropdown";
|
||||
import WhatsNewIndicator from "../whatsNewIndicator";
|
||||
import { ProgressIndicator } from "./progressIndicator";
|
||||
import { TrialIndicator } from "./trialIndicator";
|
||||
|
||||
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
|
||||
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
|
||||
|
|
@ -31,6 +36,38 @@ export const NavigationMenu = async ({
|
|||
const session = await auth();
|
||||
const isAuthenticated = session?.user !== undefined;
|
||||
|
||||
const repoStats = await getReposStats();
|
||||
if (isServiceError(repoStats)) {
|
||||
throw new ServiceErrorException(repoStats);
|
||||
}
|
||||
|
||||
const sampleRepos = await getRepos({
|
||||
where: {
|
||||
jobs: {
|
||||
some: {
|
||||
type: RepoJobType.INDEX,
|
||||
status: {
|
||||
in: [
|
||||
RepoJobStatus.PENDING,
|
||||
RepoJobStatus.IN_PROGRESS,
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
indexedAt: null,
|
||||
},
|
||||
take: 5,
|
||||
});
|
||||
|
||||
if (isServiceError(sampleRepos)) {
|
||||
throw new ServiceErrorException(sampleRepos);
|
||||
}
|
||||
|
||||
const {
|
||||
numberOfRepos,
|
||||
numberOfReposWithFirstTimeIndexingJobsInProgress,
|
||||
} = repoStats;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-fit bg-background">
|
||||
<div className="flex flex-row justify-between items-center py-1.5 px-3">
|
||||
|
|
@ -55,48 +92,55 @@ export const NavigationMenu = async ({
|
|||
)}
|
||||
|
||||
<NavigationMenuBase>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuList className="gap-2">
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink
|
||||
href={`/${domain}`}
|
||||
className={navigationMenuTriggerStyle()}
|
||||
className={cn(navigationMenuTriggerStyle(), "gap-2")}
|
||||
>
|
||||
<SearchIcon className="w-4 h-4 mr-1" />
|
||||
Search
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink
|
||||
href={`/${domain}/repos`}
|
||||
href={`/${domain}/chat`}
|
||||
className={navigationMenuTriggerStyle()}
|
||||
>
|
||||
Repositories
|
||||
<MessageCircleIcon className="w-4 h-4 mr-1" />
|
||||
Ask
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem className="relative">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<NavigationMenuLink
|
||||
href={`/${domain}/repos`}
|
||||
className={navigationMenuTriggerStyle()}
|
||||
>
|
||||
<BookMarkedIcon className="w-4 h-4 mr-1" />
|
||||
<span className="mr-2">Repositories</span>
|
||||
<Badge variant="secondary" className="px-1.5 relative">
|
||||
{getShortenedNumberDisplayString(numberOfRepos)}
|
||||
{numberOfReposWithFirstTimeIndexingJobsInProgress > 0 && (
|
||||
<CircleIcon className="absolute -right-0.5 -top-0.5 h-2 w-2 text-green-600" fill="currentColor" />
|
||||
)}
|
||||
</Badge>
|
||||
</NavigationMenuLink>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{numberOfRepos} total {numberOfRepos === 1 ? 'repository' : 'repositories'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</NavigationMenuItem>
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
{env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined && (
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink
|
||||
href={`/${domain}/agents`}
|
||||
className={navigationMenuTriggerStyle()}
|
||||
>
|
||||
Agents
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
)}
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink
|
||||
href={`/${domain}/connections`}
|
||||
className={navigationMenuTriggerStyle()}
|
||||
>
|
||||
Connections
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink
|
||||
href={`/${domain}/settings`}
|
||||
className={navigationMenuTriggerStyle()}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4 mr-1" />
|
||||
Settings
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
|
|
@ -107,10 +151,11 @@ export const NavigationMenu = async ({
|
|||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ProgressNavIndicator />
|
||||
<WarningNavIndicator />
|
||||
<ErrorNavIndicator />
|
||||
<TrialNavIndicator subscription={subscription} />
|
||||
<ProgressIndicator
|
||||
numberOfReposWithFirstTimeIndexingJobsInProgress={numberOfReposWithFirstTimeIndexingJobsInProgress}
|
||||
sampleRepos={sampleRepos}
|
||||
/>
|
||||
<TrialIndicator subscription={subscription} />
|
||||
<WhatsNewIndicator />
|
||||
<form
|
||||
action={async () => {
|
||||
|
|
@ -145,7 +190,5 @@ export const NavigationMenu = async ({
|
|||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { RepositoryQuery } from "@/lib/types";
|
||||
import { getCodeHostInfoForRepo, getShortenedNumberDisplayString } from "@/lib/utils";
|
||||
import clsx from "clsx";
|
||||
import { FileIcon, Loader2Icon, RefreshCwIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface ProgressIndicatorProps {
|
||||
numberOfReposWithFirstTimeIndexingJobsInProgress: number;
|
||||
sampleRepos: RepositoryQuery[];
|
||||
}
|
||||
|
||||
export const ProgressIndicator = ({
|
||||
numberOfReposWithFirstTimeIndexingJobsInProgress: numRepos,
|
||||
sampleRepos,
|
||||
}: ProgressIndicatorProps) => {
|
||||
const domain = useDomain();
|
||||
const router = useRouter();
|
||||
|
||||
if (numRepos === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numReposString = getShortenedNumberDisplayString(numRepos);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Link href={`/${domain}/repos`}>
|
||||
<Badge variant="outline" className="flex flex-row items-center gap-2 h-8">
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
<span>{numReposString}</span>
|
||||
</Badge>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="p-4 w-72">
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
<p className="text-md font-medium">{`Syncing ${numReposString} ${numRepos === 1 ? 'repository' : 'repositories'}`}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground"
|
||||
onClick={() => {
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<div className="flex flex-col gap-2">
|
||||
{sampleRepos.map((repo) => (
|
||||
<RepoItem key={repo.repoId} repo={repo} />
|
||||
))}
|
||||
</div>
|
||||
{numRepos > sampleRepos.length && (
|
||||
<div className="mt-2">
|
||||
<Link href={`/${domain}/repos`} className="text-sm text-link hover:underline">
|
||||
{`View ${numRepos - sampleRepos.length} more`}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const RepoItem = ({ repo }: { repo: RepositoryQuery }) => {
|
||||
|
||||
const { repoIcon, displayName } = useMemo(() => {
|
||||
const info = getCodeHostInfoForRepo({
|
||||
name: repo.repoName,
|
||||
codeHostType: repo.codeHostType,
|
||||
displayName: repo.repoDisplayName,
|
||||
webUrl: repo.webUrl,
|
||||
});
|
||||
|
||||
if (info) {
|
||||
return {
|
||||
repoIcon: <Image
|
||||
src={info.icon}
|
||||
alt={info.codeHostName}
|
||||
className={`w-4 h-4 ${info.iconClassName}`}
|
||||
/>,
|
||||
displayName: info.displayName,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repoIcon: <FileIcon className="w-4 h-4" />,
|
||||
displayName: repo.repoName,
|
||||
}
|
||||
|
||||
|
||||
}, [repo.repoName, repo.codeHostType, repo.repoDisplayName, repo.webUrl]);
|
||||
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={'/'}
|
||||
className={clsx("flex flex-row items-center gap-2 border rounded-md p-2 text-clip")}
|
||||
>
|
||||
{repoIcon}
|
||||
<span className="text-sm truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ interface Props {
|
|||
} | null | ServiceError;
|
||||
}
|
||||
|
||||
export const TrialNavIndicator = ({ subscription }: Props) => {
|
||||
export const TrialIndicator = ({ subscription }: Props) => {
|
||||
const domain = useDomain();
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import { cn, getCodeHostInfoForRepo } from "@/lib/utils";
|
||||
import { LaptopIcon } from "@radix-ui/react-icons";
|
||||
import Image from "next/image";
|
||||
import { getBrowsePath } from "../browse/hooks/useBrowseNavigation";
|
||||
import { getBrowsePath } from "../browse/hooks/utils";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import { useCallback, useState, useMemo, useRef, useEffect } from "react";
|
||||
import { useToast } from "@/components/hooks/use-toast";
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { env } from "@/env.mjs";
|
||||
import { unwrapServiceError } from "@/lib/utils";
|
||||
import { RepoIndexingStatus } from "@prisma/client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getRepos } from "@/app/api/(client)/client";
|
||||
|
||||
export const ProgressNavIndicator = () => {
|
||||
const domain = useDomain();
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const { data: inProgressRepos, isPending, isError } = useQuery({
|
||||
queryKey: ['repos'],
|
||||
queryFn: () => unwrapServiceError(getRepos()),
|
||||
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE || repo.repoIndexingStatus === RepoIndexingStatus.INDEXING),
|
||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
||||
});
|
||||
|
||||
if (isPending || isError || inProgressRepos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${domain}/connections`}
|
||||
onClick={() => captureEvent('wa_progress_nav_pressed', {})}
|
||||
>
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_progress_nav_hover', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-full text-green-700 dark:text-green-400 text-xs font-medium hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors cursor-pointer">
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
<span>{inProgressRepos.length}</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div className="flex flex-col gap-4 p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse"></div>
|
||||
<h3 className="text-sm font-medium text-green-700 dark:text-green-400">Indexing in Progress</h3>
|
||||
</div>
|
||||
<p className="text-sm text-green-600/90 dark:text-green-300/90 leading-relaxed">
|
||||
The following repositories are currently being indexed:
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 pl-4">
|
||||
{
|
||||
inProgressRepos.slice(0, 10)
|
||||
.map(item => (
|
||||
<div key={item.repoId} className="flex items-center gap-2 px-3 py-2 bg-green-50 dark:bg-green-900/20
|
||||
rounded-md text-sm text-green-700 dark:text-green-300
|
||||
border border-green-200/50 dark:border-green-800/50
|
||||
hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors">
|
||||
<span className="font-medium truncate">{item.repoName}</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{inProgressRepos.length > 10 && (
|
||||
<div className="text-sm text-green-600/90 dark:text-green-300/90 pl-3 pt-1">
|
||||
And {inProgressRepos.length - 10} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
158
packages/web/src/app/[domain]/components/repositoryCarousel.tsx
Normal file
158
packages/web/src/app/[domain]/components/repositoryCarousel.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import { captureEvent } from "@/hooks/useCaptureEvent";
|
||||
import { RepositoryQuery } from "@/lib/types";
|
||||
import { getCodeHostInfoForRepo } from "@/lib/utils";
|
||||
import { FileIcon } from "@radix-ui/react-icons";
|
||||
import clsx from "clsx";
|
||||
import Autoscroll from "embla-carousel-auto-scroll";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { getBrowsePath } from "../browse/hooks/utils";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
|
||||
interface RepositoryCarouselProps {
|
||||
displayRepos: RepositoryQuery[];
|
||||
numberOfReposWithIndex: number;
|
||||
}
|
||||
|
||||
export function RepositoryCarousel({
|
||||
displayRepos,
|
||||
numberOfReposWithIndex,
|
||||
}: RepositoryCarouselProps) {
|
||||
const domain = useDomain();
|
||||
|
||||
if (numberOfReposWithIndex === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<span className="text-sm">No repositories found</span>
|
||||
|
||||
<div className="w-full max-w-lg">
|
||||
<div className="flex flex-row items-center gap-2 border rounded-md p-4 justify-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<>
|
||||
Create a{" "}
|
||||
<Link href={`https://docs.sourcebot.dev/docs/connections/overview`} className="text-blue-500 hover:underline inline-flex items-center gap-1">
|
||||
connection
|
||||
</Link>{" "}
|
||||
to start indexing repositories
|
||||
</>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<span className="text-sm">
|
||||
{`${numberOfReposWithIndex} `}
|
||||
<Link
|
||||
href={`/${domain}/repos`}
|
||||
className="text-link hover:underline"
|
||||
>
|
||||
{numberOfReposWithIndex > 1 ? 'repositories' : 'repository'}
|
||||
</Link>
|
||||
{` indexed`}
|
||||
</span>
|
||||
<Carousel
|
||||
opts={{
|
||||
align: "start",
|
||||
loop: true,
|
||||
}}
|
||||
className="w-full max-w-lg"
|
||||
plugins={[
|
||||
Autoscroll({
|
||||
startDelay: 0,
|
||||
speed: 1,
|
||||
stopOnMouseEnter: true,
|
||||
stopOnInteraction: false,
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<CarouselContent>
|
||||
{displayRepos.map((repo, index) => (
|
||||
<CarouselItem key={index} className="basis-auto">
|
||||
<RepositoryBadge
|
||||
key={index}
|
||||
repo={repo}
|
||||
/>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
{process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === "demo" && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Interested in using Sourcebot on your code? Check out our{' '}
|
||||
<a
|
||||
href="https://docs.sourcebot.dev/docs/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
onClick={() => captureEvent('wa_demo_docs_link_pressed', {})}
|
||||
>
|
||||
docs
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface RepositoryBadgeProps {
|
||||
repo: RepositoryQuery;
|
||||
}
|
||||
|
||||
const RepositoryBadge = ({
|
||||
repo
|
||||
}: RepositoryBadgeProps) => {
|
||||
const domain = useDomain();
|
||||
const { repoIcon, displayName } = (() => {
|
||||
const info = getCodeHostInfoForRepo({
|
||||
codeHostType: repo.codeHostType,
|
||||
name: repo.repoName,
|
||||
displayName: repo.repoDisplayName,
|
||||
webUrl: repo.webUrl,
|
||||
});
|
||||
|
||||
if (info) {
|
||||
return {
|
||||
repoIcon: <Image
|
||||
src={info.icon}
|
||||
alt={info.codeHostName}
|
||||
className={`w-4 h-4 ${info.iconClassName}`}
|
||||
/>,
|
||||
displayName: info.displayName,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repoIcon: <FileIcon className="w-4 h-4" />,
|
||||
displayName: repo.repoName,
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={getBrowsePath({
|
||||
repoName: repo.repoName,
|
||||
path: '/',
|
||||
pathType: 'tree',
|
||||
domain,
|
||||
})}
|
||||
|
||||
className={clsx("flex flex-row items-center gap-2 border rounded-md p-2 text-clip")}
|
||||
>
|
||||
{repoIcon}
|
||||
<span className="text-sm font-mono">
|
||||
{displayName}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,16 @@
|
|||
'use client';
|
||||
|
||||
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MessageCircleIcon, SearchIcon, TriangleAlert } from "lucide-react";
|
||||
import { MessageCircleIcon, SearchIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
|
||||
export type SearchMode = "precise" | "agentic";
|
||||
|
||||
|
|
@ -17,24 +20,47 @@ const AGENTIC_SEARCH_DOCS_URL = "https://docs.sourcebot.dev/docs/features/ask/ov
|
|||
|
||||
export interface SearchModeSelectorProps {
|
||||
searchMode: SearchMode;
|
||||
isAgenticSearchEnabled: boolean;
|
||||
onSearchModeChange: (searchMode: SearchMode) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SearchModeSelector = ({
|
||||
searchMode,
|
||||
isAgenticSearchEnabled,
|
||||
onSearchModeChange,
|
||||
className,
|
||||
}: SearchModeSelectorProps) => {
|
||||
const domain = useDomain();
|
||||
const [focusedSearchMode, setFocusedSearchMode] = useState<SearchMode>(searchMode);
|
||||
const router = useRouter();
|
||||
|
||||
const onSearchModeChanged = useCallback((value: SearchMode) => {
|
||||
router.push(`/${domain}/${value === "precise" ? "search" : "chat"}`);
|
||||
}, [domain, router]);
|
||||
|
||||
useHotkeys("mod+i", (e) => {
|
||||
e.preventDefault();
|
||||
onSearchModeChanged("agentic");
|
||||
}, {
|
||||
enableOnFormTags: true,
|
||||
enableOnContentEditable: true,
|
||||
description: "Switch to agentic search",
|
||||
});
|
||||
|
||||
useHotkeys("mod+p", (e) => {
|
||||
e.preventDefault();
|
||||
onSearchModeChanged("precise");
|
||||
}, {
|
||||
enableOnFormTags: true,
|
||||
enableOnContentEditable: true,
|
||||
description: "Switch to precise search",
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-row items-center", className)}>
|
||||
<Select
|
||||
value={searchMode}
|
||||
onValueChange={(value) => onSearchModeChange(value as "precise" | "agentic")}
|
||||
onValueChange={(value) => {
|
||||
onSearchModeChanged(value as SearchMode);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="flex flex-row items-center h-6 mt-0.5 font-mono font-semibold text-xs p-0 w-fit border-none bg-inherit rounded-md"
|
||||
|
|
@ -99,16 +125,10 @@ export const SearchModeSelector = ({
|
|||
<div
|
||||
onMouseEnter={() => setFocusedSearchMode("agentic")}
|
||||
onFocus={() => setFocusedSearchMode("agentic")}
|
||||
className={cn({
|
||||
"cursor-not-allowed": !isAgenticSearchEnabled,
|
||||
})}
|
||||
>
|
||||
<SelectItem
|
||||
value="agentic"
|
||||
disabled={!isAgenticSearchEnabled}
|
||||
className={cn({
|
||||
"cursor-pointer": isAgenticSearchEnabled,
|
||||
})}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between w-full gap-1.5">
|
||||
<span>Ask</span>
|
||||
|
|
@ -129,14 +149,8 @@ export const SearchModeSelector = ({
|
|||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{!isAgenticSearchEnabled && (
|
||||
<TriangleAlert className="w-4 h-4 flex-shrink-0 text-warning" />
|
||||
)}
|
||||
<p className="font-semibold">Ask Sourcebot</p>
|
||||
</div>
|
||||
{!isAgenticSearchEnabled && (
|
||||
<p className="text-destructive">Language model not configured. <Link href={AGENTIC_SEARCH_DOCS_URL} className="text-link hover:underline">See setup instructions.</Link></p>
|
||||
)}
|
||||
<Separator orientation="horizontal" className="w-full my-0.5" />
|
||||
<p>Use natural language to search, summarize and understand your codebase using a reasoning agent.</p>
|
||||
<Link
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { getConnections } from "@/actions";
|
||||
import { unwrapServiceError } from "@/lib/utils";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { env } from "@/env.mjs";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ConnectionSyncStatus } from "@prisma/client";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export const WarningNavIndicator = () => {
|
||||
const domain = useDomain();
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const { data: connections, isPending, isError } = useQuery({
|
||||
queryKey: ['connections', domain],
|
||||
queryFn: () => unwrapServiceError(getConnections(domain)),
|
||||
select: (data) => data.filter(connection => connection.syncStatus === ConnectionSyncStatus.SYNCED_WITH_WARNINGS),
|
||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
||||
});
|
||||
|
||||
if (isPending || isError || connections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_warning_nav_pressed', {})}>
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_warning_nav_hover', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-full text-yellow-700 dark:text-yellow-400 text-xs font-medium hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors cursor-pointer">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<span>{connections.length}</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<div className="flex flex-col gap-4 p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-yellow-500"></div>
|
||||
<h3 className="text-sm font-medium text-yellow-700 dark:text-yellow-400">Missing References</h3>
|
||||
</div>
|
||||
<p className="text-sm text-yellow-600/90 dark:text-yellow-300/90 leading-relaxed">
|
||||
The following connections have references that could not be found:
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 pl-4">
|
||||
<TooltipProvider>
|
||||
{connections.slice(0, 10).map(connection => (
|
||||
<Link key={connection.name} href={`/${domain}/connections/${connection.id}`} onClick={() => captureEvent('wa_warning_nav_connection_pressed', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-yellow-50 dark:bg-yellow-900/20
|
||||
rounded-md text-sm text-yellow-700 dark:text-yellow-300
|
||||
border border-yellow-200/50 dark:border-yellow-800/50
|
||||
hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-medium truncate max-w-[200px]">{connection.name}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{connection.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
{connections.length > 10 && (
|
||||
<div className="text-sm text-yellow-600/90 dark:text-yellow-300/90 pl-3 pt-1">
|
||||
And {connections.length - 10} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
|
@ -64,7 +64,7 @@ export const RepoList = ({ connectionId }: RepoListProps) => {
|
|||
const { data: unfilteredRepos, isPending: isReposPending, error: reposError, refetch: refetchRepos } = useQuery({
|
||||
queryKey: ['repos', domain, connectionId],
|
||||
queryFn: async () => {
|
||||
const repos = await unwrapServiceError(getRepos({ connectionId }));
|
||||
const repos = await unwrapServiceError(getRepos());
|
||||
return repos.sort((a, b) => {
|
||||
const priorityA = getPriority(a.repoIndexingStatus);
|
||||
const priorityB = getPriority(b.repoIndexingStatus);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { getAnonymousAccessStatus, getMemberApprovalRequired } from "@/actions";
|
|||
import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
|
||||
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
||||
import { GitHubStarToast } from "./components/githubStarToast";
|
||||
import { UpgradeToast } from "./components/upgradeToast";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode,
|
||||
|
|
@ -154,6 +155,7 @@ export default async function Layout(props: LayoutProps) {
|
|||
{children}
|
||||
<SyntaxReferenceGuide />
|
||||
<GitHubStarToast />
|
||||
<UpgradeToast />
|
||||
</SyntaxGuideProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,101 +1,11 @@
|
|||
import { getRepos, getSearchContexts } from "@/actions";
|
||||
import { Footer } from "@/app/components/footer";
|
||||
import { getOrgFromDomain } from "@/data/org";
|
||||
import { getConfiguredLanguageModelsInfo, getUserChatHistory } from "@/features/chat/actions";
|
||||
import { isServiceError, measure } from "@/lib/utils";
|
||||
import { Homepage } from "./components/homepage";
|
||||
import { NavigationMenu } from "./components/navigationMenu";
|
||||
import { PageNotFound } from "./components/pageNotFound";
|
||||
import { UpgradeToast } from "./components/upgradeToast";
|
||||
import { ServiceErrorException } from "@/lib/serviceError";
|
||||
import { auth } from "@/auth";
|
||||
import { cookies } from "next/headers";
|
||||
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, SEARCH_MODE_COOKIE_NAME } from "@/lib/constants";
|
||||
import { env } from "@/env.mjs";
|
||||
import { loadJsonFile } from "@sourcebot/shared";
|
||||
import { DemoExamples, demoExamplesSchema } from "@/types";
|
||||
import { createLogger } from "@sourcebot/logger";
|
||||
import SearchPage from "./search/page";
|
||||
|
||||
const logger = createLogger('web-homepage');
|
||||
|
||||
export default async function Home(props: { params: Promise<{ domain: string }> }) {
|
||||
logger.debug('Starting homepage load...');
|
||||
const { data: HomePage, durationMs } = await measure(() => HomeInternal(props), 'HomeInternal', /* outputLog = */ false);
|
||||
logger.debug(`Homepage load completed in ${durationMs}ms.`);
|
||||
|
||||
return HomePage;
|
||||
interface Props {
|
||||
params: Promise<{ domain: string }>;
|
||||
searchParams: Promise<{ query?: string }>;
|
||||
}
|
||||
|
||||
const HomeInternal = async (props: { params: Promise<{ domain: string }> }) => {
|
||||
const params = await props.params;
|
||||
|
||||
const {
|
||||
domain
|
||||
} = params;
|
||||
|
||||
|
||||
const org = (await measure(() => getOrgFromDomain(domain), 'getOrgFromDomain')).data;
|
||||
if (!org) {
|
||||
return <PageNotFound />
|
||||
}
|
||||
|
||||
const session = (await measure(() => auth(), 'auth')).data;
|
||||
const models = (await measure(() => getConfiguredLanguageModelsInfo(), 'getConfiguredLanguageModelsInfo')).data;
|
||||
const repos = (await measure(() => getRepos(), 'getRepos')).data;
|
||||
const searchContexts = (await measure(() => getSearchContexts(domain), 'getSearchContexts')).data;
|
||||
const chatHistory = session ? (await measure(() => getUserChatHistory(domain), 'getUserChatHistory')).data : [];
|
||||
|
||||
if (isServiceError(repos)) {
|
||||
throw new ServiceErrorException(repos);
|
||||
}
|
||||
|
||||
if (isServiceError(searchContexts)) {
|
||||
throw new ServiceErrorException(searchContexts);
|
||||
}
|
||||
|
||||
if (isServiceError(chatHistory)) {
|
||||
throw new ServiceErrorException(chatHistory);
|
||||
}
|
||||
|
||||
const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
|
||||
|
||||
// Read search mode from cookie, defaulting to agentic if not set
|
||||
// (assuming a language model is configured).
|
||||
const cookieStore = (await measure(() => cookies(), 'cookies')).data;
|
||||
const searchModeCookie = cookieStore.get(SEARCH_MODE_COOKIE_NAME);
|
||||
const initialSearchMode = (
|
||||
searchModeCookie?.value === "agentic" ||
|
||||
searchModeCookie?.value === "precise"
|
||||
) ? searchModeCookie.value : models.length > 0 ? "agentic" : "precise";
|
||||
|
||||
const isAgenticSearchTutorialDismissed = cookieStore.get(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME)?.value === "true";
|
||||
|
||||
const demoExamples = env.SOURCEBOT_DEMO_EXAMPLES_PATH ? await (async () => {
|
||||
try {
|
||||
return (await measure(() => loadJsonFile<DemoExamples>(env.SOURCEBOT_DEMO_EXAMPLES_PATH!, demoExamplesSchema), 'loadExamplesJsonFile')).data;
|
||||
} catch (error) {
|
||||
console.error('Failed to load demo examples:', error);
|
||||
return undefined;
|
||||
}
|
||||
})() : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center overflow-hidden min-h-screen">
|
||||
<NavigationMenu
|
||||
domain={domain}
|
||||
/>
|
||||
<UpgradeToast />
|
||||
|
||||
<Homepage
|
||||
initialRepos={indexedRepos}
|
||||
searchContexts={searchContexts}
|
||||
languageModels={models}
|
||||
chatHistory={chatHistory}
|
||||
initialSearchMode={initialSearchMode}
|
||||
demoExamples={demoExamples}
|
||||
isAgenticSearchTutorialDismissed={isAgenticSearchTutorialDismissed}
|
||||
/>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
export default async function Home(props: Props) {
|
||||
// Default to rendering the search page.
|
||||
return <SearchPage {...props} />;
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import { cn, getRepoImageSrc } from "@/lib/utils"
|
|||
import { RepoIndexingStatus } from "@sourcebot/db";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import Link from "next/link"
|
||||
import { getBrowsePath } from "../browse/hooks/useBrowseNavigation"
|
||||
import { getBrowsePath } from "../browse/hooks/utils"
|
||||
|
||||
export type RepositoryColumnInfo = {
|
||||
repoId: number
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
||||
import { NavigationMenu } from "../../components/navigationMenu"
|
||||
import { RepositoryCarousel } from "../../components/repositoryCarousel"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { SyntaxReferenceGuideHint } from "../../components/syntaxReferenceGuideHint"
|
||||
import Link from "next/link"
|
||||
import { SearchBar } from "../../components/searchBar"
|
||||
import { SearchModeSelector } from "../../components/searchModeSelector"
|
||||
import { getRepos, getReposStats } from "@/actions"
|
||||
import { ServiceErrorException } from "@/lib/serviceError"
|
||||
import { isServiceError } from "@/lib/utils"
|
||||
|
||||
export interface SearchLandingPageProps {
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export const SearchLandingPage = async ({
|
||||
domain,
|
||||
}: SearchLandingPageProps) => {
|
||||
const carouselRepos = await getRepos({
|
||||
where: {
|
||||
indexedAt: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
|
||||
const repoStats = await getReposStats();
|
||||
|
||||
if (isServiceError(carouselRepos)) throw new ServiceErrorException(carouselRepos);
|
||||
if (isServiceError(repoStats)) throw new ServiceErrorException(repoStats);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center overflow-hidden min-h-screen">
|
||||
<NavigationMenu
|
||||
domain={domain}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
|
||||
<div className="max-h-44 w-auto">
|
||||
<SourcebotLogo
|
||||
className="h-18 md:h-40 w-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 w-full max-w-[800px] border rounded-md shadow-sm">
|
||||
<SearchBar
|
||||
autoFocus={true}
|
||||
className="border-none pt-0.5 pb-0"
|
||||
/>
|
||||
<Separator />
|
||||
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
|
||||
<SearchModeSelector
|
||||
searchMode="precise"
|
||||
className="ml-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<RepositoryCarousel
|
||||
numberOfReposWithIndex={repoStats.numberOfReposWithIndex}
|
||||
displayRepos={carouselRepos}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center w-fit gap-6">
|
||||
<Separator className="mt-5" />
|
||||
<span className="font-semibold">How to search</span>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<HowToSection
|
||||
title="Search in files or paths"
|
||||
>
|
||||
<QueryExample>
|
||||
<Query query="test todo" domain={domain}>test todo</Query> <QueryExplanation>(both test and todo)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="test or todo" domain={domain}>test <Highlight>or</Highlight> todo</Query> <QueryExplanation>(either test or todo)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query={`"exit boot"`} domain={domain}>{`"exit boot"`}</Query> <QueryExplanation>(exact match)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="TODO case:yes" domain={domain}>TODO <Highlight>case:</Highlight>yes</Query> <QueryExplanation>(case sensitive)</QueryExplanation>
|
||||
</QueryExample>
|
||||
</HowToSection>
|
||||
<HowToSection
|
||||
title="Filter results"
|
||||
>
|
||||
<QueryExample>
|
||||
<Query query="file:README setup" domain={domain}><Highlight>file:</Highlight>README setup</Query> <QueryExplanation>(by filename)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="repo:torvalds/linux test" domain={domain}><Highlight>repo:</Highlight>torvalds/linux test</Query> <QueryExplanation>(by repo)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="lang:typescript" domain={domain}><Highlight>lang:</Highlight>typescript</Query> <QueryExplanation>(by language)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="rev:HEAD" domain={domain}><Highlight>rev:</Highlight>HEAD</Query> <QueryExplanation>(by branch or tag)</QueryExplanation>
|
||||
</QueryExample>
|
||||
</HowToSection>
|
||||
<HowToSection
|
||||
title="Advanced"
|
||||
>
|
||||
<QueryExample>
|
||||
<Query query="file:\.py$" domain={domain}><Highlight>file:</Highlight>{`\\.py$`}</Query> <QueryExplanation>{`(files that end in ".py")`}</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="sym:main" domain={domain}><Highlight>sym:</Highlight>main</Query> <QueryExplanation>{`(symbols named "main")`}</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="todo -lang:c" domain={domain}>todo <Highlight>-lang:c</Highlight></Query> <QueryExplanation>(negate filter)</QueryExplanation>
|
||||
</QueryExample>
|
||||
<QueryExample>
|
||||
<Query query="content:README" domain={domain}><Highlight>content:</Highlight>README</Query> <QueryExplanation>(search content only)</QueryExplanation>
|
||||
</QueryExample>
|
||||
</HowToSection>
|
||||
</div>
|
||||
<SyntaxReferenceGuideHint />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="dark:text-gray-300 text-sm mb-2 underline">{title}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
const Highlight = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<span className="text-highlight">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const QueryExample = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<span className="text-sm font-mono">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const QueryExplanation = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<span className="text-gray-500 dark:text-gray-400 ml-3">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const Query = ({ query, domain, children }: { query: string, domain: string, children: React.ReactNode }) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/${domain}/search?query=${query}`}
|
||||
className="cursor-pointer hover:underline"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
'use client';
|
||||
|
||||
import { CodeSnippet } from "@/app/components/codeSnippet";
|
||||
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
||||
import { useToast } from "@/components/hooks/use-toast";
|
||||
import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { RepositoryInfo, SearchResultFile, SearchStats } from "@/features/search/types";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
||||
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
||||
import { SearchQueryParams } from "@/lib/types";
|
||||
import { createPathWithQueryParams, measure, unwrapServiceError } from "@/lib/utils";
|
||||
import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalStorage } from "@uidotdev/usehooks";
|
||||
import { AlertTriangleIcon, BugIcon, FilterIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { ImperativePanelHandle } from "react-resizable-panels";
|
||||
import { search } from "../../../api/(client)/client";
|
||||
import { CopyIconButton } from "../../components/copyIconButton";
|
||||
import { SearchBar } from "../../components/searchBar";
|
||||
import { TopBar } from "../../components/topBar";
|
||||
import { CodePreviewPanel } from "./codePreviewPanel";
|
||||
import { FilterPanel } from "./filterPanel";
|
||||
import { useFilteredMatches } from "./filterPanel/useFilterMatches";
|
||||
import { SearchResultsPanel } from "./searchResultsPanel";
|
||||
|
||||
const DEFAULT_MAX_MATCH_COUNT = 500;
|
||||
|
||||
interface SearchResultsPageProps {
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
export const SearchResultsPage = ({
|
||||
searchQuery,
|
||||
}: SearchResultsPageProps) => {
|
||||
const router = useRouter();
|
||||
const { setSearchHistory } = useSearchHistory();
|
||||
const captureEvent = useCaptureEvent();
|
||||
const domain = useDomain();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Encodes the number of matches to return in the search response.
|
||||
const _maxMatchCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MAX_MATCH_COUNT}`);
|
||||
const maxMatchCount = isNaN(_maxMatchCount) ? DEFAULT_MAX_MATCH_COUNT : _maxMatchCount;
|
||||
|
||||
const {
|
||||
data: searchResponse,
|
||||
isPending: isSearchPending,
|
||||
isFetching: isFetching,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: ["search", searchQuery, maxMatchCount],
|
||||
queryFn: () => measure(() => unwrapServiceError(search({
|
||||
query: searchQuery,
|
||||
matches: maxMatchCount,
|
||||
contextLines: 3,
|
||||
whole: false,
|
||||
}, domain)), "client.search"),
|
||||
select: ({ data, durationMs }) => ({
|
||||
...data,
|
||||
totalClientSearchDurationMs: durationMs,
|
||||
}),
|
||||
enabled: searchQuery.length > 0,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast({
|
||||
description: `❌ Search failed. Reason: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}, [error, toast]);
|
||||
|
||||
|
||||
// Write the query to the search history
|
||||
useEffect(() => {
|
||||
if (searchQuery.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toUTCString();
|
||||
setSearchHistory((searchHistory) => [
|
||||
{
|
||||
query: searchQuery,
|
||||
date: now,
|
||||
},
|
||||
...searchHistory.filter(search => search.query !== searchQuery),
|
||||
])
|
||||
}, [searchQuery, setSearchHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileLanguages = searchResponse.files?.map(file => file.language) || [];
|
||||
|
||||
captureEvent("search_finished", {
|
||||
durationMs: searchResponse.totalClientSearchDurationMs,
|
||||
fileCount: searchResponse.stats.fileCount,
|
||||
matchCount: searchResponse.stats.totalMatchCount,
|
||||
actualMatchCount: searchResponse.stats.actualMatchCount,
|
||||
filesSkipped: searchResponse.stats.filesSkipped,
|
||||
contentBytesLoaded: searchResponse.stats.contentBytesLoaded,
|
||||
indexBytesLoaded: searchResponse.stats.indexBytesLoaded,
|
||||
crashes: searchResponse.stats.crashes,
|
||||
shardFilesConsidered: searchResponse.stats.shardFilesConsidered,
|
||||
filesConsidered: searchResponse.stats.filesConsidered,
|
||||
filesLoaded: searchResponse.stats.filesLoaded,
|
||||
shardsScanned: searchResponse.stats.shardsScanned,
|
||||
shardsSkipped: searchResponse.stats.shardsSkipped,
|
||||
shardsSkippedFilter: searchResponse.stats.shardsSkippedFilter,
|
||||
ngramMatches: searchResponse.stats.ngramMatches,
|
||||
ngramLookups: searchResponse.stats.ngramLookups,
|
||||
wait: searchResponse.stats.wait,
|
||||
matchTreeConstruction: searchResponse.stats.matchTreeConstruction,
|
||||
matchTreeSearch: searchResponse.stats.matchTreeSearch,
|
||||
regexpsConsidered: searchResponse.stats.regexpsConsidered,
|
||||
flushReason: searchResponse.stats.flushReason,
|
||||
fileLanguages,
|
||||
});
|
||||
}, [captureEvent, searchQuery, searchResponse]);
|
||||
|
||||
|
||||
const onLoadMoreResults = useCallback(() => {
|
||||
const url = createPathWithQueryParams(`/${domain}/search`,
|
||||
[SearchQueryParams.query, searchQuery],
|
||||
[SearchQueryParams.matches, `${maxMatchCount * 2}`],
|
||||
)
|
||||
router.push(url);
|
||||
}, [maxMatchCount, router, searchQuery, domain]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-clip">
|
||||
{/* TopBar */}
|
||||
<TopBar
|
||||
domain={domain}
|
||||
>
|
||||
<SearchBar
|
||||
size="sm"
|
||||
defaultQuery={searchQuery}
|
||||
className="w-full"
|
||||
/>
|
||||
</TopBar>
|
||||
|
||||
{(isSearchPending || isFetching) ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||
<SymbolIcon className="h-6 w-6 animate-spin" />
|
||||
<p className="font-semibold text-center">Searching...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||
<AlertTriangleIcon className="h-6 w-6" />
|
||||
<p className="font-semibold text-center">Failed to search</p>
|
||||
<p className="text-sm text-center">{error.message}</p>
|
||||
</div>
|
||||
) : (
|
||||
<PanelGroup
|
||||
fileMatches={searchResponse.files}
|
||||
isMoreResultsButtonVisible={searchResponse.isSearchExhaustive === false}
|
||||
onLoadMoreResults={onLoadMoreResults}
|
||||
isBranchFilteringEnabled={searchResponse.isBranchFilteringEnabled}
|
||||
repoInfo={searchResponse.repositoryInfo}
|
||||
searchDurationMs={searchResponse.totalClientSearchDurationMs}
|
||||
numMatches={searchResponse.stats.actualMatchCount}
|
||||
searchStats={searchResponse.stats}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelGroupProps {
|
||||
fileMatches: SearchResultFile[];
|
||||
isMoreResultsButtonVisible?: boolean;
|
||||
onLoadMoreResults: () => void;
|
||||
isBranchFilteringEnabled: boolean;
|
||||
repoInfo: RepositoryInfo[];
|
||||
searchDurationMs: number;
|
||||
numMatches: number;
|
||||
searchStats?: SearchStats;
|
||||
}
|
||||
|
||||
const PanelGroup = ({
|
||||
fileMatches,
|
||||
isMoreResultsButtonVisible,
|
||||
onLoadMoreResults,
|
||||
isBranchFilteringEnabled,
|
||||
repoInfo: _repoInfo,
|
||||
searchDurationMs: _searchDurationMs,
|
||||
numMatches,
|
||||
searchStats,
|
||||
}: PanelGroupProps) => {
|
||||
const [previewedFile, setPreviewedFile] = useState<SearchResultFile | undefined>(undefined);
|
||||
const filteredFileMatches = useFilteredMatches(fileMatches);
|
||||
const filterPanelRef = useRef<ImperativePanelHandle>(null);
|
||||
const [selectedMatchIndex, setSelectedMatchIndex] = useState(0);
|
||||
|
||||
const [isFilterPanelCollapsed, setIsFilterPanelCollapsed] = useLocalStorage('isFilterPanelCollapsed', false);
|
||||
|
||||
useHotkeys("mod+b", () => {
|
||||
if (isFilterPanelCollapsed) {
|
||||
filterPanelRef.current?.expand();
|
||||
} else {
|
||||
filterPanelRef.current?.collapse();
|
||||
}
|
||||
}, {
|
||||
enableOnFormTags: true,
|
||||
enableOnContentEditable: true,
|
||||
description: "Toggle filter panel",
|
||||
});
|
||||
|
||||
const searchDurationMs = useMemo(() => {
|
||||
return Math.round(_searchDurationMs);
|
||||
}, [_searchDurationMs]);
|
||||
|
||||
const repoInfo = useMemo(() => {
|
||||
return _repoInfo.reduce((acc, repo) => {
|
||||
acc[repo.id] = repo;
|
||||
return acc;
|
||||
}, {} as Record<number, RepositoryInfo>);
|
||||
}, [_repoInfo]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full"
|
||||
>
|
||||
{/* ~~ Filter panel ~~ */}
|
||||
<ResizablePanel
|
||||
ref={filterPanelRef}
|
||||
minSize={20}
|
||||
maxSize={30}
|
||||
defaultSize={isFilterPanelCollapsed ? 0 : 20}
|
||||
collapsible={true}
|
||||
id={'filter-panel'}
|
||||
order={1}
|
||||
onCollapse={() => setIsFilterPanelCollapsed(true)}
|
||||
onExpand={() => setIsFilterPanelCollapsed(false)}
|
||||
>
|
||||
<FilterPanel
|
||||
matches={fileMatches}
|
||||
repoInfo={repoInfo}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
{isFilterPanelCollapsed && (
|
||||
<div className="flex flex-col items-center h-full p-2">
|
||||
<Tooltip
|
||||
delayDuration={100}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
filterPanelRef.current?.expand();
|
||||
}}
|
||||
>
|
||||
<FilterIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="flex flex-row items-center gap-2">
|
||||
<KeyboardShortcutHint shortcut="⌘ B" />
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span>Open filter panel</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<AnimatedResizableHandle />
|
||||
|
||||
{/* ~~ Search results ~~ */}
|
||||
<ResizablePanel
|
||||
minSize={10}
|
||||
id={'search-results-panel'}
|
||||
order={2}
|
||||
>
|
||||
<div className="py-1 px-2 flex flex-row items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoCircledIcon className="w-4 h-4 mr-2" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="flex flex-col items-start gap-2 p-4">
|
||||
<div className="flex flex-row items-center w-full">
|
||||
<BugIcon className="w-4 h-4 mr-1.5" />
|
||||
<p className="text-md font-medium">Search stats for nerds</p>
|
||||
<CopyIconButton
|
||||
onCopy={() => {
|
||||
navigator.clipboard.writeText(JSON.stringify(searchStats, null, 2));
|
||||
return true;
|
||||
}}
|
||||
className="ml-auto"
|
||||
/>
|
||||
</div>
|
||||
<CodeSnippet renderNewlines>
|
||||
{JSON.stringify(searchStats, null, 2)}
|
||||
</CodeSnippet>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{
|
||||
fileMatches.length > 0 ? (
|
||||
<p className="text-sm font-medium">{`[${searchDurationMs} ms] Found ${numMatches} matches in ${fileMatches.length} ${fileMatches.length > 1 ? 'files' : 'file'}`}</p>
|
||||
) : (
|
||||
<p className="text-sm font-medium">No results</p>
|
||||
)
|
||||
}
|
||||
{isMoreResultsButtonVisible && (
|
||||
<div
|
||||
className="cursor-pointer text-blue-500 text-sm hover:underline ml-4"
|
||||
onClick={onLoadMoreResults}
|
||||
>
|
||||
(load more)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filteredFileMatches.length > 0 ? (
|
||||
<SearchResultsPanel
|
||||
fileMatches={filteredFileMatches}
|
||||
onOpenFilePreview={(fileMatch, matchIndex) => {
|
||||
setSelectedMatchIndex(matchIndex ?? 0);
|
||||
setPreviewedFile(fileMatch);
|
||||
}}
|
||||
isLoadMoreButtonVisible={!!isMoreResultsButtonVisible}
|
||||
onLoadMoreButtonClicked={onLoadMoreResults}
|
||||
isBranchFilteringEnabled={isBranchFilteringEnabled}
|
||||
repoInfo={repoInfo}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-sm text-muted-foreground">No results found</p>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
|
||||
{previewedFile && (
|
||||
<>
|
||||
<AnimatedResizableHandle />
|
||||
{/* ~~ Code preview ~~ */}
|
||||
<ResizablePanel
|
||||
minSize={10}
|
||||
collapsible={true}
|
||||
id={'code-preview-panel'}
|
||||
order={3}
|
||||
onCollapse={() => setPreviewedFile(undefined)}
|
||||
>
|
||||
<CodePreviewPanel
|
||||
previewedFile={previewedFile}
|
||||
onClose={() => setPreviewedFile(undefined)}
|
||||
selectedMatchIndex={selectedMatchIndex}
|
||||
onSelectedMatchIndexChange={setSelectedMatchIndex}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import { SearchResultFile, SearchResultChunk } from "@/features/search/types";
|
||||
import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter";
|
||||
import Link from "next/link";
|
||||
import { getBrowsePath } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
|
||||
import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,378 +1,23 @@
|
|||
'use client';
|
||||
import { SearchLandingPage } from "./components/searchLandingPage";
|
||||
import { SearchResultsPage } from "./components/searchResultsPage";
|
||||
|
||||
import {
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
||||
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
||||
import { SearchQueryParams } from "@/lib/types";
|
||||
import { createPathWithQueryParams, measure, unwrapServiceError } from "@/lib/utils";
|
||||
import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { search } from "../../api/(client)/client";
|
||||
import { TopBar } from "../components/topBar";
|
||||
import { CodePreviewPanel } from "./components/codePreviewPanel";
|
||||
import { FilterPanel } from "./components/filterPanel";
|
||||
import { SearchResultsPanel } from "./components/searchResultsPanel";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { useToast } from "@/components/hooks/use-toast";
|
||||
import { RepositoryInfo, SearchResultFile, SearchStats } from "@/features/search/types";
|
||||
import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle";
|
||||
import { useFilteredMatches } from "./components/filterPanel/useFilterMatches";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ImperativePanelHandle } from "react-resizable-panels";
|
||||
import { AlertTriangleIcon, BugIcon, FilterIcon } from "lucide-react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { useLocalStorage } from "@uidotdev/usehooks";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
||||
import { SearchBar } from "../components/searchBar";
|
||||
import { CodeSnippet } from "@/app/components/codeSnippet";
|
||||
import { CopyIconButton } from "../components/copyIconButton";
|
||||
interface SearchPageProps {
|
||||
params: Promise<{ domain: string }>;
|
||||
searchParams: Promise<{ query?: string }>;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_MATCH_COUNT = 500;
|
||||
export default async function SearchPage(props: SearchPageProps) {
|
||||
const { domain } = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const query = searchParams?.query;
|
||||
|
||||
if (query === undefined || query.length === 0) {
|
||||
return <SearchLandingPage domain={domain} />
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
// We need a suspense boundary here since we are accessing query params
|
||||
// in the top level page.
|
||||
// @see : https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
|
||||
return (
|
||||
<Suspense>
|
||||
<SearchPageInternal />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const SearchPageInternal = () => {
|
||||
const router = useRouter();
|
||||
const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? "";
|
||||
const { setSearchHistory } = useSearchHistory();
|
||||
const captureEvent = useCaptureEvent();
|
||||
const domain = useDomain();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Encodes the number of matches to return in the search response.
|
||||
const _maxMatchCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MAX_MATCH_COUNT}`);
|
||||
const maxMatchCount = isNaN(_maxMatchCount) ? DEFAULT_MAX_MATCH_COUNT : _maxMatchCount;
|
||||
|
||||
const {
|
||||
data: searchResponse,
|
||||
isPending: isSearchPending,
|
||||
isFetching: isFetching,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: ["search", searchQuery, maxMatchCount],
|
||||
queryFn: () => measure(() => unwrapServiceError(search({
|
||||
query: searchQuery,
|
||||
matches: maxMatchCount,
|
||||
contextLines: 3,
|
||||
whole: false,
|
||||
}, domain)), "client.search"),
|
||||
select: ({ data, durationMs }) => ({
|
||||
...data,
|
||||
totalClientSearchDurationMs: durationMs,
|
||||
}),
|
||||
enabled: searchQuery.length > 0,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast({
|
||||
description: `❌ Search failed. Reason: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}, [error, toast]);
|
||||
|
||||
|
||||
// Write the query to the search history
|
||||
useEffect(() => {
|
||||
if (searchQuery.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toUTCString();
|
||||
setSearchHistory((searchHistory) => [
|
||||
{
|
||||
query: searchQuery,
|
||||
date: now,
|
||||
},
|
||||
...searchHistory.filter(search => search.query !== searchQuery),
|
||||
])
|
||||
}, [searchQuery, setSearchHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileLanguages = searchResponse.files?.map(file => file.language) || [];
|
||||
|
||||
captureEvent("search_finished", {
|
||||
durationMs: searchResponse.totalClientSearchDurationMs,
|
||||
fileCount: searchResponse.stats.fileCount,
|
||||
matchCount: searchResponse.stats.totalMatchCount,
|
||||
actualMatchCount: searchResponse.stats.actualMatchCount,
|
||||
filesSkipped: searchResponse.stats.filesSkipped,
|
||||
contentBytesLoaded: searchResponse.stats.contentBytesLoaded,
|
||||
indexBytesLoaded: searchResponse.stats.indexBytesLoaded,
|
||||
crashes: searchResponse.stats.crashes,
|
||||
shardFilesConsidered: searchResponse.stats.shardFilesConsidered,
|
||||
filesConsidered: searchResponse.stats.filesConsidered,
|
||||
filesLoaded: searchResponse.stats.filesLoaded,
|
||||
shardsScanned: searchResponse.stats.shardsScanned,
|
||||
shardsSkipped: searchResponse.stats.shardsSkipped,
|
||||
shardsSkippedFilter: searchResponse.stats.shardsSkippedFilter,
|
||||
ngramMatches: searchResponse.stats.ngramMatches,
|
||||
ngramLookups: searchResponse.stats.ngramLookups,
|
||||
wait: searchResponse.stats.wait,
|
||||
matchTreeConstruction: searchResponse.stats.matchTreeConstruction,
|
||||
matchTreeSearch: searchResponse.stats.matchTreeSearch,
|
||||
regexpsConsidered: searchResponse.stats.regexpsConsidered,
|
||||
flushReason: searchResponse.stats.flushReason,
|
||||
fileLanguages,
|
||||
});
|
||||
}, [captureEvent, searchQuery, searchResponse]);
|
||||
|
||||
|
||||
const onLoadMoreResults = useCallback(() => {
|
||||
const url = createPathWithQueryParams(`/${domain}/search`,
|
||||
[SearchQueryParams.query, searchQuery],
|
||||
[SearchQueryParams.matches, `${maxMatchCount * 2}`],
|
||||
)
|
||||
router.push(url);
|
||||
}, [maxMatchCount, router, searchQuery, domain]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-clip">
|
||||
{/* TopBar */}
|
||||
<TopBar
|
||||
domain={domain}
|
||||
>
|
||||
<SearchBar
|
||||
size="sm"
|
||||
defaultQuery={searchQuery}
|
||||
className="w-full"
|
||||
/>
|
||||
</TopBar>
|
||||
|
||||
{(isSearchPending || isFetching) ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||
<SymbolIcon className="h-6 w-6 animate-spin" />
|
||||
<p className="font-semibold text-center">Searching...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||
<AlertTriangleIcon className="h-6 w-6" />
|
||||
<p className="font-semibold text-center">Failed to search</p>
|
||||
<p className="text-sm text-center">{error.message}</p>
|
||||
</div>
|
||||
) : (
|
||||
<PanelGroup
|
||||
fileMatches={searchResponse.files}
|
||||
isMoreResultsButtonVisible={searchResponse.isSearchExhaustive === false}
|
||||
onLoadMoreResults={onLoadMoreResults}
|
||||
isBranchFilteringEnabled={searchResponse.isBranchFilteringEnabled}
|
||||
repoInfo={searchResponse.repositoryInfo}
|
||||
searchDurationMs={searchResponse.totalClientSearchDurationMs}
|
||||
numMatches={searchResponse.stats.actualMatchCount}
|
||||
searchStats={searchResponse.stats}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelGroupProps {
|
||||
fileMatches: SearchResultFile[];
|
||||
isMoreResultsButtonVisible?: boolean;
|
||||
onLoadMoreResults: () => void;
|
||||
isBranchFilteringEnabled: boolean;
|
||||
repoInfo: RepositoryInfo[];
|
||||
searchDurationMs: number;
|
||||
numMatches: number;
|
||||
searchStats?: SearchStats;
|
||||
}
|
||||
|
||||
const PanelGroup = ({
|
||||
fileMatches,
|
||||
isMoreResultsButtonVisible,
|
||||
onLoadMoreResults,
|
||||
isBranchFilteringEnabled,
|
||||
repoInfo: _repoInfo,
|
||||
searchDurationMs: _searchDurationMs,
|
||||
numMatches,
|
||||
searchStats,
|
||||
}: PanelGroupProps) => {
|
||||
const [previewedFile, setPreviewedFile] = useState<SearchResultFile | undefined>(undefined);
|
||||
const filteredFileMatches = useFilteredMatches(fileMatches);
|
||||
const filterPanelRef = useRef<ImperativePanelHandle>(null);
|
||||
const [selectedMatchIndex, setSelectedMatchIndex] = useState(0);
|
||||
|
||||
const [isFilterPanelCollapsed, setIsFilterPanelCollapsed] = useLocalStorage('isFilterPanelCollapsed', false);
|
||||
|
||||
useHotkeys("mod+b", () => {
|
||||
if (isFilterPanelCollapsed) {
|
||||
filterPanelRef.current?.expand();
|
||||
} else {
|
||||
filterPanelRef.current?.collapse();
|
||||
}
|
||||
}, {
|
||||
enableOnFormTags: true,
|
||||
enableOnContentEditable: true,
|
||||
description: "Toggle filter panel",
|
||||
});
|
||||
|
||||
const searchDurationMs = useMemo(() => {
|
||||
return Math.round(_searchDurationMs);
|
||||
}, [_searchDurationMs]);
|
||||
|
||||
const repoInfo = useMemo(() => {
|
||||
return _repoInfo.reduce((acc, repo) => {
|
||||
acc[repo.id] = repo;
|
||||
return acc;
|
||||
}, {} as Record<number, RepositoryInfo>);
|
||||
}, [_repoInfo]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full"
|
||||
>
|
||||
{/* ~~ Filter panel ~~ */}
|
||||
<ResizablePanel
|
||||
ref={filterPanelRef}
|
||||
minSize={20}
|
||||
maxSize={30}
|
||||
defaultSize={isFilterPanelCollapsed ? 0 : 20}
|
||||
collapsible={true}
|
||||
id={'filter-panel'}
|
||||
order={1}
|
||||
onCollapse={() => setIsFilterPanelCollapsed(true)}
|
||||
onExpand={() => setIsFilterPanelCollapsed(false)}
|
||||
>
|
||||
<FilterPanel
|
||||
matches={fileMatches}
|
||||
repoInfo={repoInfo}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
{isFilterPanelCollapsed && (
|
||||
<div className="flex flex-col items-center h-full p-2">
|
||||
<Tooltip
|
||||
delayDuration={100}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
filterPanelRef.current?.expand();
|
||||
}}
|
||||
>
|
||||
<FilterIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="flex flex-row items-center gap-2">
|
||||
<KeyboardShortcutHint shortcut="⌘ B" />
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span>Open filter panel</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<AnimatedResizableHandle />
|
||||
|
||||
{/* ~~ Search results ~~ */}
|
||||
<ResizablePanel
|
||||
minSize={10}
|
||||
id={'search-results-panel'}
|
||||
order={2}
|
||||
>
|
||||
<div className="py-1 px-2 flex flex-row items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoCircledIcon className="w-4 h-4 mr-2" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="flex flex-col items-start gap-2 p-4">
|
||||
<div className="flex flex-row items-center w-full">
|
||||
<BugIcon className="w-4 h-4 mr-1.5" />
|
||||
<p className="text-md font-medium">Search stats for nerds</p>
|
||||
<CopyIconButton
|
||||
onCopy={() => {
|
||||
navigator.clipboard.writeText(JSON.stringify(searchStats, null, 2));
|
||||
return true;
|
||||
}}
|
||||
className="ml-auto"
|
||||
/>
|
||||
</div>
|
||||
<CodeSnippet renderNewlines>
|
||||
{JSON.stringify(searchStats, null, 2)}
|
||||
</CodeSnippet>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{
|
||||
fileMatches.length > 0 ? (
|
||||
<p className="text-sm font-medium">{`[${searchDurationMs} ms] Found ${numMatches} matches in ${fileMatches.length} ${fileMatches.length > 1 ? 'files' : 'file'}`}</p>
|
||||
) : (
|
||||
<p className="text-sm font-medium">No results</p>
|
||||
)
|
||||
}
|
||||
{isMoreResultsButtonVisible && (
|
||||
<div
|
||||
className="cursor-pointer text-blue-500 text-sm hover:underline ml-4"
|
||||
onClick={onLoadMoreResults}
|
||||
>
|
||||
(load more)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filteredFileMatches.length > 0 ? (
|
||||
<SearchResultsPanel
|
||||
fileMatches={filteredFileMatches}
|
||||
onOpenFilePreview={(fileMatch, matchIndex) => {
|
||||
setSelectedMatchIndex(matchIndex ?? 0);
|
||||
setPreviewedFile(fileMatch);
|
||||
}}
|
||||
isLoadMoreButtonVisible={!!isMoreResultsButtonVisible}
|
||||
onLoadMoreButtonClicked={onLoadMoreResults}
|
||||
isBranchFilteringEnabled={isBranchFilteringEnabled}
|
||||
repoInfo={repoInfo}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-sm text-muted-foreground">No results found</p>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
|
||||
{previewedFile && (
|
||||
<>
|
||||
<AnimatedResizableHandle />
|
||||
{/* ~~ Code preview ~~ */}
|
||||
<ResizablePanel
|
||||
minSize={10}
|
||||
collapsible={true}
|
||||
id={'code-preview-panel'}
|
||||
order={3}
|
||||
onCollapse={() => setPreviewedFile(undefined)}
|
||||
>
|
||||
<CodePreviewPanel
|
||||
previewedFile={previewedFile}
|
||||
onClose={() => setPreviewedFile(undefined)}
|
||||
selectedMatchIndex={selectedMatchIndex}
|
||||
onSelectedMatchIndexChange={setSelectedMatchIndex}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
<SearchResultsPage
|
||||
searchQuery={query}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,10 @@ export default async function SettingsLayout(
|
|||
),
|
||||
href: `/${domain}/settings/members`,
|
||||
}] : []),
|
||||
...(userRoleInOrg === OrgRole.OWNER ? [{
|
||||
title: "Connections",
|
||||
href: `/${domain}/connections`,
|
||||
}] : []),
|
||||
{
|
||||
title: "Secrets",
|
||||
href: `/${domain}/settings/secrets`,
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ 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"
|
||||
"group inline-flex h-8 w-max items-center justify-center rounded-md bg-background px-1.5 py-1.5 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<
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { getBrowsePath } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
|
||||
import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils";
|
||||
import { PathHeader } from "@/app/[domain]/components/pathHeader";
|
||||
import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter";
|
||||
import { FindRelatedSymbolsResponse } from "@/features/codeNav/types";
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { cn } from '@/lib/utils';
|
|||
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { getBrowsePath } from '@/app/[domain]/browse/hooks/useBrowseNavigation';
|
||||
import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils";
|
||||
|
||||
|
||||
export const FileListItem = ({
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { FileTreeNode as RawFileTreeNode } from "../actions";
|
|||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import React, { useCallback, useMemo, useState, useEffect, useRef } from "react";
|
||||
import { FileTreeItemComponent } from "./fileTreeItemComponent";
|
||||
import { getBrowsePath } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
|
||||
import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils";
|
||||
import { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
|
||||
|
|
@ -116,7 +116,7 @@ export const PureFileTreePanel = ({ tree: _tree, path }: PureFileTreePanelProps)
|
|||
})}
|
||||
</>
|
||||
);
|
||||
}, [path]);
|
||||
}, [domain, path, repoName, revisionName, setIsCollapsed]);
|
||||
|
||||
const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]);
|
||||
|
||||
|
|
|
|||
|
|
@ -376,6 +376,19 @@ export const getDisplayTime = (date: Date) => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a number to a string
|
||||
*/
|
||||
export const getShortenedNumberDisplayString = (number: number) => {
|
||||
if (number < 1000) {
|
||||
return number.toString();
|
||||
} else if (number < 1000000) {
|
||||
return `${(number / 1000).toFixed(1)}k`;
|
||||
} else {
|
||||
return `${(number / 1000000).toFixed(1)}m`;
|
||||
}
|
||||
}
|
||||
|
||||
export const measureSync = <T>(cb: () => T, measureName: string, outputLog: boolean = true) => {
|
||||
const startMark = `${measureName}.start`;
|
||||
const endMark = `${measureName}.end`;
|
||||
|
|
|
|||
|
|
@ -149,7 +149,8 @@ const config = {
|
|||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
'spin-slow': 'spin 1.5s linear infinite',
|
||||
'bounce-slow': 'bounce 1.5s linear infinite'
|
||||
'bounce-slow': 'bounce 1.5s linear infinite',
|
||||
'ping-slow': 'ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue