mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +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 path from "path";
|
||||
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>
|
||||
)
|
||||
}
|
||||
254
src/app/page.tsx
254
src/app/page.tsx
|
|
@ -1,7 +1,5 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
|
|
@ -9,56 +7,19 @@ import {
|
|||
} from "@/components/ui/resizable";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
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 { defaultKeymap } from "@codemirror/commands";
|
||||
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 { SymbolIcon } from "@radix-ui/react-icons";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useState } from "react";
|
||||
import logoDark from "../../public/sb_logo_dark.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";
|
||||
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const defaultQuery = useNonEmptyQueryParam("query") ?? "";
|
||||
|
|
@ -71,7 +32,7 @@ export default function Home() {
|
|||
const [code, setCode] = useState("");
|
||||
const [filepath, setFilepath] = useState("");
|
||||
|
||||
const [fileMatches, setFileMatches] = useState<ZoekFileMatch[]>([]);
|
||||
const [fileMatches, setFileMatches] = useState<ZoektFileMatch[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchDurationMs, setSearchDurationMs] = useState(0);
|
||||
|
||||
|
|
@ -80,6 +41,7 @@ export default function Home() {
|
|||
|
||||
return (
|
||||
<main className="h-screen overflow-hidden">
|
||||
{/* TopBar */}
|
||||
<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="grow flex flex-row gap-4 items-center">
|
||||
|
|
@ -119,15 +81,13 @@ export default function Home() {
|
|||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
{/* Search Results & Code Preview */}
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel minSize={20}>
|
||||
<ScrollArea className="h-full overflow-y-auto">
|
||||
<div className="flex flex-col gap-2">
|
||||
{fileMatches.map((match, index) => (
|
||||
<FileMatch
|
||||
key={index}
|
||||
match={match}
|
||||
onOpenFile={() => {
|
||||
<SearchResults
|
||||
fileMatches={fileMatches}
|
||||
onOpenFileMatch={(match) => {
|
||||
const url = createPathWithQueryParams(
|
||||
`http://localhost:3000/api/source`,
|
||||
[pathQueryParamName, match.FileName],
|
||||
|
|
@ -145,202 +105,20 @@ export default function Home() {
|
|||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Scrollbar orientation="vertical" />
|
||||
</ScrollArea>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle={true} />
|
||||
{isCodePanelOpen && (
|
||||
<ResizableHandle withHandle={isCodePanelOpen} />
|
||||
<ResizablePanel
|
||||
minSize={20}
|
||||
hidden={!isCodePanelOpen}
|
||||
>
|
||||
<CodeEditor
|
||||
<CodePreview
|
||||
code={code}
|
||||
filepath={filepath}
|
||||
onClose={() => setIsCodePanelOpen(false)}
|
||||
keymapType="default"
|
||||
/>
|
||||
</ResizablePanel>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
</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