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';
import type { NetworkExplorer } from 'types/networks';
import type { ChainIndicatorId } from 'ui/home/indicators/types';
import stripTrailingSlash from 'lib/stripTrailingSlash';
const getEnvValue = (env: string | undefined) => env?.replaceAll('\'', '"');
const parseEnvJson = <DataType>(env: string | undefined): DataType | null => {
try {
......@@ -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 envValue = getEnvValue(process.env.NEXT_PUBLIC_WEB3_DEFAULT_WALLET);
const SUPPORTED_WALLETS: Array<WalletType> = [
......
......@@ -63,13 +63,7 @@ frontend:
NEXT_PUBLIC_APP_INSTANCE:
_default: eth_goerli
NEXT_PUBLIC_NETWORK_NAME:
_default: Göerli
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
_default: Blockscout
NEXT_PUBLIC_NETWORK_ID:
_default: 5
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 React from 'react';
import * as addressMock from 'mocks/address/address';
import * as contractMock from 'mocks/contract/info';
import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import MockAddressPage from 'ui/address/testUtils/MockAddressPage';
import ContractCode from './ContractCode';
......@@ -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.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({
status: 200,
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(
<TestApp>
<MockAddressPage>
<ContractCode addressHash={ addressHash } noSocket/>
</MockAddressPage>
</TestApp>,
{ hooksConfig },
);
......@@ -62,7 +78,7 @@ test('verified with changed byte code socket', async({ mount, page, createSocket
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({
status: 200,
body: JSON.stringify(contractMock.withMultiplePaths),
......@@ -77,7 +93,9 @@ test('verified with multiple sources +@mobile', async({ mount, page }) => {
);
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();
});
......
......@@ -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 = (() => {
if (data?.is_verified_via_eth_bytecode_db) {
return (
......@@ -201,6 +188,7 @@ const ContractCode = ({ addressHash, noSocket }: Props) => {
{ data.optimization_runs && <InfoItem label="Optimization runs" value={ String(data.optimization_runs) } isLoading={ isPlaceholderData }/> }
{ data.verified_at &&
<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>
) }
<Flex flexDir="column" rowGap={ 6 }>
......@@ -212,7 +200,7 @@ const ContractCode = ({ addressHash, noSocket }: Props) => {
isLoading={ isPlaceholderData }
/>
) }
{ data?.is_verified && (
{ data?.source_code && (
<ContractSourceCode
address={ addressHash }
implementationAddress={ addressInfo?.implementation_address ?? undefined }
......@@ -257,14 +245,6 @@ const ContractCode = ({ addressHash, noSocket }: Props) => {
isLoading={ isPlaceholderData }
/>
) }
{ externalLibraries && (
<RawDataSnippet
data={ externalLibraries }
title="External Libraries"
textareaMaxHeight="200px"
isLoading={ isPlaceholderData }
/>
) }
</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';
import CodeEditor from 'ui/shared/monaco/CodeEditor';
import formatFilePath from 'ui/shared/monaco/utils/formatFilePath';
import ContractExternalLibraries from './ContractExternalLibraries';
const SOURCE_CODE_OPTIONS = [
{ id: 'primary', label: 'Proxy' } as const,
{ id: 'secondary', label: 'Implementation' } as const,
......@@ -100,17 +102,17 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
<Tooltip label="Visualize contract code using Sol2Uml JS library">
<LinkInternal
href={ route({ pathname: '/visualize/sol2uml', query: { address: diagramLinkAddress } }) }
ml="auto"
ml={{ base: '0', lg: 'auto' }}
>
<Skeleton isLoaded={ !isLoading }>
View UML diagram
</Skeleton>
</LinkInternal>
</Tooltip>
) : <Box ml="auto"/>;
) : null;
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;
const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
......@@ -124,13 +126,21 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
onChange={ handleSelectChange }
focusBorderColor="none"
w="auto"
ml={ 3 }
fontWeight={ 600 }
borderRadius="base"
>
{ SOURCE_CODE_OPTIONS.map((option) => <option key={ option.id } value={ option.id }>{ option.label }</option>) }
</Select>
) : 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 = (() => {
if (isLoading) {
return <Skeleton h="557px" w="100%"/>;
......@@ -146,7 +156,9 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
<CodeEditor
data={ primaryEditorData }
remappings={ primaryContractQuery.data?.compiler_settings?.remappings }
libraries={ primaryContractQuery.data?.external_libraries ?? undefined }
language={ primaryContractQuery.data?.language ?? undefined }
mainFile={ primaryEditorData[0]?.file_path }
/>
</Box>
{ secondaryEditorData && (
......@@ -154,7 +166,9 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
<CodeEditor
data={ secondaryEditorData }
remappings={ secondaryContractQuery.data?.compiler_settings?.remappings }
libraries={ secondaryContractQuery.data?.external_libraries ?? undefined }
language={ secondaryContractQuery.data?.language ?? undefined }
mainFile={ secondaryEditorData?.[0]?.file_path }
/>
</Box>
) }
......@@ -168,9 +182,10 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
return (
<section>
<Flex justifyContent="space-between" alignItems="center" mb={ 3 }>
<Flex alignItems="center" mb={ 3 } columnGap={ 3 } rowGap={ 2 } flexWrap="wrap">
{ heading }
{ editorSourceTypeSelector }
{ externalLibraries }
{ diagramLink }
{ copyToClipboard }
</Flex>
......
......@@ -19,6 +19,7 @@ type CommonProps = {
alias?: string | null;
isLoading?: boolean;
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
query?: Record<string, string>;
}
type AddressTokenTxProps = {
......@@ -46,15 +47,15 @@ const AddressLink = (props: Props) => {
let url;
if (type === 'transaction') {
url = route({ pathname: '/tx/[hash]', query: { hash } });
url = route({ pathname: '/tx/[hash]', query: { ...props.query, hash } });
} else if (type === 'token') {
url = route({ pathname: '/token/[hash]', query: { hash } });
url = route({ pathname: '/token/[hash]', query: { ...props.query, hash } });
} 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') {
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 {
url = route({ pathname: '/address/[hash]', query: { hash } });
url = route({ pathname: '/address/[hash]', query: { ...props.query, hash } });
}
const content = (() => {
......
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 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 type { SmartContractExternalLibrary } from 'types/api/contract';
import useClientRect from 'lib/hooks/useClientRect';
import useIsMobile from 'lib/hooks/useIsMobile';
......@@ -15,6 +16,7 @@ import CodeEditorBreadcrumbs from './CodeEditorBreadcrumbs';
import CodeEditorLoading from './CodeEditorLoading';
import CodeEditorSideBar, { CONTAINER_WIDTH as SIDE_BAR_WIDTH } from './CodeEditorSideBar';
import CodeEditorTabs from './CodeEditorTabs';
import addExternalLibraryWarningDecoration from './utils/addExternalLibraryWarningDecoration';
import addFileImportDecorations from './utils/addFileImportDecorations';
import getFullPathOfImportedFile from './utils/getFullPathOfImportedFile';
import * as themes from './utils/themes';
......@@ -36,10 +38,12 @@ const EDITOR_HEIGHT = 500;
interface Props {
data: Array<File>;
remappings?: Array<string>;
libraries?: Array<SmartContractExternalLibrary>;
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 [ editor, setEditor ] = React.useState<monaco.editor.IStandaloneCodeEditor | undefined>();
const [ index, setIndex ] = React.useState(0);
......@@ -49,6 +53,7 @@ const CodeEditor = ({ data, remappings, language }: Props) => {
const [ containerRect, containerNodeRef ] = useClientRect<HTMLDivElement>();
const { colorMode } = useColorMode();
const borderRadius = useToken('radii', 'md');
const isMobile = useIsMobile();
const themeColors = useThemeColors();
......@@ -74,7 +79,13 @@ const CodeEditor = ({ data, remappings, language }: Props) => {
.filter((file) => !loadedModelsPaths.includes(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({
id: 'close-tab',
......@@ -174,6 +185,12 @@ const CodeEditor = ({ data, remappings, language }: Props) => {
width: `${ editorWidth }px`,
height: '100%',
},
'.monaco-editor': {
'border-bottom-left-radius': borderRadius,
},
'.monaco-editor .overflow-guard': {
'border-bottom-left-radius': borderRadius,
},
'.highlight': {
backgroundColor: themeColors['custom.findMatchHighlightBackground'],
},
......@@ -182,18 +199,34 @@ const CodeEditor = ({ data, remappings, language }: Props) => {
textDecoration: 'underline',
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) {
const sx = {
...containerSx,
'.monaco-editor': {
'border-radius': borderRadius,
},
'.monaco-editor .overflow-guard': {
'border-radius': borderRadius,
},
};
return (
<Box overflow="hidden" borderRadius="md" height={ `${ EDITOR_HEIGHT }px` }>
<Box height={ `${ EDITOR_HEIGHT }px` } sx={ sx }>
<MonacoEditor
language={ editorLanguage }
path={ data[index].file_path }
defaultValue={ data[index].source_code }
options={ EDITOR_OPTIONS }
onMount={ handleEditorDidMount }
loading={ <CodeEditorLoading/> }
loading={ <CodeEditorLoading borderRadius="md"/> }
/>
</Box>
);
......@@ -202,19 +235,25 @@ const CodeEditor = ({ data, remappings, language }: Props) => {
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 }
overflow={{ base: 'hidden', lg: 'visible' }}
borderRadius="md"
onClick={ handleClick }
onKeyDown={ handleKeyDown }
onKeyUp={ handleKeyUp }
>
<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 }/>
<MonacoEditor
className="editor-container"
......@@ -224,7 +263,7 @@ const CodeEditor = ({ data, remappings, language }: Props) => {
defaultValue={ data[index].source_code }
options={ EDITOR_OPTIONS }
onMount={ handleEditorDidMount }
loading={ <CodeEditorLoading/> }
loading={ <CodeEditorLoading borderBottomLeftRadius="md"/> }
/>
</Box>
<CodeEditorSideBar
......@@ -233,6 +272,7 @@ const CodeEditor = ({ data, remappings, language }: Props) => {
monaco={ instance }
editor={ editor }
selectedFile={ data[index].file_path }
mainFile={ mainFile }
/>
</Flex>
);
......
......@@ -11,11 +11,12 @@ interface Props {
data: Array<File>;
onFileSelect: (index: number) => void;
selectedFile: string;
mainFile?: string;
isActive: boolean;
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 tree = React.useMemo(() => {
return composeFileTree(data);
......@@ -46,7 +47,14 @@ const CodeEditorFileExplorer = ({ data, onFileSelect, selectedFile, isActive, se
return (
<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>
);
};
......
......@@ -5,6 +5,7 @@ import React from 'react';
import type { FileTree } from './types';
import CodeEditorFileIcon from './CodeEditorFileIcon';
import CodeEditorMainFileIndicator from './CodeEditorMainFileIndicator';
import iconFolderOpen from './icons/folder-open.svg';
import iconFolder from './icons/folder.svg';
import useThemeColors from './utils/useThemeColors';
......@@ -15,9 +16,10 @@ interface Props {
isCollapsed?: boolean;
onItemClick: (event: React.MouseEvent) => void;
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 = {
borderWidth: '0px',
cursor: 'pointer',
......@@ -65,6 +67,7 @@ const CodeEditorFileTree = ({ tree, level = 0, onItemClick, isCollapsed, selecte
onItemClick={ onItemClick }
isCollapsed={ isCollapsed }
selectedFile={ selectedFile }
mainFile={ mainFile }
/>
</AccordionPanel>
</>
......@@ -82,6 +85,7 @@ const CodeEditorFileTree = ({ tree, level = 0, onItemClick, isCollapsed, selecte
onClick={ onItemClick }
data-file-path={ leaf.file_path }
display="flex"
position="relative"
alignItems="center"
overflow="hidden"
_hover={{
......@@ -89,6 +93,13 @@ const CodeEditorFileTree = ({ tree, level = 0, onItemClick, isCollapsed, selecte
}}
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"/>
{ leafName }
</AccordionItem>
......
import { Center } from '@chakra-ui/react';
import { Center, chakra } from '@chakra-ui/react';
import React from 'react';
import ContentLoader from 'ui/shared/ContentLoader';
import useThemeColors from './utils/useThemeColors';
const CodeEditorLoading = () => {
interface Props {
className?: string;
}
const CodeEditorLoading = ({ className }: Props) => {
const themeColors = useThemeColors();
return (
<Center bgColor={ themeColors['editor.background'] } w="100%" h="100%">
<Center bgColor={ themeColors['editor.background'] } w="100%" h="100%" overflow="hidden" className={ className }>
<ContentLoader/>
</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 {
data: Array<File>;
onFileSelect: (index: number, lineNumber?: number) => void;
selectedFile: string;
mainFile?: string;
}
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 [ isDrawerOpen, setIsDrawerOpen ] = useBoolean(false);
......@@ -132,6 +133,7 @@ const CodeEditorSideBar = ({ onFileSelect, data, monaco, editor, selectedFile }:
data={ data }
onFileSelect={ handleFileSelect }
selectedFile={ selectedFile }
mainFile={ mainFile }
isActive={ tabIndex === 0 }
setActionBarRenderer={ setActionBarRenderer }
/>
......
......@@ -5,10 +5,12 @@ import { alt } from 'lib/html-entities';
import useThemeColors from 'ui/shared/monaco/utils/useThemeColors';
import CodeEditorFileIcon from './CodeEditorFileIcon';
import CodeEditorMainFileIndicator from './CodeEditorMainFileIndicator';
import getFilePathParts from './utils/getFilePathParts';
interface Props {
isActive?: boolean;
isMainFile?: boolean;
path: string;
onClick: (path: string) => void;
onClose: (path: string) => void;
......@@ -16,7 +18,7 @@ interface Props {
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 themeColors = useThemeColors();
......@@ -55,6 +57,7 @@ const CodeEditorTab = ({ isActive, path, onClick, onClose, isCloseDisabled, tabs
<CodeEditorFileIcon mr="4px" fileName={ fileName }/>
<span>{ fileName }</span>
{ folderName && <chakra.span fontSize="11px" opacity={ 0.8 } ml={ 1 }>{ folderName[0] === '.' ? '' : '...' }{ folderName }</chakra.span> }
{ isMainFile && <CodeEditorMainFileIndicator ml={ 2 }/> }
<Box
className="codicon codicon-close"
boxSize="20px"
......
......@@ -7,11 +7,12 @@ import useThemeColors from './utils/useThemeColors';
interface Props {
tabs: Array<string>;
activeTab: string;
mainFile?: string;
onTabSelect: (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 tabsPathChunks = React.useMemo(() => {
......@@ -20,6 +21,8 @@ const CodeEditorTabs = ({ tabs, activeTab, onTabSelect, onTabClose }: Props) =>
return (
<Flex
borderTopLeftRadius="md"
overflow="hidden"
bgColor={ themeColors['sideBar.background'] }
flexWrap="wrap"
>
......@@ -28,6 +31,7 @@ const CodeEditorTabs = ({ tabs, activeTab, onTabSelect, onTabClose }: Props) =>
key={ tab }
path={ tab }
isActive={ activeTab === tab }
isMainFile={ mainFile === tab }
onClick={ onTabSelect }
onClose={ onTabClose }
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
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));
}
......@@ -47,12 +47,56 @@ describe('returns unmodified path if it is already absolute', () => {
});
});
it('correctly manages remappings', () => {
describe('correctly manages remappings', () => {
it('without context', () => {
const result = getFullPathOfImportedFile(
'/index.sol',
'node_modules/@openzeppelin/contracts/access/AccessControl.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('/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 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] !== '.') {
let result = importedFilePath;
if (remappings && remappings.length > 0) {
const [ alias, target ] = remappings.map((item) => item.split('=')).find(([ key ]) => importedFilePath.startsWith(key)) || [];
if (alias) {
result = importedFilePath.replace(alias, target);
// how remappings work - https://docs.soliditylang.org/en/v0.8.13/path-resolution.html#import-remapping
if (compilerRemappings && compilerRemappings.length > 0) {
const remappings = formatCompilerRemappings(compilerRemappings);
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
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 = {
// 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.findMatchHighlightBackground': 'rgba(198, 246, 213, 1)',
'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
'custom.riskWarning.primaryBackground': '#FEEBCB', // orange.100
'custom.riskWarning.background': '#FFFAF0', // orange.50
} as const,
};
......@@ -69,11 +71,13 @@ export const dark = {
// 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.findMatchHighlightBackground': 'rgba(34, 84, 61, 1)',
'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
'custom.riskWarning.primaryBackground': 'rgba(246, 173, 85, 0.3)', // orange.300
'custom.riskWarning.background': 'rgba(246, 173, 85, 0.1)', // orange.300
} as const,
};
......@@ -31,7 +31,7 @@ const VerifiedContractsListItem = ({ data, isLoading }: Props) => {
<ListItemMobile rowGap={ 3 }>
<Address columnGap={ 2 } overflow="hidden" w="100%">
<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">
<HashStringShorten hash={ data.address.hash } isTooltipDisabled/>
</Skeleton>
......
......@@ -31,7 +31,7 @@ const VerifiedContractsTableItem = ({ data, isLoading }: Props) => {
<Flex columnGap={ 2 }>
<AddressIcon address={ data.address } isLoading={ isLoading }/>
<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">
<Skeleton isLoaded={ !isLoading } color="text_secondary" my={ 1 }>
<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