mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 12:25:22 +00:00
Refactor things into seperate components
This commit is contained in:
parent
c73325d8ce
commit
30ac9165e1
7 changed files with 311 additions and 272 deletions
|
|
@ -6,7 +6,7 @@ import { StatusCodes } from "http-status-codes";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { GetSourceResponse, pathQueryParamName, repoQueryParamName } from "@/lib/api";
|
import { GetSourceResponse, pathQueryParamName, repoQueryParamName } from "@/lib/types";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
98
src/app/codePreview.tsx
Normal file
98
src/app/codePreview.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { EditorView, keymap, ViewPlugin, ViewUpdate } from "@codemirror/view";
|
||||||
|
import { Cross1Icon, FileIcon } from "@radix-ui/react-icons";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import CodeMirror from '@uiw/react-codemirror';
|
||||||
|
import { vim } from "@replit/codemirror-vim";
|
||||||
|
import { defaultKeymap } from "@codemirror/commands";
|
||||||
|
import { javascript } from "@codemirror/lang-javascript";
|
||||||
|
import { Scrollbar } from "@radix-ui/react-scroll-area";
|
||||||
|
|
||||||
|
interface CodePreviewProps {
|
||||||
|
code: string;
|
||||||
|
filepath: string;
|
||||||
|
keymapType: "default" | "vim";
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CodePreview = ({
|
||||||
|
code,
|
||||||
|
filepath,
|
||||||
|
keymapType,
|
||||||
|
onClose,
|
||||||
|
}: CodePreviewProps) => {
|
||||||
|
const { theme: _theme, systemTheme } = useTheme();
|
||||||
|
const theme = useMemo(() => {
|
||||||
|
if (_theme === "system") {
|
||||||
|
return systemTheme ?? "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
return _theme ?? "light";
|
||||||
|
}, [_theme]);
|
||||||
|
|
||||||
|
const [gutterWidth, setGutterWidth] = useState(0);
|
||||||
|
const gutterWidthPlugin = useMemo(() => {
|
||||||
|
return ViewPlugin.fromClass(class {
|
||||||
|
width: number = 0;
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.measureWidth(view)
|
||||||
|
}
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
if (update.geometryChanged) this.measureWidth(update.view)
|
||||||
|
}
|
||||||
|
measureWidth(view: EditorView) {
|
||||||
|
let gutter = view.scrollDOM.querySelector('.cm-gutters') as HTMLElement
|
||||||
|
if (gutter) this.width = gutter.offsetWidth
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<div className="flex flex-row bg-cyan-200 dark:bg-cyan-900 items-center justify-between pr-3">
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<div
|
||||||
|
style={{ width: `${gutterWidth}px` }}
|
||||||
|
className="flex justify-center items-center"
|
||||||
|
>
|
||||||
|
<FileIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<span>{filepath}</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||||
|
<Cross1Icon
|
||||||
|
className="h-4 w-4"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-full overflow-y-auto">
|
||||||
|
<CodeMirror
|
||||||
|
readOnly={true}
|
||||||
|
value={code}
|
||||||
|
theme={theme === "dark" ? "dark" : "light"}
|
||||||
|
extensions={[
|
||||||
|
...(keymapType === "vim" ? [
|
||||||
|
vim(),
|
||||||
|
] : [
|
||||||
|
keymap.of(defaultKeymap),
|
||||||
|
]),
|
||||||
|
javascript(),
|
||||||
|
gutterWidthPlugin.extension,
|
||||||
|
EditorView.updateListener.of(update => {
|
||||||
|
const width = update.view.plugin(gutterWidthPlugin)?.width;
|
||||||
|
if (width) {
|
||||||
|
setGutterWidth(width);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Scrollbar orientation="vertical" />
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
304
src/app/page.tsx
304
src/app/page.tsx
|
|
@ -1,7 +1,5 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
|
|
@ -9,56 +7,19 @@ import {
|
||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
||||||
import { GetSourceResponse, pathQueryParamName, repoQueryParamName } from "@/lib/api";
|
import { GetSourceResponse, pathQueryParamName, repoQueryParamName, ZoektFileMatch } from "@/lib/types";
|
||||||
import { createPathWithQueryParams } from "@/lib/utils";
|
import { createPathWithQueryParams } from "@/lib/utils";
|
||||||
import { defaultKeymap } from "@codemirror/commands";
|
import { SymbolIcon } from "@radix-ui/react-icons";
|
||||||
import { javascript } from "@codemirror/lang-javascript";
|
|
||||||
import { EditorView, keymap, ViewPlugin, ViewUpdate } from "@codemirror/view";
|
|
||||||
import { Cross1Icon, FileIcon, SymbolIcon } from "@radix-ui/react-icons";
|
|
||||||
import { ScrollArea, Scrollbar } from "@radix-ui/react-scroll-area";
|
|
||||||
import { vim } from "@replit/codemirror-vim";
|
|
||||||
import CodeMirror from '@uiw/react-codemirror';
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
|
||||||
import logoDark from "../../public/sb_logo_dark.png";
|
import logoDark from "../../public/sb_logo_dark.png";
|
||||||
import logoLight from "../../public/sb_logo_light.png";
|
import logoLight from "../../public/sb_logo_light.png";
|
||||||
|
import { CodePreview } from "./codePreview";
|
||||||
|
import { SearchBar } from "./searchBar";
|
||||||
|
import { SearchResults } from "./searchResults";
|
||||||
import { ThemeSelectorButton } from "./themeSelectorButton";
|
import { ThemeSelectorButton } from "./themeSelectorButton";
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ZoekResult {
|
|
||||||
QueryStr: string,
|
|
||||||
FileMatches: ZoekFileMatch[] | null,
|
|
||||||
Stats: {
|
|
||||||
// Duration in nanoseconds
|
|
||||||
Duration: number,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ZoekSearchResponse {
|
|
||||||
result: ZoekResult,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const defaultQuery = useNonEmptyQueryParam("query") ?? "";
|
const defaultQuery = useNonEmptyQueryParam("query") ?? "";
|
||||||
|
|
@ -71,7 +32,7 @@ export default function Home() {
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [filepath, setFilepath] = useState("");
|
const [filepath, setFilepath] = useState("");
|
||||||
|
|
||||||
const [fileMatches, setFileMatches] = useState<ZoekFileMatch[]>([]);
|
const [fileMatches, setFileMatches] = useState<ZoektFileMatch[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [searchDurationMs, setSearchDurationMs] = useState(0);
|
const [searchDurationMs, setSearchDurationMs] = useState(0);
|
||||||
|
|
||||||
|
|
@ -80,6 +41,7 @@ export default function Home() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="h-screen overflow-hidden">
|
<main className="h-screen overflow-hidden">
|
||||||
|
{/* TopBar */}
|
||||||
<div className="sticky top-0 left-0 right-0 z-10">
|
<div className="sticky top-0 left-0 right-0 z-10">
|
||||||
<div className="flex flex-row justify-between items-center py-1 px-2 gap-4">
|
<div className="flex flex-row justify-between items-center py-1 px-2 gap-4">
|
||||||
<div className="grow flex flex-row gap-4 items-center">
|
<div className="grow flex flex-row gap-4 items-center">
|
||||||
|
|
@ -119,228 +81,44 @@ export default function Home() {
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search Results & Code Preview */}
|
||||||
<ResizablePanelGroup direction="horizontal">
|
<ResizablePanelGroup direction="horizontal">
|
||||||
<ResizablePanel minSize={20}>
|
<ResizablePanel minSize={20}>
|
||||||
<ScrollArea className="h-full overflow-y-auto">
|
<SearchResults
|
||||||
<div className="flex flex-col gap-2">
|
fileMatches={fileMatches}
|
||||||
{fileMatches.map((match, index) => (
|
onOpenFileMatch={(match) => {
|
||||||
<FileMatch
|
const url = createPathWithQueryParams(
|
||||||
key={index}
|
`http://localhost:3000/api/source`,
|
||||||
match={match}
|
[pathQueryParamName, match.FileName],
|
||||||
onOpenFile={() => {
|
[repoQueryParamName, match.Repo]
|
||||||
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,
|
// @todo : this query should definitely be cached s.t., when the user is switching between files,
|
||||||
// we aren't re-fetching the same file.
|
// we aren't re-fetching the same file.
|
||||||
fetch(url)
|
fetch(url)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then((body: GetSourceResponse) => {
|
.then((body: GetSourceResponse) => {
|
||||||
setIsCodePanelOpen(true);
|
setIsCodePanelOpen(true);
|
||||||
setCode(body.content);
|
setCode(body.content);
|
||||||
setFilepath(match.FileName);
|
setFilepath(match.FileName);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
</ResizablePanel>
|
||||||
</div>
|
<ResizableHandle withHandle={isCodePanelOpen} />
|
||||||
<Scrollbar orientation="vertical" />
|
<ResizablePanel
|
||||||
</ScrollArea>
|
minSize={20}
|
||||||
|
hidden={!isCodePanelOpen}
|
||||||
|
>
|
||||||
|
<CodePreview
|
||||||
|
code={code}
|
||||||
|
filepath={filepath}
|
||||||
|
onClose={() => setIsCodePanelOpen(false)}
|
||||||
|
keymapType="default"
|
||||||
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle withHandle={true} />
|
|
||||||
{isCodePanelOpen && (
|
|
||||||
<ResizablePanel
|
|
||||||
minSize={20}
|
|
||||||
>
|
|
||||||
<CodeEditor
|
|
||||||
code={code}
|
|
||||||
filepath={filepath}
|
|
||||||
onClose={() => setIsCodePanelOpen(false)}
|
|
||||||
keymapType="default"
|
|
||||||
/>
|
|
||||||
</ResizablePanel>
|
|
||||||
)}
|
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CodeEditorProps {
|
|
||||||
code: string;
|
|
||||||
filepath: string;
|
|
||||||
keymapType: "default" | "vim";
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CodeEditor = ({
|
|
||||||
code,
|
|
||||||
filepath,
|
|
||||||
keymapType,
|
|
||||||
onClose,
|
|
||||||
}: CodeEditorProps) => {
|
|
||||||
const { theme: _theme, systemTheme } = useTheme();
|
|
||||||
const theme = useMemo(() => {
|
|
||||||
if (_theme === "system") {
|
|
||||||
return systemTheme ?? "light";
|
|
||||||
}
|
|
||||||
|
|
||||||
return _theme ?? "light";
|
|
||||||
}, [_theme]);
|
|
||||||
|
|
||||||
const [gutterWidth, setGutterWidth] = useState(0);
|
|
||||||
const gutterWidthPlugin = useMemo(() => {
|
|
||||||
return ViewPlugin.fromClass(class {
|
|
||||||
width: number = 0;
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.measureWidth(view)
|
|
||||||
}
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
if (update.geometryChanged) this.measureWidth(update.view)
|
|
||||||
}
|
|
||||||
measureWidth(view: EditorView) {
|
|
||||||
let gutter = view.scrollDOM.querySelector('.cm-gutters') as HTMLElement
|
|
||||||
if (gutter) this.width = gutter.offsetWidth
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full">
|
|
||||||
<div className="flex flex-row bg-cyan-200 dark:bg-cyan-900 items-center justify-between pr-3">
|
|
||||||
<div className="flex flex-row">
|
|
||||||
<div
|
|
||||||
style={{ width: `${gutterWidth}px` }}
|
|
||||||
className="flex justify-center items-center"
|
|
||||||
>
|
|
||||||
<FileIcon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<span>{filepath}</span>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
|
||||||
<Cross1Icon
|
|
||||||
className="h-4 w-4"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<ScrollArea className="h-full overflow-y-auto">
|
|
||||||
<CodeMirror
|
|
||||||
readOnly={true}
|
|
||||||
value={code}
|
|
||||||
theme={theme === "dark" ? "dark" : "light"}
|
|
||||||
extensions={[
|
|
||||||
...(keymapType === "vim" ? [
|
|
||||||
vim(),
|
|
||||||
] : [
|
|
||||||
keymap.of(defaultKeymap),
|
|
||||||
]),
|
|
||||||
javascript(),
|
|
||||||
gutterWidthPlugin.extension,
|
|
||||||
EditorView.updateListener.of(update => {
|
|
||||||
const width = update.view.plugin(gutterWidthPlugin)?.width;
|
|
||||||
if (width) {
|
|
||||||
setGutterWidth(width);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Scrollbar orientation="vertical" />
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchBarProps {
|
|
||||||
query: string;
|
|
||||||
numResults: number;
|
|
||||||
onLoadingChange: (isLoading: boolean) => void;
|
|
||||||
onQueryChange: (query: string) => void;
|
|
||||||
onSearchResult: (result?: ZoekResult) => void,
|
|
||||||
}
|
|
||||||
|
|
||||||
const SearchBar = ({
|
|
||||||
query,
|
|
||||||
numResults,
|
|
||||||
onLoadingChange,
|
|
||||||
onQueryChange,
|
|
||||||
onSearchResult,
|
|
||||||
}: SearchBarProps) => {
|
|
||||||
const SEARCH_DEBOUNCE_MS = 200;
|
|
||||||
|
|
||||||
// @todo : we should probably be cancelling any running requests
|
|
||||||
const search = useDebouncedCallback((query: string) => {
|
|
||||||
if (query === "") {
|
|
||||||
onSearchResult(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('making query...');
|
|
||||||
|
|
||||||
onLoadingChange(true);
|
|
||||||
fetch(`http://localhost:3000/api/search?query=${query}&numResults=${numResults}`)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(({ data }: { data: ZoekSearchResponse }) => {
|
|
||||||
onSearchResult(data.result);
|
|
||||||
})
|
|
||||||
// @todo : error handling
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
}).finally(() => {
|
|
||||||
console.log('done making query');
|
|
||||||
onLoadingChange(false);
|
|
||||||
});
|
|
||||||
}, 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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileMatchProps {
|
|
||||||
match: ZoekFileMatch;
|
|
||||||
onOpenFile: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileMatch = ({
|
|
||||||
match,
|
|
||||||
onOpenFile,
|
|
||||||
}: FileMatchProps) => {
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="bg-cyan-200 dark:bg-cyan-900 primary-foreground px-2">
|
|
||||||
<span>{match.Repo} · {match.FileName}</span>
|
|
||||||
</div>
|
|
||||||
{match.Matches.map((match, index) => {
|
|
||||||
const fragment = match.Fragments[0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="font-mono px-4 py-0.5 text-sm cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
onOpenFile();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p>{match.LineNum}: {fragment.Pre}<span className="font-bold">{fragment.Match}</span>{fragment.Post}</p>
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
63
src/app/searchBar.tsx
Normal file
63
src/app/searchBar.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ZoektResult, ZoektSearchResponse } from "@/lib/types";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
query: string;
|
||||||
|
numResults: number;
|
||||||
|
onLoadingChange: (isLoading: boolean) => void;
|
||||||
|
onQueryChange: (query: string) => void;
|
||||||
|
onSearchResult: (result?: ZoektResult) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchBar = ({
|
||||||
|
query,
|
||||||
|
numResults,
|
||||||
|
onLoadingChange,
|
||||||
|
onQueryChange,
|
||||||
|
onSearchResult,
|
||||||
|
}: SearchBarProps) => {
|
||||||
|
const SEARCH_DEBOUNCE_MS = 200;
|
||||||
|
|
||||||
|
// @todo : we should probably be cancelling any running requests
|
||||||
|
const search = useDebouncedCallback((query: string) => {
|
||||||
|
if (query === "") {
|
||||||
|
onSearchResult(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
search(query);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
className="max-w-lg"
|
||||||
|
placeholder="Search..."
|
||||||
|
onChange={(e) => {
|
||||||
|
const query = e.target.value;
|
||||||
|
onQueryChange(query);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
src/app/searchResults.tsx
Normal file
68
src/app/searchResults.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ZoektFileMatch } from "@/lib/types";
|
||||||
|
import { Scrollbar } from "@radix-ui/react-scroll-area";
|
||||||
|
|
||||||
|
interface SearchResultsProps {
|
||||||
|
fileMatches: ZoektFileMatch[];
|
||||||
|
onOpenFileMatch: (match: ZoektFileMatch) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchResults = ({
|
||||||
|
fileMatches,
|
||||||
|
onOpenFileMatch,
|
||||||
|
}: SearchResultsProps) => {
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-full overflow-y-auto">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{fileMatches.map((match, index) => (
|
||||||
|
<FileMatch
|
||||||
|
key={index}
|
||||||
|
match={match}
|
||||||
|
onOpenFile={() => {
|
||||||
|
onOpenFileMatch(match);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Scrollbar orientation="vertical" />
|
||||||
|
</ScrollArea>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileMatchProps {
|
||||||
|
match: ZoektFileMatch;
|
||||||
|
onOpenFile: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileMatch = ({
|
||||||
|
match,
|
||||||
|
onOpenFile,
|
||||||
|
}: FileMatchProps) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="bg-cyan-200 dark:bg-cyan-900 primary-foreground px-2">
|
||||||
|
<span>{match.Repo} · {match.FileName}</span>
|
||||||
|
</div>
|
||||||
|
{match.Matches.map((match, index) => {
|
||||||
|
const fragment = match.Fragments[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="font-mono px-4 py-0.5 text-sm cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenFile();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>{match.LineNum}: {fragment.Pre}<span className="font-bold">{fragment.Match}</span>{fragment.Post}</p>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
|
|
||||||
|
|
||||||
export const pathQueryParamName = "path";
|
|
||||||
export const repoQueryParamName = "repo";
|
|
||||||
|
|
||||||
export type GetSourceResponse = {
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
40
src/lib/types.ts
Normal file
40
src/lib/types.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
|
||||||
|
|
||||||
|
export const pathQueryParamName = "path";
|
||||||
|
export const repoQueryParamName = "repo";
|
||||||
|
|
||||||
|
export type GetSourceResponse = {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoektMatch {
|
||||||
|
URL: string,
|
||||||
|
FileName: string,
|
||||||
|
LineNum: number,
|
||||||
|
Fragments: {
|
||||||
|
Pre: string,
|
||||||
|
Match: string,
|
||||||
|
Post: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoektFileMatch {
|
||||||
|
FileName: string,
|
||||||
|
Repo: string,
|
||||||
|
Language: string,
|
||||||
|
Matches: ZoektMatch[],
|
||||||
|
URL: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoektResult {
|
||||||
|
QueryStr: string,
|
||||||
|
FileMatches: ZoektFileMatch[] | null,
|
||||||
|
Stats: {
|
||||||
|
// Duration in nanoseconds
|
||||||
|
Duration: number,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoektSearchResponse {
|
||||||
|
result: ZoektResult,
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue