mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-14 13:25:21 +00:00
Various improvements and optimizations on the web side
This commit is contained in:
parent
5fe554e7da
commit
c1467bcd82
39 changed files with 1200 additions and 1558 deletions
|
|
@ -10,7 +10,7 @@ import { prisma } from "@/prisma";
|
||||||
import { render } from "@react-email/components";
|
import { 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
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 = ({
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
@ -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 >
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { ResizablePanel } from "@/components/ui/resizable";
|
|
||||||
import { ChatBox } from "@/features/chat/components/chatBox";
|
|
||||||
import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar";
|
|
||||||
import { CustomSlateEditor } from "@/features/chat/customSlateEditor";
|
|
||||||
import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread";
|
|
||||||
import { LanguageModelInfo, SearchScope } from "@/features/chat/types";
|
|
||||||
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { Descendant } from "slate";
|
|
||||||
import { useLocalStorage } from "usehooks-ts";
|
|
||||||
|
|
||||||
interface NewChatPanelProps {
|
|
||||||
languageModels: LanguageModelInfo[];
|
|
||||||
repos: RepositoryQuery[];
|
|
||||||
searchContexts: SearchContextQuery[];
|
|
||||||
order: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NewChatPanel = ({
|
|
||||||
languageModels,
|
|
||||||
repos,
|
|
||||||
searchContexts,
|
|
||||||
order,
|
|
||||||
}: NewChatPanelProps) => {
|
|
||||||
const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage<SearchScope[]>("selectedSearchScopes", [], { initializeWithValue: false });
|
|
||||||
const { createNewChatThread, isLoading } = useCreateNewChatThread();
|
|
||||||
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
|
|
||||||
|
|
||||||
const onSubmit = useCallback((children: Descendant[]) => {
|
|
||||||
createNewChatThread(children, selectedSearchScopes);
|
|
||||||
}, [createNewChatThread, selectedSearchScopes]);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResizablePanel
|
|
||||||
order={order}
|
|
||||||
id="new-chat-panel"
|
|
||||||
defaultSize={85}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col h-full w-full items-center justify-start pt-[20vh]">
|
|
||||||
<h2 className="text-4xl font-bold mb-8">What can I help you understand?</h2>
|
|
||||||
<div className="border rounded-md w-full max-w-3xl mx-auto mb-8 shadow-sm">
|
|
||||||
<CustomSlateEditor>
|
|
||||||
<ChatBox
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
className="min-h-[80px]"
|
|
||||||
preferredSuggestionsBoxPlacement="bottom-start"
|
|
||||||
isRedirecting={isLoading}
|
|
||||||
languageModels={languageModels}
|
|
||||||
selectedSearchScopes={selectedSearchScopes}
|
|
||||||
searchContexts={searchContexts}
|
|
||||||
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
|
||||||
/>
|
|
||||||
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
|
|
||||||
<ChatBoxToolbar
|
|
||||||
languageModels={languageModels}
|
|
||||||
repos={repos}
|
|
||||||
searchContexts={searchContexts}
|
|
||||||
selectedSearchScopes={selectedSearchScopes}
|
|
||||||
onSelectedSearchScopesChange={setSelectedSearchScopes}
|
|
||||||
isContextSelectorOpen={isContextSelectorOpen}
|
|
||||||
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CustomSlateEditor>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"use client"
|
"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">
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
|
||||||
import { CircleXIcon } from "lucide-react";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { unwrapServiceError } from "@/lib/utils";
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { ConnectionSyncStatus, RepoIndexingStatus } from "@sourcebot/db";
|
|
||||||
import { getConnections } from "@/actions";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { getRepos } from "@/app/api/(client)/client";
|
|
||||||
|
|
||||||
export const ErrorNavIndicator = () => {
|
|
||||||
const domain = useDomain();
|
|
||||||
const captureEvent = useCaptureEvent();
|
|
||||||
|
|
||||||
const { data: repos, isPending: isPendingRepos, isError: isErrorRepos } = useQuery({
|
|
||||||
queryKey: ['repos', domain],
|
|
||||||
queryFn: () => unwrapServiceError(getRepos()),
|
|
||||||
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.FAILED),
|
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: connections, isPending: isPendingConnections, isError: isErrorConnections } = useQuery({
|
|
||||||
queryKey: ['connections', domain],
|
|
||||||
queryFn: () => unwrapServiceError(getConnections(domain)),
|
|
||||||
select: (data) => data.filter(connection => connection.syncStatus === ConnectionSyncStatus.FAILED),
|
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isPendingRepos || isErrorRepos || isPendingConnections || isErrorConnections) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (repos.length === 0 && connections.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HoverCard openDelay={50}>
|
|
||||||
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_error_nav_hover', {})}>
|
|
||||||
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_error_nav_pressed', {})}>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-full text-red-700 dark:text-red-400 text-xs font-medium hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors cursor-pointer">
|
|
||||||
<CircleXIcon className="h-4 w-4" />
|
|
||||||
{repos.length + connections.length > 0 && (
|
|
||||||
<span>{repos.length + connections.length}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent className="w-80 border border-red-200 dark:border-red-800 rounded-lg">
|
|
||||||
<div className="flex flex-col gap-6 p-5">
|
|
||||||
{connections.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-4 pb-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-2 w-2 rounded-full bg-red-500"></div>
|
|
||||||
<h3 className="text-sm font-medium text-red-700 dark:text-red-400">Connection Sync Issues</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-red-600/90 dark:text-red-300/90 leading-relaxed">
|
|
||||||
The following connections have failed to sync:
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<TooltipProvider>
|
|
||||||
{connections
|
|
||||||
.slice(0, 10)
|
|
||||||
.map(connection => (
|
|
||||||
<Link key={connection.name} href={`/${domain}/connections/${connection.id}`} onClick={() => captureEvent('wa_error_nav_job_pressed', {})}>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 bg-red-50 dark:bg-red-900/20
|
|
||||||
rounded-md text-sm text-red-700 dark:text-red-300
|
|
||||||
border border-red-200/50 dark:border-red-800/50
|
|
||||||
hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="font-medium truncate max-w-[200px]">{connection.name}</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{connection.name}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</TooltipProvider>
|
|
||||||
{connections.length > 10 && (
|
|
||||||
<div className="text-sm text-red-600/90 dark:text-red-300/90 pl-3 pt-1">
|
|
||||||
And {connections.length - 10} more...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{repos.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-2 w-2 rounded-full bg-red-500"></div>
|
|
||||||
<h3 className="text-sm font-medium text-red-700 dark:text-red-400">Repository Indexing Issues</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-red-600/90 dark:text-red-300/90 leading-relaxed">
|
|
||||||
The following repositories failed to index:
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<TooltipProvider>
|
|
||||||
{repos
|
|
||||||
.slice(0, 10)
|
|
||||||
.map(repo => (
|
|
||||||
<div key={repo.repoId} className="flex items-center gap-2 px-3 py-2 bg-red-50 dark:bg-red-900/20
|
|
||||||
rounded-md text-sm text-red-700 dark:text-red-300
|
|
||||||
border border-red-200/50 dark:border-red-800/50
|
|
||||||
hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="text-sm font-medium truncate max-w-[200px]">{repo.repoName}</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{repo.repoName}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</TooltipProvider>
|
|
||||||
{repos.length > 10 && (
|
|
||||||
<div className="text-sm text-red-600/90 dark:text-red-300/90 pl-3 pt-1">
|
|
||||||
And {repos.length - 10} more...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
|
||||||
import { LanguageModelInfo } from "@/features/chat/types";
|
|
||||||
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
|
||||||
import { useHotkeys } from "react-hotkeys-hook";
|
|
||||||
import { AgenticSearch } from "./agenticSearch";
|
|
||||||
import { PreciseSearch } from "./preciseSearch";
|
|
||||||
import { SearchMode } from "./toolbar";
|
|
||||||
import { CustomSlateEditor } from "@/features/chat/customSlateEditor";
|
|
||||||
import { setSearchModeCookie } from "@/actions";
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { DemoExamples } from "@/types";
|
|
||||||
|
|
||||||
interface HomepageProps {
|
|
||||||
initialRepos: RepositoryQuery[];
|
|
||||||
searchContexts: SearchContextQuery[];
|
|
||||||
languageModels: LanguageModelInfo[];
|
|
||||||
chatHistory: {
|
|
||||||
id: string;
|
|
||||||
createdAt: Date;
|
|
||||||
name: string | null;
|
|
||||||
}[];
|
|
||||||
initialSearchMode: SearchMode;
|
|
||||||
demoExamples: DemoExamples | undefined;
|
|
||||||
isAgenticSearchTutorialDismissed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const Homepage = ({
|
|
||||||
initialRepos,
|
|
||||||
searchContexts,
|
|
||||||
languageModels,
|
|
||||||
chatHistory,
|
|
||||||
initialSearchMode,
|
|
||||||
demoExamples,
|
|
||||||
isAgenticSearchTutorialDismissed,
|
|
||||||
}: HomepageProps) => {
|
|
||||||
const [searchMode, setSearchMode] = useState<SearchMode>(initialSearchMode);
|
|
||||||
const isAgenticSearchEnabled = languageModels.length > 0;
|
|
||||||
|
|
||||||
const onSearchModeChanged = useCallback(async (newMode: SearchMode) => {
|
|
||||||
setSearchMode(newMode);
|
|
||||||
await setSearchModeCookie(newMode);
|
|
||||||
}, [setSearchMode]);
|
|
||||||
|
|
||||||
useHotkeys("mod+i", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSearchModeChanged("agentic");
|
|
||||||
}, {
|
|
||||||
enableOnFormTags: true,
|
|
||||||
enableOnContentEditable: true,
|
|
||||||
description: "Switch to agentic search",
|
|
||||||
});
|
|
||||||
|
|
||||||
useHotkeys("mod+p", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSearchModeChanged("precise");
|
|
||||||
}, {
|
|
||||||
enableOnFormTags: true,
|
|
||||||
enableOnContentEditable: true,
|
|
||||||
description: "Switch to precise search",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
|
|
||||||
<div className="max-h-44 w-auto">
|
|
||||||
<SourcebotLogo
|
|
||||||
className="h-18 md:h-40 w-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{searchMode === "precise" ? (
|
|
||||||
<PreciseSearch
|
|
||||||
initialRepos={initialRepos}
|
|
||||||
searchModeSelectorProps={{
|
|
||||||
searchMode: "precise",
|
|
||||||
isAgenticSearchEnabled,
|
|
||||||
onSearchModeChange: onSearchModeChanged,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CustomSlateEditor>
|
|
||||||
<AgenticSearch
|
|
||||||
searchModeSelectorProps={{
|
|
||||||
searchMode: "agentic",
|
|
||||||
isAgenticSearchEnabled,
|
|
||||||
onSearchModeChange: onSearchModeChanged,
|
|
||||||
}}
|
|
||||||
languageModels={languageModels}
|
|
||||||
repos={initialRepos}
|
|
||||||
searchContexts={searchContexts}
|
|
||||||
chatHistory={chatHistory}
|
|
||||||
demoExamples={demoExamples}
|
|
||||||
isTutorialDismissed={isAgenticSearchTutorialDismissed}
|
|
||||||
/>
|
|
||||||
</CustomSlateEditor>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { SyntaxReferenceGuideHint } from "../syntaxReferenceGuideHint";
|
|
||||||
import { RepositorySnapshot } from "./repositorySnapshot";
|
|
||||||
import { RepositoryQuery } from "@/lib/types";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { SearchBar } from "../searchBar/searchBar";
|
|
||||||
import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar";
|
|
||||||
|
|
||||||
interface PreciseSearchProps {
|
|
||||||
initialRepos: RepositoryQuery[];
|
|
||||||
searchModeSelectorProps: SearchModeSelectorProps;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PreciseSearch = ({
|
|
||||||
initialRepos,
|
|
||||||
searchModeSelectorProps,
|
|
||||||
}: PreciseSearchProps) => {
|
|
||||||
const domain = useDomain();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="mt-4 w-full max-w-[800px] border rounded-md shadow-sm">
|
|
||||||
<SearchBar
|
|
||||||
autoFocus={true}
|
|
||||||
className="border-none pt-0.5 pb-0"
|
|
||||||
/>
|
|
||||||
<Separator />
|
|
||||||
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
|
|
||||||
<SearchModeSelector
|
|
||||||
{...searchModeSelectorProps}
|
|
||||||
className="ml-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-8">
|
|
||||||
<RepositorySnapshot
|
|
||||||
repos={initialRepos}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center w-fit gap-6">
|
|
||||||
<Separator className="mt-5" />
|
|
||||||
<span className="font-semibold">How to search</span>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
||||||
<HowToSection
|
|
||||||
title="Search in files or paths"
|
|
||||||
>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="test todo" domain={domain}>test todo</Query> <QueryExplanation>(both test and todo)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="test or todo" domain={domain}>test <Highlight>or</Highlight> todo</Query> <QueryExplanation>(either test or todo)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query={`"exit boot"`} domain={domain}>{`"exit boot"`}</Query> <QueryExplanation>(exact match)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="TODO case:yes" domain={domain}>TODO <Highlight>case:</Highlight>yes</Query> <QueryExplanation>(case sensitive)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
</HowToSection>
|
|
||||||
<HowToSection
|
|
||||||
title="Filter results"
|
|
||||||
>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="file:README setup" domain={domain}><Highlight>file:</Highlight>README setup</Query> <QueryExplanation>(by filename)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="repo:torvalds/linux test" domain={domain}><Highlight>repo:</Highlight>torvalds/linux test</Query> <QueryExplanation>(by repo)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="lang:typescript" domain={domain}><Highlight>lang:</Highlight>typescript</Query> <QueryExplanation>(by language)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="rev:HEAD" domain={domain}><Highlight>rev:</Highlight>HEAD</Query> <QueryExplanation>(by branch or tag)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
</HowToSection>
|
|
||||||
<HowToSection
|
|
||||||
title="Advanced"
|
|
||||||
>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="file:\.py$" domain={domain}><Highlight>file:</Highlight>{`\\.py$`}</Query> <QueryExplanation>{`(files that end in ".py")`}</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="sym:main" domain={domain}><Highlight>sym:</Highlight>main</Query> <QueryExplanation>{`(symbols named "main")`}</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="todo -lang:c" domain={domain}>todo <Highlight>-lang:c</Highlight></Query> <QueryExplanation>(negate filter)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="content:README" domain={domain}><Highlight>content:</Highlight>README</Query> <QueryExplanation>(search content only)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
</HowToSection>
|
|
||||||
</div>
|
|
||||||
<SyntaxReferenceGuideHint />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="dark:text-gray-300 text-sm mb-2 underline">{title}</span>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const Highlight = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<span className="text-highlight">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const QueryExample = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<span className="text-sm font-mono">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const QueryExplanation = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<span className="text-gray-500 dark:text-gray-400 ml-3">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const Query = ({ query, domain, children }: { query: string, domain: string, children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/${domain}/search?query=${query}`}
|
|
||||||
className="cursor-pointer hover:underline"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Carousel,
|
|
||||||
CarouselContent,
|
|
||||||
CarouselItem,
|
|
||||||
} from "@/components/ui/carousel";
|
|
||||||
import Autoscroll from "embla-carousel-auto-scroll";
|
|
||||||
import { getCodeHostInfoForRepo } from "@/lib/utils";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { FileIcon } from "@radix-ui/react-icons";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { RepositoryQuery } from "@/lib/types";
|
|
||||||
import { getBrowsePath } from "../../browse/hooks/useBrowseNavigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
|
|
||||||
interface RepositoryCarouselProps {
|
|
||||||
repos: RepositoryQuery[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RepositoryCarousel = ({
|
|
||||||
repos,
|
|
||||||
}: RepositoryCarouselProps) => {
|
|
||||||
return (
|
|
||||||
<Carousel
|
|
||||||
opts={{
|
|
||||||
align: "start",
|
|
||||||
loop: true,
|
|
||||||
}}
|
|
||||||
className="w-full max-w-lg"
|
|
||||||
plugins={[
|
|
||||||
Autoscroll({
|
|
||||||
startDelay: 0,
|
|
||||||
speed: 1,
|
|
||||||
stopOnMouseEnter: true,
|
|
||||||
stopOnInteraction: false,
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CarouselContent>
|
|
||||||
{repos.map((repo, index) => (
|
|
||||||
<CarouselItem key={index} className="basis-auto">
|
|
||||||
<RepositoryBadge
|
|
||||||
key={index}
|
|
||||||
repo={repo}
|
|
||||||
/>
|
|
||||||
</CarouselItem>
|
|
||||||
))}
|
|
||||||
</CarouselContent>
|
|
||||||
</Carousel>
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RepositoryBadgeProps {
|
|
||||||
repo: RepositoryQuery;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RepositoryBadge = ({
|
|
||||||
repo
|
|
||||||
}: RepositoryBadgeProps) => {
|
|
||||||
const domain = useDomain();
|
|
||||||
const { repoIcon, displayName } = (() => {
|
|
||||||
const info = getCodeHostInfoForRepo({
|
|
||||||
codeHostType: repo.codeHostType,
|
|
||||||
name: repo.repoName,
|
|
||||||
displayName: repo.repoDisplayName,
|
|
||||||
webUrl: repo.webUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (info) {
|
|
||||||
return {
|
|
||||||
repoIcon: <Image
|
|
||||||
src={info.icon}
|
|
||||||
alt={info.codeHostName}
|
|
||||||
className={`w-4 h-4 ${info.iconClassName}`}
|
|
||||||
/>,
|
|
||||||
displayName: info.displayName,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
repoIcon: <FileIcon className="w-4 h-4" />,
|
|
||||||
displayName: repo.repoName,
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={getBrowsePath({
|
|
||||||
repoName: repo.repoName,
|
|
||||||
path: '/',
|
|
||||||
pathType: 'tree',
|
|
||||||
domain
|
|
||||||
})}
|
|
||||||
|
|
||||||
className={clsx("flex flex-row items-center gap-2 border rounded-md p-2 text-clip")}
|
|
||||||
>
|
|
||||||
{repoIcon}
|
|
||||||
<span className="text-sm font-mono">
|
|
||||||
{displayName}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { RepositoryCarousel } from "./repositoryCarousel";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { unwrapServiceError } from "@/lib/utils";
|
|
||||||
import { getRepos } from "@/app/api/(client)/client";
|
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import {
|
|
||||||
Carousel,
|
|
||||||
CarouselContent,
|
|
||||||
CarouselItem,
|
|
||||||
} from "@/components/ui/carousel";
|
|
||||||
import { RepoIndexingStatus } from "@sourcebot/db";
|
|
||||||
import { SymbolIcon } from "@radix-ui/react-icons";
|
|
||||||
import { RepositoryQuery } from "@/lib/types";
|
|
||||||
import { captureEvent } from "@/hooks/useCaptureEvent";
|
|
||||||
|
|
||||||
interface RepositorySnapshotProps {
|
|
||||||
repos: RepositoryQuery[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_REPOS_TO_DISPLAY_IN_CAROUSEL = 15;
|
|
||||||
|
|
||||||
export function RepositorySnapshot({
|
|
||||||
repos: initialRepos,
|
|
||||||
}: RepositorySnapshotProps) {
|
|
||||||
const domain = useDomain();
|
|
||||||
|
|
||||||
const { data: repos, isPending, isError } = useQuery({
|
|
||||||
queryKey: ['repos', domain],
|
|
||||||
queryFn: () => unwrapServiceError(getRepos()),
|
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
|
||||||
placeholderData: initialRepos,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isPending || isError || !repos) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<RepoSkeleton />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use `indexedAt` to determine if a repo has __ever__ been indexed.
|
|
||||||
// The repo indexing status only tells us the repo's current indexing status.
|
|
||||||
const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
|
|
||||||
|
|
||||||
// If there are no indexed repos...
|
|
||||||
if (indexedRepos.length === 0) {
|
|
||||||
|
|
||||||
// ... show a loading state if repos are being indexed now
|
|
||||||
if (repos.some((repo) => repo.repoIndexingStatus === RepoIndexingStatus.INDEXING || repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE)) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-row items-center gap-3">
|
|
||||||
<SymbolIcon className="h-4 w-4 animate-spin" />
|
|
||||||
<span className="text-sm">indexing in progress...</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// ... otherwise, show the empty state.
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<EmptyRepoState />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<span className="text-sm">
|
|
||||||
{`${indexedRepos.length} `}
|
|
||||||
<Link
|
|
||||||
href={`${domain}/repos`}
|
|
||||||
className="text-link hover:underline"
|
|
||||||
>
|
|
||||||
{indexedRepos.length > 1 ? 'repositories' : 'repository'}
|
|
||||||
</Link>
|
|
||||||
{` indexed`}
|
|
||||||
</span>
|
|
||||||
<RepositoryCarousel
|
|
||||||
repos={indexedRepos.slice(0, MAX_REPOS_TO_DISPLAY_IN_CAROUSEL)}
|
|
||||||
/>
|
|
||||||
{process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === "demo" && (
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
Interested in using Sourcebot on your code? Check out our{' '}
|
|
||||||
<a
|
|
||||||
href="https://docs.sourcebot.dev/docs/overview"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
onClick={() => captureEvent('wa_demo_docs_link_pressed', {})}
|
|
||||||
>
|
|
||||||
docs
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyRepoState() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<span className="text-sm">No repositories found</span>
|
|
||||||
|
|
||||||
<div className="w-full max-w-lg">
|
|
||||||
<div className="flex flex-row items-center gap-2 border rounded-md p-4 justify-center">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
<>
|
|
||||||
Create a{" "}
|
|
||||||
<Link href={`https://docs.sourcebot.dev/docs/connections/overview`} className="text-blue-500 hover:underline inline-flex items-center gap-1">
|
|
||||||
connection
|
|
||||||
</Link>{" "}
|
|
||||||
to start indexing repositories
|
|
||||||
</>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RepoSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
{/* Skeleton for "Search X repositories" text */}
|
|
||||||
<div className="flex items-center gap-1 text-sm">
|
|
||||||
<Skeleton className="h-4 w-14" /> {/* "Search X" */}
|
|
||||||
<Skeleton className="h-4 w-24" /> {/* "repositories" */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Skeleton for repository carousel */}
|
|
||||||
<Carousel
|
|
||||||
opts={{
|
|
||||||
align: "start",
|
|
||||||
loop: true,
|
|
||||||
}}
|
|
||||||
className="w-full max-w-lg"
|
|
||||||
>
|
|
||||||
<CarouselContent>
|
|
||||||
{[1, 2, 3].map((_, index) => (
|
|
||||||
<CarouselItem key={index} className="basis-auto">
|
|
||||||
<div className="flex flex-row items-center gap-2 border rounded-md p-2">
|
|
||||||
<Skeleton className="h-4 w-4 rounded-sm" /> {/* Icon */}
|
|
||||||
<Skeleton className="h-4 w-32" /> {/* Repository name */}
|
|
||||||
</div>
|
|
||||||
</CarouselItem>
|
|
||||||
))}
|
|
||||||
</CarouselContent>
|
|
||||||
</Carousel>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
|
import { getRepos, getReposStats } from "@/actions";
|
||||||
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { 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>
|
||||||
|
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { RepositoryQuery } from "@/lib/types";
|
||||||
|
import { getCodeHostInfoForRepo, getShortenedNumberDisplayString } from "@/lib/utils";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { FileIcon, Loader2Icon, RefreshCwIcon } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
interface ProgressIndicatorProps {
|
||||||
|
numberOfReposWithFirstTimeIndexingJobsInProgress: number;
|
||||||
|
sampleRepos: RepositoryQuery[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProgressIndicator = ({
|
||||||
|
numberOfReposWithFirstTimeIndexingJobsInProgress: numRepos,
|
||||||
|
sampleRepos,
|
||||||
|
}: ProgressIndicatorProps) => {
|
||||||
|
const domain = useDomain();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (numRepos === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numReposString = getShortenedNumberDisplayString(numRepos);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Link href={`/${domain}/repos`}>
|
||||||
|
<Badge variant="outline" className="flex flex-row items-center gap-2 h-8">
|
||||||
|
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||||
|
<span>{numReposString}</span>
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="p-4 w-72">
|
||||||
|
<div className="flex flex-row gap-1 items-center">
|
||||||
|
<p className="text-md font-medium">{`Syncing ${numReposString} ${numRepos === 1 ? 'repository' : 'repositories'}`}</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-3" />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{sampleRepos.map((repo) => (
|
||||||
|
<RepoItem key={repo.repoId} repo={repo} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{numRepos > sampleRepos.length && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Link href={`/${domain}/repos`} className="text-sm text-link hover:underline">
|
||||||
|
{`View ${numRepos - sampleRepos.length} more`}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RepoItem = ({ repo }: { repo: RepositoryQuery }) => {
|
||||||
|
|
||||||
|
const { repoIcon, displayName } = useMemo(() => {
|
||||||
|
const info = getCodeHostInfoForRepo({
|
||||||
|
name: repo.repoName,
|
||||||
|
codeHostType: repo.codeHostType,
|
||||||
|
displayName: repo.repoDisplayName,
|
||||||
|
webUrl: repo.webUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (info) {
|
||||||
|
return {
|
||||||
|
repoIcon: <Image
|
||||||
|
src={info.icon}
|
||||||
|
alt={info.codeHostName}
|
||||||
|
className={`w-4 h-4 ${info.iconClassName}`}
|
||||||
|
/>,
|
||||||
|
displayName: info.displayName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repoIcon: <FileIcon className="w-4 h-4" />,
|
||||||
|
displayName: repo.repoName,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}, [repo.repoName, repo.codeHostType, repo.repoDisplayName, repo.webUrl]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={'/'}
|
||||||
|
className={clsx("flex flex-row items-center gap-2 border rounded-md p-2 text-clip")}
|
||||||
|
>
|
||||||
|
{repoIcon}
|
||||||
|
<span className="text-sm truncate">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,7 @@ interface Props {
|
||||||
} | null | ServiceError;
|
} | null | ServiceError;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TrialNavIndicator = ({ subscription }: Props) => {
|
export const TrialIndicator = ({ subscription }: Props) => {
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
const captureEvent = useCaptureEvent();
|
const captureEvent = useCaptureEvent();
|
||||||
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { unwrapServiceError } from "@/lib/utils";
|
|
||||||
import { RepoIndexingStatus } from "@prisma/client";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Loader2Icon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { getRepos } from "@/app/api/(client)/client";
|
|
||||||
|
|
||||||
export const ProgressNavIndicator = () => {
|
|
||||||
const domain = useDomain();
|
|
||||||
const captureEvent = useCaptureEvent();
|
|
||||||
|
|
||||||
const { data: inProgressRepos, isPending, isError } = useQuery({
|
|
||||||
queryKey: ['repos'],
|
|
||||||
queryFn: () => unwrapServiceError(getRepos()),
|
|
||||||
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE || repo.repoIndexingStatus === RepoIndexingStatus.INDEXING),
|
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isPending || isError || inProgressRepos.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/${domain}/connections`}
|
|
||||||
onClick={() => captureEvent('wa_progress_nav_pressed', {})}
|
|
||||||
>
|
|
||||||
<HoverCard openDelay={50}>
|
|
||||||
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_progress_nav_hover', {})}>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-full text-green-700 dark:text-green-400 text-xs font-medium hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors cursor-pointer">
|
|
||||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
|
||||||
<span>{inProgressRepos.length}</span>
|
|
||||||
</div>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent className="w-80 border border-green-200 dark:border-green-800 rounded-lg">
|
|
||||||
<div className="flex flex-col gap-4 p-5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse"></div>
|
|
||||||
<h3 className="text-sm font-medium text-green-700 dark:text-green-400">Indexing in Progress</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-green-600/90 dark:text-green-300/90 leading-relaxed">
|
|
||||||
The following repositories are currently being indexed:
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-2 pl-4">
|
|
||||||
{
|
|
||||||
inProgressRepos.slice(0, 10)
|
|
||||||
.map(item => (
|
|
||||||
<div key={item.repoId} className="flex items-center gap-2 px-3 py-2 bg-green-50 dark:bg-green-900/20
|
|
||||||
rounded-md text-sm text-green-700 dark:text-green-300
|
|
||||||
border border-green-200/50 dark:border-green-800/50
|
|
||||||
hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors">
|
|
||||||
<span className="font-medium truncate">{item.repoName}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{inProgressRepos.length > 10 && (
|
|
||||||
<div className="text-sm text-green-600/90 dark:text-green-300/90 pl-3 pt-1">
|
|
||||||
And {inProgressRepos.length - 10} more...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
158
packages/web/src/app/[domain]/components/repositoryCarousel.tsx
Normal file
158
packages/web/src/app/[domain]/components/repositoryCarousel.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
} from "@/components/ui/carousel";
|
||||||
|
import { captureEvent } from "@/hooks/useCaptureEvent";
|
||||||
|
import { RepositoryQuery } from "@/lib/types";
|
||||||
|
import { getCodeHostInfoForRepo } from "@/lib/utils";
|
||||||
|
import { FileIcon } from "@radix-ui/react-icons";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Autoscroll from "embla-carousel-auto-scroll";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getBrowsePath } from "../browse/hooks/utils";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
|
interface RepositoryCarouselProps {
|
||||||
|
displayRepos: RepositoryQuery[];
|
||||||
|
numberOfReposWithIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepositoryCarousel({
|
||||||
|
displayRepos,
|
||||||
|
numberOfReposWithIndex,
|
||||||
|
}: RepositoryCarouselProps) {
|
||||||
|
const domain = useDomain();
|
||||||
|
|
||||||
|
if (numberOfReposWithIndex === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<span className="text-sm">No repositories found</span>
|
||||||
|
|
||||||
|
<div className="w-full max-w-lg">
|
||||||
|
<div className="flex flex-row items-center gap-2 border rounded-md p-4 justify-center">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
<>
|
||||||
|
Create a{" "}
|
||||||
|
<Link href={`https://docs.sourcebot.dev/docs/connections/overview`} className="text-blue-500 hover:underline inline-flex items-center gap-1">
|
||||||
|
connection
|
||||||
|
</Link>{" "}
|
||||||
|
to start indexing repositories
|
||||||
|
</>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<span className="text-sm">
|
||||||
|
{`${numberOfReposWithIndex} `}
|
||||||
|
<Link
|
||||||
|
href={`/${domain}/repos`}
|
||||||
|
className="text-link hover:underline"
|
||||||
|
>
|
||||||
|
{numberOfReposWithIndex > 1 ? 'repositories' : 'repository'}
|
||||||
|
</Link>
|
||||||
|
{` indexed`}
|
||||||
|
</span>
|
||||||
|
<Carousel
|
||||||
|
opts={{
|
||||||
|
align: "start",
|
||||||
|
loop: true,
|
||||||
|
}}
|
||||||
|
className="w-full max-w-lg"
|
||||||
|
plugins={[
|
||||||
|
Autoscroll({
|
||||||
|
startDelay: 0,
|
||||||
|
speed: 1,
|
||||||
|
stopOnMouseEnter: true,
|
||||||
|
stopOnInteraction: false,
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CarouselContent>
|
||||||
|
{displayRepos.map((repo, index) => (
|
||||||
|
<CarouselItem key={index} className="basis-auto">
|
||||||
|
<RepositoryBadge
|
||||||
|
key={index}
|
||||||
|
repo={repo}
|
||||||
|
/>
|
||||||
|
</CarouselItem>
|
||||||
|
))}
|
||||||
|
</CarouselContent>
|
||||||
|
</Carousel>
|
||||||
|
{process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === "demo" && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Interested in using Sourcebot on your code? Check out our{' '}
|
||||||
|
<a
|
||||||
|
href="https://docs.sourcebot.dev/docs/overview"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
onClick={() => captureEvent('wa_demo_docs_link_pressed', {})}
|
||||||
|
>
|
||||||
|
docs
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RepositoryBadgeProps {
|
||||||
|
repo: RepositoryQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RepositoryBadge = ({
|
||||||
|
repo
|
||||||
|
}: RepositoryBadgeProps) => {
|
||||||
|
const domain = useDomain();
|
||||||
|
const { repoIcon, displayName } = (() => {
|
||||||
|
const info = getCodeHostInfoForRepo({
|
||||||
|
codeHostType: repo.codeHostType,
|
||||||
|
name: repo.repoName,
|
||||||
|
displayName: repo.repoDisplayName,
|
||||||
|
webUrl: repo.webUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (info) {
|
||||||
|
return {
|
||||||
|
repoIcon: <Image
|
||||||
|
src={info.icon}
|
||||||
|
alt={info.codeHostName}
|
||||||
|
className={`w-4 h-4 ${info.iconClassName}`}
|
||||||
|
/>,
|
||||||
|
displayName: info.displayName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repoIcon: <FileIcon className="w-4 h-4" />,
|
||||||
|
displayName: repo.repoName,
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={getBrowsePath({
|
||||||
|
repoName: repo.repoName,
|
||||||
|
path: '/',
|
||||||
|
pathType: 'tree',
|
||||||
|
domain,
|
||||||
|
})}
|
||||||
|
|
||||||
|
className={clsx("flex flex-row items-center gap-2 border rounded-md p-2 text-clip")}
|
||||||
|
>
|
||||||
|
{repoIcon}
|
||||||
|
<span className="text-sm font-mono">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
'use client';
|
'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
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
|
||||||
import { AlertTriangleIcon } from "lucide-react";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { getConnections } from "@/actions";
|
|
||||||
import { unwrapServiceError } from "@/lib/utils";
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { ConnectionSyncStatus } from "@prisma/client";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
|
|
||||||
export const WarningNavIndicator = () => {
|
|
||||||
const domain = useDomain();
|
|
||||||
const captureEvent = useCaptureEvent();
|
|
||||||
|
|
||||||
const { data: connections, isPending, isError } = useQuery({
|
|
||||||
queryKey: ['connections', domain],
|
|
||||||
queryFn: () => unwrapServiceError(getConnections(domain)),
|
|
||||||
select: (data) => data.filter(connection => connection.syncStatus === ConnectionSyncStatus.SYNCED_WITH_WARNINGS),
|
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isPending || isError || connections.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_warning_nav_pressed', {})}>
|
|
||||||
<HoverCard openDelay={50}>
|
|
||||||
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_warning_nav_hover', {})}>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-full text-yellow-700 dark:text-yellow-400 text-xs font-medium hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors cursor-pointer">
|
|
||||||
<AlertTriangleIcon className="h-4 w-4" />
|
|
||||||
<span>{connections.length}</span>
|
|
||||||
</div>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent className="w-80 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
|
||||||
<div className="flex flex-col gap-4 p-5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-2 w-2 rounded-full bg-yellow-500"></div>
|
|
||||||
<h3 className="text-sm font-medium text-yellow-700 dark:text-yellow-400">Missing References</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-yellow-600/90 dark:text-yellow-300/90 leading-relaxed">
|
|
||||||
The following connections have references that could not be found:
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-2 pl-4">
|
|
||||||
<TooltipProvider>
|
|
||||||
{connections.slice(0, 10).map(connection => (
|
|
||||||
<Link key={connection.name} href={`/${domain}/connections/${connection.id}`} onClick={() => captureEvent('wa_warning_nav_connection_pressed', {})}>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 bg-yellow-50 dark:bg-yellow-900/20
|
|
||||||
rounded-md text-sm text-yellow-700 dark:text-yellow-300
|
|
||||||
border border-yellow-200/50 dark:border-yellow-800/50
|
|
||||||
hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="font-medium truncate max-w-[200px]">{connection.name}</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{connection.name}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</TooltipProvider>
|
|
||||||
{connections.length > 10 && (
|
|
||||||
<div className="text-sm text-yellow-600/90 dark:text-yellow-300/90 pl-3 pt-1">
|
|
||||||
And {connections.length - 10} more...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -64,7 +64,7 @@ export const RepoList = ({ connectionId }: RepoListProps) => {
|
||||||
const { data: unfilteredRepos, isPending: isReposPending, error: reposError, refetch: refetchRepos } = useQuery({
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
||||||
|
import { NavigationMenu } from "../../components/navigationMenu"
|
||||||
|
import { RepositoryCarousel } from "../../components/repositoryCarousel"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { SyntaxReferenceGuideHint } from "../../components/syntaxReferenceGuideHint"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { SearchBar } from "../../components/searchBar"
|
||||||
|
import { SearchModeSelector } from "../../components/searchModeSelector"
|
||||||
|
import { getRepos, getReposStats } from "@/actions"
|
||||||
|
import { ServiceErrorException } from "@/lib/serviceError"
|
||||||
|
import { isServiceError } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface SearchLandingPageProps {
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchLandingPage = async ({
|
||||||
|
domain,
|
||||||
|
}: SearchLandingPageProps) => {
|
||||||
|
const carouselRepos = await getRepos({
|
||||||
|
where: {
|
||||||
|
indexedAt: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const repoStats = await getReposStats();
|
||||||
|
|
||||||
|
if (isServiceError(carouselRepos)) throw new ServiceErrorException(carouselRepos);
|
||||||
|
if (isServiceError(repoStats)) throw new ServiceErrorException(repoStats);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center overflow-hidden min-h-screen">
|
||||||
|
<NavigationMenu
|
||||||
|
domain={domain}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
|
||||||
|
<div className="max-h-44 w-auto">
|
||||||
|
<SourcebotLogo
|
||||||
|
className="h-18 md:h-40 w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 w-full max-w-[800px] border rounded-md shadow-sm">
|
||||||
|
<SearchBar
|
||||||
|
autoFocus={true}
|
||||||
|
className="border-none pt-0.5 pb-0"
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
|
||||||
|
<SearchModeSelector
|
||||||
|
searchMode="precise"
|
||||||
|
className="ml-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<RepositoryCarousel
|
||||||
|
numberOfReposWithIndex={repoStats.numberOfReposWithIndex}
|
||||||
|
displayRepos={carouselRepos}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center w-fit gap-6">
|
||||||
|
<Separator className="mt-5" />
|
||||||
|
<span className="font-semibold">How to search</span>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
<HowToSection
|
||||||
|
title="Search in files or paths"
|
||||||
|
>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="test todo" domain={domain}>test todo</Query> <QueryExplanation>(both test and todo)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="test or todo" domain={domain}>test <Highlight>or</Highlight> todo</Query> <QueryExplanation>(either test or todo)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query={`"exit boot"`} domain={domain}>{`"exit boot"`}</Query> <QueryExplanation>(exact match)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="TODO case:yes" domain={domain}>TODO <Highlight>case:</Highlight>yes</Query> <QueryExplanation>(case sensitive)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
</HowToSection>
|
||||||
|
<HowToSection
|
||||||
|
title="Filter results"
|
||||||
|
>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="file:README setup" domain={domain}><Highlight>file:</Highlight>README setup</Query> <QueryExplanation>(by filename)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="repo:torvalds/linux test" domain={domain}><Highlight>repo:</Highlight>torvalds/linux test</Query> <QueryExplanation>(by repo)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="lang:typescript" domain={domain}><Highlight>lang:</Highlight>typescript</Query> <QueryExplanation>(by language)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="rev:HEAD" domain={domain}><Highlight>rev:</Highlight>HEAD</Query> <QueryExplanation>(by branch or tag)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
</HowToSection>
|
||||||
|
<HowToSection
|
||||||
|
title="Advanced"
|
||||||
|
>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="file:\.py$" domain={domain}><Highlight>file:</Highlight>{`\\.py$`}</Query> <QueryExplanation>{`(files that end in ".py")`}</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="sym:main" domain={domain}><Highlight>sym:</Highlight>main</Query> <QueryExplanation>{`(symbols named "main")`}</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="todo -lang:c" domain={domain}>todo <Highlight>-lang:c</Highlight></Query> <QueryExplanation>(negate filter)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="content:README" domain={domain}><Highlight>content:</Highlight>README</Query> <QueryExplanation>(search content only)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
</HowToSection>
|
||||||
|
</div>
|
||||||
|
<SyntaxReferenceGuideHint />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="dark:text-gray-300 text-sm mb-2 underline">{title}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const Highlight = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<span className="text-highlight">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const QueryExample = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<span className="text-sm font-mono">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const QueryExplanation = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 ml-3">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Query = ({ query, domain, children }: { query: string, domain: string, children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/${domain}/search?query=${query}`}
|
||||||
|
className="cursor-pointer hover:underline"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,372 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CodeSnippet } from "@/app/components/codeSnippet";
|
||||||
|
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
||||||
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
|
import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { RepositoryInfo, SearchResultFile, SearchStats } from "@/features/search/types";
|
||||||
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
||||||
|
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
||||||
|
import { SearchQueryParams } from "@/lib/types";
|
||||||
|
import { createPathWithQueryParams, measure, unwrapServiceError } from "@/lib/utils";
|
||||||
|
import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalStorage } from "@uidotdev/usehooks";
|
||||||
|
import { AlertTriangleIcon, BugIcon, FilterIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useHotkeys } from "react-hotkeys-hook";
|
||||||
|
import { ImperativePanelHandle } from "react-resizable-panels";
|
||||||
|
import { search } from "../../../api/(client)/client";
|
||||||
|
import { CopyIconButton } from "../../components/copyIconButton";
|
||||||
|
import { SearchBar } from "../../components/searchBar";
|
||||||
|
import { TopBar } from "../../components/topBar";
|
||||||
|
import { CodePreviewPanel } from "./codePreviewPanel";
|
||||||
|
import { FilterPanel } from "./filterPanel";
|
||||||
|
import { useFilteredMatches } from "./filterPanel/useFilterMatches";
|
||||||
|
import { SearchResultsPanel } from "./searchResultsPanel";
|
||||||
|
|
||||||
|
const DEFAULT_MAX_MATCH_COUNT = 500;
|
||||||
|
|
||||||
|
interface SearchResultsPageProps {
|
||||||
|
searchQuery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchResultsPage = ({
|
||||||
|
searchQuery,
|
||||||
|
}: SearchResultsPageProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { setSearchHistory } = useSearchHistory();
|
||||||
|
const captureEvent = useCaptureEvent();
|
||||||
|
const domain = useDomain();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Encodes the number of matches to return in the search response.
|
||||||
|
const _maxMatchCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MAX_MATCH_COUNT}`);
|
||||||
|
const maxMatchCount = isNaN(_maxMatchCount) ? DEFAULT_MAX_MATCH_COUNT : _maxMatchCount;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: searchResponse,
|
||||||
|
isPending: isSearchPending,
|
||||||
|
isFetching: isFetching,
|
||||||
|
error
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["search", searchQuery, maxMatchCount],
|
||||||
|
queryFn: () => measure(() => unwrapServiceError(search({
|
||||||
|
query: searchQuery,
|
||||||
|
matches: maxMatchCount,
|
||||||
|
contextLines: 3,
|
||||||
|
whole: false,
|
||||||
|
}, domain)), "client.search"),
|
||||||
|
select: ({ data, durationMs }) => ({
|
||||||
|
...data,
|
||||||
|
totalClientSearchDurationMs: durationMs,
|
||||||
|
}),
|
||||||
|
enabled: searchQuery.length > 0,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
toast({
|
||||||
|
description: `❌ Search failed. Reason: ${error.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [error, toast]);
|
||||||
|
|
||||||
|
|
||||||
|
// Write the query to the search history
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchQuery.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toUTCString();
|
||||||
|
setSearchHistory((searchHistory) => [
|
||||||
|
{
|
||||||
|
query: searchQuery,
|
||||||
|
date: now,
|
||||||
|
},
|
||||||
|
...searchHistory.filter(search => search.query !== searchQuery),
|
||||||
|
])
|
||||||
|
}, [searchQuery, setSearchHistory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchResponse) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileLanguages = searchResponse.files?.map(file => file.language) || [];
|
||||||
|
|
||||||
|
captureEvent("search_finished", {
|
||||||
|
durationMs: searchResponse.totalClientSearchDurationMs,
|
||||||
|
fileCount: searchResponse.stats.fileCount,
|
||||||
|
matchCount: searchResponse.stats.totalMatchCount,
|
||||||
|
actualMatchCount: searchResponse.stats.actualMatchCount,
|
||||||
|
filesSkipped: searchResponse.stats.filesSkipped,
|
||||||
|
contentBytesLoaded: searchResponse.stats.contentBytesLoaded,
|
||||||
|
indexBytesLoaded: searchResponse.stats.indexBytesLoaded,
|
||||||
|
crashes: searchResponse.stats.crashes,
|
||||||
|
shardFilesConsidered: searchResponse.stats.shardFilesConsidered,
|
||||||
|
filesConsidered: searchResponse.stats.filesConsidered,
|
||||||
|
filesLoaded: searchResponse.stats.filesLoaded,
|
||||||
|
shardsScanned: searchResponse.stats.shardsScanned,
|
||||||
|
shardsSkipped: searchResponse.stats.shardsSkipped,
|
||||||
|
shardsSkippedFilter: searchResponse.stats.shardsSkippedFilter,
|
||||||
|
ngramMatches: searchResponse.stats.ngramMatches,
|
||||||
|
ngramLookups: searchResponse.stats.ngramLookups,
|
||||||
|
wait: searchResponse.stats.wait,
|
||||||
|
matchTreeConstruction: searchResponse.stats.matchTreeConstruction,
|
||||||
|
matchTreeSearch: searchResponse.stats.matchTreeSearch,
|
||||||
|
regexpsConsidered: searchResponse.stats.regexpsConsidered,
|
||||||
|
flushReason: searchResponse.stats.flushReason,
|
||||||
|
fileLanguages,
|
||||||
|
});
|
||||||
|
}, [captureEvent, searchQuery, searchResponse]);
|
||||||
|
|
||||||
|
|
||||||
|
const onLoadMoreResults = useCallback(() => {
|
||||||
|
const url = createPathWithQueryParams(`/${domain}/search`,
|
||||||
|
[SearchQueryParams.query, searchQuery],
|
||||||
|
[SearchQueryParams.matches, `${maxMatchCount * 2}`],
|
||||||
|
)
|
||||||
|
router.push(url);
|
||||||
|
}, [maxMatchCount, router, searchQuery, domain]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen overflow-clip">
|
||||||
|
{/* TopBar */}
|
||||||
|
<TopBar
|
||||||
|
domain={domain}
|
||||||
|
>
|
||||||
|
<SearchBar
|
||||||
|
size="sm"
|
||||||
|
defaultQuery={searchQuery}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</TopBar>
|
||||||
|
|
||||||
|
{(isSearchPending || isFetching) ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||||
|
<SymbolIcon className="h-6 w-6 animate-spin" />
|
||||||
|
<p className="font-semibold text-center">Searching...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||||
|
<AlertTriangleIcon className="h-6 w-6" />
|
||||||
|
<p className="font-semibold text-center">Failed to search</p>
|
||||||
|
<p className="text-sm text-center">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PanelGroup
|
||||||
|
fileMatches={searchResponse.files}
|
||||||
|
isMoreResultsButtonVisible={searchResponse.isSearchExhaustive === false}
|
||||||
|
onLoadMoreResults={onLoadMoreResults}
|
||||||
|
isBranchFilteringEnabled={searchResponse.isBranchFilteringEnabled}
|
||||||
|
repoInfo={searchResponse.repositoryInfo}
|
||||||
|
searchDurationMs={searchResponse.totalClientSearchDurationMs}
|
||||||
|
numMatches={searchResponse.stats.actualMatchCount}
|
||||||
|
searchStats={searchResponse.stats}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PanelGroupProps {
|
||||||
|
fileMatches: SearchResultFile[];
|
||||||
|
isMoreResultsButtonVisible?: boolean;
|
||||||
|
onLoadMoreResults: () => void;
|
||||||
|
isBranchFilteringEnabled: boolean;
|
||||||
|
repoInfo: RepositoryInfo[];
|
||||||
|
searchDurationMs: number;
|
||||||
|
numMatches: number;
|
||||||
|
searchStats?: SearchStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PanelGroup = ({
|
||||||
|
fileMatches,
|
||||||
|
isMoreResultsButtonVisible,
|
||||||
|
onLoadMoreResults,
|
||||||
|
isBranchFilteringEnabled,
|
||||||
|
repoInfo: _repoInfo,
|
||||||
|
searchDurationMs: _searchDurationMs,
|
||||||
|
numMatches,
|
||||||
|
searchStats,
|
||||||
|
}: PanelGroupProps) => {
|
||||||
|
const [previewedFile, setPreviewedFile] = useState<SearchResultFile | undefined>(undefined);
|
||||||
|
const filteredFileMatches = useFilteredMatches(fileMatches);
|
||||||
|
const filterPanelRef = useRef<ImperativePanelHandle>(null);
|
||||||
|
const [selectedMatchIndex, setSelectedMatchIndex] = useState(0);
|
||||||
|
|
||||||
|
const [isFilterPanelCollapsed, setIsFilterPanelCollapsed] = useLocalStorage('isFilterPanelCollapsed', false);
|
||||||
|
|
||||||
|
useHotkeys("mod+b", () => {
|
||||||
|
if (isFilterPanelCollapsed) {
|
||||||
|
filterPanelRef.current?.expand();
|
||||||
|
} else {
|
||||||
|
filterPanelRef.current?.collapse();
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
enableOnFormTags: true,
|
||||||
|
enableOnContentEditable: true,
|
||||||
|
description: "Toggle filter panel",
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchDurationMs = useMemo(() => {
|
||||||
|
return Math.round(_searchDurationMs);
|
||||||
|
}, [_searchDurationMs]);
|
||||||
|
|
||||||
|
const repoInfo = useMemo(() => {
|
||||||
|
return _repoInfo.reduce((acc, repo) => {
|
||||||
|
acc[repo.id] = repo;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, RepositoryInfo>);
|
||||||
|
}, [_repoInfo]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResizablePanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
|
{/* ~~ Filter panel ~~ */}
|
||||||
|
<ResizablePanel
|
||||||
|
ref={filterPanelRef}
|
||||||
|
minSize={20}
|
||||||
|
maxSize={30}
|
||||||
|
defaultSize={isFilterPanelCollapsed ? 0 : 20}
|
||||||
|
collapsible={true}
|
||||||
|
id={'filter-panel'}
|
||||||
|
order={1}
|
||||||
|
onCollapse={() => setIsFilterPanelCollapsed(true)}
|
||||||
|
onExpand={() => setIsFilterPanelCollapsed(false)}
|
||||||
|
>
|
||||||
|
<FilterPanel
|
||||||
|
matches={fileMatches}
|
||||||
|
repoInfo={repoInfo}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
{isFilterPanelCollapsed && (
|
||||||
|
<div className="flex flex-col items-center h-full p-2">
|
||||||
|
<Tooltip
|
||||||
|
delayDuration={100}
|
||||||
|
>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => {
|
||||||
|
filterPanelRef.current?.expand();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilterIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="flex flex-row items-center gap-2">
|
||||||
|
<KeyboardShortcutHint shortcut="⌘ B" />
|
||||||
|
<Separator orientation="vertical" className="h-4" />
|
||||||
|
<span>Open filter panel</span>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AnimatedResizableHandle />
|
||||||
|
|
||||||
|
{/* ~~ Search results ~~ */}
|
||||||
|
<ResizablePanel
|
||||||
|
minSize={10}
|
||||||
|
id={'search-results-panel'}
|
||||||
|
order={2}
|
||||||
|
>
|
||||||
|
<div className="py-1 px-2 flex flex-row items-center">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<InfoCircledIcon className="w-4 h-4 mr-2" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="flex flex-col items-start gap-2 p-4">
|
||||||
|
<div className="flex flex-row items-center w-full">
|
||||||
|
<BugIcon className="w-4 h-4 mr-1.5" />
|
||||||
|
<p className="text-md font-medium">Search stats for nerds</p>
|
||||||
|
<CopyIconButton
|
||||||
|
onCopy={() => {
|
||||||
|
navigator.clipboard.writeText(JSON.stringify(searchStats, null, 2));
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
className="ml-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CodeSnippet renderNewlines>
|
||||||
|
{JSON.stringify(searchStats, null, 2)}
|
||||||
|
</CodeSnippet>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
{
|
||||||
|
fileMatches.length > 0 ? (
|
||||||
|
<p className="text-sm font-medium">{`[${searchDurationMs} ms] Found ${numMatches} matches in ${fileMatches.length} ${fileMatches.length > 1 ? 'files' : 'file'}`}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-medium">No results</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{isMoreResultsButtonVisible && (
|
||||||
|
<div
|
||||||
|
className="cursor-pointer text-blue-500 text-sm hover:underline ml-4"
|
||||||
|
onClick={onLoadMoreResults}
|
||||||
|
>
|
||||||
|
(load more)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{filteredFileMatches.length > 0 ? (
|
||||||
|
<SearchResultsPanel
|
||||||
|
fileMatches={filteredFileMatches}
|
||||||
|
onOpenFilePreview={(fileMatch, matchIndex) => {
|
||||||
|
setSelectedMatchIndex(matchIndex ?? 0);
|
||||||
|
setPreviewedFile(fileMatch);
|
||||||
|
}}
|
||||||
|
isLoadMoreButtonVisible={!!isMoreResultsButtonVisible}
|
||||||
|
onLoadMoreButtonClicked={onLoadMoreResults}
|
||||||
|
isBranchFilteringEnabled={isBranchFilteringEnabled}
|
||||||
|
repoInfo={repoInfo}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
|
<p className="text-sm text-muted-foreground">No results found</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
{previewedFile && (
|
||||||
|
<>
|
||||||
|
<AnimatedResizableHandle />
|
||||||
|
{/* ~~ Code preview ~~ */}
|
||||||
|
<ResizablePanel
|
||||||
|
minSize={10}
|
||||||
|
collapsible={true}
|
||||||
|
id={'code-preview-panel'}
|
||||||
|
order={3}
|
||||||
|
onCollapse={() => setPreviewedFile(undefined)}
|
||||||
|
>
|
||||||
|
<CodePreviewPanel
|
||||||
|
previewedFile={previewedFile}
|
||||||
|
onClose={() => setPreviewedFile(undefined)}
|
||||||
|
selectedMatchIndex={selectedMatchIndex}
|
||||||
|
onSelectedMatchIndexChange={setSelectedMatchIndex}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { SearchResultFile, SearchResultChunk } from "@/features/search/types";
|
import { 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";
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
|
|
|
||||||
|
|
@ -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<
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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 = ({
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue