2024-08-25 00:45:44 +00:00
|
|
|
'use client';
|
|
|
|
|
|
2024-08-26 00:57:34 +00:00
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Separator } from "@/components/ui/separator";
|
2024-08-25 02:39:59 +00:00
|
|
|
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
2024-08-26 00:57:34 +00:00
|
|
|
import { defaultKeymap } from "@codemirror/commands";
|
|
|
|
|
import { javascript } from "@codemirror/lang-javascript";
|
2024-08-26 04:30:09 +00:00
|
|
|
import { keymap } from "@codemirror/view";
|
2024-08-25 03:58:31 +00:00
|
|
|
import { SymbolIcon } from "@radix-ui/react-icons";
|
2024-08-25 06:01:46 +00:00
|
|
|
import { ScrollArea, Scrollbar } from "@radix-ui/react-scroll-area";
|
2024-08-26 04:30:09 +00:00
|
|
|
import CodeMirror from '@uiw/react-codemirror';
|
2024-08-26 00:57:34 +00:00
|
|
|
import Image from "next/image";
|
|
|
|
|
import { useRouter } from "next/navigation";
|
2024-08-26 04:30:09 +00:00
|
|
|
import { useEffect, useState } from "react";
|
2024-08-26 00:57:34 +00:00
|
|
|
import { useDebouncedCallback } from 'use-debounce';
|
|
|
|
|
import logo from "../../public/sb_logo_large_3.png";
|
|
|
|
|
|
2024-08-25 06:01:46 +00:00
|
|
|
import {
|
|
|
|
|
ResizableHandle,
|
|
|
|
|
ResizablePanel,
|
|
|
|
|
ResizablePanelGroup,
|
2024-08-26 00:57:34 +00:00
|
|
|
} from "@/components/ui/resizable";
|
2024-08-26 04:30:09 +00:00
|
|
|
import { GetSourceResponse, pathQueryParamName, repoQueryParamName } from "@/lib/api";
|
|
|
|
|
import { createPathWithQueryParams } from "@/lib/utils";
|
2024-08-25 00:45:44 +00:00
|
|
|
|
|
|
|
|
interface ZoekMatch {
|
|
|
|
|
URL: string,
|
|
|
|
|
FileName: string,
|
|
|
|
|
LineNum: number,
|
|
|
|
|
Fragments: {
|
|
|
|
|
Pre: string,
|
|
|
|
|
Match: string,
|
|
|
|
|
Post: string
|
|
|
|
|
}[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ZoekFileMatch {
|
|
|
|
|
FileName: string,
|
|
|
|
|
Repo: string,
|
|
|
|
|
Language: string,
|
|
|
|
|
Matches: ZoekMatch[],
|
|
|
|
|
URL: string,
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-25 03:10:01 +00:00
|
|
|
interface ZoekResult {
|
|
|
|
|
QueryStr: string,
|
|
|
|
|
FileMatches: ZoekFileMatch[] | null,
|
2024-08-25 03:58:31 +00:00
|
|
|
Stats: {
|
|
|
|
|
// Duration in nanoseconds
|
|
|
|
|
Duration: number,
|
|
|
|
|
}
|
2024-08-25 00:45:44 +00:00
|
|
|
}
|
|
|
|
|
|
2024-08-25 03:10:01 +00:00
|
|
|
interface ZoekSearchResponse {
|
2024-08-25 03:58:31 +00:00
|
|
|
result: ZoekResult,
|
2024-08-25 03:10:01 +00:00
|
|
|
}
|
2024-08-23 20:54:13 +00:00
|
|
|
|
|
|
|
|
export default function Home() {
|
2024-08-25 02:39:59 +00:00
|
|
|
const router = useRouter();
|
|
|
|
|
const defaultQuery = useNonEmptyQueryParam("query") ?? "";
|
|
|
|
|
const defaultNumResults = useNonEmptyQueryParam("numResults");
|
2024-08-26 00:57:34 +00:00
|
|
|
|
2024-08-25 02:39:59 +00:00
|
|
|
const [query, setQuery] = useState(defaultQuery);
|
|
|
|
|
const [numResults, _setNumResults] = useState(defaultNumResults && !isNaN(Number(defaultNumResults)) ? Number(defaultNumResults) : 100);
|
2024-08-23 20:54:13 +00:00
|
|
|
|
2024-08-26 04:30:09 +00:00
|
|
|
const [isCodePanelOpen, setIsCodePanelOpen] = useState(false);
|
|
|
|
|
const [code, setCode] = useState("");
|
2024-08-26 00:57:34 +00:00
|
|
|
|
2024-08-25 00:45:44 +00:00
|
|
|
const [fileMatches, setFileMatches] = useState<ZoekFileMatch[]>([]);
|
2024-08-25 03:58:31 +00:00
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
const [searchDurationMs, setSearchDurationMs] = useState(0);
|
2024-08-25 00:45:44 +00:00
|
|
|
|
2024-08-25 03:58:31 +00:00
|
|
|
// @todo: We need to be able to handle the case when the user navigates backwards / forwards.
|
|
|
|
|
// Currently we do not re-query.
|
2024-08-23 20:54:13 +00:00
|
|
|
|
2024-08-25 00:45:44 +00:00
|
|
|
return (
|
2024-08-26 00:57:34 +00:00
|
|
|
<main className="h-screen overflow-hidden">
|
2024-08-25 05:09:31 +00:00
|
|
|
<div className="sticky top-0 left-0 right-0 bg-white z-10">
|
|
|
|
|
<div className="flex flex-row p-1 gap-4 items-center">
|
|
|
|
|
<Image
|
|
|
|
|
src={logo}
|
|
|
|
|
className="h-12 w-auto"
|
|
|
|
|
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));
|
|
|
|
|
}
|
2024-08-25 03:58:31 +00:00
|
|
|
|
2024-08-25 05:09:31 +00:00
|
|
|
router.push(`?query=${query}&numResults=${numResults}`);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
{isLoading && (
|
|
|
|
|
<SymbolIcon className="h-4 w-4 animate-spin" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<Separator />
|
|
|
|
|
<div className="bg-accent p-2">
|
|
|
|
|
<p className="text-sm font-medium">Results for: {fileMatches.length} files in {searchDurationMs} ms</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Separator />
|
2024-08-25 00:45:44 +00:00
|
|
|
</div>
|
2024-08-25 06:01:46 +00:00
|
|
|
<ResizablePanelGroup direction="horizontal">
|
2024-08-26 00:57:34 +00:00
|
|
|
<ResizablePanel minSize={20}>
|
|
|
|
|
<ScrollArea className="h-full overflow-y-auto">
|
2024-08-25 06:01:46 +00:00
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
{fileMatches.map((match, index) => (
|
2024-08-26 00:57:34 +00:00
|
|
|
<FileMatch
|
|
|
|
|
key={index}
|
|
|
|
|
match={match}
|
2024-08-26 04:30:09 +00:00
|
|
|
onOpenFile={() => {
|
|
|
|
|
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) => {
|
|
|
|
|
setIsCodePanelOpen(true);
|
|
|
|
|
setCode(body.content);
|
|
|
|
|
});
|
2024-08-26 00:57:34 +00:00
|
|
|
}}
|
|
|
|
|
/>
|
2024-08-25 06:01:46 +00:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<Scrollbar orientation="vertical" />
|
|
|
|
|
</ScrollArea>
|
|
|
|
|
</ResizablePanel>
|
2024-08-26 00:57:34 +00:00
|
|
|
<ResizableHandle withHandle={true} />
|
|
|
|
|
{isCodePanelOpen && (
|
|
|
|
|
<ResizablePanel
|
|
|
|
|
minSize={20}
|
|
|
|
|
>
|
|
|
|
|
<CodeEditor
|
2024-08-26 04:30:09 +00:00
|
|
|
code={code}
|
2024-08-26 00:57:34 +00:00
|
|
|
/>
|
|
|
|
|
</ResizablePanel>
|
|
|
|
|
)}
|
2024-08-25 06:01:46 +00:00
|
|
|
</ResizablePanelGroup>
|
2024-08-25 00:45:44 +00:00
|
|
|
</main>
|
|
|
|
|
);
|
|
|
|
|
}
|
2024-08-23 20:54:13 +00:00
|
|
|
|
2024-08-26 00:57:34 +00:00
|
|
|
interface CodeEditorProps {
|
|
|
|
|
code: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CodeEditor = ({
|
|
|
|
|
code,
|
|
|
|
|
}: CodeEditorProps) => {
|
|
|
|
|
return (
|
2024-08-26 04:30:09 +00:00
|
|
|
<ScrollArea className="h-full overflow-y-auto">
|
|
|
|
|
<CodeMirror
|
|
|
|
|
editable={false}
|
|
|
|
|
value={code}
|
|
|
|
|
extensions={[
|
|
|
|
|
keymap.of(defaultKeymap),
|
|
|
|
|
javascript(),
|
|
|
|
|
]}
|
|
|
|
|
/>
|
|
|
|
|
<Scrollbar orientation="vertical" />
|
|
|
|
|
</ScrollArea>
|
2024-08-26 00:57:34 +00:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-25 02:39:59 +00:00
|
|
|
interface SearchBarProps {
|
|
|
|
|
query: string;
|
|
|
|
|
numResults: number;
|
2024-08-25 03:58:31 +00:00
|
|
|
onLoadingChange: (isLoading: boolean) => void;
|
2024-08-25 02:39:59 +00:00
|
|
|
onQueryChange: (query: string) => void;
|
2024-08-25 03:10:01 +00:00
|
|
|
onSearchResult: (result?: ZoekResult) => void,
|
2024-08-25 02:39:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SearchBar = ({
|
|
|
|
|
query,
|
|
|
|
|
numResults,
|
2024-08-25 03:58:31 +00:00
|
|
|
onLoadingChange,
|
2024-08-25 02:39:59 +00:00
|
|
|
onQueryChange,
|
|
|
|
|
onSearchResult,
|
|
|
|
|
}: SearchBarProps) => {
|
|
|
|
|
const SEARCH_DEBOUNCE_MS = 200;
|
|
|
|
|
|
2024-08-25 03:58:31 +00:00
|
|
|
// @todo : we should probably be cancelling any running requests
|
2024-08-25 02:39:59 +00:00
|
|
|
const search = useDebouncedCallback((query: string) => {
|
|
|
|
|
if (query === "") {
|
2024-08-25 03:10:01 +00:00
|
|
|
onSearchResult(undefined);
|
2024-08-25 02:39:59 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
console.log('making query...');
|
2024-08-25 03:58:31 +00:00
|
|
|
|
|
|
|
|
onLoadingChange(true);
|
2024-08-26 04:30:09 +00:00
|
|
|
fetch(`http://localhost:3000/api/search?query=${query}&numResults=${numResults}`)
|
2024-08-25 02:39:59 +00:00
|
|
|
.then(response => response.json())
|
2024-08-25 03:10:01 +00:00
|
|
|
.then(({ data }: { data: ZoekSearchResponse }) => {
|
|
|
|
|
onSearchResult(data.result);
|
2024-08-25 02:39:59 +00:00
|
|
|
})
|
|
|
|
|
// @todo : error handling
|
|
|
|
|
.catch(error => {
|
|
|
|
|
console.error('Error:', error);
|
|
|
|
|
}).finally(() => {
|
|
|
|
|
console.log('done making query');
|
2024-08-25 03:58:31 +00:00
|
|
|
onLoadingChange(false);
|
|
|
|
|
});
|
2024-08-25 02:39:59 +00:00
|
|
|
}, SEARCH_DEBOUNCE_MS);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
search(query);
|
|
|
|
|
}, [query]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
value={query}
|
|
|
|
|
className="max-w-lg"
|
|
|
|
|
placeholder="Search..."
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const query = e.target.value;
|
|
|
|
|
onQueryChange(query);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-25 00:45:44 +00:00
|
|
|
interface FileMatchProps {
|
|
|
|
|
match: ZoekFileMatch;
|
2024-08-26 04:30:09 +00:00
|
|
|
onOpenFile: () => void;
|
2024-08-25 00:45:44 +00:00
|
|
|
}
|
2024-08-23 20:54:13 +00:00
|
|
|
|
2024-08-25 00:45:44 +00:00
|
|
|
const FileMatch = ({
|
|
|
|
|
match,
|
2024-08-26 00:57:34 +00:00
|
|
|
onOpenFile,
|
2024-08-25 00:45:44 +00:00
|
|
|
}: FileMatchProps) => {
|
2024-08-23 20:54:13 +00:00
|
|
|
|
2024-08-25 00:45:44 +00:00
|
|
|
return (
|
|
|
|
|
<div>
|
2024-08-25 04:18:53 +00:00
|
|
|
<div className="bg-cyan-200 primary-foreground px-2">
|
2024-08-25 04:34:24 +00:00
|
|
|
<span>{match.Repo} · {match.FileName}</span>
|
2024-08-25 04:18:53 +00:00
|
|
|
</div>
|
2024-08-25 04:34:24 +00:00
|
|
|
{match.Matches.map((match, index) => {
|
|
|
|
|
const fragment = match.Fragments[0];
|
2024-08-26 00:57:34 +00:00
|
|
|
|
2024-08-25 04:34:24 +00:00
|
|
|
return (
|
2024-08-26 00:57:34 +00:00
|
|
|
<div
|
|
|
|
|
key={index}
|
|
|
|
|
className="font-mono px-4 py-0.5 text-sm cursor-pointer"
|
|
|
|
|
onClick={() =>{
|
2024-08-26 04:30:09 +00:00
|
|
|
onOpenFile();
|
2024-08-26 00:57:34 +00:00
|
|
|
}}
|
|
|
|
|
>
|
2024-08-25 04:34:24 +00:00
|
|
|
<p>{match.LineNum}: {fragment.Pre}<span className="font-bold">{fragment.Match}</span>{fragment.Post}</p>
|
|
|
|
|
<Separator />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2024-08-25 00:45:44 +00:00
|
|
|
</div>
|
|
|
|
|
);
|
2024-08-23 20:54:13 +00:00
|
|
|
}
|