diff --git a/package.json b/package.json index c2cb612d..b1ba8900 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,15 @@ "@codemirror/search": "^6.5.6", "@codemirror/state": "^6.4.1", "@codemirror/view": "^6.33.0", + "@hookform/resolvers": "^3.9.0", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@replit/codemirror-vim": "^6.2.1", - "@uidotdev/usehooks": "^2.4.1", + "@tanstack/react-query": "^5.53.3", "@uiw/react-codemirror": "^4.23.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -30,11 +32,13 @@ "next-themes": "^0.3.0", "react": "^18", "react-dom": "^18", + "react-hook-form": "^7.53.0", "react-resizable-panels": "^2.1.1", "sharp": "^0.33.5", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", - "use-debounce": "^10.0.3" + "usehooks-ts": "^3.1.0", + "zod": "^3.23.8" }, "devDependencies": { "@types/node": "^20", diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28c..00000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/sb_logo_dark_large.png b/public/sb_logo_dark_large.png new file mode 100644 index 00000000..6abddb77 Binary files /dev/null and b/public/sb_logo_dark_large.png differ diff --git a/public/sb_logo_large_3.png b/public/sb_logo_large_3.png deleted file mode 100644 index 7265839e..00000000 Binary files a/public/sb_logo_large_3.png and /dev/null differ diff --git a/public/sb_logo_light_large.png b/public/sb_logo_light_large.png new file mode 100644 index 00000000..37d136fa Binary files /dev/null and b/public/sb_logo_light_large.png differ diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index d2f84222..00000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index e9b74872..dfa2e7fc 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -16,6 +16,6 @@ export async function GET(request: NextRequest) { ); const res = await fetch(url); const data = await res.json(); - - return Response.json({ data }) + + return Response.json({ ...data }) } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3f74f658..29214f13 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Inter } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "next-themes"; import { Suspense } from "react"; +import { QueryClientProvider } from "./queryClientProvider"; const inter = Inter({ subsets: ["latin"] }); @@ -29,13 +30,15 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - {/* - @todo : ideally we don't wrap everything in a suspense boundary. - @see : https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout - */} - - {children} - + + {/* + @todo : ideally we don't wrap everything in a suspense boundary. + @see : https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout + */} + + {children} + + diff --git a/src/app/page.tsx b/src/app/page.tsx index d28dd5e9..db96f926 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,140 +1,51 @@ 'use client'; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@/components/ui/resizable"; -import { Separator } from "@/components/ui/separator"; -import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; -import { GetSourceResponse, KeymapType, pathQueryParamName, repoQueryParamName, ZoektFileMatch } from "@/lib/types"; -import { createPathWithQueryParams } from "@/lib/utils"; -import { SymbolIcon } from "@radix-ui/react-icons"; import Image from "next/image"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import logoDark from "../../public/sb_logo_dark.png"; -import logoLight from "../../public/sb_logo_light.png"; -import { CodePreview, CodePreviewFile } from "./codePreview"; +import logoDark from "../../public/sb_logo_dark_large.png"; +import logoLight from "../../public/sb_logo_light_large.png"; import { SearchBar } from "./searchBar"; -import { SearchResults } from "./searchResults"; import { SettingsDropdown } from "./settingsDropdown"; -import { useLocalStorage } from "@uidotdev/usehooks"; +import { GitHubLogoIcon } from "@radix-ui/react-icons"; +import { Button } from "@/components/ui/button"; + +const SOURCEBOT_GITHUB_URL = "https://github.com/TaqlaAI/sourcebot-search"; export default function Home() { - const router = useRouter(); - const defaultQuery = useNonEmptyQueryParam("query") ?? ""; - const defaultNumResults = useNonEmptyQueryParam("numResults"); - - const [query, setQuery] = useState(defaultQuery); - const [numResults, _setNumResults] = useState(defaultNumResults && !isNaN(Number(defaultNumResults)) ? Number(defaultNumResults) : 100); - - const [isCodePanelOpen, setIsCodePanelOpen] = useState(false); - const [previewFile, setPreviewFile] = useState(undefined); - const [selectedMatchIndex, setSelectedMatchIndex] = useState(0); - - const [fileMatches, setFileMatches] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [searchDurationMs, setSearchDurationMs] = useState(0); - - const [keymapType, saveKeymapType] = useLocalStorage("keymapType", "default"); - - // @todo: We need to be able to handle the case when the user navigates backwards / forwards. - // Currently we do not re-query. return ( -
+
{/* TopBar */} -
-
-
- {"Sourcebot - {"Sourcebot - setQuery(query)} - onLoadingChange={(isLoading) => setIsLoading(isLoading)} - onSearchResult={(result) => { - if (result) { - setFileMatches(result.FileMatches ?? []); - setSearchDurationMs(Math.round(result.Stats.Duration / 1000000)); - } - - router.push(`?query=${query}&numResults=${numResults}`); - }} - /> - {isLoading && ( - - )} -
- -
- -
-

Results for: {fileMatches.length} files in {searchDurationMs} ms

-
- -
- - {/* Search Results & Code Preview */} - - - { - const url = createPathWithQueryParams( - `http://localhost:3000/api/source`, - [pathQueryParamName, match.FileName], - [repoQueryParamName, match.Repo] - ); - - // @todo : this query should definitely be cached s.t., when the user is switching between files, - // we aren't re-fetching the same file. - fetch(url) - .then(response => response.json()) - .then((body: GetSourceResponse) => { - if (body.encoding !== "base64") { - throw new Error("Expected base64 encoding"); - } - - const content = atob(body.content); - setSelectedMatchIndex(0); - setPreviewFile({ - content: content, - filepath: match.FileName, - matches: match.Matches, - }); - setIsCodePanelOpen(true); - }); +
+
+ + +
+
+
+
+ {"Sourcebot - - - - -
- ); + +
+ +
+ + + ) } diff --git a/src/app/queryClientProvider.tsx b/src/app/queryClientProvider.tsx new file mode 100644 index 00000000..41e7ff0d --- /dev/null +++ b/src/app/queryClientProvider.tsx @@ -0,0 +1,14 @@ +'use client'; + +import * as React from "react" +import { QueryClient, QueryClientProvider as QueryClientProviderBase, QueryClientProviderProps } from "@tanstack/react-query" + +const queryClient = new QueryClient(); + +export const QueryClientProvider = ({ children, ...props }: Omit) => { + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/src/app/codePreview.tsx b/src/app/search/codePreview.tsx similarity index 97% rename from src/app/codePreview.tsx rename to src/app/search/codePreview.tsx index 28e11cc4..df9e88c5 100644 --- a/src/app/codePreview.tsx +++ b/src/app/search/codePreview.tsx @@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency"; +import { useKeymapType } from "@/hooks/useKeymapType"; import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension"; import { markMatches, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension"; import { ZoektMatch } from "@/lib/types"; @@ -26,7 +27,6 @@ export interface CodePreviewFile { interface CodePreviewProps { file?: CodePreviewFile; - keymapType: "default" | "vim"; selectedMatchIndex: number; onSelectedMatchIndexChange: (index: number) => void; onClose: () => void; @@ -34,7 +34,6 @@ interface CodePreviewProps { export const CodePreview = ({ file, - keymapType, selectedMatchIndex, onSelectedMatchIndexChange, onClose, @@ -42,6 +41,7 @@ export const CodePreview = ({ const editorRef = useRef(null); const { theme: _theme, systemTheme } = useTheme(); + const [ keymapType ] = useKeymapType(); const theme = useMemo(() => { if (_theme === "system") { @@ -90,7 +90,7 @@ export const CodePreview = ({ } markMatches(selectedMatchIndex, file.matches, editorRef.current.view); - }, [file, selectedMatchIndex]); + }, [file, file?.matches, selectedMatchIndex]); const onUpClicked = useCallback(() => { onSelectedMatchIndexChange(selectedMatchIndex - 1); diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx new file mode 100644 index 00000000..8c71c9ae --- /dev/null +++ b/src/app/search/page.tsx @@ -0,0 +1,183 @@ +'use client'; + +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { Separator } from "@/components/ui/separator"; +import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; +import { GetSourceResponse, pathQueryParamName, repoQueryParamName, ZoektFileMatch, ZoektSearchResponse } from "@/lib/types"; +import { createPathWithQueryParams } from "@/lib/utils"; +import { SymbolIcon } from "@radix-ui/react-icons"; +import { useQuery } from "@tanstack/react-query"; +import Image from "next/image"; +import { useMemo, useState } from "react"; +import logoDark from "../../../public/sb_logo_dark.png"; +import logoLight from "../../../public/sb_logo_light.png"; +import { SearchBar } from "../searchBar"; +import { SettingsDropdown } from "../settingsDropdown"; +import { CodePreview, CodePreviewFile } from "./codePreview"; +import { SearchResults } from "./searchResults"; +import { useRouter } from "next/navigation"; + +export default function SearchPage() { + const router = useRouter(); + const searchQuery = useNonEmptyQueryParam("query") ?? ""; + const numResults = useNonEmptyQueryParam("numResults") ?? "100"; + + const [selectedMatchIndex, setSelectedMatchIndex] = useState(0); + const [selectedFile, setSelectedFile] = useState(undefined); + + const { data: searchResponse, isLoading } = useQuery({ + queryKey: ["search", searchQuery, numResults], + queryFn: async (): Promise => { + console.log("Fetching search results"); + const result = await fetch(`/api/search?query=${searchQuery}&numResults=${numResults}`) + .then(response => response.json()); + console.log("Done"); + return result; + }, + enabled: searchQuery.length > 0, + }); + + const { fileMatches, searchDurationMs } = useMemo((): { fileMatches: ZoektFileMatch[], searchDurationMs: number } => { + if (!searchResponse) { + return { + fileMatches: [], + searchDurationMs: 0, + }; + } + return { + fileMatches: searchResponse.result.FileMatches ?? [], + searchDurationMs: Math.round(searchResponse.result.Stats.Duration / 1000000), + } + }, [searchResponse]); + + + return ( +
+ {/* TopBar */} +
+
+
+
{ + router.push("/"); + }} + > + {"Sourcebot + {"Sourcebot +
+ + {isLoading && ( + + )} +
+ +
+ +
+

Results for: {fileMatches.length} files in {searchDurationMs} ms

+
+ +
+ + {/* Search Results & Code Preview */} + + + { + setSelectedFile(match); + setSelectedMatchIndex(0); + }} + /> + + + + +
+ ); +} + +interface CodePreviewWrapperProps { + fileMatch?: ZoektFileMatch; + onClose: () => void; + selectedMatchIndex: number; + onSelectedMatchIndexChange: (index: number) => void; +} + +const CodePreviewWrapper = ({ + fileMatch, + onClose, + selectedMatchIndex, + onSelectedMatchIndexChange, +}: CodePreviewWrapperProps) => { + + const { data: file } = useQuery({ + queryKey: ["source", fileMatch?.FileName, fileMatch?.Repo], + queryFn: async (): Promise => { + if (!fileMatch) { + return undefined; + } + + const url = createPathWithQueryParams( + `/api/source`, + [pathQueryParamName, fileMatch.FileName], + [repoQueryParamName, fileMatch.Repo] + ); + + const result = await fetch(url) + .then(response => response.json()) + .then((body: GetSourceResponse) => { + if (body.encoding !== "base64") { + throw new Error("Expected base64 encoding"); + } + + const content = atob(body.content); + return { + content, + filepath: fileMatch.FileName, + matches: fileMatch.Matches, + }; + }); + return result; + }, + enabled: fileMatch !== undefined, + }); + + return ( + + ) + +} \ No newline at end of file diff --git a/src/app/searchResults.tsx b/src/app/search/searchResults.tsx similarity index 100% rename from src/app/searchResults.tsx rename to src/app/search/searchResults.tsx diff --git a/src/app/searchBar.tsx b/src/app/searchBar.tsx index 7a3c8c6d..327e8972 100644 --- a/src/app/searchBar.tsx +++ b/src/app/searchBar.tsx @@ -1,63 +1,85 @@ 'use client'; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { ZoektResult, ZoektSearchResponse } from "@/lib/types"; -import { useEffect } from "react"; -import { useDebouncedCallback } from "use-debounce"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { cva } from "class-variance-authority"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; interface SearchBarProps { - query: string; - numResults: number; - onLoadingChange: (isLoading: boolean) => void; - onQueryChange: (query: string) => void; - onSearchResult: (result?: ZoektResult) => void, + className?: string; + size?: "default" | "sm"; + defaultQuery?: string; } -export const SearchBar = ({ - query, - numResults, - onLoadingChange, - onQueryChange, - onSearchResult, -}: SearchBarProps) => { - const SEARCH_DEBOUNCE_MS = 200; +const formSchema = z.object({ + query: z.string(), +}); - // @todo : we should probably be cancelling any running requests - const search = useDebouncedCallback((query: string) => { - if (query === "") { - onSearchResult(undefined); - return; +const searchBarVariants = cva( + "w-full", + { + variants: { + size: { + default: "h-10", + sm: "h-8" + } + }, + defaultVariants: { + size: "default", } - console.log('making query...'); + } +) - onLoadingChange(true); - fetch(`http://localhost:3000/api/search?query=${query}&numResults=${numResults}`) - .then(response => response.json()) - .then(({ data }: { data: ZoektSearchResponse }) => { - onSearchResult(data.result); - }) - // @todo : error handling - .catch(error => { - console.error('Error:', error); - }).finally(() => { - console.log('done making query'); - onLoadingChange(false); - }); - }, SEARCH_DEBOUNCE_MS); +export const SearchBar = ({ + className, + size, + defaultQuery, +}: SearchBarProps) => { + const router = useRouter(); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + query: defaultQuery ?? "", + } + }); - useEffect(() => { - search(query); - }, [query, search]); + const onSubmit = (values: z.infer) => { + router.push(`/search?query=${values.query}&numResults=100`); + } return ( - { - const query = e.target.value; - onQueryChange(query); - }} - /> +
+ + ( + + + + + + + )} + /> + + ) } \ No newline at end of file diff --git a/src/app/settingsDropdown.tsx b/src/app/settingsDropdown.tsx index 74d037e5..2fc75879 100644 --- a/src/app/settingsDropdown.tsx +++ b/src/app/settingsDropdown.tsx @@ -23,18 +23,20 @@ import { import { useTheme } from "next-themes" import { useMemo } from "react" import { KeymapType } from "@/lib/types" +import { cn } from "@/lib/utils" +import { useKeymapType } from "@/hooks/useKeymapType" interface SettingsDropdownProps { - keymapType: KeymapType; - onKeymapTypeChange: (keymapType: KeymapType) => void; + menuButtonClassName?: string; } export const SettingsDropdown = ({ - keymapType, - onKeymapTypeChange, + menuButtonClassName, }: SettingsDropdownProps) => { const { theme: _theme, setTheme } = useTheme(); + const [ keymapType, setKeymapType ] = useKeymapType(); + const theme = useMemo(() => { return _theme ?? "light"; }, [_theme]); @@ -55,7 +57,7 @@ export const SettingsDropdown = ({ return ( - @@ -91,7 +93,7 @@ export const SettingsDropdown = ({ - onKeymapTypeChange(value as KeymapType)}> + setKeymapType(value as KeymapType)}> Default diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 00000000..ce264aef --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,178 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +