Commit a6096618 authored by tom's avatar tom

basic search implementation

parent 6ee39a5c
...@@ -3,29 +3,29 @@ import MonacoEditor from '@monaco-editor/react'; ...@@ -3,29 +3,29 @@ import MonacoEditor from '@monaco-editor/react';
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import React from 'react'; import React from 'react';
import type { File } from './types'; import type { File, Monaco } from './types';
import CodeEditorSideBar from './CodeEditorSideBar'; import CodeEditorSideBar from './CodeEditorSideBar';
import * as themes from './utils/themes'; import * as themes from './utils/themes';
export type Monaco = typeof monaco;
interface Props { interface Props {
data: Array<File>; data: Array<File>;
} }
const CodeEditor = ({ data }: Props) => { const CodeEditor = ({ data }: Props) => {
const instance = React.useRef<Monaco>(); const [ instance, setInstance ] = React.useState<Monaco | undefined>();
const [ index, setIndex ] = React.useState(0); const [ index, setIndex ] = React.useState(0);
const editorRef = React.useRef<monaco.editor.IStandaloneCodeEditor>();
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
React.useEffect(() => { React.useEffect(() => {
instance.current?.editor.setTheme(colorMode === 'light' ? 'blockscout-light' : 'blockscout-dark'); instance?.editor.setTheme(colorMode === 'light' ? 'blockscout-light' : 'blockscout-dark');
}, [ colorMode ]); }, [ colorMode, instance?.editor ]);
const handleEditorDidMount = React.useCallback((editor: monaco.editor.IStandaloneCodeEditor, monaco: Monaco) => { const handleEditorDidMount = React.useCallback((editor: monaco.editor.IStandaloneCodeEditor, monaco: Monaco) => {
instance.current = monaco; setInstance(monaco);
editorRef.current = editor;
monaco.editor.defineTheme('blockscout-light', themes.light); monaco.editor.defineTheme('blockscout-light', themes.light);
monaco.editor.defineTheme('blockscout-dark', themes.dark); monaco.editor.defineTheme('blockscout-dark', themes.dark);
...@@ -34,6 +34,13 @@ const CodeEditor = ({ data }: Props) => { ...@@ -34,6 +34,13 @@ const CodeEditor = ({ data }: Props) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ ]); }, [ ]);
const handleSelectFile = React.useCallback((index: number, lineNumber?: number) => {
setIndex(index);
if (lineNumber !== undefined && !Object.is(lineNumber, NaN)) {
editorRef.current?.revealLineInCenter(lineNumber);
}
}, []);
return ( return (
<Flex overflow="hidden" borderRadius="md" height="500px"> <Flex overflow="hidden" borderRadius="md" height="500px">
<Box flexGrow={ 1 }> <Box flexGrow={ 1 }>
...@@ -45,7 +52,7 @@ const CodeEditor = ({ data }: Props) => { ...@@ -45,7 +52,7 @@ const CodeEditor = ({ data }: Props) => {
onMount={ handleEditorDidMount } onMount={ handleEditorDidMount }
/> />
</Box> </Box>
<CodeEditorSideBar data={ data } onFileSelect={ setIndex }/> <CodeEditorSideBar data={ data } onFileSelect={ handleSelectFile } monaco={ instance }/>
</Flex> </Flex>
); );
}; };
......
import { Accordion, AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box, Input } from '@chakra-ui/react';
import React from 'react';
import type { File, Monaco, SearchResult } from './types';
import useDebounce from 'lib/hooks/useDebounce';
import CodeEditorSearchResultItem from './CodeEditorSearchResultItem';
interface Props {
data: Array<File>;
monaco: Monaco | undefined;
onFileSelect: (index: number, lineNumber?: number) => void;
}
const CodeEditorSearch = ({ monaco, data, onFileSelect }: Props) => {
const [ searchTerm, changeSearchTerm ] = React.useState('');
const [ searchResults, setSearchResults ] = React.useState<Array<SearchResult>>([]);
const debouncedSearchTerm = useDebounce(searchTerm, 300);
React.useEffect(() => {
if (!monaco) {
return;
}
if (!debouncedSearchTerm) {
setSearchResults([]);
}
const result: Array<SearchResult> = monaco.editor.getModels()
.map((model) => {
const matches = model.findMatches(debouncedSearchTerm, false, false, false, null, false);
return {
file_path: model.uri.path,
matches: matches.map(({ range }) => ({ ...range, lineContent: model.getLineContent(range.startLineNumber) })),
};
})
.filter(({ matches }) => matches.length > 0);
setSearchResults(result.length > 0 ? result : []);
}, [ debouncedSearchTerm, monaco ]);
const handleSearchTermChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
changeSearchTerm(event.target.value);
}, []);
const handleFileLineClick = React.useCallback((event: React.MouseEvent) => {
const filePath = (event.currentTarget as HTMLDivElement).getAttribute('data-file-path');
const lineNumber = (event.currentTarget as HTMLDivElement).getAttribute('data-line-number');
const fileIndex = data.findIndex((item) => item.file_path === filePath);
if (fileIndex > -1) {
onFileSelect(fileIndex, Number(lineNumber));
}
}, [ data, onFileSelect ]);
return (
<Box>
<Input size="xs" onChange={ handleSearchTermChange } value={ searchTerm } placeholder="Search"/>
<Accordion
key={ debouncedSearchTerm }
allowMultiple
defaultIndex={ searchResults.map((item, index) => index) }
reduceMotion
mt={ 3 }
>
{
searchResults.map((item, index) => {
const fileName = item.file_path.split('/').at(-1);
return (
<AccordionItem key={ index } borderWidth="0px" _last={{ borderBottomWidth: '0px' }}>
<AccordionButton p={ 0 } _hover={{ bgColor: 'inherit' }} fontSize="sm">
<AccordionIcon/>
<span>{ fileName }</span>
</AccordionButton>
<AccordionPanel p={ 0 }>
{ item.matches.map((match) => (
<CodeEditorSearchResultItem
key={ item.file_path + '_' + match.startLineNumber + '_' + match.startColumn }
filePath={ item.file_path }
onClick={ handleFileLineClick }
{ ...match }
/>
),
) }
</AccordionPanel>
</AccordionItem>
);
})
}
</Accordion>
</Box>
);
};
export default React.memo(CodeEditorSearch);
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { SearchResult } from './types';
import type ArrayElement from 'types/utils/ArrayElement';
interface Props extends ArrayElement<SearchResult['matches']> {
filePath: string;
onClick: (event: React.MouseEvent) => void;
}
const CodeEditorSearchResultItem = ({ lineContent, filePath, onClick, startLineNumber }: Props) => {
return (
<Box
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
cursor="pointer"
data-file-path={ filePath }
data-line-number={ startLineNumber }
onClick={ onClick }
>
{ lineContent }
</Box>
);
};
export default React.memo(CodeEditorSearchResultItem);
import { Box } from '@chakra-ui/react'; import { Box, Flex, Tooltip } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { File } from './types'; import type { File, Monaco } from './types';
import CodeEditorFileExplorer from './CodeEditorFileExplorer'; import CodeEditorFileExplorer from './CodeEditorFileExplorer';
import CodeEditorSearch from './CodeEditorSearch';
interface Props { interface Props {
monaco: Monaco | undefined;
data: Array<File>; data: Array<File>;
onFileSelect: (index: number) => void; onFileSelect: (index: number, lineNumber?: number) => void;
} }
const CodeEditorSideBar = ({ onFileSelect, data }: Props) => { const CodeEditorSideBar = ({ onFileSelect, data, monaco }: Props) => {
const [ activePanelIndex, setActivePanelIndex ] = React.useState(0);
const PANELS = React.useMemo(() => ([
{ id: 'explorer', label: 'Explorer', text: 'E', component: <CodeEditorFileExplorer data={ data } onFileSelect={ onFileSelect }/> },
{ id: 'search', label: 'Search', text: 'S', component: <CodeEditorSearch data={ data } onFileSelect={ onFileSelect } monaco={ monaco }/> },
]), [ data, monaco, onFileSelect ]);
const activePanel = PANELS[activePanelIndex];
const handleTabClick = React.useCallback((event: React.MouseEvent) => {
const id = (event.currentTarget as HTMLDivElement).getAttribute('data-id');
const index = PANELS.findIndex((item) => item.id === id);
if (index > -1) {
setActivePanelIndex(index);
}
}, [ PANELS ]);
return ( return (
<Box w="250px" flexShrink={ 0 } bgColor="lightpink" fontSize="sm" overflowY="scroll"> <Box w="250px" flexShrink={ 0 } bgColor="lightpink" fontSize="sm" overflowY="scroll" px={ 3 }>
<Box fontFamily="heading" pl={ 3 } letterSpacing={ 0.5 } fontWeight={ 600 }>EXPLORER</Box> <Flex>
<CodeEditorFileExplorer data={ data } onFileSelect={ onFileSelect }/> <Box fontFamily="heading" letterSpacing={ 0.5 } fontWeight={ 600 } textTransform="uppercase" lineHeight={ 6 }>
{ activePanel.label }
</Box>
<Flex ml="auto" columnGap={ 2 }>
{ PANELS.map(({ id, text, label }) => (
<Tooltip key={ id } label={ label }>
<Box data-id={ id } boxSize={ 6 } cursor="pointer" textAlign="center" bgColor="lightblue" onClick={ handleTabClick }>
{ text }
</Box>
</Tooltip>
)) }
</Flex>
</Flex>
{ activePanel.component }
</Box> </Box>
); );
}; };
......
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
export interface File { export interface File {
file_path: string; file_path: string;
source_code: string; source_code: string;
...@@ -13,3 +15,13 @@ export interface FileTreeFolder { ...@@ -13,3 +15,13 @@ export interface FileTreeFolder {
} }
export type FileTree = Array<FileTreeFile | FileTreeFolder>; export type FileTree = Array<FileTreeFile | FileTreeFolder>;
export type Monaco = typeof monaco;
export interface SearchResult {
file_path: string;
matches: Array<
Pick<monaco.editor.FindMatch['range'], 'startColumn' | 'endColumn' | 'startLineNumber' | 'endLineNumber'> &
{ lineContent: string }
>;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment