Commit af0156eb authored by tom goriunov's avatar tom goriunov Committed by GitHub

contract source code: warnings (#981)

* add decoration for external library warning

* add decoration for same name warning

* formatted tooltip text

* show source code even if contract is not verified

* add support for remappings context in file import resolution

* library warning decoration styles

* external libraries popover

* disable contract name warning for a while

* add tab to contract page link

* change api host for preview

* add main file indicator

* change button styles and update screenshots

* change isNeedProxy for passing pw tests

* rollback review env changes
parent 05e381ca
...@@ -6,6 +6,8 @@ import type { WalletType } from 'types/client/wallets'; ...@@ -6,6 +6,8 @@ import type { WalletType } from 'types/client/wallets';
import type { NetworkExplorer } from 'types/networks'; import type { NetworkExplorer } from 'types/networks';
import type { ChainIndicatorId } from 'ui/home/indicators/types'; import type { ChainIndicatorId } from 'ui/home/indicators/types';
import stripTrailingSlash from 'lib/stripTrailingSlash';
const getEnvValue = (env: string | undefined) => env?.replaceAll('\'', '"'); const getEnvValue = (env: string | undefined) => env?.replaceAll('\'', '"');
const parseEnvJson = <DataType>(env: string | undefined): DataType | null => { const parseEnvJson = <DataType>(env: string | undefined): DataType | null => {
try { try {
...@@ -15,7 +17,6 @@ const parseEnvJson = <DataType>(env: string | undefined): DataType | null => { ...@@ -15,7 +17,6 @@ const parseEnvJson = <DataType>(env: string | undefined): DataType | null => {
} }
}; };
const stripTrailingSlash = (str: string) => str[str.length - 1] === '/' ? str.slice(0, -1) : str;
const getWeb3DefaultWallet = (): WalletType => { const getWeb3DefaultWallet = (): WalletType => {
const envValue = getEnvValue(process.env.NEXT_PUBLIC_WEB3_DEFAULT_WALLET); const envValue = getEnvValue(process.env.NEXT_PUBLIC_WEB3_DEFAULT_WALLET);
const SUPPORTED_WALLETS: Array<WalletType> = [ const SUPPORTED_WALLETS: Array<WalletType> = [
......
...@@ -63,13 +63,7 @@ frontend: ...@@ -63,13 +63,7 @@ frontend:
NEXT_PUBLIC_APP_INSTANCE: NEXT_PUBLIC_APP_INSTANCE:
_default: eth_goerli _default: eth_goerli
NEXT_PUBLIC_NETWORK_NAME: NEXT_PUBLIC_NETWORK_NAME:
_default: Göerli _default: Blockscout
NEXT_PUBLIC_NETWORK_SHORT_NAME:
_default: Göerli
NEXT_PUBLIC_NETWORK_LOGO:
_default: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/goerli.svg
NEXT_PUBLIC_NETWORK_ICON:
_default: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/goerli.svg
NEXT_PUBLIC_NETWORK_ID: NEXT_PUBLIC_NETWORK_ID:
_default: 5 _default: 5
NEXT_PUBLIC_NETWORK_CURRENCY_NAME: NEXT_PUBLIC_NETWORK_CURRENCY_NAME:
......
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="m17.667 13.75-5.5-9.834c-.417-.833-1.25-1.25-2.167-1.25-.916 0-1.75.417-2.167 1.25l-5.5 9.834a2.656 2.656 0 0 0 0 2.5C2.75 17 3.583 17.5 4.5 17.5h11c.917 0 1.667-.5 2.167-1.25.5-.75.416-1.667 0-2.5Zm-1.417 1.666c-.083.084-.25.417-.75.417h-11c-.417 0-.667-.25-.75-.417-.083-.166-.25-.416 0-.833l5.5-9.916c.25-.417.584-.417.75-.417.167 0 .5 0 .75.417l5.5 9.916c.167.417 0 .75 0 .833Z" fill="currentColor"/>
<path d="M10 7.5c-.5 0-.833.333-.833.833v2.5c0 .5.333.834.833.834.5 0 .834-.334.834-.834v-2.5c0-.5-.334-.833-.834-.833ZM10 14.167a.833.833 0 1 0 0-1.667.833.833 0 0 0 0 1.667Z" fill="currentColor"/>
</svg>
const stripTrailingSlash = (str: string) => str[str.length - 1] === '/' ? str.slice(0, -1) : str;
export default stripTrailingSlash;
import { test as base, expect } from '@playwright/experimental-ct-react'; import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as contractMock from 'mocks/contract/info'; import * as contractMock from 'mocks/contract/info';
import * as socketServer from 'playwright/fixtures/socketServer'; import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl'; import buildApiUrl from 'playwright/utils/buildApiUrl';
import MockAddressPage from 'ui/address/testUtils/MockAddressPage';
import ContractCode from './ContractCode'; import ContractCode from './ContractCode';
...@@ -24,16 +26,30 @@ const test = base.extend<socketServer.SocketServerFixture>({ ...@@ -24,16 +26,30 @@ const test = base.extend<socketServer.SocketServerFixture>({
// test cases which use socket cannot run in parallel since the socket server always run on the same port // test cases which use socket cannot run in parallel since the socket server always run on the same port
test.describe.configure({ mode: 'serial' }); test.describe.configure({ mode: 'serial' });
test('verified with changed byte code +@mobile +@dark-mode', async({ mount, page }) => { test('full view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
await page.route(CONTRACT_API_URL, (route) => route.fulfill({ await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200, status: 200,
body: JSON.stringify(contractMock.withChangedByteCode), body: JSON.stringify(contractMock.withChangedByteCode),
})); }));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
const ADDRESS_API_URL = buildApiUrl('address', { hash: addressHash });
await page.route(ADDRESS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(addressMock.contract),
}));
const PROXY_CONTRACT_API_URL = buildApiUrl('contract', { hash: addressMock.contract.implementation_address as string });
await page.route(PROXY_CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.withChangedByteCode),
}));
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<ContractCode addressHash={ addressHash } noSocket/> <MockAddressPage>
<ContractCode addressHash={ addressHash } noSocket/>
</MockAddressPage>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
...@@ -62,7 +78,7 @@ test('verified with changed byte code socket', async({ mount, page, createSocket ...@@ -62,7 +78,7 @@ test('verified with changed byte code socket', async({ mount, page, createSocket
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('verified with multiple sources +@mobile', async({ mount, page }) => { test('verified with multiple sources', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({ await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200, status: 200,
body: JSON.stringify(contractMock.withMultiplePaths), body: JSON.stringify(contractMock.withMultiplePaths),
...@@ -77,7 +93,9 @@ test('verified with multiple sources +@mobile', async({ mount, page }) => { ...@@ -77,7 +93,9 @@ test('verified with multiple sources +@mobile', async({ mount, page }) => {
); );
const section = page.locator('section', { hasText: 'Contract source code' }); const section = page.locator('section', { hasText: 'Contract source code' });
await expect(section).toHaveScreenshot();
await page.getByRole('button', { name: 'View external libraries' }).click();
await expect(section).toHaveScreenshot(); await expect(section).toHaveScreenshot();
}); });
......
...@@ -108,19 +108,6 @@ const ContractCode = ({ addressHash, noSocket }: Props) => { ...@@ -108,19 +108,6 @@ const ContractCode = ({ addressHash, noSocket }: Props) => {
); );
})(); })();
const externalLibraries = (() => {
if (!data?.external_libraries || data?.external_libraries.length === 0) {
return null;
}
return data.external_libraries.map((item) => (
<Box key={ item.address_hash }>
<chakra.span fontWeight={ 500 }>{ item.name }: </chakra.span>
<LinkInternal href={ route({ pathname: '/address/[hash]', query: { hash: item.address_hash, tab: 'contract' } }) }>{ item.address_hash }</LinkInternal>
</Box>
));
})();
const verificationAlert = (() => { const verificationAlert = (() => {
if (data?.is_verified_via_eth_bytecode_db) { if (data?.is_verified_via_eth_bytecode_db) {
return ( return (
...@@ -201,6 +188,7 @@ const ContractCode = ({ addressHash, noSocket }: Props) => { ...@@ -201,6 +188,7 @@ const ContractCode = ({ addressHash, noSocket }: Props) => {
{ data.optimization_runs && <InfoItem label="Optimization runs" value={ String(data.optimization_runs) } isLoading={ isPlaceholderData }/> } { data.optimization_runs && <InfoItem label="Optimization runs" value={ String(data.optimization_runs) } isLoading={ isPlaceholderData }/> }
{ data.verified_at && { data.verified_at &&
<InfoItem label="Verified at" value={ dayjs(data.verified_at).format('LLLL') } wordBreak="break-word" isLoading={ isPlaceholderData }/> } <InfoItem label="Verified at" value={ dayjs(data.verified_at).format('LLLL') } wordBreak="break-word" isLoading={ isPlaceholderData }/> }
{ data.file_path && <InfoItem label="Contract file path" value={ data.file_path } wordBreak="break-word" isLoading={ isPlaceholderData }/> }
</Grid> </Grid>
) } ) }
<Flex flexDir="column" rowGap={ 6 }> <Flex flexDir="column" rowGap={ 6 }>
...@@ -212,7 +200,7 @@ const ContractCode = ({ addressHash, noSocket }: Props) => { ...@@ -212,7 +200,7 @@ const ContractCode = ({ addressHash, noSocket }: Props) => {
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
/> />
) } ) }
{ data?.is_verified && ( { data?.source_code && (
<ContractSourceCode <ContractSourceCode
address={ addressHash } address={ addressHash }
implementationAddress={ addressInfo?.implementation_address ?? undefined } implementationAddress={ addressInfo?.implementation_address ?? undefined }
...@@ -257,14 +245,6 @@ const ContractCode = ({ addressHash, noSocket }: Props) => { ...@@ -257,14 +245,6 @@ const ContractCode = ({ addressHash, noSocket }: Props) => {
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
/> />
) } ) }
{ externalLibraries && (
<RawDataSnippet
data={ externalLibraries }
title="External Libraries"
textareaMaxHeight="200px"
isLoading={ isPlaceholderData }
/>
) }
</Flex> </Flex>
</> </>
); );
......
import {
Alert,
Box,
Button,
Flex,
Heading,
Icon,
Modal,
ModalCloseButton,
ModalContent,
Popover,
PopoverBody,
PopoverContent,
PopoverTrigger,
StackDivider,
useDisclosure,
VStack,
} from '@chakra-ui/react';
import React from 'react';
import type { SmartContractExternalLibrary } from 'types/api/contract';
import arrowIcon from 'icons/arrows/east-mini.svg';
import iconWarning from 'icons/status/warning.svg';
import useIsMobile from 'lib/hooks/useIsMobile';
import { apos } from 'lib/html-entities';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
interface Props {
className?: string;
data: Array<SmartContractExternalLibrary>;
}
const Item = (data: SmartContractExternalLibrary) => {
return (
<Flex flexDir="column" py={ 2 } w="100%" rowGap={ 1 }>
<Box>{ data.name }</Box>
<Address>
<AddressIcon address={{ hash: data.address_hash, is_contract: true, implementation_name: null }}/>
<AddressLink hash={ data.address_hash } type="address" ml={ 2 } fontWeight={ 500 } fontSize="sm" target="_blank" query={{ tab: 'contract' }}/>
<CopyToClipboard text={ data.address_hash }/>
</Address>
</Flex>
);
};
const ContractExternalLibraries = ({ className, data }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const isMobile = useIsMobile();
if (data.length === 0) {
return null;
}
const button = (
<Button
className={ className }
size="sm"
variant="outline"
colorScheme="gray"
onClick={ onToggle }
fontWeight={ 600 }
px={ 2 }
aria-label="View external libraries"
>
<span>{ data.length } { data.length > 1 ? 'Libraries' : 'Library' } </span>
<Icon as={ iconWarning } boxSize={ 5 } color="orange.400" ml="2px"/>
<Icon as={ arrowIcon } transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 } ml={ 2 }/>
</Button>
);
const content = (
<>
<Heading size="sm">External libraries ({ data.length })</Heading>
<Alert status="warning" mt={ 4 }>
The linked library{ apos }s source code may not be the real one.
Check the source code at the library address (if any) if you want to be sure in case if there is any library linked
</Alert>
<VStack
divider={ <StackDivider borderColor="divider"/> }
spacing={ 2 }
mt={ 4 }
>
{ data.map((item) => <Item key={ item.address_hash } { ...item }/>) }
</VStack>
</>
);
if (isMobile) {
return (
<>
{ button }
<Modal isOpen={ isOpen } onClose={ onClose } size="full">
<ModalContent paddingTop={ 4 }>
<ModalCloseButton/>
{ content }
</ModalContent>
</Modal>
</>
);
}
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
{ button }
</PopoverTrigger>
<PopoverContent w="400px">
<PopoverBody >
{ content }
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default ContractExternalLibraries;
...@@ -12,6 +12,8 @@ import LinkInternal from 'ui/shared/LinkInternal'; ...@@ -12,6 +12,8 @@ import LinkInternal from 'ui/shared/LinkInternal';
import CodeEditor from 'ui/shared/monaco/CodeEditor'; import CodeEditor from 'ui/shared/monaco/CodeEditor';
import formatFilePath from 'ui/shared/monaco/utils/formatFilePath'; import formatFilePath from 'ui/shared/monaco/utils/formatFilePath';
import ContractExternalLibraries from './ContractExternalLibraries';
const SOURCE_CODE_OPTIONS = [ const SOURCE_CODE_OPTIONS = [
{ id: 'primary', label: 'Proxy' } as const, { id: 'primary', label: 'Proxy' } as const,
{ id: 'secondary', label: 'Implementation' } as const, { id: 'secondary', label: 'Implementation' } as const,
...@@ -100,17 +102,17 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => { ...@@ -100,17 +102,17 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
<Tooltip label="Visualize contract code using Sol2Uml JS library"> <Tooltip label="Visualize contract code using Sol2Uml JS library">
<LinkInternal <LinkInternal
href={ route({ pathname: '/visualize/sol2uml', query: { address: diagramLinkAddress } }) } href={ route({ pathname: '/visualize/sol2uml', query: { address: diagramLinkAddress } }) }
ml="auto" ml={{ base: '0', lg: 'auto' }}
> >
<Skeleton isLoaded={ !isLoading }> <Skeleton isLoaded={ !isLoading }>
View UML diagram View UML diagram
</Skeleton> </Skeleton>
</LinkInternal> </LinkInternal>
</Tooltip> </Tooltip>
) : <Box ml="auto"/>; ) : null;
const copyToClipboard = activeContractData?.length === 1 ? const copyToClipboard = activeContractData?.length === 1 ?
<CopyToClipboard text={ activeContractData[0].source_code } isLoading={ isLoading } ml={ 3 }/> : <CopyToClipboard text={ activeContractData[0].source_code } isLoading={ isLoading } ml={{ base: 'auto', lg: diagramLink ? '0' : 'auto' }}/> :
null; null;
const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => { const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
...@@ -124,13 +126,21 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => { ...@@ -124,13 +126,21 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
onChange={ handleSelectChange } onChange={ handleSelectChange }
focusBorderColor="none" focusBorderColor="none"
w="auto" w="auto"
ml={ 3 } fontWeight={ 600 }
borderRadius="base" borderRadius="base"
> >
{ SOURCE_CODE_OPTIONS.map((option) => <option key={ option.id } value={ option.id }>{ option.label }</option>) } { SOURCE_CODE_OPTIONS.map((option) => <option key={ option.id } value={ option.id }>{ option.label }</option>) }
</Select> </Select>
) : null; ) : null;
const externalLibraries = (() => {
if (sourceType === 'secondary') {
return secondaryContractQuery.data?.external_libraries && <ContractExternalLibraries data={ secondaryContractQuery.data.external_libraries }/>;
}
return primaryContractQuery.data?.external_libraries && <ContractExternalLibraries data={ primaryContractQuery.data.external_libraries }/>;
})();
const content = (() => { const content = (() => {
if (isLoading) { if (isLoading) {
return <Skeleton h="557px" w="100%"/>; return <Skeleton h="557px" w="100%"/>;
...@@ -146,7 +156,9 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => { ...@@ -146,7 +156,9 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
<CodeEditor <CodeEditor
data={ primaryEditorData } data={ primaryEditorData }
remappings={ primaryContractQuery.data?.compiler_settings?.remappings } remappings={ primaryContractQuery.data?.compiler_settings?.remappings }
libraries={ primaryContractQuery.data?.external_libraries ?? undefined }
language={ primaryContractQuery.data?.language ?? undefined } language={ primaryContractQuery.data?.language ?? undefined }
mainFile={ primaryEditorData[0]?.file_path }
/> />
</Box> </Box>
{ secondaryEditorData && ( { secondaryEditorData && (
...@@ -154,7 +166,9 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => { ...@@ -154,7 +166,9 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
<CodeEditor <CodeEditor
data={ secondaryEditorData } data={ secondaryEditorData }
remappings={ secondaryContractQuery.data?.compiler_settings?.remappings } remappings={ secondaryContractQuery.data?.compiler_settings?.remappings }
libraries={ secondaryContractQuery.data?.external_libraries ?? undefined }
language={ secondaryContractQuery.data?.language ?? undefined } language={ secondaryContractQuery.data?.language ?? undefined }
mainFile={ secondaryEditorData?.[0]?.file_path }
/> />
</Box> </Box>
) } ) }
...@@ -168,9 +182,10 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => { ...@@ -168,9 +182,10 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
return ( return (
<section> <section>
<Flex justifyContent="space-between" alignItems="center" mb={ 3 }> <Flex alignItems="center" mb={ 3 } columnGap={ 3 } rowGap={ 2 } flexWrap="wrap">
{ heading } { heading }
{ editorSourceTypeSelector } { editorSourceTypeSelector }
{ externalLibraries }
{ diagramLink } { diagramLink }
{ copyToClipboard } { copyToClipboard }
</Flex> </Flex>
......
...@@ -19,6 +19,7 @@ type CommonProps = { ...@@ -19,6 +19,7 @@ type CommonProps = {
alias?: string | null; alias?: string | null;
isLoading?: boolean; isLoading?: boolean;
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void; onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
query?: Record<string, string>;
} }
type AddressTokenTxProps = { type AddressTokenTxProps = {
...@@ -46,15 +47,15 @@ const AddressLink = (props: Props) => { ...@@ -46,15 +47,15 @@ const AddressLink = (props: Props) => {
let url; let url;
if (type === 'transaction') { if (type === 'transaction') {
url = route({ pathname: '/tx/[hash]', query: { hash } }); url = route({ pathname: '/tx/[hash]', query: { ...props.query, hash } });
} else if (type === 'token') { } else if (type === 'token') {
url = route({ pathname: '/token/[hash]', query: { hash } }); url = route({ pathname: '/token/[hash]', query: { ...props.query, hash } });
} else if (type === 'block') { } else if (type === 'block') {
url = route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: props.blockHeight } }); url = route({ pathname: '/block/[height_or_hash]', query: { ...props.query, height_or_hash: props.blockHeight } });
} else if (type === 'address_token') { } else if (type === 'address_token') {
url = route({ pathname: '/address/[hash]', query: { hash, tab: 'token_transfers', token: props.tokenHash, scroll_to_tabs: 'true' } }); url = route({ pathname: '/address/[hash]', query: { ...props.query, hash, tab: 'token_transfers', token: props.tokenHash, scroll_to_tabs: 'true' } });
} else { } else {
url = route({ pathname: '/address/[hash]', query: { hash } }); url = route({ pathname: '/address/[hash]', query: { ...props.query, hash } });
} }
const content = (() => { const content = (() => {
......
import type { SystemStyleObject } from '@chakra-ui/react'; import type { SystemStyleObject } from '@chakra-ui/react';
import { Box, useColorMode, Flex } from '@chakra-ui/react'; import { Box, useColorMode, Flex, useToken } from '@chakra-ui/react';
import type { EditorProps } from '@monaco-editor/react'; import type { EditorProps } from '@monaco-editor/react';
import MonacoEditor from '@monaco-editor/react'; 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, Monaco } from './types'; import type { File, Monaco } from './types';
import type { SmartContractExternalLibrary } from 'types/api/contract';
import useClientRect from 'lib/hooks/useClientRect'; import useClientRect from 'lib/hooks/useClientRect';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
...@@ -15,6 +16,7 @@ import CodeEditorBreadcrumbs from './CodeEditorBreadcrumbs'; ...@@ -15,6 +16,7 @@ import CodeEditorBreadcrumbs from './CodeEditorBreadcrumbs';
import CodeEditorLoading from './CodeEditorLoading'; import CodeEditorLoading from './CodeEditorLoading';
import CodeEditorSideBar, { CONTAINER_WIDTH as SIDE_BAR_WIDTH } from './CodeEditorSideBar'; import CodeEditorSideBar, { CONTAINER_WIDTH as SIDE_BAR_WIDTH } from './CodeEditorSideBar';
import CodeEditorTabs from './CodeEditorTabs'; import CodeEditorTabs from './CodeEditorTabs';
import addExternalLibraryWarningDecoration from './utils/addExternalLibraryWarningDecoration';
import addFileImportDecorations from './utils/addFileImportDecorations'; import addFileImportDecorations from './utils/addFileImportDecorations';
import getFullPathOfImportedFile from './utils/getFullPathOfImportedFile'; import getFullPathOfImportedFile from './utils/getFullPathOfImportedFile';
import * as themes from './utils/themes'; import * as themes from './utils/themes';
...@@ -36,10 +38,12 @@ const EDITOR_HEIGHT = 500; ...@@ -36,10 +38,12 @@ const EDITOR_HEIGHT = 500;
interface Props { interface Props {
data: Array<File>; data: Array<File>;
remappings?: Array<string>; remappings?: Array<string>;
libraries?: Array<SmartContractExternalLibrary>;
language?: string; language?: string;
mainFile?: string;
} }
const CodeEditor = ({ data, remappings, language }: Props) => { const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props) => {
const [ instance, setInstance ] = React.useState<Monaco | undefined>(); const [ instance, setInstance ] = React.useState<Monaco | undefined>();
const [ editor, setEditor ] = React.useState<monaco.editor.IStandaloneCodeEditor | undefined>(); const [ editor, setEditor ] = React.useState<monaco.editor.IStandaloneCodeEditor | undefined>();
const [ index, setIndex ] = React.useState(0); const [ index, setIndex ] = React.useState(0);
...@@ -49,6 +53,7 @@ const CodeEditor = ({ data, remappings, language }: Props) => { ...@@ -49,6 +53,7 @@ const CodeEditor = ({ data, remappings, language }: Props) => {
const [ containerRect, containerNodeRef ] = useClientRect<HTMLDivElement>(); const [ containerRect, containerNodeRef ] = useClientRect<HTMLDivElement>();
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const borderRadius = useToken('radii', 'md');
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const themeColors = useThemeColors(); const themeColors = useThemeColors();
...@@ -74,7 +79,13 @@ const CodeEditor = ({ data, remappings, language }: Props) => { ...@@ -74,7 +79,13 @@ const CodeEditor = ({ data, remappings, language }: Props) => {
.filter((file) => !loadedModelsPaths.includes(file.file_path)) .filter((file) => !loadedModelsPaths.includes(file.file_path))
.map((file) => monaco.editor.createModel(file.source_code, editorLanguage, monaco.Uri.parse(file.file_path))); .map((file) => monaco.editor.createModel(file.source_code, editorLanguage, monaco.Uri.parse(file.file_path)));
loadedModels.concat(newModels).forEach(addFileImportDecorations); if (language === 'solidity') {
loadedModels.concat(newModels)
.forEach((models) => {
addFileImportDecorations(models);
libraries?.length && addExternalLibraryWarningDecoration(models, libraries);
});
}
editor.addAction({ editor.addAction({
id: 'close-tab', id: 'close-tab',
...@@ -174,6 +185,12 @@ const CodeEditor = ({ data, remappings, language }: Props) => { ...@@ -174,6 +185,12 @@ const CodeEditor = ({ data, remappings, language }: Props) => {
width: `${ editorWidth }px`, width: `${ editorWidth }px`,
height: '100%', height: '100%',
}, },
'.monaco-editor': {
'border-bottom-left-radius': borderRadius,
},
'.monaco-editor .overflow-guard': {
'border-bottom-left-radius': borderRadius,
},
'.highlight': { '.highlight': {
backgroundColor: themeColors['custom.findMatchHighlightBackground'], backgroundColor: themeColors['custom.findMatchHighlightBackground'],
}, },
...@@ -182,18 +199,34 @@ const CodeEditor = ({ data, remappings, language }: Props) => { ...@@ -182,18 +199,34 @@ const CodeEditor = ({ data, remappings, language }: Props) => {
textDecoration: 'underline', textDecoration: 'underline',
cursor: 'pointer', cursor: 'pointer',
}, },
}), [ editorWidth, themeColors ]); '.risk-warning-primary': {
backgroundColor: themeColors['custom.riskWarning.primaryBackground'],
},
'.risk-warning': {
backgroundColor: themeColors['custom.riskWarning.background'],
},
}), [ editorWidth, themeColors, borderRadius ]);
if (data.length === 1) { if (data.length === 1) {
const sx = {
...containerSx,
'.monaco-editor': {
'border-radius': borderRadius,
},
'.monaco-editor .overflow-guard': {
'border-radius': borderRadius,
},
};
return ( return (
<Box overflow="hidden" borderRadius="md" height={ `${ EDITOR_HEIGHT }px` }> <Box height={ `${ EDITOR_HEIGHT }px` } sx={ sx }>
<MonacoEditor <MonacoEditor
language={ editorLanguage } language={ editorLanguage }
path={ data[index].file_path } path={ data[index].file_path }
defaultValue={ data[index].source_code } defaultValue={ data[index].source_code }
options={ EDITOR_OPTIONS } options={ EDITOR_OPTIONS }
onMount={ handleEditorDidMount } onMount={ handleEditorDidMount }
loading={ <CodeEditorLoading/> } loading={ <CodeEditorLoading borderRadius="md"/> }
/> />
</Box> </Box>
); );
...@@ -202,19 +235,25 @@ const CodeEditor = ({ data, remappings, language }: Props) => { ...@@ -202,19 +235,25 @@ const CodeEditor = ({ data, remappings, language }: Props) => {
return ( return (
<Flex <Flex
className={ isMetaPressed ? 'meta-pressed' : undefined } className={ isMetaPressed ? 'meta-pressed' : undefined }
overflow="hidden"
borderRadius="md"
width="100%" width="100%"
height={ `${ EDITOR_HEIGHT + TABS_HEIGHT + BREADCRUMBS_HEIGHT }px` } height={ `${ EDITOR_HEIGHT + TABS_HEIGHT + BREADCRUMBS_HEIGHT }px` }
position="relative" position="relative"
ref={ containerNodeRef } ref={ containerNodeRef }
sx={ containerSx } sx={ containerSx }
overflow={{ base: 'hidden', lg: 'visible' }}
borderRadius="md"
onClick={ handleClick } onClick={ handleClick }
onKeyDown={ handleKeyDown } onKeyDown={ handleKeyDown }
onKeyUp={ handleKeyUp } onKeyUp={ handleKeyUp }
> >
<Box flexGrow={ 1 }> <Box flexGrow={ 1 }>
<CodeEditorTabs tabs={ tabs } activeTab={ data[index].file_path } onTabSelect={ handleTabSelect } onTabClose={ handleTabClose }/> <CodeEditorTabs
tabs={ tabs }
activeTab={ data[index].file_path }
mainFile={ mainFile }
onTabSelect={ handleTabSelect }
onTabClose={ handleTabClose }
/>
<CodeEditorBreadcrumbs path={ data[index].file_path }/> <CodeEditorBreadcrumbs path={ data[index].file_path }/>
<MonacoEditor <MonacoEditor
className="editor-container" className="editor-container"
...@@ -224,7 +263,7 @@ const CodeEditor = ({ data, remappings, language }: Props) => { ...@@ -224,7 +263,7 @@ const CodeEditor = ({ data, remappings, language }: Props) => {
defaultValue={ data[index].source_code } defaultValue={ data[index].source_code }
options={ EDITOR_OPTIONS } options={ EDITOR_OPTIONS }
onMount={ handleEditorDidMount } onMount={ handleEditorDidMount }
loading={ <CodeEditorLoading/> } loading={ <CodeEditorLoading borderBottomLeftRadius="md"/> }
/> />
</Box> </Box>
<CodeEditorSideBar <CodeEditorSideBar
...@@ -233,6 +272,7 @@ const CodeEditor = ({ data, remappings, language }: Props) => { ...@@ -233,6 +272,7 @@ const CodeEditor = ({ data, remappings, language }: Props) => {
monaco={ instance } monaco={ instance }
editor={ editor } editor={ editor }
selectedFile={ data[index].file_path } selectedFile={ data[index].file_path }
mainFile={ mainFile }
/> />
</Flex> </Flex>
); );
......
...@@ -11,11 +11,12 @@ interface Props { ...@@ -11,11 +11,12 @@ interface Props {
data: Array<File>; data: Array<File>;
onFileSelect: (index: number) => void; onFileSelect: (index: number) => void;
selectedFile: string; selectedFile: string;
mainFile?: string;
isActive: boolean; isActive: boolean;
setActionBarRenderer: React.Dispatch<React.SetStateAction<(() => JSX.Element) | undefined>>; setActionBarRenderer: React.Dispatch<React.SetStateAction<(() => JSX.Element) | undefined>>;
} }
const CodeEditorFileExplorer = ({ data, onFileSelect, selectedFile, isActive, setActionBarRenderer }: Props) => { const CodeEditorFileExplorer = ({ data, onFileSelect, selectedFile, mainFile, isActive, setActionBarRenderer }: Props) => {
const [ key, setKey ] = React.useState(0); const [ key, setKey ] = React.useState(0);
const tree = React.useMemo(() => { const tree = React.useMemo(() => {
return composeFileTree(data); return composeFileTree(data);
...@@ -46,7 +47,14 @@ const CodeEditorFileExplorer = ({ data, onFileSelect, selectedFile, isActive, se ...@@ -46,7 +47,14 @@ const CodeEditorFileExplorer = ({ data, onFileSelect, selectedFile, isActive, se
return ( return (
<Box> <Box>
<CodeEditorFileTree key={ key } tree={ tree } onItemClick={ handleFileClick } isCollapsed={ key > 0 } selectedFile={ selectedFile }/> <CodeEditorFileTree
key={ key }
tree={ tree }
onItemClick={ handleFileClick }
isCollapsed={ key > 0 }
selectedFile={ selectedFile }
mainFile={ mainFile }
/>
</Box> </Box>
); );
}; };
......
...@@ -5,6 +5,7 @@ import React from 'react'; ...@@ -5,6 +5,7 @@ import React from 'react';
import type { FileTree } from './types'; import type { FileTree } from './types';
import CodeEditorFileIcon from './CodeEditorFileIcon'; import CodeEditorFileIcon from './CodeEditorFileIcon';
import CodeEditorMainFileIndicator from './CodeEditorMainFileIndicator';
import iconFolderOpen from './icons/folder-open.svg'; import iconFolderOpen from './icons/folder-open.svg';
import iconFolder from './icons/folder.svg'; import iconFolder from './icons/folder.svg';
import useThemeColors from './utils/useThemeColors'; import useThemeColors from './utils/useThemeColors';
...@@ -15,9 +16,10 @@ interface Props { ...@@ -15,9 +16,10 @@ interface Props {
isCollapsed?: boolean; isCollapsed?: boolean;
onItemClick: (event: React.MouseEvent) => void; onItemClick: (event: React.MouseEvent) => void;
selectedFile: string; selectedFile: string;
mainFile?: string;
} }
const CodeEditorFileTree = ({ tree, level = 0, onItemClick, isCollapsed, selectedFile }: Props) => { const CodeEditorFileTree = ({ tree, level = 0, onItemClick, isCollapsed, selectedFile, mainFile }: Props) => {
const itemProps: ChakraProps = { const itemProps: ChakraProps = {
borderWidth: '0px', borderWidth: '0px',
cursor: 'pointer', cursor: 'pointer',
...@@ -65,6 +67,7 @@ const CodeEditorFileTree = ({ tree, level = 0, onItemClick, isCollapsed, selecte ...@@ -65,6 +67,7 @@ const CodeEditorFileTree = ({ tree, level = 0, onItemClick, isCollapsed, selecte
onItemClick={ onItemClick } onItemClick={ onItemClick }
isCollapsed={ isCollapsed } isCollapsed={ isCollapsed }
selectedFile={ selectedFile } selectedFile={ selectedFile }
mainFile={ mainFile }
/> />
</AccordionPanel> </AccordionPanel>
</> </>
...@@ -82,6 +85,7 @@ const CodeEditorFileTree = ({ tree, level = 0, onItemClick, isCollapsed, selecte ...@@ -82,6 +85,7 @@ const CodeEditorFileTree = ({ tree, level = 0, onItemClick, isCollapsed, selecte
onClick={ onItemClick } onClick={ onItemClick }
data-file-path={ leaf.file_path } data-file-path={ leaf.file_path }
display="flex" display="flex"
position="relative"
alignItems="center" alignItems="center"
overflow="hidden" overflow="hidden"
_hover={{ _hover={{
...@@ -89,6 +93,13 @@ const CodeEditorFileTree = ({ tree, level = 0, onItemClick, isCollapsed, selecte ...@@ -89,6 +93,13 @@ const CodeEditorFileTree = ({ tree, level = 0, onItemClick, isCollapsed, selecte
}} }}
bgColor={ selectedFile === leaf.file_path ? themeColors['list.inactiveSelectionBackground'] : 'none' } bgColor={ selectedFile === leaf.file_path ? themeColors['list.inactiveSelectionBackground'] : 'none' }
> >
{ mainFile === leaf.file_path && (
<CodeEditorMainFileIndicator
position="absolute"
top={ `${ (22 - 12) / 2 }px` }
left={ `${ (26 - 12 - 2) + (level * 8) }px` }
/>
) }
<CodeEditorFileIcon fileName={ leaf.name } mr="4px"/> <CodeEditorFileIcon fileName={ leaf.name } mr="4px"/>
{ leafName } { leafName }
</AccordionItem> </AccordionItem>
......
import { Center } from '@chakra-ui/react'; import { Center, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
import useThemeColors from './utils/useThemeColors'; import useThemeColors from './utils/useThemeColors';
const CodeEditorLoading = () => { interface Props {
className?: string;
}
const CodeEditorLoading = ({ className }: Props) => {
const themeColors = useThemeColors(); const themeColors = useThemeColors();
return ( return (
<Center bgColor={ themeColors['editor.background'] } w="100%" h="100%"> <Center bgColor={ themeColors['editor.background'] } w="100%" h="100%" overflow="hidden" className={ className }>
<ContentLoader/> <ContentLoader/>
</Center> </Center>
); );
}; };
export default React.memo(CodeEditorLoading); export default React.memo(chakra(CodeEditorLoading));
import { Box, chakra, Icon, Tooltip } from '@chakra-ui/react';
import React from 'react';
import iconStar from 'icons/star_filled.svg';
interface Props {
className?: string;
}
const CodeEditorMainFileIndicator = ({ className }: Props) => {
return (
<Tooltip label="The main file containing verified contract">
<Box className={ className } >
<Icon as={ iconStar } boxSize={ 3 } display="block" color="green.500"/>
</Box>
</Tooltip>
);
};
export default chakra(CodeEditorMainFileIndicator);
...@@ -18,11 +18,12 @@ interface Props { ...@@ -18,11 +18,12 @@ interface Props {
data: Array<File>; data: Array<File>;
onFileSelect: (index: number, lineNumber?: number) => void; onFileSelect: (index: number, lineNumber?: number) => void;
selectedFile: string; selectedFile: string;
mainFile?: string;
} }
export const CONTAINER_WIDTH = 250; export const CONTAINER_WIDTH = 250;
const CodeEditorSideBar = ({ onFileSelect, data, monaco, editor, selectedFile }: Props) => { const CodeEditorSideBar = ({ onFileSelect, data, monaco, editor, selectedFile, mainFile }: Props) => {
const [ isStuck, setIsStuck ] = React.useState(false); const [ isStuck, setIsStuck ] = React.useState(false);
const [ isDrawerOpen, setIsDrawerOpen ] = useBoolean(false); const [ isDrawerOpen, setIsDrawerOpen ] = useBoolean(false);
...@@ -132,6 +133,7 @@ const CodeEditorSideBar = ({ onFileSelect, data, monaco, editor, selectedFile }: ...@@ -132,6 +133,7 @@ const CodeEditorSideBar = ({ onFileSelect, data, monaco, editor, selectedFile }:
data={ data } data={ data }
onFileSelect={ handleFileSelect } onFileSelect={ handleFileSelect }
selectedFile={ selectedFile } selectedFile={ selectedFile }
mainFile={ mainFile }
isActive={ tabIndex === 0 } isActive={ tabIndex === 0 }
setActionBarRenderer={ setActionBarRenderer } setActionBarRenderer={ setActionBarRenderer }
/> />
......
...@@ -5,10 +5,12 @@ import { alt } from 'lib/html-entities'; ...@@ -5,10 +5,12 @@ import { alt } from 'lib/html-entities';
import useThemeColors from 'ui/shared/monaco/utils/useThemeColors'; import useThemeColors from 'ui/shared/monaco/utils/useThemeColors';
import CodeEditorFileIcon from './CodeEditorFileIcon'; import CodeEditorFileIcon from './CodeEditorFileIcon';
import CodeEditorMainFileIndicator from './CodeEditorMainFileIndicator';
import getFilePathParts from './utils/getFilePathParts'; import getFilePathParts from './utils/getFilePathParts';
interface Props { interface Props {
isActive?: boolean; isActive?: boolean;
isMainFile?: boolean;
path: string; path: string;
onClick: (path: string) => void; onClick: (path: string) => void;
onClose: (path: string) => void; onClose: (path: string) => void;
...@@ -16,7 +18,7 @@ interface Props { ...@@ -16,7 +18,7 @@ interface Props {
tabsPathChunks: Array<Array<string>>; tabsPathChunks: Array<Array<string>>;
} }
const CodeEditorTab = ({ isActive, path, onClick, onClose, isCloseDisabled, tabsPathChunks }: Props) => { const CodeEditorTab = ({ isActive, isMainFile, path, onClick, onClose, isCloseDisabled, tabsPathChunks }: Props) => {
const [ fileName, folderName ] = getFilePathParts(path, tabsPathChunks); const [ fileName, folderName ] = getFilePathParts(path, tabsPathChunks);
const themeColors = useThemeColors(); const themeColors = useThemeColors();
...@@ -55,6 +57,7 @@ const CodeEditorTab = ({ isActive, path, onClick, onClose, isCloseDisabled, tabs ...@@ -55,6 +57,7 @@ const CodeEditorTab = ({ isActive, path, onClick, onClose, isCloseDisabled, tabs
<CodeEditorFileIcon mr="4px" fileName={ fileName }/> <CodeEditorFileIcon mr="4px" fileName={ fileName }/>
<span>{ fileName }</span> <span>{ fileName }</span>
{ folderName && <chakra.span fontSize="11px" opacity={ 0.8 } ml={ 1 }>{ folderName[0] === '.' ? '' : '...' }{ folderName }</chakra.span> } { folderName && <chakra.span fontSize="11px" opacity={ 0.8 } ml={ 1 }>{ folderName[0] === '.' ? '' : '...' }{ folderName }</chakra.span> }
{ isMainFile && <CodeEditorMainFileIndicator ml={ 2 }/> }
<Box <Box
className="codicon codicon-close" className="codicon codicon-close"
boxSize="20px" boxSize="20px"
......
...@@ -7,11 +7,12 @@ import useThemeColors from './utils/useThemeColors'; ...@@ -7,11 +7,12 @@ import useThemeColors from './utils/useThemeColors';
interface Props { interface Props {
tabs: Array<string>; tabs: Array<string>;
activeTab: string; activeTab: string;
mainFile?: string;
onTabSelect: (tab: string) => void; onTabSelect: (tab: string) => void;
onTabClose: (tab: string) => void; onTabClose: (tab: string) => void;
} }
const CodeEditorTabs = ({ tabs, activeTab, onTabSelect, onTabClose }: Props) => { const CodeEditorTabs = ({ tabs, activeTab, mainFile, onTabSelect, onTabClose }: Props) => {
const themeColors = useThemeColors(); const themeColors = useThemeColors();
const tabsPathChunks = React.useMemo(() => { const tabsPathChunks = React.useMemo(() => {
...@@ -20,6 +21,8 @@ const CodeEditorTabs = ({ tabs, activeTab, onTabSelect, onTabClose }: Props) => ...@@ -20,6 +21,8 @@ const CodeEditorTabs = ({ tabs, activeTab, onTabSelect, onTabClose }: Props) =>
return ( return (
<Flex <Flex
borderTopLeftRadius="md"
overflow="hidden"
bgColor={ themeColors['sideBar.background'] } bgColor={ themeColors['sideBar.background'] }
flexWrap="wrap" flexWrap="wrap"
> >
...@@ -28,6 +31,7 @@ const CodeEditorTabs = ({ tabs, activeTab, onTabSelect, onTabClose }: Props) => ...@@ -28,6 +31,7 @@ const CodeEditorTabs = ({ tabs, activeTab, onTabSelect, onTabClose }: Props) =>
key={ tab } key={ tab }
path={ tab } path={ tab }
isActive={ activeTab === tab } isActive={ activeTab === tab }
isMainFile={ mainFile === tab }
onClick={ onTabSelect } onClick={ onTabSelect }
onClose={ onTabClose } onClose={ onTabClose }
isCloseDisabled={ tabs.length === 1 } isCloseDisabled={ tabs.length === 1 }
......
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import type { SmartContractExternalLibrary } from 'types/api/contract';
export default function addExternalLibraryWarningDecoration(model: monaco.editor.ITextModel, libraries: Array<SmartContractExternalLibrary>) {
const options: monaco.editor.IModelDecorationOptions = {
isWholeLine: true,
hoverMessage: [
{ value: '**This is an external library linked to the verified contract**' },
// eslint-disable-next-line max-len
{ value: 'The linked library source code only affects the bytecode part with external `DELEGATECALL` to the library and it is not possible to automatically ensure that provided library is really the one deployed at specified address. If you want to be sure, check the source code of the library at the given address. (See [issue](https://github.com/blockscout/blockscout-rs/issues/532) for more details)',
},
],
};
const names = libraries.map(getLibraryName(model)).filter(Boolean).join('|');
if (!names) {
return;
}
const [ firstLineMatch ] = model.findMatches(`(^library ${ names })\\s?\\{`, false, true, false, null, true);
if (!firstLineMatch) {
return;
}
const firstLineDecoration: monaco.editor.IModelDeltaDecoration = {
range: {
startColumn: 1,
endColumn: 10, // doesn't really matter since isWholeLine is true
startLineNumber: firstLineMatch.range.startLineNumber,
endLineNumber: firstLineMatch.range.startLineNumber,
},
options: {
...options,
className: '.risk-warning-primary',
marginClassName: '.risk-warning-primary',
},
};
const lastLineRange: monaco.IRange = {
startLineNumber: firstLineMatch.range.startLineNumber,
startColumn: 1,
endColumn: 10,
endLineNumber: model.getLineCount(),
};
const [ lastLineMatch ] = model
.findMatches(`^\\}`, lastLineRange, true, false, null, true)
.sort(sortByEndLineNumberAsc);
const restDecoration: monaco.editor.IModelDeltaDecoration = {
range: {
startLineNumber: firstLineMatch.range.startLineNumber + 1,
endLineNumber: lastLineMatch.range.startLineNumber,
startColumn: 1,
endColumn: 10, // doesn't really matter since isWholeLine is true
},
options: {
...options,
className: '.risk-warning',
marginClassName: '.risk-warning',
},
};
model.deltaDecorations([], [ firstLineDecoration, restDecoration ]);
}
const getLibraryName = (model: monaco.editor.ITextModel) => (library: SmartContractExternalLibrary) => {
const containsFileName = library.name.includes(':');
if (!containsFileName) {
return library.name;
}
const [ fileName, libraryName ] = library.name.split(':');
if (model.uri.path !== `/${ fileName }`) {
return;
}
return libraryName;
};
const sortByEndLineNumberAsc = (a: monaco.editor.FindMatch, b: monaco.editor.FindMatch) => {
if (a.range.endLineNumber < b.range.endLineNumber) {
return -1;
}
if (a.range.endLineNumber > b.range.endLineNumber) {
return 1;
}
return 0;
};
...@@ -28,5 +28,8 @@ export default function addFileImportDecorations(model: monaco.editor.ITextModel ...@@ -28,5 +28,8 @@ export default function addFileImportDecorations(model: monaco.editor.ITextModel
options, options,
})); }));
// TODO: add support for "import * as" - https://docs.soliditylang.org/en/latest/grammar.html#a4.SolidityParser.importDirective
// but we need a live example first to test it
model.deltaDecorations([], regularImportDecorations.concat(namedImportDecorations)); model.deltaDecorations([], regularImportDecorations.concat(namedImportDecorations));
} }
...@@ -47,12 +47,56 @@ describe('returns unmodified path if it is already absolute', () => { ...@@ -47,12 +47,56 @@ describe('returns unmodified path if it is already absolute', () => {
}); });
}); });
it('correctly manages remappings', () => { describe('correctly manages remappings', () => {
const result = getFullPathOfImportedFile( it('without context', () => {
'/index.sol', const result = getFullPathOfImportedFile(
'node_modules/@openzeppelin/contracts/access/AccessControl.sol', '/index.sol',
[ '@ensdomains/=node_modules/@ensdomains/', '@openzeppelin/=node_modules/@openzeppelin/' ], '@openzeppelin/contracts/access/AccessControl.sol',
); [
'@ensdomains/=node_modules/@ensdomains/',
'@openzeppelin/=node_modules/@openzeppelin/',
],
);
expect(result).toBe('/node_modules/@openzeppelin/contracts/access/AccessControl.sol');
});
it('with empty context', () => {
const result = getFullPathOfImportedFile(
'./index.sol',
'@base58-solidity/Base58.sol',
[
'@openzeppelin/=node_modules/@openzeppelin/',
':@base58-solidity/=lib/base58-solidity/contracts/',
],
);
expect(result).toBe('/node_modules/@openzeppelin/contracts/access/AccessControl.sol'); expect(result).toBe('/lib/base58-solidity/contracts/Base58.sol');
});
it('with non-empty context for file inside context directory', () => {
const result = getFullPathOfImportedFile(
'/module_1/index.sol',
'@base58-solidity/Base58.sol',
[
'@openzeppelin/=node_modules/@openzeppelin/',
'module_1:@base58-solidity/=lib/base58-solidity/contracts/',
],
);
expect(result).toBe('/lib/base58-solidity/contracts/Base58.sol');
});
it('with non-empty context for file outside context directory', () => {
const result = getFullPathOfImportedFile(
'/module_2/index.sol',
'@base58-solidity/Base58.sol',
[
'@openzeppelin/=node_modules/@openzeppelin/',
'module_1:@base58-solidity/=lib/base58-solidity/contracts/',
],
);
expect(result).toBe('/@base58-solidity/Base58.sol');
});
}); });
import stripLeadingSlash from 'lib/stripLeadingSlash'; import stripLeadingSlash from 'lib/stripLeadingSlash';
import stripTrailingSlash from 'lib/stripTrailingSlash';
export default function getFullPathOfImportedFile(baseFilePath: string, importedFilePath: string, remappings?: Array<string>) { // FIXME support multiline imports - https://base-goerli.blockscout.com/address/0x3442844D5d4938CA70f8C227dB88F6069C0b82A9?tab=contract
export default function getFullPathOfImportedFile(baseFilePath: string, importedFilePath: string, compilerRemappings?: Array<string>) {
if (importedFilePath[0] !== '.') { if (importedFilePath[0] !== '.') {
let result = importedFilePath; let result = importedFilePath;
if (remappings && remappings.length > 0) { // how remappings work - https://docs.soliditylang.org/en/v0.8.13/path-resolution.html#import-remapping
const [ alias, target ] = remappings.map((item) => item.split('=')).find(([ key ]) => importedFilePath.startsWith(key)) || []; if (compilerRemappings && compilerRemappings.length > 0) {
if (alias) { const remappings = formatCompilerRemappings(compilerRemappings);
result = importedFilePath.replace(alias, target);
const { prefix, target } = remappings.find(({ context, prefix }) => {
if (context) {
const contextPart = '/' + stripLeadingSlash(stripTrailingSlash(context));
return baseFilePath.startsWith(contextPart + '/') && importedFilePath.startsWith(prefix);
}
return importedFilePath.startsWith(prefix);
}) || {};
if (prefix && target) {
result = importedFilePath.replace(prefix, target);
} }
} }
...@@ -43,3 +57,22 @@ export default function getFullPathOfImportedFile(baseFilePath: string, imported ...@@ -43,3 +57,22 @@ export default function getFullPathOfImportedFile(baseFilePath: string, imported
return '/' + result.join('/'); return '/' + result.join('/');
} }
interface Remapping {
context?: string;
prefix: string;
target: string;
}
function formatCompilerRemappings(remappings: Array<string>): Array<Remapping> {
return remappings.map((item) => {
const chunks = item.split(':');
const [ prefix, target ] = chunks[chunks.length - 1].split('=');
return {
context: chunks.length > 1 ? chunks[0] : undefined,
prefix,
target,
};
});
}
...@@ -29,12 +29,14 @@ export const light = { ...@@ -29,12 +29,14 @@ export const light = {
// not able to use rgba for standard variables, so we use custom prefix here // 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.list.hoverBackground': 'rgba(16, 17, 18, 0.08)', // blackAlpha.200
'custom.findMatchHighlightBackground': 'rgba(234,92,0,0.33)', 'custom.findMatchHighlightBackground': 'rgba(198, 246, 213, 1)',
'custom.inputOption.activeBackground': 'rgba(0, 144, 241, 0.2)', 'custom.inputOption.activeBackground': 'rgba(0, 144, 241, 0.2)',
'custom.inputOption.hoverBackground': 'rgba(184, 184, 184, 0.31)', 'custom.inputOption.hoverBackground': 'rgba(184, 184, 184, 0.31)',
// don't know the name of this variables in vscode // don't know the name of this variables in vscode
'custom.fileLink.hoverForeground': '#4299E1', // blue.400 'custom.fileLink.hoverForeground': '#4299E1', // blue.400
'custom.riskWarning.primaryBackground': '#FEEBCB', // orange.100
'custom.riskWarning.background': '#FFFAF0', // orange.50
} as const, } as const,
}; };
...@@ -69,11 +71,13 @@ export const dark = { ...@@ -69,11 +71,13 @@ export const dark = {
// not able to use rgba for standard variables, so we use custom prefix here // 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.list.hoverBackground': 'rgba(255, 255, 255, 0.08)', // whiteAlpha.200
'custom.findMatchHighlightBackground': 'rgba(234,92,0,0.33)', 'custom.findMatchHighlightBackground': 'rgba(34, 84, 61, 1)',
'custom.inputOption.activeBackground': 'rgba(0, 127, 212, 0.4)', 'custom.inputOption.activeBackground': 'rgba(0, 127, 212, 0.4)',
'custom.inputOption.hoverBackground': 'rgba(90, 93, 94, 0.31)', 'custom.inputOption.hoverBackground': 'rgba(90, 93, 94, 0.31)',
// don't know the name of this variables in vscode // don't know the name of this variables in vscode
'custom.fileLink.hoverForeground': '#4299E1', // blue.400 'custom.fileLink.hoverForeground': '#4299E1', // blue.400
'custom.riskWarning.primaryBackground': 'rgba(246, 173, 85, 0.3)', // orange.300
'custom.riskWarning.background': 'rgba(246, 173, 85, 0.1)', // orange.300
} as const, } as const,
}; };
...@@ -31,7 +31,7 @@ const VerifiedContractsListItem = ({ data, isLoading }: Props) => { ...@@ -31,7 +31,7 @@ const VerifiedContractsListItem = ({ data, isLoading }: Props) => {
<ListItemMobile rowGap={ 3 }> <ListItemMobile rowGap={ 3 }>
<Address columnGap={ 2 } overflow="hidden" w="100%"> <Address columnGap={ 2 } overflow="hidden" w="100%">
<AddressIcon address={ data.address } isLoading={ isLoading }/> <AddressIcon address={ data.address } isLoading={ isLoading }/>
<AddressLink hash={ data.address.hash } type="address" alias={ data.address.name } isLoading={ isLoading }/> <AddressLink hash={ data.address.hash } type="address" alias={ data.address.name } isLoading={ isLoading } query={{ tab: 'contract' }}/>
<Skeleton isLoaded={ !isLoading } color="text_secondary" ml="auto"> <Skeleton isLoaded={ !isLoading } color="text_secondary" ml="auto">
<HashStringShorten hash={ data.address.hash } isTooltipDisabled/> <HashStringShorten hash={ data.address.hash } isTooltipDisabled/>
</Skeleton> </Skeleton>
......
...@@ -31,7 +31,7 @@ const VerifiedContractsTableItem = ({ data, isLoading }: Props) => { ...@@ -31,7 +31,7 @@ const VerifiedContractsTableItem = ({ data, isLoading }: Props) => {
<Flex columnGap={ 2 }> <Flex columnGap={ 2 }>
<AddressIcon address={ data.address } isLoading={ isLoading }/> <AddressIcon address={ data.address } isLoading={ isLoading }/>
<Flex columnGap={ 2 } flexWrap="wrap" w="calc(100% - 32px)"> <Flex columnGap={ 2 } flexWrap="wrap" w="calc(100% - 32px)">
<AddressLink hash={ data.address.hash } type="address" alias={ data.address.name } isLoading={ isLoading } my={ 1 }/> <AddressLink hash={ data.address.hash } type="address" alias={ data.address.name } isLoading={ isLoading } my={ 1 } query={{ tab: 'contract' }}/>
<Flex alignItems="center"> <Flex alignItems="center">
<Skeleton isLoaded={ !isLoading } color="text_secondary" my={ 1 }> <Skeleton isLoaded={ !isLoading } color="text_secondary" my={ 1 }>
<HashStringShorten hash={ data.address.hash } isTooltipDisabled/> <HashStringShorten hash={ data.address.hash } isTooltipDisabled/>
......
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