Hotkeys, cleanup, and UX improvements (#9)

This commit is contained in:
Brendan Kellam 2024-09-25 20:12:20 -07:00 committed by GitHub
parent a51a5e2764
commit afede30de6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 512 additions and 440 deletions

View file

@ -1,4 +1,5 @@
import { FileSourceResponse, fileSourceResponseSchema, ListRepositoriesResponse, listRepositoriesResponseSchema, SearchRequest, SearchResponse, searchResponseSchema } from "@/lib/schemas";
import { fileSourceResponseSchema, listRepositoriesResponseSchema, searchResponseSchema } from "@/lib/schemas";
import { FileSourceResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "@/lib/types";
export const search = async (body: SearchRequest): Promise<SearchResponse> => {
const result = await fetch(`/api/search`, {

View file

@ -66,4 +66,23 @@
body {
@apply bg-background text-foreground;
}
}
.cm-editor .cm-gutters {
background-color: transparent;
border-right: none;
}
.cm-editor .cm-lineNumbers .cm-gutterElement {
padding-left: 0.5;
text-align: left;
}
.cm-editor .cm-searchMatch {
border: dotted;
background: transparent;
}
.cm-editor .cm-searchMatch-selected {
border: solid;
}

View file

@ -1,6 +1,5 @@
'use client';
import { Repository } from "@/lib/schemas";
import {
Carousel,
CarouselContent,
@ -11,6 +10,7 @@ import { getRepoCodeHostInfo } from "@/lib/utils";
import Image from "next/image";
import { FileIcon } from "@radix-ui/react-icons";
import clsx from "clsx";
import { Repository } from "@/lib/types";
interface RepositoryCarouselProps {
repos: Repository[];

View file

@ -8,7 +8,7 @@ import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExt
import { useThemeNormalized } from "@/hooks/useThemeNormalized";
import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension";
import { highlightRanges, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension";
import { SearchResultFileMatch } from "@/lib/schemas";
import { SearchResultFileMatch } from "@/lib/types";
import { defaultKeymap } from "@codemirror/commands";
import { search } from "@codemirror/search";
import { EditorView, keymap } from "@codemirror/view";
@ -28,19 +28,19 @@ export interface CodePreviewFile {
language: string;
}
interface CodePreviewPanelProps {
interface CodePreviewProps {
file?: CodePreviewFile;
selectedMatchIndex: number;
onSelectedMatchIndexChange: (index: number) => void;
onClose: () => void;
}
export const CodePreviewPanel = ({
export const CodePreview = ({
file,
selectedMatchIndex,
onSelectedMatchIndexChange,
onClose,
}: CodePreviewPanelProps) => {
}: CodePreviewProps) => {
const editorRef = useRef<ReactCodeMirrorRef>(null);
const [ keymapType ] = useKeymapType();
@ -67,6 +67,7 @@ export const CodePreviewPanel = ({
keymapExtension,
gutterWidthExtension,
syntaxHighlighting,
EditorView.lineWrapping,
searchResultHighlightExtension(),
search({
top: true,

View file

@ -0,0 +1,63 @@
'use client';
import { fetchFileSource } from "@/app/api/(client)/client";
import { getCodeHostFilePreviewLink } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { CodePreview, CodePreviewFile } from "./codePreview";
import { SearchResultFile } from "@/lib/types";
interface CodePreviewPanelProps {
fileMatch?: SearchResultFile;
onClose: () => void;
selectedMatchIndex: number;
onSelectedMatchIndexChange: (index: number) => void;
}
export const CodePreviewPanel = ({
fileMatch,
onClose,
selectedMatchIndex,
onSelectedMatchIndexChange,
}: CodePreviewPanelProps) => {
const { data: file } = useQuery({
queryKey: ["source", fileMatch?.FileName, fileMatch?.Repository],
queryFn: async (): Promise<CodePreviewFile | undefined> => {
if (!fileMatch) {
return undefined;
}
return fetchFileSource(fileMatch.FileName, fileMatch.Repository)
.then(({ source }) => {
// @todo : refector this to use the templates provided by zoekt.
const link = getCodeHostFilePreviewLink(fileMatch.Repository, fileMatch.FileName)
const decodedSource = atob(source);
// Filter out filename matches
const filteredMatches = fileMatch.ChunkMatches.filter((match) => {
return !match.FileName;
});
return {
content: decodedSource,
filepath: fileMatch.FileName,
matches: filteredMatches,
link: link,
language: fileMatch.Language,
};
});
},
enabled: fileMatch !== undefined,
});
return (
<CodePreview
file={file}
onClose={onClose}
selectedMatchIndex={selectedMatchIndex}
onSelectedMatchIndexChange={onSelectedMatchIndexChange}
/>
)
}

View file

@ -0,0 +1,125 @@
'use client';
import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency";
import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension";
import { useThemeNormalized } from "@/hooks/useThemeNormalized";
import { lineOffsetExtension } from "@/lib/extensions/lineOffsetExtension";
import { SearchResultRange } from "@/lib/types";
import CodeMirror, { Decoration, DecorationSet, EditorState, EditorView, ReactCodeMirrorRef, StateField, Transaction } from "@uiw/react-codemirror";
import { useMemo, useRef } from "react";
const markDecoration = Decoration.mark({
class: "cm-searchMatch-selected"
});
interface CodePreviewProps {
content: string,
language: string,
ranges: SearchResultRange[],
lineOffset: number,
}
export const CodePreview = ({
content,
language,
ranges,
lineOffset,
}: CodePreviewProps) => {
const editorRef = useRef<ReactCodeMirrorRef>(null);
const { theme } = useThemeNormalized();
const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef.current?.view);
const rangeHighlighting = useExtensionWithDependency(editorRef.current?.view ?? null, () => {
return [
StateField.define<DecorationSet>({
create(editorState: EditorState) {
const document = editorState.doc;
const decorations = ranges
.sort((a, b) => {
return a.Start.ByteOffset - b.Start.ByteOffset;
})
.filter(({ Start, End }) => {
const startLine = Start.LineNumber - lineOffset;
const endLine = End.LineNumber - lineOffset;
if (
startLine < 1 ||
endLine < 1 ||
startLine > document.lines ||
endLine > document.lines
) {
return false;
}
return true;
})
.map(({ Start, End }) => {
const startLine = Start.LineNumber - lineOffset;
const endLine = End.LineNumber - lineOffset;
const from = document.line(startLine).from + Start.Column - 1;
const to = document.line(endLine).from + End.Column - 1;
return markDecoration.range(from, to);
});
return Decoration.set(decorations);
},
update(highlights: DecorationSet, _transaction: Transaction) {
return highlights;
},
provide: (field) => EditorView.decorations.from(field),
}),
];
}, [ranges, lineOffset]);
const extensions = useMemo(() => {
return [
syntaxHighlighting,
EditorView.lineWrapping,
lineOffsetExtension(lineOffset),
rangeHighlighting,
];
}, [syntaxHighlighting, lineOffset, rangeHighlighting]);
return (
<CodeMirror
ref={editorRef}
readOnly={true}
editable={false}
value={content}
theme={theme === "dark" ? "dark" : "light"}
basicSetup={{
lineNumbers: true,
syntaxHighlighting: true,
// Disable all this other stuff...
... {
foldGutter: false,
highlightActiveLineGutter: false,
highlightSpecialChars: false,
history: false,
drawSelection: false,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: false,
bracketMatching: false,
closeBrackets: false,
autocompletion: false,
rectangularSelection: false,
crosshairCursor: false,
highlightActiveLine: false,
highlightSelectionMatches: false,
closeBracketsKeymap: false,
defaultKeymap: false,
searchKeymap: false,
historyKeymap: false,
foldKeymap: false,
completionKeymap: false,
lintKeymap: false,
}
}}
extensions={extensions}
/>
)
}

View file

@ -0,0 +1,48 @@
'use client';
import { useMemo } from "react";
import { CodePreview } from "./codePreview";
import { SearchResultFile, SearchResultFileMatch } from "@/lib/types";
interface FileMatchProps {
match: SearchResultFileMatch;
file: SearchResultFile;
onOpen: () => void;
}
export const FileMatch = ({
match,
file,
onOpen,
}: FileMatchProps) => {
const content = useMemo(() => {
return atob(match.Content);
}, [match.Content]);
// If it's just the title, don't show a code preview
if (match.FileName) {
return null;
}
return (
<div
tabIndex={0}
className="cursor-pointer p-1 focus:ring-inset focus:ring-4 bg-white dark:bg-[#282c34]"
onKeyDown={(e) => {
if (e.key !== "Enter") {
return;
}
onOpen();
}}
onClick={onOpen}
>
<CodePreview
content={content}
language={file.Language}
ranges={match.Ranges}
lineOffset={match.ContentStart.LineNumber - 1}
/>
</div>
);
}

View file

@ -0,0 +1,169 @@
'use client';
import { getRepoCodeHostInfo } from "@/lib/utils";
import { useCallback, useMemo, useState } from "react";
import Image from "next/image";
import { DoubleArrowDownIcon, DoubleArrowUpIcon, FileIcon } from "@radix-ui/react-icons";
import clsx from "clsx";
import { Separator } from "@/components/ui/separator";
import { SearchResultFile } from "@/lib/types";
import { FileMatch } from "./fileMatch";
const MAX_MATCHES_TO_PREVIEW = 3;
interface FileMatchContainerProps {
file: SearchResultFile;
onOpenFile: () => void;
onMatchIndexChanged: (matchIndex: number) => void;
}
export const FileMatchContainer = ({
file,
onOpenFile,
onMatchIndexChanged,
}: FileMatchContainerProps) => {
const [showAll, setShowAll] = useState(false);
const matchCount = useMemo(() => {
return file.ChunkMatches.length;
}, [file]);
const matches = useMemo(() => {
const sortedMatches = file.ChunkMatches.sort((a, b) => {
return a.ContentStart.LineNumber - b.ContentStart.LineNumber;
});
if (!showAll) {
return sortedMatches.slice(0, MAX_MATCHES_TO_PREVIEW);
}
return sortedMatches;
}, [file, showAll]);
const fileNameRange = useMemo(() => {
for (const match of matches) {
if (match.FileName && match.Ranges.length > 0) {
const range = match.Ranges[0];
return {
from: range.Start.Column - 1,
to: range.End.Column - 1,
}
}
}
return null;
}, [matches]);
const { repoIcon, repoName, repoLink } = useMemo(() => {
const info = getRepoCodeHostInfo(file.Repository);
if (info) {
return {
repoName: info.repoName,
repoLink: info.repoLink,
repoIcon: <Image
src={info.icon}
alt={info.costHostName}
className="w-4 h-4 dark:invert"
/>
}
}
return {
repoName: file.Repository,
repoLink: undefined,
repoIcon: <FileIcon className="w-4 h-4" />
}
}, [file]);
const isMoreContentButtonVisible = useMemo(() => {
return matchCount > MAX_MATCHES_TO_PREVIEW;
}, [matchCount]);
const onShowMoreMatches = useCallback(() => {
setShowAll(!showAll);
}, [showAll]);
const onOpenMatch = useCallback((index: number) => {
const matchIndex = matches.slice(0, index).reduce((acc, match) => {
return acc + match.Ranges.length;
}, 0);
onOpenFile();
onMatchIndexChanged(matchIndex);
}, [matches, onMatchIndexChanged, onOpenFile]);
return (
<div>
<div
className="sticky top-0 bg-cyan-200 dark:bg-cyan-900 primary-foreground px-2 py-0.5 flex flex-row items-center justify-between border cursor-pointer z-10"
onClick={() => {
onOpenFile();
}}
>
<div className="flex flex-row gap-2 items-center">
{repoIcon}
<span
className={clsx("font-medium", {
"cursor-pointer hover:underline": repoLink,
})}
onClick={() => {
if (repoLink) {
window.open(repoLink, "_blank");
}
}}
>
{repoName}
</span>
<span>·</span>
{!fileNameRange ? (
<span>{file.FileName}</span>
) : (
<span>
{file.FileName.slice(0, fileNameRange.from)}
<span className="bg-yellow-200 dark:bg-blue-700">
{file.FileName.slice(fileNameRange.from, fileNameRange.to)}
</span>
{file.FileName.slice(fileNameRange.to)}
</span>
)}
</div>
</div>
{matches.map((match, index) => (
<div
key={index}
>
<FileMatch
match={match}
file={file}
onOpen={() => {
onOpenMatch(index);
}}
/>
{(index !== matches.length - 1 || isMoreContentButtonVisible) && (
<Separator className="dark:bg-gray-400" />
)}
</div>
))}
{isMoreContentButtonVisible && (
<div
tabIndex={0}
className="px-4 bg-accent p-0.5"
onKeyDown={(e) => {
if (e.key !== "Enter") {
return;
}
onShowMoreMatches();
}}
onClick={onShowMoreMatches}
>
<p
className="text-blue-500 cursor-pointer text-sm flex flex-row items-center gap-2"
>
{showAll ? <DoubleArrowUpIcon className="w-3 h-3" /> : <DoubleArrowDownIcon className="w-3 h-3" />}
{showAll ? `Show fewer matches` : `Show ${matchCount - MAX_MATCHES_TO_PREVIEW} more matches`}
</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,47 @@
'use client';
import { ScrollArea } from "@/components/ui/scroll-area";
import { Scrollbar } from "@radix-ui/react-scroll-area";
import { FileMatchContainer } from "./fileMatchContainer";
import { SearchResultFile } from "@/lib/types";
interface SearchResultsPanelProps {
fileMatches: SearchResultFile[];
onOpenFileMatch: (fileMatch: SearchResultFile) => void;
onMatchIndexChanged: (matchIndex: number) => void;
}
export const SearchResultsPanel = ({
fileMatches,
onOpenFileMatch,
onMatchIndexChanged,
}: SearchResultsPanelProps) => {
if (fileMatches.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-sm text-muted-foreground">No results found</p>
</div>
);
}
return (
<ScrollArea
className="h-full"
>
{fileMatches.map((fileMatch, index) => (
<FileMatchContainer
key={index}
file={fileMatch}
onOpenFile={() => {
onOpenFileMatch(fileMatch);
}}
onMatchIndexChanged={(matchIndex) => {
onMatchIndexChanged(matchIndex);
}}
/>
))}
<Scrollbar orientation="vertical" />
</ScrollArea>
)
}

View file

@ -7,8 +7,7 @@ import {
} from "@/components/ui/resizable";
import { Separator } from "@/components/ui/separator";
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
import { SearchResultFile } from "@/lib/schemas";
import { createPathWithQueryParams, getCodeHostFilePreviewLink } from "@/lib/utils";
import { createPathWithQueryParams } from "@/lib/utils";
import { SymbolIcon } from "@radix-ui/react-icons";
import { useQuery } from "@tanstack/react-query";
import Image from "next/image";
@ -16,12 +15,13 @@ import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import logoDark from "../../../public/sb_logo_dark.png";
import logoLight from "../../../public/sb_logo_light.png";
import { fetchFileSource, search } from "../api/(client)/client";
import { search } from "../api/(client)/client";
import { SearchBar } from "../searchBar";
import { SettingsDropdown } from "../settingsDropdown";
import { CodePreviewFile, CodePreviewPanel } from "./codePreviewPanel";
import { SearchResultsPanel } from "./searchResultsPanel";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { CodePreviewPanel } from "./components/codePreviewPanel";
import { SearchResultsPanel } from "./components/searchResultsPanel";
import { SearchResultFile } from "@/lib/types";
const DEFAULT_NUM_RESULTS = 100;
@ -174,7 +174,7 @@ export default function SearchPage() {
minSize={20}
hidden={!selectedFile}
>
<CodePreviewWrapper
<CodePreviewPanel
fileMatch={selectedFile}
onClose={() => setSelectedFile(undefined)}
selectedMatchIndex={selectedMatchIndex}
@ -185,59 +185,3 @@ export default function SearchPage() {
</div>
);
}
interface CodePreviewWrapperProps {
fileMatch?: SearchResultFile;
onClose: () => void;
selectedMatchIndex: number;
onSelectedMatchIndexChange: (index: number) => void;
}
const CodePreviewWrapper = ({
fileMatch,
onClose,
selectedMatchIndex,
onSelectedMatchIndexChange,
}: CodePreviewWrapperProps) => {
const { data: file } = useQuery({
queryKey: ["source", fileMatch?.FileName, fileMatch?.Repository],
queryFn: async (): Promise<CodePreviewFile | undefined> => {
if (!fileMatch) {
return undefined;
}
return fetchFileSource(fileMatch.FileName, fileMatch.Repository)
.then(({ source }) => {
// @todo : refector this to use the templates provided by zoekt.
const link = getCodeHostFilePreviewLink(fileMatch.Repository, fileMatch.FileName)
const decodedSource = atob(source);
// Filter out filename matches
const filteredMatches = fileMatch.ChunkMatches.filter((match) => {
return !match.FileName;
});
return {
content: decodedSource,
filepath: fileMatch.FileName,
matches: filteredMatches,
link: link,
language: fileMatch.Language,
};
});
},
enabled: fileMatch !== undefined,
});
return (
<CodePreviewPanel
file={file}
onClose={onClose}
selectedMatchIndex={selectedMatchIndex}
onSelectedMatchIndexChange={onSelectedMatchIndexChange}
/>
)
}

View file

@ -1,333 +0,0 @@
'use client';
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { useExtensionWithDependency } from "@/hooks/useExtensionWithDependency";
import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension";
import { useThemeNormalized } from "@/hooks/useThemeNormalized";
import { lineOffsetExtension } from "@/lib/extensions/lineOffsetExtension";
import { SearchResultFile, SearchResultRange } from "@/lib/schemas";
import { getRepoCodeHostInfo } from "@/lib/utils";
import { DoubleArrowDownIcon, DoubleArrowUpIcon, FileIcon } from "@radix-ui/react-icons";
import { Scrollbar } from "@radix-ui/react-scroll-area";
import CodeMirror, { Decoration, DecorationSet, EditorState, EditorView, ReactCodeMirrorRef, StateField, Transaction } from '@uiw/react-codemirror';
import clsx from "clsx";
import Image from "next/image";
import { useMemo, useRef, useState } from "react";
const MAX_MATCHES_TO_PREVIEW = 3;
interface SearchResultsPanelProps {
fileMatches: SearchResultFile[];
onOpenFileMatch: (fileMatch: SearchResultFile) => void;
onMatchIndexChanged: (matchIndex: number) => void;
}
export const SearchResultsPanel = ({
fileMatches,
onOpenFileMatch,
onMatchIndexChanged,
}: SearchResultsPanelProps) => {
if (fileMatches.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-sm text-muted-foreground">No results found</p>
</div>
);
}
return (
<ScrollArea className="h-full">
{fileMatches.map((fileMatch, index) => (
<FilePreview
key={index}
file={fileMatch}
onOpenFile={() => {
onOpenFileMatch(fileMatch);
}}
onMatchIndexChanged={(matchIndex) => {
onMatchIndexChanged(matchIndex);
}}
/>
))}
<Scrollbar orientation="vertical" />
</ScrollArea>
)
}
interface FilePreviewProps {
file: SearchResultFile;
onOpenFile: () => void;
onMatchIndexChanged: (matchIndex: number) => void;
}
const FilePreview = ({
file,
onOpenFile,
onMatchIndexChanged,
}: FilePreviewProps) => {
const [showAll, setShowAll] = useState(false);
const matchCount = useMemo(() => {
return file.ChunkMatches.length;
}, [file]);
const matches = useMemo(() => {
const sortedMatches = file.ChunkMatches.sort((a, b) => {
return a.ContentStart.LineNumber - b.ContentStart.LineNumber;
});
if (!showAll) {
return sortedMatches.slice(0, MAX_MATCHES_TO_PREVIEW);
}
return sortedMatches;
}, [file, showAll]);
const fileNameRange = useMemo(() => {
for (const match of matches) {
if (match.FileName && match.Ranges.length > 0) {
const range = match.Ranges[0];
return {
from: range.Start.Column - 1,
to: range.End.Column - 1,
}
}
}
return null;
}, [matches]);
const { repoIcon, repoName, repoLink } = useMemo(() => {
const info = getRepoCodeHostInfo(file.Repository);
if (info) {
return {
repoName: info.repoName,
repoLink: info.repoLink,
repoIcon: <Image
src={info.icon}
alt={info.costHostName}
className="w-4 h-4 dark:invert"
/>
}
}
return {
repoName: file.Repository,
repoLink: undefined,
repoIcon: <FileIcon className="w-4 h-4" />
}
}, [file]);
const isMoreContentButtonVisible = useMemo(() => {
return matchCount > MAX_MATCHES_TO_PREVIEW;
}, [matchCount]);
return (
<div>
<div
className="sticky top-0 bg-cyan-200 dark:bg-cyan-900 primary-foreground px-2 py-0.5 flex flex-row items-center justify-between border cursor-pointer z-10"
onClick={() => {
onOpenFile();
}}
>
<div className="flex flex-row gap-2 items-center">
{repoIcon}
<span
className={clsx("font-medium", {
"cursor-pointer hover:underline": repoLink,
})}
onClick={() => {
if (repoLink) {
window.open(repoLink, "_blank");
}
}}
>
{repoName}
</span>
<span>·</span>
{!fileNameRange ? (
<span>{file.FileName}</span>
) : (
<span>
{file.FileName.slice(0, fileNameRange.from)}
<span className="bg-yellow-200 dark:bg-blue-700">
{file.FileName.slice(fileNameRange.from, fileNameRange.to)}
</span>
{file.FileName.slice(fileNameRange.to)}
</span>
)}
</div>
</div>
{matches.map((match, index) => {
const content = atob(match.Content);
// If it's just the title, don't show a code preview
if (match.FileName) {
return null;
}
const lineOffset = match.ContentStart.LineNumber - 1;
return (
<div
key={index}
className="cursor-pointer"
onClick={() => {
const matchIndex = matches.slice(0, index).reduce((acc, match) => {
return acc + match.Ranges.length;
}, 0);
onOpenFile();
onMatchIndexChanged(matchIndex);
}}
>
<CodePreview
content={content}
language={file.Language}
ranges={match.Ranges}
lineOffset={lineOffset}
/>
{(index !== matches.length - 1 || isMoreContentButtonVisible) && (
<Separator className="dark:bg-gray-400" />
)}
</div>
);
})}
{isMoreContentButtonVisible && (
<div className="px-4 bg-accent">
<p
onClick={() => setShowAll(!showAll)}
className="text-blue-500 cursor-pointer text-sm flex flex-row items-center gap-2"
>
{showAll ? <DoubleArrowUpIcon className="w-3 h-3" /> : <DoubleArrowDownIcon className="w-3 h-3" />}
{showAll ? `Show fewer matches` : `Show ${matchCount - MAX_MATCHES_TO_PREVIEW} more matches`}
</p>
</div>
)}
</div>
);
}
const markDecoration = Decoration.mark({
class: "cm-searchMatch"
});
const cmTheme = EditorView.baseTheme({
"&light .cm-searchMatch": {
border: "1px #6b7280ff",
},
"&dark .cm-searchMatch": {
border: "1px #d1d5dbff",
},
});
const CodePreview = ({
content,
language,
ranges,
lineOffset,
}: {
content: string,
language: string,
ranges: SearchResultRange[],
lineOffset: number,
}) => {
const editorRef = useRef<ReactCodeMirrorRef>(null);
const { theme } = useThemeNormalized();
const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef.current?.view);
const rangeHighlighting = useExtensionWithDependency(editorRef.current?.view ?? null, () => {
return [
StateField.define<DecorationSet>({
create(editorState: EditorState) {
const document = editorState.doc;
const decorations = ranges
.sort((a, b) => {
return a.Start.ByteOffset - b.Start.ByteOffset;
})
.filter(({ Start, End }) => {
const startLine = Start.LineNumber - lineOffset;
const endLine = End.LineNumber - lineOffset;
if (
startLine < 1 ||
endLine < 1 ||
startLine > document.lines ||
endLine > document.lines
) {
return false;
}
return true;
})
.map(({ Start, End }) => {
const startLine = Start.LineNumber - lineOffset;
const endLine = End.LineNumber - lineOffset;
const from = document.line(startLine).from + Start.Column - 1;
const to = document.line(endLine).from + End.Column - 1;
return markDecoration.range(from, to);
});
return Decoration.set(decorations);
},
update(highlights: DecorationSet, _transaction: Transaction) {
return highlights;
},
provide: (field) => EditorView.decorations.from(field),
}),
cmTheme
];
}, [ranges, lineOffset]);
const extensions = useMemo(() => {
return [
syntaxHighlighting,
lineOffsetExtension(lineOffset),
rangeHighlighting,
];
}, [syntaxHighlighting, lineOffset, rangeHighlighting]);
return (
<CodeMirror
ref={editorRef}
readOnly={true}
editable={false}
value={content}
theme={theme === "dark" ? "dark" : "light"}
basicSetup={{
lineNumbers: true,
syntaxHighlighting: true,
// Disable all this other stuff...
... {
foldGutter: false,
highlightActiveLineGutter: false,
highlightSpecialChars: false,
history: false,
drawSelection: false,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: false,
bracketMatching: false,
closeBrackets: false,
autocompletion: false,
rectangularSelection: false,
crosshairCursor: false,
highlightActiveLine: false,
highlightSelectionMatches: false,
closeBracketsKeymap: false,
defaultKeymap: false,
searchKeymap: false,
historyKeymap: false,
foldKeymap: false,
completionKeymap: false,
lintKeymap: false,
}
}}
extensions={extensions}
/>
)
}

View file

@ -1,6 +1,6 @@
import { EditorSelection, Extension, StateEffect, StateField, Text, Transaction } from "@codemirror/state";
import { Decoration, DecorationSet, EditorView } from "@codemirror/view";
import { SearchResultRange } from "../schemas";
import { SearchResultRange } from "../types";
const setMatchState = StateEffect.define<{
selectedMatchIndex: number,
@ -46,26 +46,10 @@ const matchHighlighter = StateField.define<DecorationSet>({
});
const matchMark = Decoration.mark({
class: "tq-searchMatch"
class: "cm-searchMatch"
});
const selectedMatchMark = Decoration.mark({
class: "tq-searchMatch-selected"
});
const highlightTheme = EditorView.baseTheme({
"&light .tq-searchMatch": {
border: "1px dotted #6b7280ff",
},
"&light .tq-searchMatch-selected": {
backgroundColor: "#00ff00aa"
},
"&dark .tq-searchMatch": {
border: "1px dotted #d1d5dbff",
},
"&dark .tq-searchMatch-selected": {
backgroundColor: "#00ff007a",
}
class: "cm-searchMatch-selected"
});
export const highlightRanges = (selectedMatchIndex: number, ranges: SearchResultRange[], view: EditorView) => {
@ -90,7 +74,6 @@ export const highlightRanges = (selectedMatchIndex: number, ranges: SearchResult
export const searchResultHighlightExtension = (): Extension => {
return [
highlightTheme,
matchHighlighter,
]
}

View file

@ -1,6 +1,5 @@
import { z } from "zod";
export type SearchRequest = z.infer<typeof searchRequestSchema>;
export const searchRequestSchema = z.object({
query: z.string(),
numResults: z.number(),
@ -8,15 +7,8 @@ export const searchRequestSchema = z.object({
});
export type SearchResponse = z.infer<typeof searchResponseSchema>;
export type SearchResult = SearchResponse["Result"];
export type SearchResultFile = NonNullable<SearchResult["Files"]>[number];
export type SearchResultFileMatch = SearchResultFile["ChunkMatches"][number];
export type SearchResultRange = z.infer<typeof rangeSchema>;
export type SearchResultLocation = z.infer<typeof locationSchema>;
// @see : https://github.com/TaqlaAI/zoekt/blob/main/api.go#L212
const locationSchema = z.object({
export const locationSchema = z.object({
// 0-based byte offset from the beginning of the file
ByteOffset: z.number(),
// 1-based line number from the beginning of the file
@ -25,7 +17,7 @@ const locationSchema = z.object({
Column: z.number(),
});
const rangeSchema = z.object({
export const rangeSchema = z.object({
Start: locationSchema,
End: locationSchema,
});
@ -79,22 +71,16 @@ export const searchResponseSchema = z.object({
}),
});
export type FileSourceRequest = z.infer<typeof fileSourceRequestSchema>;
export const fileSourceRequestSchema = z.object({
fileName: z.string(),
repository: z.string()
});
export type FileSourceResponse = z.infer<typeof fileSourceResponseSchema>;
export const fileSourceResponseSchema = z.object({
source: z.string(),
});
export type ListRepositoriesResponse = z.infer<typeof listRepositoriesResponseSchema>;
export type Repository = z.infer<typeof repositorySchema>;
// @see : https://github.com/TaqlaAI/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L728
const repoStatsSchema = z.object({
Repos: z.number(),
@ -120,7 +106,7 @@ const indexMetadataSchema = z.object({
});
// @see : https://github.com/TaqlaAI/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L555
const repositorySchema = z.object({
export const repositorySchema = z.object({
Name: z.string(),
URL: z.string(),
Source: z.string(),

View file

@ -1,6 +1,7 @@
import escapeStringRegexp from "escape-string-regexp";
import { SHARD_MAX_MATCH_COUNT, TOTAL_MAX_MATCH_COUNT } from "../environment";
import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, listRepositoriesResponseSchema, SearchRequest, SearchResponse, searchResponseSchema } from "../schemas";
import { listRepositoriesResponseSchema, searchResponseSchema } from "../schemas";
import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "../types";
import { fileNotFound, invalidZoektResponse, ServiceError, unexpectedError } from "../serviceError";
import { isServiceError } from "../utils";
import { zoektFetch } from "./zoektClient";

View file

@ -1 +1,19 @@
export type KeymapType = "default" | "vim";
import { z } from "zod";
import { fileSourceRequestSchema, fileSourceResponseSchema, listRepositoriesResponseSchema, locationSchema, rangeSchema, repositorySchema, searchRequestSchema, searchResponseSchema } from "./schemas";
export type KeymapType = "default" | "vim";
export type SearchResponse = z.infer<typeof searchResponseSchema>;
export type SearchResult = SearchResponse["Result"];
export type SearchResultFile = NonNullable<SearchResult["Files"]>[number];
export type SearchResultFileMatch = SearchResultFile["ChunkMatches"][number];
export type SearchResultRange = z.infer<typeof rangeSchema>;
export type SearchResultLocation = z.infer<typeof locationSchema>;
export type FileSourceRequest = z.infer<typeof fileSourceRequestSchema>;
export type FileSourceResponse = z.infer<typeof fileSourceResponseSchema>;
export type ListRepositoriesResponse = z.infer<typeof listRepositoriesResponseSchema>;
export type Repository = z.infer<typeof repositorySchema>;
export type SearchRequest = z.infer<typeof searchRequestSchema>;