Commit 5cbbf863 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #732 from blockscout/contract-editor/highlight-named-imports

contract editor: improve navigation across files
parents dead2995 f903f9c3
...@@ -8,7 +8,12 @@ export const verified: Partial<SmartContract> = { ...@@ -8,7 +8,12 @@ export const verified: Partial<SmartContract> = {
constructor_args: 'constructor_args', constructor_args: 'constructor_args',
creation_bytecode: 'creation_bytecode', creation_bytecode: 'creation_bytecode',
deployed_bytecode: 'deployed_bytecode', deployed_bytecode: 'deployed_bytecode',
compiler_settings: 'compiler_settings', compiler_settings: {
evmVersion: 'london',
remappings: [
'@openzeppelin/=node_modules/@openzeppelin/',
],
},
evm_version: 'default', evm_version: 'default',
is_verified: true, is_verified: true,
name: 'WPOA', name: 'WPOA',
......
...@@ -30,7 +30,10 @@ export interface SmartContract { ...@@ -30,7 +30,10 @@ export interface SmartContract {
file_path: string; file_path: string;
additional_sources: Array<{ file_path: string; source_code: string }>; additional_sources: Array<{ file_path: string; source_code: string }>;
external_libraries: Array<SmartContractExternalLibrary> | null; external_libraries: Array<SmartContractExternalLibrary> | null;
compiler_settings: unknown; compiler_settings?: {
evmVersion?: string;
remappings?: Array<string>;
};
verified_twin_address_hash: string | null; verified_twin_address_hash: string | null;
minimal_proxy_address_hash: string | null; minimal_proxy_address_hash: string | null;
} }
......
...@@ -201,6 +201,7 @@ const ContractCode = ({ addressHash, noSocket }: Props) => { ...@@ -201,6 +201,7 @@ const ContractCode = ({ addressHash, noSocket }: Props) => {
isViper={ Boolean(data.is_vyper_contract) } isViper={ Boolean(data.is_vyper_contract) }
filePath={ data.file_path } filePath={ data.file_path }
additionalSource={ data.additional_sources } additionalSource={ data.additional_sources }
remappings={ data.compiler_settings?.remappings }
/> />
) } ) }
{ Boolean(data.compiler_settings) && ( { Boolean(data.compiler_settings) && (
......
...@@ -16,9 +16,10 @@ interface Props { ...@@ -16,9 +16,10 @@ interface Props {
isViper: boolean; isViper: boolean;
filePath?: string; filePath?: string;
additionalSource?: SmartContract['additional_sources']; additionalSource?: SmartContract['additional_sources'];
remappings?: Array<string>;
} }
const ContractSourceCode = ({ data, hasSol2Yml, address, isViper, filePath, additionalSource }: Props) => { const ContractSourceCode = ({ data, hasSol2Yml, address, isViper, filePath, additionalSource, remappings }: Props) => {
const heading = ( const heading = (
<Text fontWeight={ 500 }> <Text fontWeight={ 500 }>
<span>Contract source code</span> <span>Contract source code</span>
...@@ -56,7 +57,7 @@ const ContractSourceCode = ({ data, hasSol2Yml, address, isViper, filePath, addi ...@@ -56,7 +57,7 @@ const ContractSourceCode = ({ data, hasSol2Yml, address, isViper, filePath, addi
{ diagramLink } { diagramLink }
{ copyToClipboard } { copyToClipboard }
</Flex> </Flex>
<CodeEditor data={ editorData }/> <CodeEditor data={ editorData } remappings={ remappings }/>
</section> </section>
); );
}; };
......
...@@ -35,9 +35,10 @@ const EDITOR_HEIGHT = 500; ...@@ -35,9 +35,10 @@ const EDITOR_HEIGHT = 500;
interface Props { interface Props {
data: Array<File>; data: Array<File>;
remappings?: Array<string>;
} }
const CodeEditor = ({ data }: Props) => { const CodeEditor = ({ data, remappings }: 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);
...@@ -136,15 +137,23 @@ const CodeEditor = ({ data }: Props) => { ...@@ -136,15 +137,23 @@ const CodeEditor = ({ data }: Props) => {
const target = event.target as HTMLSpanElement; const target = event.target as HTMLSpanElement;
const isImportLink = target.classList.contains('import-link'); const isImportLink = target.classList.contains('import-link');
if (isImportLink) { if (isImportLink) {
const path = target.innerText; const path = [
const fullPath = getFullPathOfImportedFile(data[index].file_path, path); target.previousElementSibling as HTMLSpanElement,
target,
target.nextElementSibling as HTMLSpanElement,
]
.filter((element) => element?.classList.contains('import-link'))
.map((element: HTMLSpanElement) => element.innerText)
.join('');
const fullPath = getFullPathOfImportedFile(data[index].file_path, path, remappings);
const fileIndex = data.findIndex((file) => file.file_path === fullPath); const fileIndex = data.findIndex((file) => file.file_path === fullPath);
if (fileIndex > -1) { if (fileIndex > -1) {
event.stopPropagation(); event.stopPropagation();
handleSelectFile(fileIndex); handleSelectFile(fileIndex);
} }
} }
}, [ data, handleSelectFile, index, isMetaPressed, isMobile ]); }, [ data, handleSelectFile, index, isMetaPressed, isMobile, remappings ]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
isMetaKey(event) && setIsMetaPressed(true); isMetaKey(event) && setIsMetaPressed(true);
...@@ -165,12 +174,10 @@ const CodeEditor = ({ data }: Props) => { ...@@ -165,12 +174,10 @@ const CodeEditor = ({ data }: Props) => {
'.highlight': { '.highlight': {
backgroundColor: themeColors['custom.findMatchHighlightBackground'], backgroundColor: themeColors['custom.findMatchHighlightBackground'],
}, },
'&&.meta-pressed .import-link': { '&&.meta-pressed .import-link:hover, &&.meta-pressed .import-link:hover + .import-link': {
_hover: { color: themeColors['custom.fileLink.hoverForeground'],
color: themeColors['custom.fileLink.hoverForeground'], textDecoration: 'underline',
textDecoration: 'underline', cursor: 'pointer',
cursor: 'pointer',
},
}, },
}), [ editorWidth, themeColors ]); }), [ editorWidth, themeColors ]);
......
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
export default function addFileImportDecorations(model: monaco.editor.ITextModel) { export default function addFileImportDecorations(model: monaco.editor.ITextModel) {
const matches = model.findMatches('^import (\'|")((\\/|\\.)(\\w|\\.|\\/|-)+)(\'|")', false, true, false, null, true); const options: monaco.editor.IModelDecorationOptions = {
const decorations: Array<monaco.editor.IModelDeltaDecoration> = matches.map(({ range }) => ({ inlineClassName: 'import-link',
hoverMessage: {
value: 'Cmd/Win + click to open file',
},
};
const regularImportMatches = model.findMatches('^import (\'|")(.+)(\'|")', false, true, false, null, true);
const regularImportDecorations: Array<monaco.editor.IModelDeltaDecoration> = regularImportMatches.map(({ range }) => ({
range: { range: {
...range, ...range,
startColumn: range.startColumn + 8, startColumn: range.startColumn + 8,
endColumn: range.endColumn - 1, endColumn: range.endColumn - 1,
}, },
options: { options,
inlineClassName: 'import-link', }));
hoverMessage: {
value: 'Cmd/Win + click to open file', const namedImportMatches = model.findMatches('(^import \\{.+\\} from )(\'|")(.+)(\'|")', false, true, false, null, true);
}, const namedImportDecorations: Array<monaco.editor.IModelDeltaDecoration> = namedImportMatches.map(({ range, matches }) => ({
range: {
...range,
startColumn: range.startColumn + (Array.isArray(matches) ? matches?.[1]?.length + 1 : 0),
endColumn: range.endColumn - 1,
}, },
options,
})); }));
model.deltaDecorations([], decorations);
model.deltaDecorations([], regularImportDecorations.concat(namedImportDecorations));
} }
...@@ -18,20 +18,32 @@ it('returns undefined if imported file is outside the base file folder', () => { ...@@ -18,20 +18,32 @@ it('returns undefined if imported file is outside the base file folder', () => {
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
it('returns unmodified path if it is already absolute', () => { describe('returns unmodified path if it is already absolute', () => {
const result = getFullPathOfImportedFile( it('with prefix', () => {
'/index.sol', const result = getFullPathOfImportedFile(
'/abc/contract.sol', '/index.sol',
); '/abc/contract.sol',
);
expect(result).toBe('/abc/contract.sol');
expect(result).toBe('/abc/contract.sol');
});
it('without prefix', () => {
const result = getFullPathOfImportedFile(
'/index.sol',
'abc/contract.sol',
);
expect(result).toBe('/abc/contract.sol');
});
}); });
it('returns undefined for external path', () => { it('correctly manages remappings', () => {
const result = getFullPathOfImportedFile( const result = getFullPathOfImportedFile(
'/index.sol', '/index.sol',
'https://github.com/ethereum/dapp/contract.sol', 'node_modules/@openzeppelin/contracts/access/AccessControl.sol',
[ '@ensdomains/=node_modules/@ensdomains/', '@openzeppelin/=node_modules/@openzeppelin/' ],
); );
expect(result).toBeUndefined(); expect(result).toBe('/node_modules/@openzeppelin/contracts/access/AccessControl.sol');
}); });
import stripLeadingSlash from 'lib/stripLeadingSlash'; import stripLeadingSlash from 'lib/stripLeadingSlash';
export default function getFullPathOfImportedFile(baseFilePath: string, importedFilePath: string) { export default function getFullPathOfImportedFile(baseFilePath: string, importedFilePath: string, remappings?: Array<string>) {
if (importedFilePath[0] === '/') {
return importedFilePath;
}
if (importedFilePath[0] !== '.') { if (importedFilePath[0] !== '.') {
return; 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);
}
}
return result[0] === '/' ? result : '/' + result;
} }
const baseFileChunks = stripLeadingSlash(baseFilePath).split('/'); const baseFileChunks = stripLeadingSlash(baseFilePath).split('/');
......
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