mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-13 21:05:22 +00:00
Several things: (1) Add a basic landing page and move search into /search route. (2) Make the search bar a form instead of being fancy and using debounce. (3) Add react-query for better query handling.
This commit is contained in:
parent
9af8696a6d
commit
45c176f7ae
19 changed files with 592 additions and 206 deletions
|
|
@ -14,13 +14,15 @@
|
||||||
"@codemirror/search": "^6.5.6",
|
"@codemirror/search": "^6.5.6",
|
||||||
"@codemirror/state": "^6.4.1",
|
"@codemirror/state": "^6.4.1",
|
||||||
"@codemirror/view": "^6.33.0",
|
"@codemirror/view": "^6.33.0",
|
||||||
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@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-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@replit/codemirror-vim": "^6.2.1",
|
"@replit/codemirror-vim": "^6.2.1",
|
||||||
"@uidotdev/usehooks": "^2.4.1",
|
"@tanstack/react-query": "^5.53.3",
|
||||||
"@uiw/react-codemirror": "^4.23.0",
|
"@uiw/react-codemirror": "^4.23.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -30,11 +32,13 @@
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
"react-hook-form": "^7.53.0",
|
||||||
"react-resizable-panels": "^2.1.1",
|
"react-resizable-panels": "^2.1.1",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"use-debounce": "^10.0.3"
|
"usehooks-ts": "^3.1.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
BIN
public/sb_logo_dark_large.png
Normal file
BIN
public/sb_logo_dark_large.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.8 KiB |
BIN
public/sb_logo_light_large.png
Normal file
BIN
public/sb_logo_light_large.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
|
|
@ -1 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 629 B |
|
|
@ -16,6 +16,6 @@ export async function GET(request: NextRequest) {
|
||||||
);
|
);
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
return Response.json({ data })
|
return Response.json({ ...data })
|
||||||
}
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { QueryClientProvider } from "./queryClientProvider";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
|
@ -29,13 +30,15 @@ export default function RootLayout({
|
||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
{/*
|
<QueryClientProvider>
|
||||||
@todo : ideally we don't wrap everything in a suspense boundary.
|
{/*
|
||||||
@see : https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
|
@todo : ideally we don't wrap everything in a suspense boundary.
|
||||||
*/}
|
@see : https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
|
||||||
<Suspense>
|
*/}
|
||||||
{children}
|
<Suspense>
|
||||||
</Suspense>
|
{children}
|
||||||
|
</Suspense>
|
||||||
|
</QueryClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
163
src/app/page.tsx
163
src/app/page.tsx
|
|
@ -1,140 +1,51 @@
|
||||||
'use client';
|
'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 Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import logoDark from "../../public/sb_logo_dark_large.png";
|
||||||
import { useState } from "react";
|
import logoLight from "../../public/sb_logo_light_large.png";
|
||||||
import logoDark from "../../public/sb_logo_dark.png";
|
|
||||||
import logoLight from "../../public/sb_logo_light.png";
|
|
||||||
import { CodePreview, CodePreviewFile } from "./codePreview";
|
|
||||||
import { SearchBar } from "./searchBar";
|
import { SearchBar } from "./searchBar";
|
||||||
import { SearchResults } from "./searchResults";
|
|
||||||
import { SettingsDropdown } from "./settingsDropdown";
|
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() {
|
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<CodePreviewFile | undefined>(undefined);
|
|
||||||
const [selectedMatchIndex, setSelectedMatchIndex] = useState(0);
|
|
||||||
|
|
||||||
const [fileMatches, setFileMatches] = useState<ZoektFileMatch[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [searchDurationMs, setSearchDurationMs] = useState(0);
|
|
||||||
|
|
||||||
const [keymapType, saveKeymapType] = useLocalStorage<KeymapType>("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 (
|
return (
|
||||||
<main className="flex flex-col h-screen overflow-clip">
|
<div className="h-screen flex flex-col items-center">
|
||||||
{/* TopBar */}
|
{/* TopBar */}
|
||||||
<div className="sticky top-0 left-0 right-0 z-10">
|
<div className="absolute top-0 left-0 right-0">
|
||||||
<div className="flex flex-row justify-between items-center py-1.5 px-3 gap-4">
|
<div className="flex flex-row justify-end items-center py-1.5 px-3 gap-2">
|
||||||
<div className="grow flex flex-row gap-4 items-center">
|
<Button
|
||||||
<Image
|
variant="outline"
|
||||||
src={logoDark}
|
size="icon"
|
||||||
className="h-4 w-auto hidden dark:block"
|
onClick={() => {
|
||||||
alt={"Sourcebot logo"}
|
window.open(SOURCEBOT_GITHUB_URL, "_blank");
|
||||||
/>
|
|
||||||
<Image
|
|
||||||
src={logoLight}
|
|
||||||
className="h-4 w-auto block dark:hidden"
|
|
||||||
alt={"Sourcebot logo"}
|
|
||||||
/>
|
|
||||||
<SearchBar
|
|
||||||
query={query}
|
|
||||||
numResults={numResults}
|
|
||||||
onQueryChange={(query) => 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 && (
|
|
||||||
<SymbolIcon className="h-4 w-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<SettingsDropdown
|
|
||||||
keymapType={keymapType}
|
|
||||||
onKeymapTypeChange={saveKeymapType}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="bg-accent p-1">
|
|
||||||
<p className="text-sm font-medium">Results for: {fileMatches.length} files in {searchDurationMs} ms</p>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Results & Code Preview */}
|
|
||||||
<ResizablePanelGroup direction="horizontal">
|
|
||||||
<ResizablePanel minSize={20}>
|
|
||||||
<SearchResults
|
|
||||||
fileMatches={fileMatches}
|
|
||||||
onOpenFileMatch={(match) => {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<GitHubLogoIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<SettingsDropdown />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-center items-center p-4 mt-48">
|
||||||
|
<div className="max-h-44 w-auto">
|
||||||
|
<Image
|
||||||
|
src={logoDark}
|
||||||
|
className="w-full h-full hidden dark:block"
|
||||||
|
alt={"Sourcebot logo"}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
<Image
|
||||||
<ResizableHandle withHandle={isCodePanelOpen} />
|
src={logoLight}
|
||||||
<ResizablePanel
|
className="w-full h-full block dark:hidden"
|
||||||
minSize={20}
|
alt={"Sourcebot logo"}
|
||||||
hidden={!isCodePanelOpen}
|
|
||||||
>
|
|
||||||
<CodePreview
|
|
||||||
file={previewFile}
|
|
||||||
onClose={() => setIsCodePanelOpen(false)}
|
|
||||||
selectedMatchIndex={selectedMatchIndex}
|
|
||||||
onSelectedMatchIndexChange={setSelectedMatchIndex}
|
|
||||||
keymapType={keymapType}
|
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</div>
|
||||||
</ResizablePanelGroup>
|
<div className="w-full flex flex-row mt-4">
|
||||||
</main>
|
<SearchBar />
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
src/app/queryClientProvider.tsx
Normal file
14
src/app/queryClientProvider.tsx
Normal file
|
|
@ -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<QueryClientProviderProps, 'client'>) => {
|
||||||
|
return (
|
||||||
|
<QueryClientProviderBase client={queryClient} {...props}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProviderBase>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency";
|
import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency";
|
||||||
|
import { useKeymapType } from "@/hooks/useKeymapType";
|
||||||
import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension";
|
import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension";
|
||||||
import { markMatches, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension";
|
import { markMatches, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension";
|
||||||
import { ZoektMatch } from "@/lib/types";
|
import { ZoektMatch } from "@/lib/types";
|
||||||
|
|
@ -26,7 +27,6 @@ export interface CodePreviewFile {
|
||||||
|
|
||||||
interface CodePreviewProps {
|
interface CodePreviewProps {
|
||||||
file?: CodePreviewFile;
|
file?: CodePreviewFile;
|
||||||
keymapType: "default" | "vim";
|
|
||||||
selectedMatchIndex: number;
|
selectedMatchIndex: number;
|
||||||
onSelectedMatchIndexChange: (index: number) => void;
|
onSelectedMatchIndexChange: (index: number) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -34,7 +34,6 @@ interface CodePreviewProps {
|
||||||
|
|
||||||
export const CodePreview = ({
|
export const CodePreview = ({
|
||||||
file,
|
file,
|
||||||
keymapType,
|
|
||||||
selectedMatchIndex,
|
selectedMatchIndex,
|
||||||
onSelectedMatchIndexChange,
|
onSelectedMatchIndexChange,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -42,6 +41,7 @@ export const CodePreview = ({
|
||||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||||
|
|
||||||
const { theme: _theme, systemTheme } = useTheme();
|
const { theme: _theme, systemTheme } = useTheme();
|
||||||
|
const [ keymapType ] = useKeymapType();
|
||||||
|
|
||||||
const theme = useMemo(() => {
|
const theme = useMemo(() => {
|
||||||
if (_theme === "system") {
|
if (_theme === "system") {
|
||||||
|
|
@ -90,7 +90,7 @@ export const CodePreview = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
markMatches(selectedMatchIndex, file.matches, editorRef.current.view);
|
markMatches(selectedMatchIndex, file.matches, editorRef.current.view);
|
||||||
}, [file, selectedMatchIndex]);
|
}, [file, file?.matches, selectedMatchIndex]);
|
||||||
|
|
||||||
const onUpClicked = useCallback(() => {
|
const onUpClicked = useCallback(() => {
|
||||||
onSelectedMatchIndexChange(selectedMatchIndex - 1);
|
onSelectedMatchIndexChange(selectedMatchIndex - 1);
|
||||||
183
src/app/search/page.tsx
Normal file
183
src/app/search/page.tsx
Normal file
|
|
@ -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<ZoektFileMatch | undefined>(undefined);
|
||||||
|
|
||||||
|
const { data: searchResponse, isLoading } = useQuery({
|
||||||
|
queryKey: ["search", searchQuery, numResults],
|
||||||
|
queryFn: async (): Promise<ZoektSearchResponse> => {
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col h-screen overflow-clip">
|
||||||
|
{/* TopBar */}
|
||||||
|
<div className="sticky top-0 left-0 right-0 z-10">
|
||||||
|
<div className="flex flex-row justify-between items-center py-1.5 px-3 gap-4">
|
||||||
|
<div className="grow flex flex-row gap-4 items-center">
|
||||||
|
<div
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
router.push("/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={logoDark}
|
||||||
|
className="h-4 w-auto hidden dark:block"
|
||||||
|
alt={"Sourcebot logo"}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
src={logoLight}
|
||||||
|
className="h-4 w-auto block dark:hidden"
|
||||||
|
alt={"Sourcebot logo"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SearchBar
|
||||||
|
size="sm"
|
||||||
|
defaultQuery={searchQuery}
|
||||||
|
/>
|
||||||
|
{isLoading && (
|
||||||
|
<SymbolIcon className="h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<SettingsDropdown
|
||||||
|
menuButtonClassName="w-8 h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="bg-accent p-1">
|
||||||
|
<p className="text-sm font-medium">Results for: {fileMatches.length} files in {searchDurationMs} ms</p>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Results & Code Preview */}
|
||||||
|
<ResizablePanelGroup direction="horizontal">
|
||||||
|
<ResizablePanel minSize={20}>
|
||||||
|
<SearchResults
|
||||||
|
fileMatches={fileMatches}
|
||||||
|
onOpenFileMatch={(match) => {
|
||||||
|
setSelectedFile(match);
|
||||||
|
setSelectedMatchIndex(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle withHandle={selectedFile !== undefined} />
|
||||||
|
<ResizablePanel
|
||||||
|
minSize={20}
|
||||||
|
hidden={!selectedFile}
|
||||||
|
>
|
||||||
|
<CodePreviewWrapper
|
||||||
|
fileMatch={selectedFile}
|
||||||
|
onClose={() => setSelectedFile(undefined)}
|
||||||
|
selectedMatchIndex={selectedMatchIndex}
|
||||||
|
onSelectedMatchIndexChange={setSelectedMatchIndex}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CodePreviewFile | undefined> => {
|
||||||
|
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 (
|
||||||
|
<CodePreview
|
||||||
|
file={file}
|
||||||
|
onClose={onClose}
|
||||||
|
selectedMatchIndex={selectedMatchIndex}
|
||||||
|
onSelectedMatchIndexChange={onSelectedMatchIndexChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,63 +1,85 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ZoektResult, ZoektSearchResponse } from "@/lib/types";
|
import { cn } from "@/lib/utils";
|
||||||
import { useEffect } from "react";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { cva } from "class-variance-authority";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
query: string;
|
className?: string;
|
||||||
numResults: number;
|
size?: "default" | "sm";
|
||||||
onLoadingChange: (isLoading: boolean) => void;
|
defaultQuery?: string;
|
||||||
onQueryChange: (query: string) => void;
|
|
||||||
onSearchResult: (result?: ZoektResult) => void,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SearchBar = ({
|
const formSchema = z.object({
|
||||||
query,
|
query: z.string(),
|
||||||
numResults,
|
});
|
||||||
onLoadingChange,
|
|
||||||
onQueryChange,
|
|
||||||
onSearchResult,
|
|
||||||
}: SearchBarProps) => {
|
|
||||||
const SEARCH_DEBOUNCE_MS = 200;
|
|
||||||
|
|
||||||
// @todo : we should probably be cancelling any running requests
|
const searchBarVariants = cva(
|
||||||
const search = useDebouncedCallback((query: string) => {
|
"w-full",
|
||||||
if (query === "") {
|
{
|
||||||
onSearchResult(undefined);
|
variants: {
|
||||||
return;
|
size: {
|
||||||
|
default: "h-10",
|
||||||
|
sm: "h-8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "default",
|
||||||
}
|
}
|
||||||
console.log('making query...');
|
}
|
||||||
|
)
|
||||||
|
|
||||||
onLoadingChange(true);
|
export const SearchBar = ({
|
||||||
fetch(`http://localhost:3000/api/search?query=${query}&numResults=${numResults}`)
|
className,
|
||||||
.then(response => response.json())
|
size,
|
||||||
.then(({ data }: { data: ZoektSearchResponse }) => {
|
defaultQuery,
|
||||||
onSearchResult(data.result);
|
}: SearchBarProps) => {
|
||||||
})
|
const router = useRouter();
|
||||||
// @todo : error handling
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
.catch(error => {
|
resolver: zodResolver(formSchema),
|
||||||
console.error('Error:', error);
|
defaultValues: {
|
||||||
}).finally(() => {
|
query: defaultQuery ?? "",
|
||||||
console.log('done making query');
|
}
|
||||||
onLoadingChange(false);
|
});
|
||||||
});
|
|
||||||
}, SEARCH_DEBOUNCE_MS);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const onSubmit = (values: z.infer<typeof formSchema>) => {
|
||||||
search(query);
|
router.push(`/search?query=${values.query}&numResults=100`);
|
||||||
}, [query, search]);
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Form {...form}>
|
||||||
value={query}
|
<form
|
||||||
className="w-full h-8"
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
placeholder="Search..."
|
className="w-full"
|
||||||
onChange={(e) => {
|
>
|
||||||
const query = e.target.value;
|
<FormField
|
||||||
onQueryChange(query);
|
control={form.control}
|
||||||
}}
|
name="query"
|
||||||
/>
|
render={( { field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
className={cn(searchBarVariants({ size, className }))}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -23,18 +23,20 @@ import {
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import { KeymapType } from "@/lib/types"
|
import { KeymapType } from "@/lib/types"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useKeymapType } from "@/hooks/useKeymapType"
|
||||||
|
|
||||||
interface SettingsDropdownProps {
|
interface SettingsDropdownProps {
|
||||||
keymapType: KeymapType;
|
menuButtonClassName?: string;
|
||||||
onKeymapTypeChange: (keymapType: KeymapType) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsDropdown = ({
|
export const SettingsDropdown = ({
|
||||||
keymapType,
|
menuButtonClassName,
|
||||||
onKeymapTypeChange,
|
|
||||||
}: SettingsDropdownProps) => {
|
}: SettingsDropdownProps) => {
|
||||||
|
|
||||||
const { theme: _theme, setTheme } = useTheme();
|
const { theme: _theme, setTheme } = useTheme();
|
||||||
|
const [ keymapType, setKeymapType ] = useKeymapType();
|
||||||
|
|
||||||
const theme = useMemo(() => {
|
const theme = useMemo(() => {
|
||||||
return _theme ?? "light";
|
return _theme ?? "light";
|
||||||
}, [_theme]);
|
}, [_theme]);
|
||||||
|
|
@ -55,7 +57,7 @@ export const SettingsDropdown = ({
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="icon" className="w-8 h-8">
|
<Button variant="outline" size="icon" className={cn(menuButtonClassName)}>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
@ -91,7 +93,7 @@ export const SettingsDropdown = ({
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
<DropdownMenuRadioGroup value={keymapType} onValueChange={(value) => onKeymapTypeChange(value as KeymapType)}>
|
<DropdownMenuRadioGroup value={keymapType} onValueChange={(value) => setKeymapType(value as KeymapType)}>
|
||||||
<DropdownMenuRadioItem value="default">
|
<DropdownMenuRadioItem value="default">
|
||||||
Default
|
Default
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
|
|
|
||||||
178
src/components/ui/form.tsx
Normal file
178
src/components/ui/form.tsx
Normal file
|
|
@ -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<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormItem.displayName = "FormItem"
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && "text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormLabel.displayName = "FormLabel"
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormControl.displayName = "FormControl"
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormDescription.displayName = "FormDescription"
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message) : children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-sm font-medium text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormMessage.displayName = "FormMessage"
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
26
src/components/ui/label.tsx
Normal file
26
src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
9
src/hooks/useKeymapType.ts
Normal file
9
src/hooks/useKeymapType.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { KeymapType } from "@/lib/types";
|
||||||
|
import { useLocalStorage } from "usehooks-ts";
|
||||||
|
|
||||||
|
export const useKeymapType = (): [KeymapType, (keymapType: KeymapType) => void] => {
|
||||||
|
const [keymapType, setKeymapType] = useLocalStorage<KeymapType>("keymapType", "default", { initializeWithValue: false });
|
||||||
|
return [ keymapType, setKeymapType ];
|
||||||
|
}
|
||||||
56
yarn.lock
56
yarn.lock
|
|
@ -167,6 +167,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.7.tgz#d0ece53ce99ab5a8e37ebdfe5e32452a2bfc073e"
|
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.7.tgz#d0ece53ce99ab5a8e37ebdfe5e32452a2bfc073e"
|
||||||
integrity sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==
|
integrity sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==
|
||||||
|
|
||||||
|
"@hookform/resolvers@^3.9.0":
|
||||||
|
version "3.9.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-3.9.0.tgz#cf540ac21c6c0cd24a40cf53d8e6d64391fb753d"
|
||||||
|
integrity sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg==
|
||||||
|
|
||||||
"@humanwhocodes/config-array@^0.11.14":
|
"@humanwhocodes/config-array@^0.11.14":
|
||||||
version "0.11.14"
|
version "0.11.14"
|
||||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b"
|
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b"
|
||||||
|
|
@ -546,6 +551,13 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@radix-ui/react-use-layout-effect" "1.1.0"
|
"@radix-ui/react-use-layout-effect" "1.1.0"
|
||||||
|
|
||||||
|
"@radix-ui/react-label@^2.1.0":
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-label/-/react-label-2.1.0.tgz#3aa2418d70bb242be37c51ff5e51a2adcbc372e3"
|
||||||
|
integrity sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==
|
||||||
|
dependencies:
|
||||||
|
"@radix-ui/react-primitive" "2.0.0"
|
||||||
|
|
||||||
"@radix-ui/react-menu@2.1.1":
|
"@radix-ui/react-menu@2.1.1":
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-2.1.1.tgz#bd623ace0e1ae1ac78023a505fec0541d59fb346"
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-2.1.1.tgz#bd623ace0e1ae1ac78023a505fec0541d59fb346"
|
||||||
|
|
@ -719,6 +731,18 @@
|
||||||
"@swc/counter" "^0.1.3"
|
"@swc/counter" "^0.1.3"
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
|
"@tanstack/query-core@5.53.3":
|
||||||
|
version "5.53.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.53.3.tgz#ada02058ee79fe4482c0aa6967408f22a6af2a0f"
|
||||||
|
integrity sha512-ZfjAgd7NpqDx0e4aYBt7EmS2enbulPrJwowTy+mayRE93WUUH+sIYHun1TdRjpGwDPMNNZ5D6goh7n3CwoO+HA==
|
||||||
|
|
||||||
|
"@tanstack/react-query@^5.53.3":
|
||||||
|
version "5.53.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.53.3.tgz#f4ee3effe6f2fe584da2f58dc7c0316876fa9028"
|
||||||
|
integrity sha512-286mN/91CeM7vC6CZFLKYDHSw+WyMX6ekIvzoTbpM4xyPb99VSyCKPLyPgaOatKqYm6ooMBquSq9NGRdKgsJfg==
|
||||||
|
dependencies:
|
||||||
|
"@tanstack/query-core" "5.53.3"
|
||||||
|
|
||||||
"@types/json5@^0.0.29":
|
"@types/json5@^0.0.29":
|
||||||
version "0.0.29"
|
version "0.0.29"
|
||||||
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
||||||
|
|
@ -878,11 +902,6 @@
|
||||||
"@typescript-eslint/types" "8.3.0"
|
"@typescript-eslint/types" "8.3.0"
|
||||||
eslint-visitor-keys "^3.4.3"
|
eslint-visitor-keys "^3.4.3"
|
||||||
|
|
||||||
"@uidotdev/usehooks@^2.4.1":
|
|
||||||
version "2.4.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/@uidotdev/usehooks/-/usehooks-2.4.1.tgz#4b733eaeae09a7be143c6c9ca158b56cc1ea75bf"
|
|
||||||
integrity sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==
|
|
||||||
|
|
||||||
"@uiw/codemirror-extensions-basic-setup@4.23.0":
|
"@uiw/codemirror-extensions-basic-setup@4.23.0":
|
||||||
version "4.23.0"
|
version "4.23.0"
|
||||||
resolved "https://registry.yarnpkg.com/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.23.0.tgz#c3c181153335c208a25d59b8ecbc7fc87fe85356"
|
resolved "https://registry.yarnpkg.com/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.23.0.tgz#c3c181153335c208a25d59b8ecbc7fc87fe85356"
|
||||||
|
|
@ -2460,6 +2479,11 @@ locate-path@^6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-locate "^5.0.0"
|
p-locate "^5.0.0"
|
||||||
|
|
||||||
|
lodash.debounce@^4.0.8:
|
||||||
|
version "4.0.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||||
|
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
|
||||||
|
|
||||||
lodash.merge@^4.6.2:
|
lodash.merge@^4.6.2:
|
||||||
version "4.6.2"
|
version "4.6.2"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
|
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
|
||||||
|
|
@ -2860,6 +2884,11 @@ react-dom@^18:
|
||||||
loose-envify "^1.1.0"
|
loose-envify "^1.1.0"
|
||||||
scheduler "^0.23.2"
|
scheduler "^0.23.2"
|
||||||
|
|
||||||
|
react-hook-form@^7.53.0:
|
||||||
|
version "7.53.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.53.0.tgz#3cf70951bf41fa95207b34486203ebefbd3a05ab"
|
||||||
|
integrity sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ==
|
||||||
|
|
||||||
react-is@^16.13.1:
|
react-is@^16.13.1:
|
||||||
version "16.13.1"
|
version "16.13.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||||
|
|
@ -3464,11 +3493,6 @@ use-callback-ref@^1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.0.0"
|
tslib "^2.0.0"
|
||||||
|
|
||||||
use-debounce@^10.0.3:
|
|
||||||
version "10.0.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-10.0.3.tgz#636094a37f7aa2bcc77b26b961481a0b571bf7ea"
|
|
||||||
integrity sha512-DxQSI9ZKso689WM1mjgGU3ozcxU1TJElBJ3X6S4SMzMNcm2lVH0AHmyXB+K7ewjz2BSUKJTDqTcwtSMRfB89dg==
|
|
||||||
|
|
||||||
use-sidecar@^1.1.2:
|
use-sidecar@^1.1.2:
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2"
|
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2"
|
||||||
|
|
@ -3477,6 +3501,13 @@ use-sidecar@^1.1.2:
|
||||||
detect-node-es "^1.1.0"
|
detect-node-es "^1.1.0"
|
||||||
tslib "^2.0.0"
|
tslib "^2.0.0"
|
||||||
|
|
||||||
|
usehooks-ts@^3.1.0:
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-3.1.0.tgz#156119f36efc85f1b1952616c02580f140950eca"
|
||||||
|
integrity sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==
|
||||||
|
dependencies:
|
||||||
|
lodash.debounce "^4.0.8"
|
||||||
|
|
||||||
util-deprecate@^1.0.2:
|
util-deprecate@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||||
|
|
@ -3581,3 +3612,8 @@ yocto-queue@^0.1.0:
|
||||||
version "0.1.0"
|
version "0.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||||
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
||||||
|
|
||||||
|
zod@^3.23.8:
|
||||||
|
version "3.23.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
|
||||||
|
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue