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> = {
constructor_args: 'constructor_args',
creation_bytecode: 'creation_bytecode',
deployed_bytecode: 'deployed_bytecode',
compiler_settings: 'compiler_settings',
compiler_settings: {
evmVersion: 'london',
remappings: [
'@openzeppelin/=node_modules/@openzeppelin/',
],
},
evm_version: 'default',
is_verified: true,
name: 'WPOA',
......
......@@ -30,7 +30,10 @@ export interface SmartContract {
file_path: string;
additional_sources: Array<{ file_path: string; source_code: string }>;
external_libraries: Array<SmartContractExternalLibrary> | null;
compiler_settings: unknown;
compiler_settings?: {
evmVersion?: string;
remappings?: Array<string>;
};
verified_twin_address_hash: string | null;
minimal_proxy_address_hash: string | null;
}
......
......@@ -201,6 +201,7 @@ const ContractCode = ({ addressHash, noSocket }: Props) => {
isViper={ Boolean(data.is_vyper_contract) }
filePath={ data.file_path }
additionalSource={ data.additional_sources }
remappings={ data.compiler_settings?.remappings }
/>
) }
{ Boolean(data.compiler_settings) && (
......
......@@ -16,9 +16,10 @@ interface Props {
isViper: boolean;
filePath?: string;
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 = (
<Text fontWeight={ 500 }>
<span>Contract source code</span>
......@@ -56,7 +57,7 @@ const ContractSourceCode = ({ data, hasSol2Yml, address, isViper, filePath, addi
{ diagramLink }
{ copyToClipboard }
</Flex>
<CodeEditor data={ editorData }/>
<CodeEditor data={ editorData } remappings={ remappings }/>
</section>
);
};
......
......@@ -35,9 +35,10 @@ const EDITOR_HEIGHT = 500;
interface Props {
data: Array<File>;
remappings?: Array<string>;
}
const CodeEditor = ({ data }: Props) => {
const CodeEditor = ({ data, remappings }: Props) => {
const [ instance, setInstance ] = React.useState<Monaco | undefined>();
const [ editor, setEditor ] = React.useState<monaco.editor.IStandaloneCodeEditor | undefined>();
const [ index, setIndex ] = React.useState(0);
......@@ -136,15 +137,23 @@ const CodeEditor = ({ data }: Props) => {
const target = event.target as HTMLSpanElement;
const isImportLink = target.classList.contains('import-link');
if (isImportLink) {
const path = target.innerText;
const fullPath = getFullPathOfImportedFile(data[index].file_path, path);
const 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);
if (fileIndex > -1) {
event.stopPropagation();
handleSelectFile(fileIndex);
}
}
}, [ data, handleSelectFile, index, isMetaPressed, isMobile ]);
}, [ data, handleSelectFile, index, isMetaPressed, isMobile, remappings ]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
isMetaKey(event) && setIsMetaPressed(true);
......@@ -165,12 +174,10 @@ const CodeEditor = ({ data }: Props) => {
'.highlight': {
backgroundColor: themeColors['custom.findMatchHighlightBackground'],
},
'&&.meta-pressed .import-link': {
_hover: {
color: themeColors['custom.fileLink.hoverForeground'],
textDecoration: 'underline',
cursor: 'pointer',
},
'&&.meta-pressed .import-link:hover, &&.meta-pressed .import-link:hover + .import-link': {
color: themeColors['custom.fileLink.hoverForeground'],
textDecoration: 'underline',
cursor: 'pointer',
},
}), [ editorWidth, themeColors ]);
......
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
export default function addFileImportDecorations(model: monaco.editor.ITextModel) {
const matches = model.findMatches('^import (\'|")((\\/|\\.)(\\w|\\.|\\/|-)+)(\'|")', false, true, false, null, true);
const decorations: Array<monaco.editor.IModelDeltaDecoration> = matches.map(({ range }) => ({
const options: monaco.editor.IModelDecorationOptions = {
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,
startColumn: range.startColumn + 8,
endColumn: range.endColumn - 1,
},
options: {
inlineClassName: 'import-link',
hoverMessage: {
value: 'Cmd/Win + click to open file',
},
options,
}));
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', () => {
expect(result).toBeUndefined();
});
it('returns unmodified path if it is already absolute', () => {
const result = getFullPathOfImportedFile(
'/index.sol',
'/abc/contract.sol',
);
expect(result).toBe('/abc/contract.sol');
describe('returns unmodified path if it is already absolute', () => {
it('with prefix', () => {
const result = getFullPathOfImportedFile(
'/index.sol',
'/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(
'/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';
export default function getFullPathOfImportedFile(baseFilePath: string, importedFilePath: string) {
if (importedFilePath[0] === '/') {
return importedFilePath;
}
export default function getFullPathOfImportedFile(baseFilePath: string, importedFilePath: string, remappings?: Array<string>) {
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('/');
......
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