Commit 0eece149 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #696 from blockscout/monaco-code-editor

monaco code editor
parents 13aa27c5 93007520
......@@ -52,7 +52,6 @@ jobs:
name: Run unit tests with Jest
needs: [ lint, type_check ]
runs-on: ubuntu-latest
if: ${{ false }} # disable since there are no jest test yet
steps:
- name: Checkout repo
uses: actions/checkout@v3
......
......@@ -8,6 +8,7 @@ function generateCspPolicy() {
descriptors.googleAnalytics(),
descriptors.googleFonts(),
descriptors.googleReCaptcha(),
descriptors.monaco(),
descriptors.sentry(),
descriptors.walletConnect(),
);
......
......@@ -3,5 +3,6 @@ export { app } from './app';
export { googleAnalytics } from './googleAnalytics';
export { googleFonts } from './googleFonts';
export { googleReCaptcha } from './googleReCaptcha';
export { monaco } from './monaco';
export { sentry } from './sentry';
export { walletConnect } from './walletConnect';
import type CspDev from 'csp-dev';
import { KEY_WORDS } from '../utils';
export function monaco(): CspDev.DirectiveDescriptor {
return {
'script-src': [
KEY_WORDS.BLOB,
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/loader.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/editor/editor.main.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/editor/editor.main.nls.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/basic-languages/solidity/solidity.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/base/worker/workerMain.js',
],
'style-src': [
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/editor/editor.main.css',
],
'font-src': [
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/base/browser/ui/codicons/codicon/codicon.ttf',
],
};
}
export default function isMetaKey(event: React.KeyboardEvent) {
return event.metaKey || event.getModifierState('Meta') || event.getModifierState('OS');
}
const stripLeadingSlash = (str: string) => str[0] === '/' ? str.slice(1) : str;
export default stripLeadingSlash;
......@@ -20,6 +20,7 @@ test('verified with changed byte code +@mobile +@dark-mode', async({ mount, page
status: 200,
body: JSON.stringify(contractMock.withChangedByteCode),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
const component = await mount(
<TestApp>
......@@ -36,6 +37,7 @@ test('verified with multiple sources +@mobile', async({ mount, page }) => {
status: 200,
body: JSON.stringify(contractMock.withMultiplePaths),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
await mount(
<TestApp>
......@@ -54,6 +56,7 @@ test('verified via sourcify', async({ mount, page }) => {
status: 200,
body: JSON.stringify(contractMock.verifiedViaSourcify),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
await mount(
<TestApp>
......@@ -70,6 +73,7 @@ test('self destructed', async({ mount, page }) => {
status: 200,
body: JSON.stringify(contractMock.selfDestructed),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
await mount(
<TestApp>
......@@ -87,6 +91,7 @@ test('with twin address alert +@mobile', async({ mount, page }) => {
status: 200,
body: JSON.stringify(contractMock.withTwinAddress),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
const component = await mount(
<TestApp>
......@@ -103,6 +108,7 @@ test('with proxy address alert +@mobile', async({ mount, page }) => {
status: 200,
body: JSON.stringify(contractMock.withProxyAddress),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
const component = await mount(
<TestApp>
......@@ -119,6 +125,7 @@ test('non verified', async({ mount, page }) => {
status: 200,
body: JSON.stringify(contractMock.nonVerified),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
const component = await mount(
<TestApp>
......
import { Box, chakra, Flex, Text, Tooltip } from '@chakra-ui/react';
import { Flex, Text, Tooltip } from '@chakra-ui/react';
import { route } from 'nextjs-routes';
import React from 'react';
import type { SmartContract } from 'types/api/contract';
import CodeEditor from 'ui/shared/CodeEditor';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import LinkInternal from 'ui/shared/LinkInternal';
import CodeEditor from 'ui/shared/monaco/CodeEditor';
import formatFilePath from 'ui/shared/monaco/utils/formatFilePath';
interface Props {
data: string;
......@@ -37,38 +38,25 @@ const ContractSourceCode = ({ data, hasSol2Yml, address, isViper, filePath, addi
</Tooltip>
) : null;
if (!additionalSource?.length) {
return (
<section>
<Flex justifyContent="space-between" alignItems="center" mb={ 3 }>
{ heading }
{ diagramLink }
<CopyToClipboard text={ data }/>
</Flex>
<CodeEditor value={ data } id="source_code"/>
</section>
);
}
const editorData = React.useMemo(() => {
const defaultName = isViper ? '/index.vy' : '/index.sol';
return [
{ file_path: formatFilePath(filePath || defaultName), source_code: data },
...(additionalSource || []).map((source) => ({ ...source, file_path: formatFilePath(source.file_path) })) ];
}, [ additionalSource, data, filePath, isViper ]);
const copyToClipboard = editorData.length === 1 ?
<CopyToClipboard text={ editorData[0].source_code }/> :
null;
return (
<section>
<Flex justifyContent="space-between" alignItems="center" mb={ 3 }>
{ heading }
{ diagramLink }
{ copyToClipboard }
</Flex>
<Flex flexDir="column" rowGap={ 3 }>
{ [ { file_path: filePath, source_code: data }, ...additionalSource ].map((item, index, array) => (
<Box key={ index }>
<Flex justifyContent="space-between" alignItems="flex-end" mb={ 3 }>
<chakra.span fontSize="sm" wordBreak="break-all" lineHeight="20px">
File { index + 1 } of { array.length }: { item.file_path }
</chakra.span>
<CopyToClipboard text={ item.source_code } ml={ 4 }/>
</Flex>
<CodeEditor value={ item.source_code } id={ `source_code_${ index }` }/>
</Box>
)) }
</Flex>
<CodeEditor data={ editorData }/>
</section>
);
};
......
import { chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type ReactAce from 'react-ace/lib/ace';
interface Props {
id: string;
value: string;
className?: string;
}
const CodeEditorBase = chakra(({ id, value, className }: Props) => {
const [ AceEditor, setAceEditor ] = React.useState<{default: typeof ReactAce} | null>(null);
React.useEffect(() => {
const load = async() => {
const component = await import('react-ace');
await import('ace-builds/src-noconflict/mode-csharp');
await import('ace-builds/src-noconflict/theme-tomorrow');
await import('ace-builds/src-noconflict/theme-tomorrow_night');
await import('ace-builds/src-noconflict/ext-language_tools');
setAceEditor(component);
};
load();
return () => {
setAceEditor(null);
};
}, []);
const theme = useColorModeValue('tomorrow', 'tomorrow_night');
if (!AceEditor) {
return null;
}
return (
<AceEditor.default
className={ className }
mode="csharp" // TODO need to find mode for solidity
theme={ theme }
value={ value }
name={ id }
editorProps={{ $blockScrolling: true }}
readOnly
width="100%"
showPrintMargin={ false }
maxLines={ 25 }
/>
);
});
const CodeEditor = ({ id, value }: Props) => {
// see theme/components/Textarea.ts variantFilledInactive
const bgColor = useColorModeValue('#f5f5f6', '#1a1b1b');
const gutterBgColor = useColorModeValue('gray.100', '#25282c');
return (
<CodeEditorBase
id={ id }
value={ value }
bgColor={ bgColor }
borderRadius="md"
overflow="hidden"
sx={{
'.ace_gutter': {
backgroundColor: gutterBgColor,
},
}}
/>
);
};
export default React.memo(CodeEditor);
import type { SystemStyleObject } from '@chakra-ui/react';
import { Box, useColorMode, Flex } from '@chakra-ui/react';
import type { EditorProps } from '@monaco-editor/react';
import MonacoEditor from '@monaco-editor/react';
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import React from 'react';
import type { File, Monaco } from './types';
import useClientRect from 'lib/hooks/useClientRect';
import useIsMobile from 'lib/hooks/useIsMobile';
import isMetaKey from 'lib/isMetaKey';
import CodeEditorBreadcrumbs from './CodeEditorBreadcrumbs';
import CodeEditorLoading from './CodeEditorLoading';
import CodeEditorSideBar, { CONTAINER_WIDTH as SIDE_BAR_WIDTH } from './CodeEditorSideBar';
import CodeEditorTabs from './CodeEditorTabs';
import addFileImportDecorations from './utils/addFileImportDecorations';
import getFullPathOfImportedFile from './utils/getFullPathOfImportedFile';
import * as themes from './utils/themes';
import useThemeColors from './utils/useThemeColors';
const EDITOR_OPTIONS: EditorProps['options'] = {
readOnly: true,
minimap: { enabled: false },
scrollbar: {
alwaysConsumeMouseWheel: true,
},
dragAndDrop: false,
};
const TABS_HEIGHT = 35;
const BREADCRUMBS_HEIGHT = 22;
const EDITOR_HEIGHT = 500;
interface Props {
data: Array<File>;
}
const CodeEditor = ({ data }: Props) => {
const [ instance, setInstance ] = React.useState<Monaco | undefined>();
const [ index, setIndex ] = React.useState(0);
const [ tabs, setTabs ] = React.useState([ data[index].file_path ]);
const [ isMetaPressed, setIsMetaPressed ] = React.useState(false);
const editorRef = React.useRef<monaco.editor.IStandaloneCodeEditor>();
const [ containerRect, containerNodeRef ] = useClientRect<HTMLDivElement>();
const { colorMode } = useColorMode();
const isMobile = useIsMobile();
const themeColors = useThemeColors();
const editorWidth = containerRect ? containerRect.width - (isMobile ? 0 : SIDE_BAR_WIDTH) : 0;
React.useEffect(() => {
instance?.editor.setTheme(colorMode === 'light' ? 'blockscout-light' : 'blockscout-dark');
}, [ colorMode, instance?.editor ]);
const handleEditorDidMount = React.useCallback((editor: monaco.editor.IStandaloneCodeEditor, monaco: Monaco) => {
setInstance(monaco);
editorRef.current = editor;
monaco.editor.defineTheme('blockscout-light', themes.light);
monaco.editor.defineTheme('blockscout-dark', themes.dark);
monaco.editor.setTheme(colorMode === 'light' ? 'blockscout-light' : 'blockscout-dark');
const loadedModels = monaco.editor.getModels();
const loadedModelsPaths = loadedModels.map((model) => model.uri.path);
const newModels = data.slice(1)
.filter((file) => !loadedModelsPaths.includes(file.file_path))
.map((file) => monaco.editor.createModel(file.source_code, 'sol', monaco.Uri.parse(file.file_path)));
loadedModels.concat(newModels).forEach(addFileImportDecorations);
// componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ ]);
const handleSelectFile = React.useCallback((index: number, lineNumber?: number) => {
setIndex(index);
setTabs((prev) => prev.some((item) => item === data[index].file_path) ? prev : ([ ...prev, data[index].file_path ]));
if (lineNumber !== undefined && !Object.is(lineNumber, NaN)) {
window.setTimeout(() => {
editorRef.current?.revealLineInCenter(lineNumber);
}, 0);
}
editorRef.current?.focus();
}, [ data ]);
const handleTabSelect = React.useCallback((path: string) => {
const index = data.findIndex((item) => item.file_path === path);
if (index > -1) {
setIndex(index);
}
}, [ data ]);
const handleTabClose = React.useCallback((path: string) => {
setTabs((prev) => {
if (prev.length > 1) {
const tabIndex = prev.findIndex((item) => item === path);
const isActive = data[index].file_path === path;
if (isActive) {
const nextActiveIndex = data.findIndex((item) => item.file_path === prev[Math.max(0, tabIndex - 1)]);
setIndex(nextActiveIndex);
}
return prev.filter((item) => item !== path);
}
return prev;
});
}, [ data, index ]);
const handleClick = React.useCallback((event: React.MouseEvent) => {
if (!isMetaPressed && !isMobile) {
return;
}
const target = event.target as HTMLSpanElement;
const isImportLink = target.classList.contains('import-link');
if (isImportLink) {
const path = target.innerText;
const fullPath = getFullPathOfImportedFile(data[index].file_path, path);
const fileIndex = data.findIndex((file) => file.file_path === fullPath);
if (fileIndex > -1) {
event.stopPropagation();
handleSelectFile(fileIndex);
}
}
}, [ data, handleSelectFile, index, isMetaPressed, isMobile ]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
isMetaKey(event) && setIsMetaPressed(true);
}, []);
const handleKeyUp = React.useCallback(() => {
setIsMetaPressed(false);
}, []);
const containerSx: SystemStyleObject = React.useMemo(() => ({
'.editor-container': {
position: 'absolute',
top: 0,
left: 0,
width: `${ editorWidth }px`,
height: '100%',
},
'.highlight': {
backgroundColor: themeColors['custom.findMatchHighlightBackground'],
},
'&&.meta-pressed .import-link': {
_hover: {
color: themeColors['custom.fileLink.hoverForeground'],
textDecoration: 'underline',
cursor: 'pointer',
},
},
}), [ editorWidth, themeColors ]);
if (data.length === 1) {
return (
<Box overflow="hidden" borderRadius="md" height={ `${ EDITOR_HEIGHT }px` }>
<MonacoEditor
language="sol"
path={ data[index].file_path }
defaultValue={ data[index].source_code }
options={ EDITOR_OPTIONS }
onMount={ handleEditorDidMount }
loading={ <CodeEditorLoading/> }
/>
</Box>
);
}
return (
<Flex
className={ isMetaPressed ? 'meta-pressed' : undefined }
overflow="hidden"
borderRadius="md"
width="100%"
height={ `${ EDITOR_HEIGHT + TABS_HEIGHT + BREADCRUMBS_HEIGHT }px` }
position="relative"
ref={ containerNodeRef }
sx={ containerSx }
onClick={ handleClick }
onKeyDown={ handleKeyDown }
onKeyUp={ handleKeyUp }
>
<Box flexGrow={ 1 }>
<CodeEditorTabs tabs={ tabs } activeTab={ data[index].file_path } onTabSelect={ handleTabSelect } onTabClose={ handleTabClose }/>
<CodeEditorBreadcrumbs path={ data[index].file_path }/>
<MonacoEditor
className="editor-container"
height={ `${ EDITOR_HEIGHT }px` }
language="sol"
path={ data[index].file_path }
defaultValue={ data[index].source_code }
options={ EDITOR_OPTIONS }
onMount={ handleEditorDidMount }
loading={ <CodeEditorLoading/> }
/>
</Box>
<CodeEditorSideBar data={ data } onFileSelect={ handleSelectFile } monaco={ instance } selectedFile={ data[index].file_path }/>
</Flex>
);
};
export default React.memo(CodeEditor);
import { Flex, Box } from '@chakra-ui/react';
import React from 'react';
import stripLeadingSlash from 'lib/stripLeadingSlash';
import useThemeColors from 'ui/shared/monaco/utils/useThemeColors';
interface Props {
path: string;
}
const CodeEditorBreadcrumbs = ({ path }: Props) => {
const chunks = stripLeadingSlash(path).split('/');
const themeColors = useThemeColors();
return (
<Flex
color={ themeColors['breadcrumbs.foreground'] }
bgColor={ themeColors['editor.background'] }
pl="16px"
pr="8px"
flexWrap="wrap"
fontSize="13px"
lineHeight="22px"
alignItems="center"
>
{ chunks.map((chunk, index) => {
return (
<React.Fragment key={ index }>
{ index !== 0 && (
<Box
className="codicon codicon-breadcrumb-separator"
boxSize="16px"
_before={{
content: '"\\eab6"',
}}/>
) }
<Box>{ chunk }</Box>
</React.Fragment>
);
}) }
</Flex>
);
};
export default React.memo(CodeEditorBreadcrumbs);
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { File } from './types';
import CodeEditorFileTree from './CodeEditorFileTree';
import CoderEditorCollapseButton from './CoderEditorCollapseButton';
import composeFileTree from './utils/composeFileTree';
interface Props {
data: Array<File>;
onFileSelect: (index: number) => void;
selectedFile: string;
isActive: boolean;
setActionBarRenderer: React.Dispatch<React.SetStateAction<(() => JSX.Element) | undefined>>;
}
const CodeEditorFileExplorer = ({ data, onFileSelect, selectedFile, isActive, setActionBarRenderer }: Props) => {
const [ key, setKey ] = React.useState(0);
const tree = React.useMemo(() => {
return composeFileTree(data);
}, [ data ]);
const handleCollapseButtonClick = React.useCallback(() => {
setKey((prev) => prev + 1);
}, []);
const renderActionBar = React.useCallback(() => {
return (
<CoderEditorCollapseButton onClick={ handleCollapseButtonClick } label="Collapse folders"/>
);
}, [ handleCollapseButtonClick ]);
const handleFileClick = React.useCallback((event: React.MouseEvent) => {
const filePath = (event.currentTarget as HTMLDivElement).getAttribute('data-file-path');
const fileIndex = data.findIndex((item) => item.file_path === filePath);
if (fileIndex > -1) {
onFileSelect(fileIndex);
}
}, [ data, onFileSelect ]);
React.useEffect(() => {
isActive && setActionBarRenderer(() => renderActionBar);
}, [ isActive, renderActionBar, setActionBarRenderer ]);
return (
<Box>
<CodeEditorFileTree key={ key } tree={ tree } onItemClick={ handleFileClick } isCollapsed={ key > 0 } selectedFile={ selectedFile }/>
</Box>
);
};
export default React.memo(CodeEditorFileExplorer);
import type { ChakraProps } from '@chakra-ui/react';
import { Box, Accordion, AccordionButton, AccordionItem, AccordionPanel, Icon, chakra } from '@chakra-ui/react';
import React from 'react';
import type { FileTree } from './types';
import iconFile from './icons/file.svg';
import iconFolderOpen from './icons/folder-open.svg';
import iconFolder from './icons/folder.svg';
import iconSolidity from './icons/solidity.svg';
import useThemeColors from './utils/useThemeColors';
interface Props {
tree: FileTree;
level?: number;
isCollapsed?: boolean;
onItemClick: (event: React.MouseEvent) => void;
selectedFile: string;
}
const CodeEditorFileTree = ({ tree, level = 0, onItemClick, isCollapsed, selectedFile }: Props) => {
const itemProps: ChakraProps = {
borderWidth: '0px',
cursor: 'pointer',
lineHeight: '22px',
_last: {
borderBottomWidth: '0px',
},
};
const themeColors = useThemeColors();
return (
<Accordion allowMultiple defaultIndex={ isCollapsed ? undefined : tree.map((item, index) => index) } reduceMotion>
{
tree.map((leaf, index) => {
const leafName = <chakra.span overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">{ leaf.name }</chakra.span>;
if ('children' in leaf) {
return (
<AccordionItem key={ index } { ...itemProps }>
{ ({ isExpanded }) => (
<>
<AccordionButton
pr="8px"
py="0"
pl={ `${ 8 + 8 * level }px` }
_hover={{ bgColor: themeColors['custom.list.hoverBackground'] }}
fontSize="13px"
lineHeight="22px"
h="22px"
transitionDuration="0"
>
<Box
className="codicon codicon-tree-item-expanded"
transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' }
boxSize="16px"
mr="2px"
/>
<Icon as={ isExpanded ? iconFolderOpen : iconFolder } boxSize="16px" mr="4px"/>
{ leafName }
</AccordionButton>
<AccordionPanel p="0">
<CodeEditorFileTree
tree={ leaf.children }
level={ level + 1 }
onItemClick={ onItemClick }
isCollapsed={ isCollapsed }
selectedFile={ selectedFile }
/>
</AccordionPanel>
</>
) }
</AccordionItem>
);
}
const icon = /.sol|.yul|.vy$/.test(leaf.name) ? iconSolidity : iconFile;
return (
<AccordionItem
key={ index }
{ ...itemProps }
pl={ `${ 26 + (level * 8) }px` }
pr="8px"
onClick={ onItemClick }
data-file-path={ leaf.file_path }
display="flex"
alignItems="center"
overflow="hidden"
_hover={{
bgColor: selectedFile === leaf.file_path ? themeColors['list.inactiveSelectionBackground'] : themeColors['custom.list.hoverBackground'],
}}
bgColor={ selectedFile === leaf.file_path ? themeColors['list.inactiveSelectionBackground'] : 'none' }
>
<Icon as={ icon } boxSize="16px" mr="4px"/>
{ leafName }
</AccordionItem>
);
})
}
</Accordion>
);
};
export default React.memo(CodeEditorFileTree);
import { Center } from '@chakra-ui/react';
import React from 'react';
import ContentLoader from 'ui/shared/ContentLoader';
import useThemeColors from './utils/useThemeColors';
const CodeEditorLoading = () => {
const themeColors = useThemeColors();
return (
<Center bgColor={ themeColors['editor.background'] } w="100%" h="100%">
<ContentLoader/>
</Center>
);
};
export default React.memo(CodeEditorLoading);
import type { ChakraProps } from '@chakra-ui/react';
import { Accordion, Box, Input, InputGroup, InputRightElement, useBoolean } from '@chakra-ui/react';
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import React from 'react';
import type { File, Monaco, SearchResult } from './types';
import useDebounce from 'lib/hooks/useDebounce';
import CodeEditorSearchSection from './CodeEditorSearchSection';
import CoderEditorCollapseButton from './CoderEditorCollapseButton';
import useThemeColors from './utils/useThemeColors';
interface Props {
data: Array<File>;
monaco: Monaco | undefined;
onFileSelect: (index: number, lineNumber?: number) => void;
isInputStuck: boolean;
isActive: boolean;
setActionBarRenderer: React.Dispatch<React.SetStateAction<(() => JSX.Element) | undefined>>;
}
const CodeEditorSearch = ({ monaco, data, onFileSelect, isInputStuck, isActive, setActionBarRenderer }: Props) => {
const [ searchTerm, changeSearchTerm ] = React.useState('');
const [ searchResults, setSearchResults ] = React.useState<Array<SearchResult>>([]);
const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>([]);
const [ isMatchCase, setMatchCase ] = useBoolean();
const [ isMatchWholeWord, setMatchWholeWord ] = useBoolean();
const [ isMatchRegex, setMatchRegex ] = useBoolean();
const decorations = React.useRef<Record<string, Array<string>>>({});
const themeColors = useThemeColors();
const debouncedSearchTerm = useDebounce(searchTerm, 300);
React.useEffect(() => {
if (!monaco) {
return;
}
if (!debouncedSearchTerm) {
setSearchResults([]);
}
const models = monaco.editor.getModels();
const matches = models.map((model) => model.findMatches(debouncedSearchTerm, false, isMatchRegex, isMatchCase, isMatchWholeWord ? 'true' : null, false));
models.forEach((model, index) => {
const filePath = model.uri.path;
const prevDecorations = decorations.current[filePath] || [];
const newDecorations: Array<monaco.editor.IModelDeltaDecoration> = matches[index].map(({ range }) => ({ range, options: { className: 'highlight' } }));
const newDecorationsIds = model.deltaDecorations(prevDecorations, newDecorations);
decorations.current[filePath] = newDecorationsIds;
});
const result: Array<SearchResult> = matches
.map((match, index) => {
const model = models[index];
return {
file_path: model.uri.path,
matches: match.map(({ range }) => ({ ...range, lineContent: model.getLineContent(range.startLineNumber) })),
};
})
.filter(({ matches }) => matches.length > 0);
setSearchResults(result.length > 0 ? result : []);
}, [ debouncedSearchTerm, isMatchCase, isMatchRegex, isMatchWholeWord, monaco ]);
React.useEffect(() => {
setExpandedSections(searchResults.map((item, index) => index));
}, [ searchResults ]);
const handleSearchTermChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
changeSearchTerm(event.target.value);
}, []);
const handleResultItemClick = React.useCallback((filePath: string, lineNumber: number) => {
const fileIndex = data.findIndex((item) => item.file_path === filePath);
if (fileIndex > -1) {
onFileSelect(fileIndex, Number(lineNumber));
}
}, [ data, onFileSelect ]);
const handleAccordionStateChange = React.useCallback((newValue: Array<number>) => {
setExpandedSections(newValue);
}, []);
const handleToggleCollapseClick = React.useCallback(() => {
if (expandedSections.length === 0) {
setExpandedSections(searchResults.map((item, index) => index));
} else {
setExpandedSections([]);
}
}, [ expandedSections.length, searchResults ]);
const renderActionBar = React.useCallback(() => {
return (
<CoderEditorCollapseButton
onClick={ handleToggleCollapseClick }
label={ expandedSections.length === 0 ? 'Expand all' : 'Collapse all' }
isDisabled={ searchResults.length === 0 }
isCollapsed={ expandedSections.length === 0 }
/>
);
}, [ expandedSections.length, handleToggleCollapseClick, searchResults.length ]);
React.useEffect(() => {
isActive && setActionBarRenderer(() => renderActionBar);
}, [ isActive, renderActionBar, setActionBarRenderer ]);
const buttonProps: ChakraProps = {
boxSize: '20px',
p: '1px',
cursor: 'pointer',
borderRadius: '3px',
borderWidth: '1px',
borderColor: 'transparent',
};
const searchResultNum = (() => {
if (!debouncedSearchTerm) {
return null;
}
const totalResults = searchResults.map(({ matches }) => matches.length).reduce((result, item) => result + item, 0);
if (!totalResults) {
return (
<Box px="8px" fontSize="13px" lineHeight="18px" mb="8px">
No results found. Review your settings for configured exclusions.
</Box>
);
}
return (
<Box px="8px" fontSize="13px" lineHeight="18px" mb="8px">
{ totalResults } result{ totalResults > 1 ? 's' : '' } in { searchResults.length } file{ searchResults.length > 1 ? 's' : '' }
</Box>
);
})();
return (
<Box>
<InputGroup
px="8px"
position="sticky"
top="35px"
left="0"
zIndex="2"
bgColor={ themeColors['sideBar.background'] }
pb="8px"
boxShadow={ isInputStuck ? 'md' : 'none' }
>
<Input
size="xs"
onChange={ handleSearchTermChange }
value={ searchTerm }
placeholder="Search"
variant="unstyled"
color={ themeColors['input.foreground'] }
bgColor={ themeColors['input.background'] }
borderRadius="none"
fontSize="13px"
lineHeight="20px"
borderWidth="1px"
borderColor={ themeColors['input.background'] }
py="2px"
px="4px"
transitionDuration="0"
_focus={{
borderColor: themeColors.focusBorder,
}}
/>
<InputRightElement w="auto" h="auto" right="12px" top="3px" columnGap="2px">
<Box
{ ...buttonProps }
className="codicon codicon-case-sensitive"
onClick={ setMatchCase.toggle }
bgColor={ isMatchCase ? themeColors['custom.inputOption.activeBackground'] : 'transparent' }
_hover={{ bgColor: isMatchCase ? themeColors['custom.inputOption.activeBackground'] : themeColors['custom.inputOption.hoverBackground'] }}
title="Match Case"
aria-label="Match Case"
/>
<Box
{ ...buttonProps }
className="codicon codicon-whole-word"
bgColor={ isMatchWholeWord ? themeColors['custom.inputOption.activeBackground'] : 'transparent' }
onClick={ setMatchWholeWord.toggle }
_hover={{ bgColor: isMatchWholeWord ? themeColors['custom.inputOption.activeBackground'] : themeColors['custom.inputOption.hoverBackground'] }}
title="Match Whole Word"
aria-label="Match Whole Word"
/>
<Box
{ ...buttonProps }
className="codicon codicon-regex"
bgColor={ isMatchRegex ? themeColors['custom.inputOption.activeBackground'] : 'transparent' }
onClick={ setMatchRegex.toggle }
_hover={{ bgColor: isMatchRegex ? themeColors['custom.inputOption.activeBackground'] : themeColors['custom.inputOption.hoverBackground'] }}
title="Use Regular Expression"
aria-label="Use Regular Expression"
/>
</InputRightElement>
</InputGroup>
{ searchResultNum }
<Accordion
key={ debouncedSearchTerm }
allowMultiple
index={ expandedSections }
onChange={ handleAccordionStateChange }
reduceMotion
>
{ searchResults.map((item) => <CodeEditorSearchSection key={ item.file_path } data={ item } onItemClick={ handleResultItemClick }/>) }
</Accordion>
</Box>
);
};
export default React.memo(CodeEditorSearch);
import { Box, chakra } from '@chakra-ui/react';
import React from 'react';
import type { SearchResult } from './types';
import type ArrayElement from 'types/utils/ArrayElement';
import useThemeColors from './utils/useThemeColors';
interface Props extends ArrayElement<SearchResult['matches']> {
filePath: string;
onClick: (event: React.MouseEvent) => void;
}
const calculateStartPosition = (lineContent: string, startColumn: number) => {
let start = 0;
for (let index = 0; index < startColumn; index++) {
const element = lineContent[index];
if (element === ' ') {
start = index + 1;
continue;
}
}
return start ? start - 1 : 0;
};
const CodeEditorSearchResultItem = ({ lineContent, filePath, onClick, startLineNumber, startColumn, endColumn }: Props) => {
const start = calculateStartPosition(lineContent, startColumn);
const themeColors = useThemeColors();
return (
<Box
pr="8px"
pl="36px"
fontSize="13px"
lineHeight="22px"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
cursor="pointer"
data-file-path={ filePath }
data-line-number={ startLineNumber }
onClick={ onClick }
transitionDuration="0"
_hover={{ bgColor: themeColors['custom.list.hoverBackground'] }}
>
<span>{ lineContent.slice(start, startColumn - 1) }</span>
<chakra.span bgColor={ themeColors['custom.findMatchHighlightBackground'] }>
{ lineContent.slice(startColumn - 1, endColumn - 1) }
</chakra.span>
<span>{ lineContent.slice(endColumn - 1) }</span>
</Box>
);
};
export default React.memo(CodeEditorSearchResultItem);
import { AccordionButton, AccordionItem, AccordionPanel, Icon, Box } from '@chakra-ui/react';
import React from 'react';
import type { SearchResult } from './types';
import CodeEditorSearchResultItem from './CodeEditorSearchResultItem';
import iconFile from './icons/file.svg';
import iconSolidity from './icons/solidity.svg';
import getFileName from './utils/getFileName';
import useThemeColors from './utils/useThemeColors';
interface Props {
data: SearchResult;
onItemClick: (filePath: string, lineNumber: number) => void;
}
const CodeEditorSearchSection = ({ data, onItemClick }: Props) => {
const fileName = getFileName(data.file_path);
const handleFileLineClick = React.useCallback((event: React.MouseEvent) => {
const lineNumber = Number((event.currentTarget as HTMLDivElement).getAttribute('data-line-number'));
if (!Object.is(lineNumber, NaN)) {
onItemClick(data.file_path, Number(lineNumber));
}
}, [ data.file_path, onItemClick ]);
const icon = /.sol|.yul|.vy$/.test(fileName) ? iconSolidity : iconFile;
const themeColors = useThemeColors();
return (
<AccordionItem borderWidth="0px" _last={{ borderBottomWidth: '0px' }} >
{ ({ isExpanded }) => (
<>
<AccordionButton
py={ 0 }
px={ 2 }
_hover={{ bgColor: themeColors['custom.list.hoverBackground'] }}
fontSize="13px"
transitionDuration="0"
lineHeight="22px"
alignItems="center"
>
<Box
className="codicon codicon-tree-item-expanded"
transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' }
width="20px"
height="22px"
py="3px"
/>
<Icon as={ icon } boxSize="16px" mr="4px"/>
<span>{ fileName }</span>
<Box className="monaco-count-badge" ml="auto" bgColor={ themeColors['badge.background'] }>{ data.matches.length }</Box>
</AccordionButton>
<AccordionPanel p={ 0 }>
{ data.matches.map((match) => (
<CodeEditorSearchResultItem
key={ data.file_path + '_' + match.startLineNumber + '_' + match.startColumn }
filePath={ data.file_path }
onClick={ handleFileLineClick }
{ ...match }
/>
),
) }
</AccordionPanel>
</>
) }
</AccordionItem>
);
};
export default React.memo(CodeEditorSearchSection);
import type { HTMLChakraProps } from '@chakra-ui/react';
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs, useBoolean } from '@chakra-ui/react';
import _throttle from 'lodash/throttle';
import React from 'react';
import type { File, Monaco } from './types';
import CodeEditorFileExplorer from './CodeEditorFileExplorer';
import CodeEditorSearch from './CodeEditorSearch';
import useThemeColors from './utils/useThemeColors';
interface Props {
monaco: Monaco | undefined;
data: Array<File>;
onFileSelect: (index: number, lineNumber?: number) => void;
selectedFile: string;
}
export const CONTAINER_WIDTH = 250;
const CodeEditorSideBar = ({ onFileSelect, data, monaco, selectedFile }: Props) => {
const [ isStuck, setIsStuck ] = React.useState(false);
const [ isDrawerOpen, setIsDrawerOpen ] = useBoolean(false);
const [ tabIndex, setTabIndex ] = React.useState(0);
const [ actionBarRenderer, setActionBarRenderer ] = React.useState<() => JSX.Element>();
const themeColors = useThemeColors();
const tabProps: HTMLChakraProps<'button'> = {
fontFamily: 'heading',
textTransform: 'uppercase',
fontSize: '11px',
lineHeight: '35px',
fontWeight: 500,
color: themeColors['tab.inactiveForeground'],
_selected: {
color: themeColors['tab.activeForeground'],
},
px: 0,
letterSpacing: 0.3,
};
const handleScrollThrottled = React.useRef(_throttle((event: React.SyntheticEvent) => {
setIsStuck((event.target as HTMLDivElement).scrollTop > 0);
}, 100));
const handleFileSelect = React.useCallback((index: number, lineNumber?: number) => {
isDrawerOpen && setIsDrawerOpen.off();
onFileSelect(index, lineNumber);
}, [ isDrawerOpen, onFileSelect, setIsDrawerOpen ]);
return (
<>
<Box
w={ `${ CONTAINER_WIDTH }px` }
flexShrink={ 0 }
bgColor={ themeColors['sideBar.background'] }
fontSize="13px"
overflowY="scroll"
onScroll={ handleScrollThrottled.current }
position={{ base: 'absolute', lg: 'relative' }}
right={{ base: isDrawerOpen ? '0' : `-${ CONTAINER_WIDTH }px`, lg: '0' }}
top={{ base: 0, lg: undefined }}
h="100%"
pb="22px"
boxShadow={{ base: isDrawerOpen ? 'md' : 'none', lg: 'none' }}
zIndex={{ base: '2', lg: undefined }}
transitionProperty="right"
transitionDuration="normal"
transitionTimingFunction="ease-in-out"
borderTopRightRadius="md"
borderBottomRightRadius="md"
>
<Tabs isLazy lazyBehavior="keepMounted" variant="unstyled" size="13px" index={ tabIndex } onChange={ setTabIndex }>
<TabList
columnGap={ 3 }
position="sticky"
top={ 0 }
left={ 0 }
bgColor={ themeColors['sideBar.background'] }
zIndex="1"
px={ 2 }
boxShadow={ isStuck ? 'md' : 'none' }
borderTopRightRadius="md"
>
<Tab { ...tabProps }>Explorer</Tab>
<Tab { ...tabProps }>Search</Tab>
{ actionBarRenderer?.() }
</TabList>
<TabPanels>
<TabPanel p={ 0 }>
<CodeEditorFileExplorer
data={ data }
onFileSelect={ handleFileSelect }
selectedFile={ selectedFile }
isActive={ tabIndex === 0 }
setActionBarRenderer={ setActionBarRenderer }
/>
</TabPanel>
<TabPanel p={ 0 }>
<CodeEditorSearch
data={ data }
onFileSelect={ handleFileSelect }
monaco={ monaco }
isInputStuck={ isStuck }
isActive={ tabIndex === 1 }
setActionBarRenderer={ setActionBarRenderer }
/>
</TabPanel>
</TabPanels>
</Tabs>
</Box>
<Box
boxSize="24px"
p="4px"
position="absolute"
display={{ base: 'block', lg: 'none' }}
right={ isDrawerOpen ? `${ CONTAINER_WIDTH - 1 }px` : '0' }
top="calc(50% - 12px)"
backgroundColor={ themeColors['sideBar.background'] }
borderTopLeftRadius="4px"
borderBottomLeftRadius="4px"
boxShadow="md"
onClick={ setIsDrawerOpen.toggle }
zIndex="1"
transitionProperty="right"
transitionDuration="normal"
transitionTimingFunction="ease-in-out"
title={ isDrawerOpen ? 'Open sidebar' : 'Close sidebar' }
aria-label={ isDrawerOpen ? 'Open sidebar' : 'Close sidebar' }
>
<Box
className="codicon codicon-tree-item-expanded"
transform={ isDrawerOpen ? 'rotate(-90deg)' : 'rotate(+90deg)' }
boxSize="16px"
/>
</Box>
</>
);
};
export default React.memo(CodeEditorSideBar);
import { Flex, Icon, chakra, Box } from '@chakra-ui/react';
import React from 'react';
import useThemeColors from 'ui/shared/monaco/utils/useThemeColors';
import iconFile from './icons/file.svg';
import iconSolidity from './icons/solidity.svg';
import getFilePathParts from './utils/getFilePathParts';
interface Props {
isActive?: boolean;
path: string;
onClick: (path: string) => void;
onClose: (path: string) => void;
isCloseDisabled: boolean;
tabsPathChunks: Array<Array<string>>;
}
const CodeEditorTab = ({ isActive, path, onClick, onClose, isCloseDisabled, tabsPathChunks }: Props) => {
const [ fileName, folderName ] = getFilePathParts(path, tabsPathChunks);
const themeColors = useThemeColors();
const handleClick = React.useCallback(() => {
onClick(path);
}, [ onClick, path ]);
const handleClose = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
!isCloseDisabled && onClose(path);
}, [ isCloseDisabled, onClose, path ]);
const icon = /.sol|.yul|.vy$/.test(fileName) ? iconSolidity : iconFile;
return (
<Flex
pl="10px"
pr="4px"
fontSize="13px"
lineHeight="34px"
bgColor={ isActive ? themeColors['tab.activeBackground'] : themeColors['tab.inactiveBackground'] }
borderRightWidth="1px"
borderRightColor={ themeColors['tab.border'] }
borderBottomWidth="1px"
borderBottomColor={ isActive ? 'transparent' : themeColors['tab.border'] }
color={ isActive ? themeColors['tab.activeForeground'] : themeColors['tab.inactiveForeground'] }
alignItems="center"
fontWeight={ 400 }
cursor="pointer"
onClick={ handleClick }
_hover={{
'.codicon-close': {
visibility: 'visible',
},
}}
userSelect="none"
>
<Icon as={ icon } boxSize="16px" mr="4px"/>
<span>{ fileName }</span>
{ folderName && <chakra.span fontSize="11px" opacity={ 0.8 } ml={ 1 }>{ folderName[0] === '.' ? '' : '...' }{ folderName }</chakra.span> }
<Box
className="codicon codicon-close"
boxSize="20px"
ml="4px"
p="2px"
title="Close"
aria-label="Close"
onClick={ handleClose }
borderRadius="sm"
opacity={ isCloseDisabled ? 0.3 : 1 }
visibility={{ base: 'visible', lg: isActive ? 'visible' : 'hidden' }}
color={ themeColors['icon.foreground'] }
_hover={{ bgColor: isCloseDisabled ? 'transparent' : themeColors['custom.inputOption.hoverBackground'] }}
/>
</Flex>
);
};
export default React.memo(CodeEditorTab);
import { Flex } from '@chakra-ui/react';
import React from 'react';
import CodeEditorTab from './CodeEditorTab';
import useThemeColors from './utils/useThemeColors';
interface Props {
tabs: Array<string>;
activeTab: string;
onTabSelect: (tab: string) => void;
onTabClose: (tab: string) => void;
}
const CodeEditorTabs = ({ tabs, activeTab, onTabSelect, onTabClose }: Props) => {
const themeColors = useThemeColors();
const tabsPathChunks = React.useMemo(() => {
return tabs.map((tab) => tab.split('/'));
}, [ tabs ]);
return (
<Flex
bgColor={ themeColors['sideBar.background'] }
flexWrap="wrap"
>
{ tabs.map((tab) => (
<CodeEditorTab
key={ tab }
path={ tab }
isActive={ activeTab === tab }
onClick={ onTabSelect }
onClose={ onTabClose }
isCloseDisabled={ tabs.length === 1 }
tabsPathChunks={ tabsPathChunks }
/>
)) }
</Flex>
);
};
export default React.memo(CodeEditorTabs);
import { Box } from '@chakra-ui/react';
import React from 'react';
import useThemeColors from './utils/useThemeColors';
interface Props {
onClick: () => void;
label: string;
isDisabled?: boolean;
isCollapsed?: boolean;
}
const CoderEditorCollapseButton = ({ onClick, label, isDisabled, isCollapsed }: Props) => {
const themeColors = useThemeColors();
return (
<Box
ml="auto"
alignSelf="center"
className={ isCollapsed ? 'codicon codicon-search-expand-results' : 'codicon codicon-collapse-all' }
opacity={ isDisabled ? 0.6 : 1 }
boxSize="20px"
p="2px"
borderRadius="sm"
_before={{
content: isCollapsed ? '"\\eb95"' : '"\\eac5"',
}}
_hover={{
bgColor: themeColors['custom.inputOption.hoverBackground'],
}}
onClick={ onClick }
cursor="pointer"
title={ label }
aria-label={ label }
/>
);
};
export default React.memo(CoderEditorCollapseButton);
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M13 9h5.5L13 3.5V9M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m5 2H6v16h12v-9h-7V4z" fill="#42a5f5"/>
</svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7a2 2 0 0 1 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#90a4ae"/>
</svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M10 4H4c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#90a4ae"/>
</svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="#0288d1">
<path d="m5.747 14.046 6.254 8.61 6.252-8.61-6.254 3.807z"/>
<path d="M11.999 1.343 5.747 11.83l6.252 3.807 6.253-3.807z"/>
</g>
</svg>
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
export interface File {
file_path: string;
source_code: string;
}
export interface FileTreeFile extends File {
name: string;
}
export interface FileTreeFolder {
name: string;
children: 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 }
>;
}
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
export default function addFileImportDecorations(model: monaco.editor.ITextModel) {
const matches = model.findMatches('^import "((\\/|\\.)(\\w|\\.|\\/|-)+)"', false, true, false, null, true);
const decorations: Array<monaco.editor.IModelDeltaDecoration> = matches.map(({ range }) => ({
range: {
...range,
startColumn: range.startColumn + 8,
endColumn: range.endColumn - 1,
},
options: {
inlineClassName: 'import-link',
hoverMessage: {
value: 'Cmd/Win + click to open file',
},
},
}));
model.deltaDecorations([], decorations);
}
import composeFileTree from './composeFileTree';
const files = [
{
file_path: 'index.sol',
source_code: 'zero',
},
{
file_path: 'contracts/Zeta.eth.sol',
source_code: 'one',
},
{
file_path: '/_openzeppelin/contracts/utils/Context.sol',
source_code: 'two',
},
{
file_path: '/_openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol',
source_code: 'three',
},
{
file_path: '/_openzeppelin/contracts/token/ERC20/IERC20.sol',
source_code: 'four',
},
{
file_path: '/_openzeppelin/contracts/token/ERC20/ERC20.sol',
source_code: 'five',
},
];
test('builds correct file tree', () => {
const result = composeFileTree(files);
expect(result).toMatchInlineSnapshot(`
[
{
"children": [
{
"children": [
{
"children": [
{
"children": [
{
"children": [
{
"file_path": "/_openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol",
"name": "IERC20Metadata.sol",
"source_code": "three",
},
],
"name": "extensions",
},
{
"file_path": "/_openzeppelin/contracts/token/ERC20/ERC20.sol",
"name": "ERC20.sol",
"source_code": "five",
},
{
"file_path": "/_openzeppelin/contracts/token/ERC20/IERC20.sol",
"name": "IERC20.sol",
"source_code": "four",
},
],
"name": "ERC20",
},
],
"name": "token",
},
{
"children": [
{
"file_path": "/_openzeppelin/contracts/utils/Context.sol",
"name": "Context.sol",
"source_code": "two",
},
],
"name": "utils",
},
],
"name": "contracts",
},
],
"name": "_openzeppelin",
},
{
"children": [
{
"file_path": "contracts/Zeta.eth.sol",
"name": "Zeta.eth.sol",
"source_code": "one",
},
],
"name": "contracts",
},
{
"file_path": "index.sol",
"name": "index.sol",
"source_code": "zero",
},
]
`);
});
import type { File, FileTree } from '../types';
import stripLeadingSlash from 'lib/stripLeadingSlash';
import sortFileTree from './sortFileTree';
export default function composeFileTree(files: Array<File>) {
const result: FileTree = [];
type Level = {
result: FileTree;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} & Record<string, any>;
const level: Level = { result };
files.forEach((file) => {
const path = stripLeadingSlash(file.file_path);
const segments = path.split('/');
segments.reduce((acc, segment, currentIndex, array) => {
if (!acc[segment]) {
acc[segment] = { result: [] };
acc.result.push({
name: segment,
...(currentIndex === array.length - 1 ? file : { children: acc[segment].result }),
});
}
acc.result.sort(sortFileTree);
return acc[segment];
}, level);
});
return result.sort(sortFileTree);
}
// ensure that path always starts with /
export default function formatFilePath(path: string) {
if (path[0] === '.' && path[1] === '/') {
return path.slice(1);
}
if (path[0] === '/') {
return path;
}
return '/' + path;
}
export default function getFileName(path: string) {
const chunks = path.split('/');
return chunks[chunks.length - 1];
}
import getFilePathParts from './getFilePathParts';
it('computes correct chunks if all file name are unique', () => {
const result = getFilePathParts(
'/src/utils/Ownable.sol',
[
'/src/utils/EIP712.sol'.split('/'),
'/src/token/BaseERC20.sol'.split('/'),
],
);
expect(result).toEqual([ 'Ownable.sol', undefined ]);
});
it('computes correct chunks if files with the same name is not in folders with the same name', () => {
const result = getFilePathParts(
'/src/utils/Ownable.sol',
[
'/src/utils/EIP712.sol'.split('/'),
'/lib/_openzeppelin/contracts/contracts/access/Ownable.sol'.split('/'),
],
);
expect(result).toEqual([ 'Ownable.sol', '/utils' ]);
});
it('computes correct chunks if files with the same name is in folders with the same name', () => {
const result = getFilePathParts(
'/src/utils/Ownable.sol',
[
'/src/utils/EIP712.sol'.split('/'),
'/lib/_openzeppelin/contracts/src/utils/Ownable.sol'.split('/'),
],
);
expect(result).toEqual([ 'Ownable.sol', './src/utils' ]);
});
it('computes correct chunks if file is in root directory', () => {
const result = getFilePathParts(
'/Ownable.sol',
[
'/src/utils/EIP712.sol'.split('/'),
'/lib/_openzeppelin/contracts/src/utils/Ownable.sol'.split('/'),
],
);
expect(result).toEqual([ 'Ownable.sol', './' ]);
});
export default function getFilePathParts(path: string, tabsPathChunks: Array<Array<string>>): [string, string | undefined] {
const chunks = path.split('/');
const fileName = chunks[chunks.length - 1];
const folderName = getFolderName(chunks, tabsPathChunks);
return [ fileName, folderName ];
}
function getFolderName(chunks: Array<string>, tabsPathChunks: Array<Array<string>>): string | undefined {
const fileName = chunks[chunks.length - 1];
const otherTabsPathChunks = tabsPathChunks.filter((item) => item.join('/') !== chunks.join('/'));
const tabsWithSameFileName = otherTabsPathChunks.filter((tabChunks) => tabChunks[tabChunks.length - 1] === fileName);
if (tabsWithSameFileName.length === 0 || chunks.length <= 1) {
return;
}
if (chunks.length === 2) {
return './' + chunks[chunks.length - 2];
}
let result = '/' + chunks[chunks.length - 2];
for (let index = 3; index <= chunks.length; index++) {
const element = chunks[chunks.length - index];
if (element === '') {
result = '.' + result;
}
const subFolderNames = tabsWithSameFileName.map((tab) => tab[tab.length - index]);
if (subFolderNames.includes(element)) {
result = '/' + element + result;
} else {
break;
}
}
return result;
}
import getFullPathOfImportedFile from './getFullPathOfImportedFile';
it('construct correct absolute path', () => {
const result = getFullPathOfImportedFile(
'/foo/bar/baz/index.sol',
'./.././../abc/contract.sol',
);
expect(result).toBe('/foo/abc/contract.sol');
});
it('returns undefined if imported file is outside the base file folder', () => {
const result = getFullPathOfImportedFile(
'/index.sol',
'../../abc/contract.sol',
);
expect(result).toBeUndefined();
});
it('returns unmodified path if it is already absolute', () => {
const result = getFullPathOfImportedFile(
'/index.sol',
'/abc/contract.sol',
);
expect(result).toBe('/abc/contract.sol');
});
it('returns undefined for external path', () => {
const result = getFullPathOfImportedFile(
'/index.sol',
'https://github.com/ethereum/dapp/contract.sol',
);
expect(result).toBeUndefined();
});
import stripLeadingSlash from 'lib/stripLeadingSlash';
export default function getFullPathOfImportedFile(baseFilePath: string, importedFilePath: string) {
if (importedFilePath[0] === '/') {
return importedFilePath;
}
if (importedFilePath[0] !== '.') {
return;
}
const baseFileChunks = stripLeadingSlash(baseFilePath).split('/');
const importedFileChunks = importedFilePath.split('/');
const result: Array<string> = baseFileChunks.slice(0, -1);
for (let index = 0; index < importedFileChunks.length - 1; index++) {
const element = importedFileChunks[index];
if (element === '.') {
continue;
}
if (element === '..') {
if (result.length === 0) {
break;
}
result.pop();
continue;
}
result.push(element);
}
if (result.length === 0) {
return;
}
result.push(importedFileChunks[importedFileChunks.length - 1]);
return '/' + result.join('/');
}
import type { FileTree } from '../types';
import type ArrayElement from 'types/utils/ArrayElement';
export default function sortFileTree(a: ArrayElement<FileTree>, b: ArrayElement<FileTree>) {
if ('children' in a && !('children' in b)) {
return -1;
}
if ('children' in b && !('children' in a)) {
return 1;
}
return a.name.localeCompare(b.name);
}
export const light = {
base: 'vs' as const,
inherit: true,
rules: [],
colors: {
'editor.background': '#f5f5f6',
'editorWidget.background': '#f5f5f6',
'tab.activeBackground': '#f5f5f6',
'tab.inactiveBackground': 'rgb(236, 236, 236)',
'tab.activeForeground': '#101112', // black
'tab.inactiveForeground': '#4a5568', // gray.600
'tab.border': 'rgb(243, 243, 243)',
'icon.foreground': '#616161',
'input.foreground': '#616161',
'input.background': '#fff',
'list.inactiveSelectionBackground': '#e4e6f1',
'breadcrumbs.foreground': 'rgb(97, 97, 97)',
'badge.background': '#c4c4c4',
'sideBar.background': '#eee',
focusBorder: '#0090f1',
// not able to use rgba for standard variables, so we use custom prefix here
'custom.list.hoverBackground': 'rgba(16, 17, 18, 0.08)', // blackAlpha.200
'custom.findMatchHighlightBackground': 'rgba(234,92,0,0.33)',
'custom.inputOption.activeBackground': 'rgba(0, 144, 241, 0.2)',
'custom.inputOption.hoverBackground': 'rgba(184, 184, 184, 0.31)',
// don't know the name of this variables in vscode
'custom.fileLink.hoverForeground': '#4299E1', // blue.400
} as const,
};
export const dark = {
base: 'vs-dark' as const,
inherit: true,
rules: [],
colors: {
'editor.background': '#1a1b1b',
'editorWidget.background': '#1a1b1b',
'tab.activeBackground': '#1a1b1b', // black
'tab.inactiveBackground': 'rgb(45, 45, 45)',
'tab.activeForeground': '#fff', // white
'tab.inactiveForeground': '#a0aec0', // gray.400
'tab.border': 'rgb(37, 37, 38)',
'icon.foreground': '#616161',
'input.foreground': '#cccccc',
'input.background': '#3c3c3c',
'list.inactiveSelectionBackground': '#37373d',
'badge.background': '#4d4d4d',
'breadcrumbs.foreground': 'rgb(97, 97, 97)',
'sideBar.background': '#222',
focusBorder: '#007fd4',
// not able to use rgba for standard variables, so we use custom prefix here
'custom.list.hoverBackground': 'rgba(255, 255, 255, 0.08)', // whiteAlpha.200
'custom.findMatchHighlightBackground': 'rgba(234,92,0,0.33)',
'custom.inputOption.activeBackground': 'rgba(0, 127, 212, 0.4)',
'custom.inputOption.hoverBackground': 'rgba(90, 93, 94, 0.31)',
// don't know the name of this variables in vscode
'custom.fileLink.hoverForeground': '#4299E1', // blue.400
} as const,
};
import { useColorModeValue } from '@chakra-ui/react';
import * as themes from './themes';
export default function useThemeColors() {
const theme = useColorModeValue(themes.light, themes.dark);
return theme.colors;
}
This diff is collapsed.
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