Refactor things into seperate components

This commit is contained in:
bkellam 2024-08-27 17:28:35 -07:00
parent c73325d8ce
commit 30ac9165e1
7 changed files with 311 additions and 272 deletions

View file

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

View file

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

View file

@ -1,8 +0,0 @@
export const pathQueryParamName = "path";
export const repoQueryParamName = "repo";
export type GetSourceResponse = {
content: string;
}

40
src/lib/types.ts Normal file
View 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,
}