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:
bkellam 2024-09-02 18:46:43 -07:00
parent 9af8696a6d
commit 45c176f7ae
19 changed files with 592 additions and 206 deletions

View file

@ -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",

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

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

View file

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

View file

@ -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
*/}
<Suspense>
{children}
</Suspense>
<QueryClientProvider>
{/*
@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>
</QueryClientProvider>
</ThemeProvider>
</body>
</html>

View file

@ -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<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 (
<main className="flex flex-col h-screen overflow-clip">
<div className="h-screen flex flex-col items-center">
{/* 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">
<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"}
/>
<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);
});
<div className="absolute top-0 left-0 right-0">
<div className="flex flex-row justify-end items-center py-1.5 px-3 gap-2">
<Button
variant="outline"
size="icon"
onClick={() => {
window.open(SOURCEBOT_GITHUB_URL, "_blank");
}}
>
<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>
<ResizableHandle withHandle={isCodePanelOpen} />
<ResizablePanel
minSize={20}
hidden={!isCodePanelOpen}
>
<CodePreview
file={previewFile}
onClose={() => setIsCodePanelOpen(false)}
selectedMatchIndex={selectedMatchIndex}
onSelectedMatchIndexChange={setSelectedMatchIndex}
keymapType={keymapType}
<Image
src={logoLight}
className="w-full h-full block dark:hidden"
alt={"Sourcebot logo"}
/>
</ResizablePanel>
</ResizablePanelGroup>
</main>
);
</div>
<div className="w-full flex flex-row mt-4">
<SearchBar />
</div>
</div>
</div>
)
}

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

View file

@ -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<ReactCodeMirrorRef>(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);

183
src/app/search/page.tsx Normal file
View 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}
/>
)
}

View file

@ -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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
query: defaultQuery ?? "",
}
});
useEffect(() => {
search(query);
}, [query, search]);
const onSubmit = (values: z.infer<typeof formSchema>) => {
router.push(`/search?query=${values.query}&numResults=100`);
}
return (
<Input
value={query}
className="w-full h-8"
placeholder="Search..."
onChange={(e) => {
const query = e.target.value;
onQueryChange(query);
}}
/>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="w-full"
>
<FormField
control={form.control}
name="query"
render={( { field }) => (
<FormItem>
<FormControl>
<Input
placeholder="Search..."
className={cn(searchBarVariants({ size, className }))}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)
}

View file

@ -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 (
<DropdownMenu>
<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" />
</Button>
</DropdownMenuTrigger>
@ -91,7 +93,7 @@ export const SettingsDropdown = ({
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={keymapType} onValueChange={(value) => onKeymapTypeChange(value as KeymapType)}>
<DropdownMenuRadioGroup value={keymapType} onValueChange={(value) => setKeymapType(value as KeymapType)}>
<DropdownMenuRadioItem value="default">
Default
</DropdownMenuRadioItem>

178
src/components/ui/form.tsx Normal file
View 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,
}

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

View 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 ];
}

View file

@ -167,6 +167,11 @@
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.7.tgz#d0ece53ce99ab5a8e37ebdfe5e32452a2bfc073e"
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":
version "0.11.14"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b"
@ -546,6 +551,13 @@
dependencies:
"@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":
version "2.1.1"
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"
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":
version "0.0.29"
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
@ -878,11 +902,6 @@
"@typescript-eslint/types" "8.3.0"
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":
version "4.23.0"
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:
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:
version "4.6.2"
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"
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:
version "16.13.1"
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:
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:
version "1.1.2"
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"
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:
version "1.0.2"
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"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
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==