Various improvements and optimizations on the web side

This commit is contained in:
bkellam 2025-10-15 22:39:52 -07:00
parent 5fe554e7da
commit c1467bcd82
39 changed files with 1200 additions and 1558 deletions

View file

@ -10,7 +10,7 @@ import { prisma } from "@/prisma";
import { render } from "@react-email/components"; import { render } from "@react-email/components";
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
import { decrypt, encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto"; 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 { createLogger } from "@sourcebot/logger";
import { azuredevopsSchema } from "@sourcebot/schemas/v3/azuredevops.schema"; import { azuredevopsSchema } from "@sourcebot/schemas/v3/azuredevops.schema";
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.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 }) => { withOptionalAuthV2(async ({ org, prisma }) => {
const repos = await prisma.repo.findMany({ const repos = await prisma.repo.findMany({
where: { where: {
orgId: org.id, orgId: org.id,
...(filter.status ? { ...where,
repoIndexingStatus: { in: filter.status } },
} : {}), take,
...(filter.connectionId ? {
connections: {
some: {
connectionId: filter.connectionId
}
}
} : {}),
}
}); });
return repos.map((repo) => repositoryQuerySchema.parse({ 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(() => export const getRepoInfoByName = async (repoName: string) => sew(() =>
withOptionalAuthV2(async ({ org, prisma }) => { withOptionalAuthV2(async ({ org, prisma }) => {
// @note: repo names are represented by their remote url // @note: repo names are represented by their remote url

View file

@ -3,7 +3,7 @@
import { useRef } from "react"; import { useRef } from "react";
import { FileTreeItem } from "@/features/fileTree/actions"; import { FileTreeItem } from "@/features/fileTree/actions";
import { FileTreeItemComponent } from "@/features/fileTree/components/fileTreeItemComponent"; 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 { ScrollArea } from "@/components/ui/scroll-area";
import { useBrowseParams } from "../../hooks/useBrowseParams"; import { useBrowseParams } from "../../hooks/useBrowseParams";
import { useDomain } from "@/hooks/useDomain"; import { useDomain } from "@/hooks/useDomain";

View file

@ -3,7 +3,8 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useDomain } from "@/hooks/useDomain"; import { useDomain } from "@/hooks/useDomain";
import { useCallback } from "react"; import { useCallback } from "react";
import { BrowseState, SET_BROWSE_STATE_QUERY_PARAM } from "../browseStateProvider"; import { BrowseState } from "../browseStateProvider";
import { getBrowsePath } from "./utils";
export type BrowseHighlightRange = { export type BrowseHighlightRange = {
start: { lineNumber: number; column: number; }; start: { lineNumber: number; column: number; };
@ -25,37 +26,6 @@ export interface GetBrowsePathProps {
domain: string; 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 = () => { export const useBrowseNavigation = () => {
const router = useRouter(); const router = useRouter();
const domain = useDomain(); const domain = useDomain();

View file

@ -1,7 +1,8 @@
'use client'; 'use client';
import { useMemo } from "react"; import { useMemo } from "react";
import { getBrowsePath, GetBrowsePathProps } from "./useBrowseNavigation"; import { GetBrowsePathProps } from "./useBrowseNavigation";
import { getBrowsePath } from "./utils";
import { useDomain } from "@/hooks/useDomain"; import { useDomain } from "@/hooks/useDomain";
export const useBrowsePath = ({ export const useBrowsePath = ({

View file

@ -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) => { export const getBrowseParamsFromPathParam = (pathParam: string) => {
const sentinelIndex = pathParam.search(/\/-\/(tree|blob)/); const sentinelIndex = pathParam.search(/\/-\/(tree|blob)/);
@ -7,7 +9,7 @@ export const getBrowseParamsFromPathParam = (pathParam: string) => {
const repoAndRevisionPart = decodeURIComponent(pathParam.substring(0, sentinelIndex)); const repoAndRevisionPart = decodeURIComponent(pathParam.substring(0, sentinelIndex));
const lastAtIndex = repoAndRevisionPart.lastIndexOf('@'); const lastAtIndex = repoAndRevisionPart.lastIndexOf('@');
const repoName = lastAtIndex === -1 ? repoAndRevisionPart : repoAndRevisionPart.substring(0, lastAtIndex); const repoName = lastAtIndex === -1 ? repoAndRevisionPart : repoAndRevisionPart.substring(0, lastAtIndex);
const revisionName = lastAtIndex === -1 ? undefined : repoAndRevisionPart.substring(lastAtIndex + 1); const revisionName = lastAtIndex === -1 ? undefined : repoAndRevisionPart.substring(lastAtIndex + 1);
@ -40,4 +42,28 @@ export const getBrowseParamsFromPathParam = (pathParam: string) => {
path, path,
pathType, 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;
};

View file

@ -11,13 +11,13 @@ import { cn, getCodeHostIcon } from "@/lib/utils";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
import { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard"; import { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard";
interface AskSourcebotDemoCardsProps { interface DemoCards {
demoExamples: DemoExamples; demoExamples: DemoExamples;
} }
export const AskSourcebotDemoCards = ({ export const DemoCards = ({
demoExamples, demoExamples,
}: AskSourcebotDemoCardsProps) => { }: DemoCards) => {
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
const [selectedFilterSearchScope, setSelectedFilterSearchScope] = useState<number | null>(null); const [selectedFilterSearchScope, setSelectedFilterSearchScope] = useState<number | null>(null);

View file

@ -6,47 +6,24 @@ import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolba
import { LanguageModelInfo, SearchScope } from "@/features/chat/types"; import { LanguageModelInfo, SearchScope } from "@/features/chat/types";
import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread"; import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread";
import { RepositoryQuery, SearchContextQuery } from "@/lib/types"; import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
import { useCallback, useState } from "react"; import { useState } from "react";
import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar";
import { useLocalStorage } from "usehooks-ts"; import { useLocalStorage } from "usehooks-ts";
import { DemoExamples } from "@/types"; import { SearchModeSelector } from "../../components/searchModeSelector";
import { AskSourcebotDemoCards } from "./askSourcebotDemoCards";
import { AgenticSearchTutorialDialog } from "./agenticSearchTutorialDialog";
import { setAgenticSearchTutorialDismissedCookie } from "@/actions";
import { RepositorySnapshot } from "./repositorySnapshot";
interface AgenticSearchProps { interface LandingPageChatBox {
searchModeSelectorProps: SearchModeSelectorProps;
languageModels: LanguageModelInfo[]; languageModels: LanguageModelInfo[];
repos: RepositoryQuery[]; repos: RepositoryQuery[];
searchContexts: SearchContextQuery[]; searchContexts: SearchContextQuery[];
chatHistory: {
id: string;
createdAt: Date;
name: string | null;
}[];
demoExamples: DemoExamples | undefined;
isTutorialDismissed: boolean;
} }
export const AgenticSearch = ({ export const LandingPageChatBox = ({
searchModeSelectorProps,
languageModels, languageModels,
repos, repos,
searchContexts, searchContexts,
demoExamples, }: LandingPageChatBox) => {
isTutorialDismissed,
}: AgenticSearchProps) => {
const { createNewChatThread, isLoading } = useCreateNewChatThread(); const { createNewChatThread, isLoading } = useCreateNewChatThread();
const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage<SearchScope[]>("selectedSearchScopes", [], { initializeWithValue: false }); const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage<SearchScope[]>("selectedSearchScopes", [], { initializeWithValue: false });
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
const [isTutorialOpen, setIsTutorialOpen] = useState(!isTutorialDismissed);
const onTutorialDismissed = useCallback(() => {
setIsTutorialOpen(false);
setAgenticSearchTutorialDismissedCookie(true);
}, []);
return ( return (
<div className="flex flex-col items-center w-full"> <div className="flex flex-col items-center w-full">
<div className="mt-4 w-full border rounded-md shadow-sm max-w-[800px]"> <div className="mt-4 w-full border rounded-md shadow-sm max-w-[800px]">
@ -74,34 +51,12 @@ export const AgenticSearch = ({
onContextSelectorOpenChanged={setIsContextSelectorOpen} onContextSelectorOpenChanged={setIsContextSelectorOpen}
/> />
<SearchModeSelector <SearchModeSelector
{...searchModeSelectorProps} searchMode="agentic"
className="ml-auto" className="ml-auto"
/> />
</div> </div>
</div> </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 > </div >
) )
} }

View file

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

View file

@ -1,7 +1,8 @@
"use client" "use client"
import { setAgenticSearchTutorialDismissedCookie } from "@/actions"
import { Button } from "@/components/ui/button" 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 { ModelProviderLogo } from "@/features/chat/components/chatBox/modelProviderLogo"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import mentionsDemo from "@/public/ask_sb_tutorial_at_mentions.png" import mentionsDemo from "@/public/ask_sb_tutorial_at_mentions.png"
@ -27,11 +28,9 @@ import {
} from "lucide-react" } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import Link from "next/link" 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 // 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 [currentStep, setCurrentStep] = useState(0)
const nextStep = () => { const nextStep = () => {
@ -269,11 +278,12 @@ export const AgenticSearchTutorialDialog = ({ onClose }: AgenticSearchTutorialDi
const currentStepData = tutorialSteps[currentStep]; const currentStepData = tutorialSteps[currentStep];
return ( return (
<Dialog open={true} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent <DialogContent
className="sm:max-w-[900px] p-0 flex flex-col h-[525px] overflow-hidden rounded-xl border-none bg-transparent" className="sm:max-w-[900px] p-0 flex flex-col h-[525px] overflow-hidden rounded-xl border-none bg-transparent"
closeButtonClassName="text-white" closeButtonClassName="text-white"
> >
<DialogTitle className="sr-only">Ask Sourcebot tutorial</DialogTitle>
<div className="relative flex h-full"> <div className="relative flex h-full">
{/* Left Column (Text Content & Navigation) */} {/* Left Column (Text Content & Navigation) */}
<div className="flex-1 flex flex-col justify-between bg-background"> <div className="flex-1 flex flex-col justify-between bg-background">

View file

@ -1,10 +1,14 @@
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME } from '@/lib/constants';
import { NavigationGuardProvider } from 'next-navigation-guard'; import { NavigationGuardProvider } from 'next-navigation-guard';
import { cookies } from 'next/headers';
import { TutorialDialog } from './components/tutorialDialog';
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
} }
export default async function Layout({ children }: LayoutProps) { export default async function Layout({ children }: LayoutProps) {
const isTutorialDismissed = (await cookies()).get(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME)?.value === "true";
return ( return (
// @note: we use a navigation guard here since we don't support resuming streams yet. // @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"> <div className="flex flex-col h-screen w-screen">
{children} {children}
</div> </div>
<TutorialDialog isOpen={!isTutorialDismissed} />
</NavigationGuardProvider> </NavigationGuardProvider>
) )
} }

View file

@ -1,13 +1,17 @@
import { getRepos, getSearchContexts } from "@/actions"; import { getRepos, getReposStats, getSearchContexts } from "@/actions";
import { getUserChatHistory, getConfiguredLanguageModelsInfo } from "@/features/chat/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 { ServiceErrorException } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils"; import { isServiceError, measure } from "@/lib/utils";
import { NewChatPanel } from "./components/newChatPanel"; import { LandingPageChatBox } from "./components/landingPageChatBox";
import { TopBar } from "../components/topBar"; import { RepositoryCarousel } from "../components/repositoryCarousel";
import { ResizablePanelGroup } from "@/components/ui/resizable"; import { NavigationMenu } from "../components/navigationMenu";
import { ChatSidePanel } from "./components/chatSidePanel"; import { Separator } from "@/components/ui/separator";
import { auth } from "@/auth"; import { DemoCards } from "./components/demoCards";
import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; import { env } from "@/env.mjs";
import { loadJsonFile } from "@sourcebot/shared";
import { DemoExamples, demoExamplesSchema } from "@/types";
interface PageProps { interface PageProps {
params: Promise<{ params: Promise<{
@ -18,47 +22,85 @@ interface PageProps {
export default async function Page(props: PageProps) { export default async function Page(props: PageProps) {
const params = await props.params; const params = await props.params;
const languageModels = await getConfiguredLanguageModelsInfo(); const languageModels = await getConfiguredLanguageModelsInfo();
const repos = await getRepos();
const searchContexts = await getSearchContexts(params.domain); const searchContexts = await getSearchContexts(params.domain);
const session = await auth(); const allRepos = await getRepos();
const chatHistory = session ? await getUserChatHistory(params.domain) : [];
if (isServiceError(chatHistory)) { const carouselRepos = await getRepos({
throw new ServiceErrorException(chatHistory); where: {
} indexedAt: {
not: null,
},
},
take: 10,
});
if (isServiceError(repos)) { const repoStats = await getReposStats();
throw new ServiceErrorException(repos);
if (isServiceError(allRepos)) {
throw new ServiceErrorException(allRepos);
} }
if (isServiceError(searchContexts)) { if (isServiceError(searchContexts)) {
throw new ServiceErrorException(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 ( return (
<> <div className="flex flex-col items-center overflow-hidden min-h-screen">
<TopBar <NavigationMenu
domain={params.domain} domain={params.domain}
/> />
<ResizablePanelGroup
direction="horizontal" <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">
<ChatSidePanel <SourcebotLogo
order={1} className="h-18 md:h-40 w-auto"
chatHistory={chatHistory} />
isAuthenticated={!!session} </div>
isCollapsedInitially={false} <CustomSlateEditor>
/> <LandingPageChatBox
<AnimatedResizableHandle /> languageModels={languageModels}
<NewChatPanel repos={allRepos}
languageModels={languageModels} searchContexts={searchContexts}
searchContexts={searchContexts} />
repos={indexedRepos} </CustomSlateEditor>
order={2}
/> <div className="mt-8">
</ResizablePanelGroup> <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>
) )
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 { Button } from "@/components/ui/button";
import { NavigationMenu as NavigationMenuBase, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu"; 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 { Separator } from "@/components/ui/separator";
import { SettingsDropdown } from "./settingsDropdown"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons"; import { getSubscriptionInfo } from "@/ee/features/billing/actions";
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 { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { getSubscriptionInfo } from "@/ee/features/billing/actions"; import { ServiceErrorException } from "@/lib/serviceError";
import { auth } from "@/auth"; import { cn, getShortenedNumberDisplayString, isServiceError } from "@/lib/utils";
import WhatsNewIndicator from "./whatsNewIndicator"; 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_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot"; const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
@ -31,6 +36,38 @@ export const NavigationMenu = async ({
const session = await auth(); const session = await auth();
const isAuthenticated = session?.user !== undefined; 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 ( return (
<div className="flex flex-col w-full h-fit bg-background"> <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"> <div className="flex flex-row justify-between items-center py-1.5 px-3">
@ -55,48 +92,55 @@ export const NavigationMenu = async ({
)} )}
<NavigationMenuBase> <NavigationMenuBase>
<NavigationMenuList> <NavigationMenuList className="gap-2">
<NavigationMenuItem> <NavigationMenuItem>
<NavigationMenuLink <NavigationMenuLink
href={`/${domain}`} href={`/${domain}`}
className={navigationMenuTriggerStyle()} className={cn(navigationMenuTriggerStyle(), "gap-2")}
> >
<SearchIcon className="w-4 h-4 mr-1" />
Search Search
</NavigationMenuLink> </NavigationMenuLink>
</NavigationMenuItem> </NavigationMenuItem>
<NavigationMenuItem> <NavigationMenuItem>
<NavigationMenuLink <NavigationMenuLink
href={`/${domain}/repos`} href={`/${domain}/chat`}
className={navigationMenuTriggerStyle()} className={navigationMenuTriggerStyle()}
> >
Repositories <MessageCircleIcon className="w-4 h-4 mr-1" />
Ask
</NavigationMenuLink> </NavigationMenuLink>
</NavigationMenuItem> </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 && ( {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> <NavigationMenuItem>
<NavigationMenuLink <NavigationMenuLink
href={`/${domain}/settings`} href={`/${domain}/settings`}
className={navigationMenuTriggerStyle()} className={navigationMenuTriggerStyle()}
> >
<SettingsIcon className="w-4 h-4 mr-1" />
Settings Settings
</NavigationMenuLink> </NavigationMenuLink>
</NavigationMenuItem> </NavigationMenuItem>
@ -107,10 +151,11 @@ export const NavigationMenu = async ({
</div> </div>
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<ProgressNavIndicator /> <ProgressIndicator
<WarningNavIndicator /> numberOfReposWithFirstTimeIndexingJobsInProgress={numberOfReposWithFirstTimeIndexingJobsInProgress}
<ErrorNavIndicator /> sampleRepos={sampleRepos}
<TrialNavIndicator subscription={subscription} /> />
<TrialIndicator subscription={subscription} />
<WhatsNewIndicator /> <WhatsNewIndicator />
<form <form
action={async () => { action={async () => {
@ -145,7 +190,5 @@ export const NavigationMenu = async ({
</div> </div>
<Separator /> <Separator />
</div> </div>
) )
} }

View file

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

View file

@ -13,7 +13,7 @@ interface Props {
} | null | ServiceError; } | null | ServiceError;
} }
export const TrialNavIndicator = ({ subscription }: Props) => { export const TrialIndicator = ({ subscription }: Props) => {
const domain = useDomain(); const domain = useDomain();
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();

View file

@ -3,7 +3,7 @@
import { cn, getCodeHostInfoForRepo } from "@/lib/utils"; import { cn, getCodeHostInfoForRepo } from "@/lib/utils";
import { LaptopIcon } from "@radix-ui/react-icons"; import { LaptopIcon } from "@radix-ui/react-icons";
import Image from "next/image"; import Image from "next/image";
import { getBrowsePath } from "../browse/hooks/useBrowseNavigation"; import { getBrowsePath } from "../browse/hooks/utils";
import { ChevronRight, MoreHorizontal } from "lucide-react"; import { ChevronRight, MoreHorizontal } from "lucide-react";
import { useCallback, useState, useMemo, useRef, useEffect } from "react"; import { useCallback, useState, useMemo, useRef, useEffect } from "react";
import { useToast } from "@/components/hooks/use-toast"; import { useToast } from "@/components/hooks/use-toast";

View file

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

View 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>
)
}

View file

@ -1,13 +1,16 @@
'use client'; 'use client';
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
import { useDomain } from "@/hooks/useDomain";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MessageCircleIcon, SearchIcon, TriangleAlert } from "lucide-react"; import { MessageCircleIcon, SearchIcon } from "lucide-react";
import Link from "next/link"; 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"; 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 { export interface SearchModeSelectorProps {
searchMode: SearchMode; searchMode: SearchMode;
isAgenticSearchEnabled: boolean;
onSearchModeChange: (searchMode: SearchMode) => void;
className?: string; className?: string;
} }
export const SearchModeSelector = ({ export const SearchModeSelector = ({
searchMode, searchMode,
isAgenticSearchEnabled,
onSearchModeChange,
className, className,
}: SearchModeSelectorProps) => { }: SearchModeSelectorProps) => {
const domain = useDomain();
const [focusedSearchMode, setFocusedSearchMode] = useState<SearchMode>(searchMode); 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 ( return (
<div className={cn("flex flex-row items-center", className)}> <div className={cn("flex flex-row items-center", className)}>
<Select <Select
value={searchMode} value={searchMode}
onValueChange={(value) => onSearchModeChange(value as "precise" | "agentic")} onValueChange={(value) => {
onSearchModeChanged(value as SearchMode);
}}
> >
<SelectTrigger <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" 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 <div
onMouseEnter={() => setFocusedSearchMode("agentic")} onMouseEnter={() => setFocusedSearchMode("agentic")}
onFocus={() => setFocusedSearchMode("agentic")} onFocus={() => setFocusedSearchMode("agentic")}
className={cn({
"cursor-not-allowed": !isAgenticSearchEnabled,
})}
> >
<SelectItem <SelectItem
value="agentic" value="agentic"
disabled={!isAgenticSearchEnabled} className="cursor-pointer"
className={cn({
"cursor-pointer": isAgenticSearchEnabled,
})}
> >
<div className="flex flex-row items-center justify-between w-full gap-1.5"> <div className="flex flex-row items-center justify-between w-full gap-1.5">
<span>Ask</span> <span>Ask</span>
@ -129,14 +149,8 @@ export const SearchModeSelector = ({
> >
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex flex-row items-center 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> <p className="font-semibold">Ask Sourcebot</p>
</div> </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" /> <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> <p>Use natural language to search, summarize and understand your codebase using a reasoning agent.</p>
<Link <Link

View file

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

View file

@ -64,7 +64,7 @@ export const RepoList = ({ connectionId }: RepoListProps) => {
const { data: unfilteredRepos, isPending: isReposPending, error: reposError, refetch: refetchRepos } = useQuery({ const { data: unfilteredRepos, isPending: isReposPending, error: reposError, refetch: refetchRepos } = useQuery({
queryKey: ['repos', domain, connectionId], queryKey: ['repos', domain, connectionId],
queryFn: async () => { queryFn: async () => {
const repos = await unwrapServiceError(getRepos({ connectionId })); const repos = await unwrapServiceError(getRepos());
return repos.sort((a, b) => { return repos.sort((a, b) => {
const priorityA = getPriority(a.repoIndexingStatus); const priorityA = getPriority(a.repoIndexingStatus);
const priorityB = getPriority(b.repoIndexingStatus); const priorityB = getPriority(b.repoIndexingStatus);

View file

@ -22,6 +22,7 @@ import { getAnonymousAccessStatus, getMemberApprovalRequired } from "@/actions";
import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard"; import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { GitHubStarToast } from "./components/githubStarToast"; import { GitHubStarToast } from "./components/githubStarToast";
import { UpgradeToast } from "./components/upgradeToast";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode, children: React.ReactNode,
@ -154,6 +155,7 @@ export default async function Layout(props: LayoutProps) {
{children} {children}
<SyntaxReferenceGuide /> <SyntaxReferenceGuide />
<GitHubStarToast /> <GitHubStarToast />
<UpgradeToast />
</SyntaxGuideProvider> </SyntaxGuideProvider>
) )
} }

View file

@ -1,101 +1,11 @@
import { getRepos, getSearchContexts } from "@/actions"; import SearchPage from "./search/page";
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";
const logger = createLogger('web-homepage'); interface Props {
params: Promise<{ domain: string }>;
export default async function Home(props: { params: Promise<{ domain: string }> }) { searchParams: Promise<{ query?: 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;
} }
const HomeInternal = async (props: { params: Promise<{ domain: string }> }) => { export default async function Home(props: Props) {
const params = await props.params; // Default to rendering the search page.
return <SearchPage {...props} />;
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>
)
} }

View file

@ -9,7 +9,7 @@ import { cn, getRepoImageSrc } from "@/lib/utils"
import { RepoIndexingStatus } from "@sourcebot/db"; import { RepoIndexingStatus } from "@sourcebot/db";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import Link from "next/link" import Link from "next/link"
import { getBrowsePath } from "../browse/hooks/useBrowseNavigation" import { getBrowsePath } from "../browse/hooks/utils"
export type RepositoryColumnInfo = { export type RepositoryColumnInfo = {
repoId: number repoId: number

View file

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

View file

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

View file

@ -3,7 +3,7 @@
import { SearchResultFile, SearchResultChunk } from "@/features/search/types"; import { SearchResultFile, SearchResultChunk } from "@/features/search/types";
import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter"; import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter";
import Link from "next/link"; 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"; import { useDomain } from "@/hooks/useDomain";

View file

@ -1,378 +1,23 @@
'use client'; import { SearchLandingPage } from "./components/searchLandingPage";
import { SearchResultsPage } from "./components/searchResultsPage";
import { interface SearchPageProps {
ResizablePanel, params: Promise<{ domain: string }>;
ResizablePanelGroup, searchParams: Promise<{ query?: string }>;
} 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";
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 ( return (
<Suspense> <SearchResultsPage
<SearchPageInternal /> searchQuery={query}
</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>
) )
} }

View file

@ -94,6 +94,10 @@ export default async function SettingsLayout(
), ),
href: `/${domain}/settings/members`, href: `/${domain}/settings/members`,
}] : []), }] : []),
...(userRoleInOrg === OrgRole.OWNER ? [{
title: "Connections",
href: `/${domain}/connections`,
}] : []),
{ {
title: "Secrets", title: "Secrets",
href: `/${domain}/settings/secrets`, href: `/${domain}/settings/secrets`,

View file

@ -41,7 +41,7 @@ NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva( 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< const NavigationMenuTrigger = React.forwardRef<

View file

@ -1,6 +1,6 @@
'use client'; '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 { PathHeader } from "@/app/[domain]/components/pathHeader";
import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter"; import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter";
import { FindRelatedSymbolsResponse } from "@/features/codeNav/types"; import { FindRelatedSymbolsResponse } from "@/features/codeNav/types";

View file

@ -7,7 +7,7 @@ import { cn } from '@/lib/utils';
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import React from 'react'; import React from 'react';
import { getBrowsePath } from '@/app/[domain]/browse/hooks/useBrowseNavigation'; import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils";
export const FileListItem = ({ export const FileListItem = ({

View file

@ -4,7 +4,7 @@ import { FileTreeNode as RawFileTreeNode } from "../actions";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import React, { useCallback, useMemo, useState, useEffect, useRef } from "react"; import React, { useCallback, useMemo, useState, useEffect, useRef } from "react";
import { FileTreeItemComponent } from "./fileTreeItemComponent"; 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 { useBrowseParams } from "@/app/[domain]/browse/hooks/useBrowseParams";
import { useDomain } from "@/hooks/useDomain"; 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]); const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]);

View file

@ -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) => { export const measureSync = <T>(cb: () => T, measureName: string, outputLog: boolean = true) => {
const startMark = `${measureName}.start`; const startMark = `${measureName}.start`;
const endMark = `${measureName}.end`; const endMark = `${measureName}.end`;

View file

@ -149,7 +149,8 @@ const config = {
'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out',
'spin-slow': 'spin 1.5s linear infinite', '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'
} }
} }
}, },