Commit f5f4d624 authored by tom's avatar tom

Merge branch 'main' of github.com:blockscout/frontend into marketplace-config

parents 3af5a902 d6bed3d0
......@@ -52,7 +52,6 @@ jobs:
name: Run unit tests with Jest
needs: [ lint, type_check ]
runs-on: ubuntu-latest
if: ${{ false }} # disable since there are no jest test yet
steps:
- name: Checkout repo
uses: actions/checkout@v3
......
......@@ -45,8 +45,8 @@ The app instance could be customized by passing following variables to NodeJS en
| NEXT_PUBLIC_NETWORK_NAME | `string` | Displayed name of the network | `Gnosis Chain` |
| NEXT_PUBLIC_NETWORK_SHORT_NAME | `string` *(optional)* | Used for SEO attributes (page title and description) | `OoG` |
| NEXT_PUBLIC_NETWORK_TYPE | `string` *(optional)* | Network type (used for matching pre-defined assets, e.g network logo and icon, which are stored in the project). See all possible values here | `xdai_mainnet` |
| NEXT_PUBLIC_NETWORK_ID | `number` | Chain id, see [https://chainlist.org/](https://chainlist.org/) for the reference | `99` |
| NEXT_PUBLIC_NETWORK_RPC_URL | `string` *(optional)* | Chain server RPC url, see [https://chainlist.org/](https://chainlist.org/) for the reference. If not provided, some functionality of the explorer, related to smart contracts interaction and third-party apps integration, will be unavailable | `https://core.poa.network` |
| NEXT_PUBLIC_NETWORK_ID | `number` | Chain id, see [https://chainlist.org](https://chainlist.org) for the reference | `99` |
| NEXT_PUBLIC_NETWORK_RPC_URL | `string` *(optional)* | Chain server RPC url, see [https://chainlist.org](https://chainlist.org) for the reference. If not provided, some functionality of the explorer, related to smart contracts interaction and third-party apps integration, will be unavailable | `https://core.poa.network` |
| NEXT_PUBLIC_NETWORK_CURRENCY_NAME | `string` | Network currency name | `Ether` |
| NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL | `string` | Network currency symbol | `ETH` |
| NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS | `string` | Network currency decimals | `18` |
......
......@@ -5,7 +5,7 @@ blockscout:
app: blockscout
enabled: true
image:
_default: &image blockscout/blockscout-optimism-l2-advanced:5.1.0-prerelease-303a0afa
_default: &image blockscout/blockscout-optimism-l2-advanced:5.1.2-prerelease-84e22ed2
replicas:
app: 1
# init container
......@@ -93,14 +93,8 @@ blockscout:
_default: 'true'
BLOCKSCOUT_HOST:
_default: 'blockscout-optimism-goerli.test.aws-k8s.blockscout.com'
SOCKET_ROOT:
_default: "/"
NETWORK_PATH:
_default: "/"
API_PATH:
_default: "/"
API_BASE_PATH:
_default: "/"
APPS_MENU:
_default: 'true'
EXTERNAL_APPS:
......@@ -149,6 +143,9 @@ postgres:
command: '["docker-entrypoint.sh", "-c"]'
args: '["max_connections=300"]'
customShm:
enabled: false
files:
enabled: true
......@@ -337,7 +334,7 @@ frontend:
_default: "0.2"
environment:
NEXT_PUBLIC_BLOCKSCOUT_VERSION:
_default: v5.1.0-beta
_default: v5.1.2-beta
NEXT_PUBLIC_FOOTER_GITHUB_LINK:
_default: https://github.com/blockscout/blockscout
NEXT_PUBLIC_FOOTER_TWITTER_LINK:
......
......@@ -47,14 +47,14 @@ blockscout:
resources:
limits:
memory:
_default: "5Gi"
_default: "4Gi"
cpu:
_default: "4"
_default: "3"
requests:
memory:
_default: "5Gi"
_default: "4Gi"
cpu:
_default: "4"
_default: "3"
# node label
nodeSelector:
enabled: true
......@@ -73,23 +73,17 @@ blockscout:
# # _default: ws://geth-svc:8546
# _default: ws://geth-svc.goerli.svc.cluster.local:8546
BLOCKSCOUT_VERSION:
_default: v5.1.0-beta
_default: v5.1.2-beta
ECTO_USE_SSL:
_default: 'false'
ETHEREUM_JSONRPC_VARIANT:
_default: geth
HEART_BEAT_TIMEOUT:
_default: 30
SHOW_PRICE_CHART:
_default: false
CACHE_BLOCK_COUNT_PERIOD:
_default: 7200
PORT:
_default: 4000
SUBNETWORK:
_default: Ethereum
HEALTHY_BLOCKS_PERIOD:
_default: 60
NETWORK:
_default: (Goerli)
NETWORK_ICON:
......@@ -100,10 +94,6 @@ blockscout:
_default: ETH
LOGO:
_default: /images/goerli_logo.svg
HISTORY_FETCH_INTERVAL:
_default: 60
TXS_HISTORIAN_INIT_LAG:
_default: 0
TXS_STATS_DAYS_TO_COMPILE_AT_INIT:
_default: 1
COIN_BALANCE_HISTORY_DAYS:
......@@ -138,7 +128,7 @@ blockscout:
_default: '20s'
INDEXER_INTERNAL_TRANSACTIONS_BATCH_SIZE:
_default: 15
INDEXER_DISABLE_EMPTY_BLOCK_SANITIZER:
INDEXER_DISABLE_EMPTY_BLOCKS_SANITIZER:
_default: 'true'
INDEXER_DISABLE_PENDING_TRANSACTIONS_FETCHER:
_default: 'true'
......@@ -151,13 +141,13 @@ blockscout:
DISABLE_INDEXER:
_default: 'false'
FIRST_BLOCK:
_default: '8446041'
_default: '8739119'
LAST_BLOCK:
_default: '8446041'
_default: '8739119'
TRACE_FIRST_BLOCK:
_default: '8446041'
_default: '8739119'
TRACE_LAST_BLOCK:
_default: '8446041'
_default: '8739119'
postgres:
enabled: true
......@@ -170,6 +160,10 @@ postgres:
# strategy: Recreate
persistence: true
customShm:
enabled: true
sizeLimit: 256Mi
storage: 1400Gi
resources:
......@@ -301,12 +295,12 @@ frontend:
resources:
limits:
memory:
_default: "0.1Gi"
_default: "0.3Gi"
cpu:
_default: "0.5"
requests:
memory:
_default: "0.1Gi"
_default: "0.3Gi"
cpu:
_default: "0.5"
environment:
......@@ -344,7 +338,7 @@ frontend:
NEXT_PUBLIC_API_BASE_PATH:
_default: /
NEXT_PUBLIC_BLOCKSCOUT_VERSION:
_default: v4.1.8-beta
_default: v5.1.2-beta
NEXT_PUBLIC_FOOTER_GITHUB_LINK:
_default: https://github.com/blockscout/blockscout
NEXT_PUBLIC_FOOTER_TWITTER_LINK:
......
......@@ -17,7 +17,6 @@ frontend:
# - "/(apps|auth/profile|account)"
- "/"
prefix:
# - "/(apps|auth/profile|account)"
- "/_next"
- "/node-api"
- "/account"
......@@ -46,15 +45,15 @@ frontend:
memory:
_default: "2Gi"
cpu:
_default: "2"
_default: "1"
requests:
memory:
_default: "2Gi"
cpu:
_default: "2"
_default: "1"
environment:
NEXT_PUBLIC_BLOCKSCOUT_VERSION:
_default: v4.1.8-beta
_default: v5.1.2-beta
NEXT_PUBLIC_FOOTER_GITHUB_LINK:
_default: https://github.com/blockscout/blockscout
NEXT_PUBLIC_FOOTER_TWITTER_LINK:
......
......@@ -374,7 +374,7 @@ export const RESOURCES = {
},
deposits_count: {
path: '/api/v2/optimism/deposits-count',
path: '/api/v2/optimism/deposits/count',
},
withdrawals: {
......@@ -384,7 +384,7 @@ export const RESOURCES = {
},
withdrawals_count: {
path: '/api/v2/optimism/withdrawals-count',
path: '/api/v2/optimism/withdrawals/count',
},
output_roots: {
......@@ -394,7 +394,7 @@ export const RESOURCES = {
},
output_roots_count: {
path: '/api/v2/optimism/output-roots-count',
path: '/api/v2/optimism/output-roots/count',
},
txn_batches: {
......@@ -404,7 +404,7 @@ export const RESOURCES = {
},
txn_batches_count: {
path: '/api/v2/optimism/txn-batches-count',
path: '/api/v2/optimism/txn-batches/count',
},
// DEPRECATED
......
......@@ -8,6 +8,7 @@ function generateCspPolicy() {
descriptors.googleAnalytics(),
descriptors.googleFonts(),
descriptors.googleReCaptcha(),
descriptors.monaco(),
descriptors.sentry(),
descriptors.walletConnect(),
);
......
......@@ -3,5 +3,6 @@ export { app } from './app';
export { googleAnalytics } from './googleAnalytics';
export { googleFonts } from './googleFonts';
export { googleReCaptcha } from './googleReCaptcha';
export { monaco } from './monaco';
export { sentry } from './sentry';
export { walletConnect } from './walletConnect';
import type CspDev from 'csp-dev';
import { KEY_WORDS } from '../utils';
export function monaco(): CspDev.DirectiveDescriptor {
return {
'script-src': [
KEY_WORDS.BLOB,
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/loader.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/editor/editor.main.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/editor/editor.main.nls.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/basic-languages/solidity/solidity.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/base/worker/workerMain.js',
],
'style-src': [
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/editor/editor.main.css',
],
'font-src': [
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/base/browser/ui/codicons/codicon/codicon.ttf',
],
};
}
......@@ -19,3 +19,6 @@ export const minus = String.fromCharCode(8722); // −
export const leftLineArrow = String.fromCharCode(8592); // ←
export const rightLineArrow = String.fromCharCode(8594); // →
export const apos = String.fromCharCode(39); // apostrophe '
export const shift = String.fromCharCode(8679); // upwards white arrow ⇧
export const cmd = String.fromCharCode(8984); // place of interest sign ⌘
export const alt = String.fromCharCode(9095); // alternate key symbol ⎇
export default function isMetaKey(event: React.KeyboardEvent) {
return event.metaKey || event.getModifierState('Meta') || event.getModifierState('OS');
}
const stripLeadingSlash = (str: string) => str[0] === '/' ? str.slice(1) : str;
export default stripLeadingSlash;
......@@ -13,7 +13,7 @@ const API_URL = buildApiUrl('address_token_transfers', { hash: '0xd789a607CEac2f
const hooksConfig = {
router: {
query: { hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859', token_hash: '0x1189a607CEac2f0E14867de4EB15b15C9FFB5859' },
query: { hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859', token: '0x1189a607CEac2f0E14867de4EB15b15C9FFB5859' },
},
};
......
......@@ -73,7 +73,7 @@ const AddressTokenTransfers = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLD
const [ socketAlert, setSocketAlert ] = React.useState('');
const [ newItemsCount, setNewItemsCount ] = React.useState(0);
const tokenFilter = getQueryParamString(router.query.token_hash) || undefined;
const tokenFilter = getQueryParamString(router.query.token) || undefined;
const [ filters, setFilters ] = React.useState<Filters>(
{
......
......@@ -20,6 +20,7 @@ test('verified with changed byte code +@mobile +@dark-mode', async({ mount, page
status: 200,
body: JSON.stringify(contractMock.withChangedByteCode),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
const component = await mount(
<TestApp>
......@@ -36,6 +37,7 @@ test('verified with multiple sources +@mobile', async({ mount, page }) => {
status: 200,
body: JSON.stringify(contractMock.withMultiplePaths),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
await mount(
<TestApp>
......@@ -54,6 +56,7 @@ test('verified via sourcify', async({ mount, page }) => {
status: 200,
body: JSON.stringify(contractMock.verifiedViaSourcify),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
await mount(
<TestApp>
......@@ -70,6 +73,7 @@ test('self destructed', async({ mount, page }) => {
status: 200,
body: JSON.stringify(contractMock.selfDestructed),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
await mount(
<TestApp>
......@@ -87,6 +91,7 @@ test('with twin address alert +@mobile', async({ mount, page }) => {
status: 200,
body: JSON.stringify(contractMock.withTwinAddress),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
const component = await mount(
<TestApp>
......@@ -103,6 +108,7 @@ test('with proxy address alert +@mobile', async({ mount, page }) => {
status: 200,
body: JSON.stringify(contractMock.withProxyAddress),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
const component = await mount(
<TestApp>
......@@ -119,6 +125,7 @@ test('non verified', async({ mount, page }) => {
status: 200,
body: JSON.stringify(contractMock.nonVerified),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
const component = await mount(
<TestApp>
......
......@@ -68,7 +68,7 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
];
}, [ data ]);
const { control, handleSubmit, setValue } = useForm<MethodFormFields>({
const { control, handleSubmit, setValue, getValues } = useForm<MethodFormFields>({
defaultValues: _fromPairs(inputs.map(({ name }, index) => [ getFieldName(name, index), '' ])),
});
......@@ -118,11 +118,13 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
<ContractMethodField
key={ fieldName }
name={ fieldName }
valueType={ type }
placeholder={ `${ name }(${ type })` }
control={ control }
setValue={ setValue }
getValues={ getValues }
isDisabled={ isLoading }
onClear={ handleFormChange }
onChange={ handleFormChange }
/>
);
}) }
......
import { FormControl, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
import {
FormControl,
Input,
InputGroup,
InputRightElement,
} from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps, UseFormSetValue } from 'react-hook-form';
import type { Control, ControllerRenderProps, UseFormGetValues, UseFormSetValue } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { MethodFormFields } from './types';
import type { SmartContractMethodArgType } from 'types/api/contract';
import InputClearButton from 'ui/shared/InputClearButton';
import ContractMethodFieldZeroes from './ContractMethodFieldZeroes';
import { addZeroesAllowed } from './utils';
interface Props {
control: Control<MethodFormFields>;
setValue: UseFormSetValue<MethodFormFields>;
getValues: UseFormGetValues<MethodFormFields>;
placeholder: string;
name: string;
valueType: SmartContractMethodArgType;
isDisabled: boolean;
onClear: () => void;
onChange: () => void;
}
const ContractMethodField = ({ control, name, placeholder, setValue, isDisabled, onClear }: Props) => {
const ContractMethodField = ({ control, name, valueType, placeholder, setValue, getValues, isDisabled, onChange }: Props) => {
const ref = React.useRef<HTMLInputElement>(null);
const handleClear = React.useCallback(() => {
setValue(name, '');
onClear();
onChange();
ref.current?.focus();
}, [ name, onClear, setValue ]);
}, [ name, onChange, setValue ]);
const handleAddZeroesClick = React.useCallback((power: number) => {
const value = getValues()[name];
const zeroes = Array(power).fill('0').join('');
const newValue = value ? value + zeroes : '1' + zeroes;
setValue(name, newValue);
onChange();
}, [ getValues, name, onChange, setValue ]);
const hasZerosControl = addZeroesAllowed(valueType);
const renderInput = React.useCallback(({ field }: { field: ControllerRenderProps<MethodFormFields> }) => {
return (
......@@ -32,23 +53,23 @@ const ContractMethodField = ({ control, name, placeholder, setValue, isDisabled,
flexBasis={{ base: '100%', lg: 'calc((100% - 24px) / 3 - 65px)' }}
w={{ base: '100%', lg: 'auto' }}
flexGrow={ 1 }
isDisabled={ isDisabled
}>
isDisabled={ isDisabled }
>
<InputGroup size="xs">
<Input
{ ...field }
ref={ ref }
placeholder={ placeholder }
paddingRight={ hasZerosControl ? '120px' : '40px' }
/>
{ field.value && (
<InputRightElement>
<InputClearButton onClick={ handleClear } isDisabled={ isDisabled }/>
</InputRightElement>
) }
<InputRightElement w="auto" right={ 1 }>
{ field.value && <InputClearButton onClick={ handleClear } isDisabled={ isDisabled }/> }
{ hasZerosControl && <ContractMethodFieldZeroes onClick={ handleAddZeroesClick }/> }
</InputRightElement>
</InputGroup>
</FormControl>
);
}, [ handleClear, isDisabled, name, placeholder ]);
}, [ name, isDisabled, placeholder, hasZerosControl, handleClear, handleAddZeroesClick ]);
return (
<Controller
......
import {
chakra,
Popover,
PopoverBody,
PopoverContent,
PopoverTrigger,
Portal,
Button,
List,
ListItem,
Icon,
useDisclosure,
Input,
} from '@chakra-ui/react';
import React from 'react';
import iconEastMini from 'icons/arrows/east-mini.svg';
import iconCheck from 'icons/check.svg';
import { times } from 'lib/html-entities';
interface Props {
onClick: (power: number) => void;
}
const ContractMethodFieldZeroes = ({ onClick }: Props) => {
const [ selectedOption, setSelectedOption ] = React.useState<number | undefined>(18);
const [ customValue, setCustomValue ] = React.useState<number>();
const { isOpen, onToggle, onClose } = useDisclosure();
const handleOptionClick = React.useCallback((event: React.MouseEvent) => {
const id = Number((event.currentTarget as HTMLDivElement).getAttribute('data-id'));
if (!Object.is(id, NaN)) {
setSelectedOption((prev) => prev === id ? undefined : id);
setCustomValue(undefined);
onClose();
}
}, [ onClose ]);
const handleInputChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setCustomValue(Number(event.target.value));
setSelectedOption(undefined);
}, []);
const value = selectedOption || customValue;
const handleButtonClick = React.useCallback(() => {
value && onClick(value);
}, [ onClick, value ]);
return (
<>
{ Boolean(value) && (
<Button
px={ 1 }
lineHeight={ 6 }
h={ 6 }
fontWeight={ 500 }
ml={ 1 }
variant="subtle"
colorScheme="gray"
display="inline"
onClick={ handleButtonClick }
>
{ times }
<chakra.span>10</chakra.span>
<chakra.span fontSize="xs" lineHeight={ 4 } verticalAlign="super">{ value }</chakra.span>
</Button>
) }
<Popover placement="bottom-end" isLazy isOpen={ isOpen } onClose={ onClose }>
<PopoverTrigger>
<Button
variant="subtle"
colorScheme="gray"
size="xs"
cursor="pointer"
ml={ 1 }
p={ 0 }
onClick={ onToggle }
>
<Icon as={ iconEastMini } transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } boxSize={ 6 }/>
</Button>
</PopoverTrigger>
<Portal>
<PopoverContent w="110px">
<PopoverBody py={ 2 }>
<List>
{ [ 8, 12, 16, 18, 20 ].map((id) => (
<ListItem
key={ id }
py={ 2 }
data-id={ id }
onClick={ handleOptionClick }
display="flex"
justifyContent="space-between"
alignItems="center"
cursor="pointer"
>
<span>10*{ id }</span>
{ selectedOption === id && <Icon as={ iconCheck } boxSize={ 6 } color="blue.600"/> }
</ListItem>
)) }
<ListItem
py={ 2 }
display="flex"
justifyContent="space-between"
alignItems="center"
>
<span>10*</span>
<Input
type="number"
min={ 0 }
max={ 100 }
ml={ 3 }
size="xs"
onChange={ handleInputChange }
value={ customValue || '' }
/>
</ListItem>
</List>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
</>
);
};
export default React.memo(ContractMethodFieldZeroes);
import { Box, chakra, Flex, Text, Tooltip } from '@chakra-ui/react';
import { Flex, Text, Tooltip } from '@chakra-ui/react';
import { route } from 'nextjs-routes';
import React from 'react';
import type { SmartContract } from 'types/api/contract';
import CodeEditor from 'ui/shared/CodeEditor';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import LinkInternal from 'ui/shared/LinkInternal';
import CodeEditor from 'ui/shared/monaco/CodeEditor';
import formatFilePath from 'ui/shared/monaco/utils/formatFilePath';
interface Props {
data: string;
......@@ -37,38 +38,25 @@ const ContractSourceCode = ({ data, hasSol2Yml, address, isViper, filePath, addi
</Tooltip>
) : null;
if (!additionalSource?.length) {
return (
<section>
<Flex justifyContent="space-between" alignItems="center" mb={ 3 }>
{ heading }
{ diagramLink }
<CopyToClipboard text={ data }/>
</Flex>
<CodeEditor value={ data } id="source_code"/>
</section>
);
}
const editorData = React.useMemo(() => {
const defaultName = isViper ? '/index.vy' : '/index.sol';
return [
{ file_path: formatFilePath(filePath || defaultName), source_code: data },
...(additionalSource || []).map((source) => ({ ...source, file_path: formatFilePath(source.file_path) })) ];
}, [ additionalSource, data, filePath, isViper ]);
const copyToClipboard = editorData.length === 1 ?
<CopyToClipboard text={ editorData[0].source_code }/> :
null;
return (
<section>
<Flex justifyContent="space-between" alignItems="center" mb={ 3 }>
{ heading }
{ diagramLink }
{ copyToClipboard }
</Flex>
<Flex flexDir="column" rowGap={ 3 }>
{ [ { file_path: filePath, source_code: data }, ...additionalSource ].map((item, index, array) => (
<Box key={ index }>
<Flex justifyContent="space-between" alignItems="flex-end" mb={ 3 }>
<chakra.span fontSize="sm" wordBreak="break-all" lineHeight="20px">
File { index + 1 } of { array.length }: { item.file_path }
</chakra.span>
<CopyToClipboard text={ item.source_code } ml={ 4 }/>
</Flex>
<CodeEditor value={ item.source_code } id={ `source_code_${ index }` }/>
</Box>
)) }
</Flex>
<CodeEditor data={ editorData }/>
</section>
);
};
......
......@@ -7,6 +7,24 @@ export const getNativeCoinValue = (value: string | Array<string>) => {
return BigNumber(_value).times(10 ** config.network.currency.decimals).toString();
};
export const addZeroesAllowed = (valueType: string) => {
if (valueType.includes('[]')) {
return false;
}
const REGEXP = /u?int(\d+)/i;
const match = valueType.match(REGEXP);
const power = match?.[1];
if (power) {
// show control for all inputs which allows to insert 10^18 or greater numbers
return Number(power) >= 64;
}
return false;
};
interface ExtendedError extends Error {
detectedNetwork?: {
chain: number;
......
import { Grid, GridItem, Text, Icon, Link, Box, Tooltip } from '@chakra-ui/react';
import { Grid, GridItem, Text, Icon, Link, Box, Tooltip, useColorModeValue } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import BigNumber from 'bignumber.js';
import capitalize from 'lodash/capitalize';
......@@ -40,6 +40,8 @@ const BlockDetails = ({ query }: Props) => {
const router = useRouter();
const heightOrHash = getQueryParamString(router.query.height);
const separatorColor = useColorModeValue('gray.200', 'gray.700');
const { data, isLoading, isError, error } = query;
const handleCutClick = React.useCallback(() => {
......@@ -197,11 +199,15 @@ const BlockDetails = ({ query }: Props) => {
<Text>{ BigNumber(data.gas_used || 0).toFormat() }</Text>
<Utilization
ml={ 4 }
mr={ 5 }
colorScheme="gray"
value={ BigNumber(data.gas_used || 0).dividedBy(BigNumber(data.gas_limit)).toNumber() }
/>
<GasUsedToTargetRatio value={ data.gas_target_percentage || undefined }/>
{ data.gas_target_percentage && (
<>
<TextSeparator color={ separatorColor } mx={ 1 }/>
<GasUsedToTargetRatio value={ data.gas_target_percentage }/>
</>
) }
</DetailsInfoItem>
<DetailsInfoItem
title="Gas limit"
......
import { Flex, Spinner, Text, Box, Icon } from '@chakra-ui/react';
import { Flex, Spinner, Text, Box, Icon, useColorModeValue } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import capitalize from 'lodash/capitalize';
import { route } from 'nextjs-routes';
......@@ -16,6 +16,7 @@ import AddressLink from 'ui/shared/address/AddressLink';
import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio';
import LinkInternal from 'ui/shared/LinkInternal';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import TextSeparator from 'ui/shared/TextSeparator';
import Utilization from 'ui/shared/Utilization/Utilization';
interface Props {
......@@ -29,6 +30,8 @@ const BlocksListItem = ({ data, isPending, enableTimeIncrement }: Props) => {
const burntFees = BigNumber(data.burnt_fees || 0);
const txFees = BigNumber(data.tx_fees || 0);
const separatorColor = useColorModeValue('gray.200', 'gray.700');
return (
<ListItemMobile rowGap={ 3 } key={ String(data.height) } isAnimated>
<Flex justifyContent="space-between" w="100%">
......@@ -63,10 +66,15 @@ const BlocksListItem = ({ data, isPending, enableTimeIncrement }: Props) => {
</Flex>
<Box>
<Text fontWeight={ 500 }>Gas used</Text>
<Flex columnGap={ 4 } mt={ 2 }>
<Text variant="secondary">{ BigNumber(data.gas_used || 0).toFormat() }</Text>
<Flex mt={ 2 }>
<Text variant="secondary" mr={ 4 }>{ BigNumber(data.gas_used || 0).toFormat() }</Text>
<Utilization colorScheme="gray" value={ BigNumber(data.gas_used || 0).div(BigNumber(data.gas_limit)).toNumber() }/>
<GasUsedToTargetRatio value={ data.gas_target_percentage || undefined }/>
{ data.gas_target_percentage && (
<>
<TextSeparator color={ separatorColor } mx={ 1 }/>
<GasUsedToTargetRatio value={ data.gas_target_percentage }/>
</>
) }
</Flex>
</Box>
<Flex columnGap={ 2 }>
......
......@@ -13,6 +13,7 @@ import BlockTimestamp from 'ui/blocks/BlockTimestamp';
import AddressLink from 'ui/shared/address/AddressLink';
import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio';
import LinkInternal from 'ui/shared/LinkInternal';
import TextSeparator from 'ui/shared/TextSeparator';
import Utilization from 'ui/shared/Utilization/Utilization';
interface Props {
......@@ -26,6 +27,8 @@ const BlocksTableItem = ({ data, isPending, enableTimeIncrement }: Props) => {
const burntFees = BigNumber(data.burnt_fees || 0);
const txFees = BigNumber(data.tx_fees || 0);
const separatorColor = useColorModeValue('gray.200', 'gray.700');
return (
<Tr
as={ motion.tr }
......@@ -68,11 +71,12 @@ const BlocksTableItem = ({ data, isPending, enableTimeIncrement }: Props) => {
<Utilization colorScheme="gray" value={ BigNumber(data.gas_used || 0).dividedBy(BigNumber(data.gas_limit)).toNumber() }/>
</Box>
</Tooltip>
<Tooltip label="% of Gas Target">
<Box>
<GasUsedToTargetRatio ml={ 2 } value={ data.gas_target_percentage || undefined }/>
</Box>
</Tooltip>
{ data.gas_target_percentage && (
<>
<TextSeparator color={ separatorColor } mx={ 1 }/>
<GasUsedToTargetRatio value={ data.gas_target_percentage }/>
</>
) }
</Flex>
</Td>
<Td fontSize="sm">{ totalReward.toFixed(8) }</Td>
......
......@@ -92,7 +92,7 @@ test.describe('sourcify', () => {
});
testWithSocket.describe.configure({ mode: 'serial', timeout: 20_000 });
testWithSocket('with multiple contracts +@mobile', async({ mount, page, createSocket }) => {
testWithSocket('with multiple contracts', async({ mount, page, createSocket }) => {
const component = await mount(
<TestApp withSocket>
<ContractVerificationForm config={ formConfig } hash={ hash }/>
......
import { Button, chakra, useUpdateEffect } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { route } from 'nextjs-routes';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
......@@ -20,7 +20,7 @@ import ContractVerificationSourcify from './methods/ContractVerificationSourcify
import ContractVerificationStandardInput from './methods/ContractVerificationStandardInput';
import ContractVerificationVyperContract from './methods/ContractVerificationVyperContract';
import ContractVerificationVyperMultiPartFile from './methods/ContractVerificationVyperMultiPartFile';
import { prepareRequestBody, formatSocketErrors, DEFAULT_VALUES } from './utils';
import { prepareRequestBody, formatSocketErrors, getDefaultValues } from './utils';
const METHOD_COMPONENTS = {
'flattened-code': <ContractVerificationFlattenSourceCode/>,
......@@ -40,14 +40,13 @@ interface Props {
const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Props) => {
const formApi = useForm<FormFields>({
mode: 'onBlur',
defaultValues: methodFromQuery ? DEFAULT_VALUES[methodFromQuery] : undefined,
defaultValues: methodFromQuery ? getDefaultValues(methodFromQuery, config) : undefined,
});
const { control, handleSubmit, watch, formState, setError, reset } = formApi;
const submitPromiseResolver = React.useRef<(value: unknown) => void>();
const apiFetch = useApiFetch();
const toast = useToast();
const router = useRouter();
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(data) => {
const body = prepareRequestBody(data);
......@@ -86,8 +85,8 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
isClosable: true,
});
router.push({ pathname: '/address/[hash]', query: { hash, tab: 'contract' } }, undefined, { shallow: false });
}, [ hash, router, setError, toast ]);
window.location.assign(route({ pathname: '/address/[hash]', query: { hash, tab: 'contract' } }));
}, [ hash, setError, toast ]);
const handleSocketError = React.useCallback(() => {
if (!formState.isSubmitting) {
......@@ -129,7 +128,7 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
useUpdateEffect(() => {
if (methodValue) {
reset(DEFAULT_VALUES[methodValue]);
reset(getDefaultValues(methodValue, config));
}
// !!! should run only when method is changed
}, [ methodValue ]);
......
......@@ -60,14 +60,14 @@ const ContractVerificationFieldMethod = ({ control, isDisabled, methods }: Props
const renderPopoverListItem = React.useCallback((method: SmartContractVerificationMethod) => {
switch (method) {
case 'flattened-code':
return <ListItem>Verification through flattened source code.</ListItem>;
return <ListItem key={ method }>Verification through flattened source code.</ListItem>;
case 'multi-part':
return <ListItem>Verification of multi-part Solidity files.</ListItem>;
return <ListItem key={ method }>Verification of multi-part Solidity files.</ListItem>;
case 'sourcify':
return <ListItem>Verification through <Link href="https://sourcify.dev/" target="_blank">Sourcify</Link>.</ListItem>;
return <ListItem key={ method }>Verification through <Link href="https://sourcify.dev/" target="_blank">Sourcify</Link>.</ListItem>;
case 'standard-input':
return (
<ListItem>
<ListItem key={ method }>
<span>Verification using </span>
<Link
href="https://docs.soliditylang.org/en/latest/using-the-compiler.html#input-description"
......@@ -79,9 +79,9 @@ const ContractVerificationFieldMethod = ({ control, isDisabled, methods }: Props
</ListItem>
);
case 'vyper-code':
return <ListItem>Verification of Vyper contract.</ListItem>;
return <ListItem key={ method }>Verification of Vyper contract.</ListItem>;
case 'vyper-multi-part':
return <ListItem>Verification of multi-part Vyper files.</ListItem>;
return <ListItem key={ method }>Verification of multi-part Vyper files.</ListItem>;
}
}, []);
......
......@@ -15,8 +15,8 @@ export interface FormFieldsFlattenSourceCode {
method: MethodOption;
is_yul: boolean;
name: string;
compiler: Option;
evm_version: Option;
compiler: Option | null;
evm_version: Option | null;
is_optimization_enabled: boolean;
optimization_runs: string;
code: string;
......@@ -28,7 +28,7 @@ export interface FormFieldsFlattenSourceCode {
export interface FormFieldsStandardInput {
method: MethodOption;
name: string;
compiler: Option;
compiler: Option | null;
sources: Array<File>;
autodetect_constructor_args: boolean;
constructor_args: string;
......@@ -42,8 +42,8 @@ export interface FormFieldsSourcify {
export interface FormFieldsMultiPartFile {
method: MethodOption;
compiler: Option;
evm_version: Option;
compiler: Option | null;
evm_version: Option | null;
is_optimization_enabled: boolean;
optimization_runs: string;
sources: Array<File>;
......@@ -53,15 +53,15 @@ export interface FormFieldsMultiPartFile {
export interface FormFieldsVyperContract {
method: MethodOption;
name: string;
compiler: Option;
compiler: Option | null;
code: string;
constructor_args: string;
}
export interface FormFieldsVyperMultiPartFile {
method: MethodOption;
compiler: Option;
evm_version: Option;
compiler: Option | null;
evm_version: Option | null;
sources: Array<File>;
}
......
......@@ -10,7 +10,7 @@ import type {
FormFieldsVyperContract,
FormFieldsVyperMultiPartFile,
} from './types';
import type { SmartContractVerificationMethod, SmartContractVerificationError } from 'types/api/contract';
import type { SmartContractVerificationMethod, SmartContractVerificationError, SmartContractVerificationConfig } from 'types/api/contract';
import type { Params as FetchParams } from 'lib/hooks/useFetch';
......@@ -32,7 +32,7 @@ export const METHOD_LABELS: Record<SmartContractVerificationMethod, string> = {
'vyper-multi-part': 'Vyper (Multi-part files)',
};
export const DEFAULT_VALUES = {
export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields> = {
'flattened-code': {
method: {
value: 'flattened-code' as const,
......@@ -75,7 +75,7 @@ export const DEFAULT_VALUES = {
compiler: null,
evm_version: null,
is_optimization_enabled: true,
optimization_runs: 200,
optimization_runs: '200',
sources: [],
libraries: [],
},
......@@ -100,6 +100,22 @@ export const DEFAULT_VALUES = {
},
};
export function getDefaultValues(method: SmartContractVerificationMethod, config: SmartContractVerificationConfig) {
const defaultValues = DEFAULT_VALUES[method];
if ('evm_version' in defaultValues) {
if (method === 'flattened-code' || method === 'multi-part') {
defaultValues.evm_version = config.solidity_evm_versions.find((value) => value === 'default') ? { label: 'default', value: 'default' } : null;
}
if (method === 'vyper-multi-part') {
defaultValues.evm_version = config.vyper_evm_versions.find((value) => value === 'default') ? { label: 'default', value: 'default' } : null;
}
}
return defaultValues;
}
export function isValidVerificationMethod(method?: string): method is SmartContractVerificationMethod {
return method && SUPPORTED_VERIFICATION_METHODS.includes(method) ? true : false;
}
......@@ -141,7 +157,7 @@ export function prepareRequestBody(data: FormFields): FetchParams['body'] {
const _data = data as FormFieldsStandardInput;
const body = new FormData();
body.set('compiler_version', _data.compiler?.value);
_data.compiler && body.set('compiler_version', _data.compiler.value);
body.set('contract_name', _data.name);
body.set('autodetect_constructor_args', String(Boolean(_data.autodetect_constructor_args)));
body.set('constructor_args', _data.constructor_args);
......@@ -163,8 +179,8 @@ export function prepareRequestBody(data: FormFields): FetchParams['body'] {
const _data = data as FormFieldsMultiPartFile;
const body = new FormData();
body.set('compiler_version', _data.compiler?.value);
body.set('evm_version', _data.evm_version?.value);
_data.compiler && body.set('compiler_version', _data.compiler.value);
_data.evm_version && body.set('evm_version', _data.evm_version.value);
body.set('is_optimization_enabled', String(Boolean(_data.is_optimization_enabled)));
_data.is_optimization_enabled && body.set('optimization_runs', _data.optimization_runs);
......@@ -190,8 +206,8 @@ export function prepareRequestBody(data: FormFields): FetchParams['body'] {
const _data = data as FormFieldsVyperMultiPartFile;
const body = new FormData();
body.set('compiler_version', _data.compiler?.value);
body.set('evm_version', _data.evm_version?.value);
_data.compiler && body.set('compiler_version', _data.compiler.value);
_data.evm_version && body.set('evm_version', _data.evm_version.value);
addFilesToFormData(body, _data.sources);
return body;
......
......@@ -31,14 +31,25 @@ const Deposits = () => {
const text = (() => {
if (countersQuery.isLoading) {
return <Skeleton w={{ base: '100%', lg: '320px' }} h="26px" mb={ 6 } mt={{ base: 0, lg: 6 }}/>;
return (
<Skeleton
w={{ base: '100%', lg: '320px' }}
h="24px"
mb={{ base: 7, lg: isPaginationVisible ? 0 : 7 }}
mt={{ base: 1, lg: isPaginationVisible ? 0 : 7 }}
/>
);
}
if (countersQuery.isError) {
return null;
}
return <Text mb={{ base: 6, lg: isPaginationVisible ? 0 : 6 }}>A total of { countersQuery.data.toLocaleString('en') } deposits found</Text>;
return (
<Text mb={{ base: 6, lg: isPaginationVisible ? 0 : 6 }} lineHeight={{ base: '24px', lg: '32px' }}>
A total of { countersQuery.data.toLocaleString('en') } deposits found
</Text>
);
})();
const actionBar = (
......
......@@ -30,7 +30,14 @@ const OutputRoots = () => {
const text = (() => {
if (countersQuery.isLoading || isLoading) {
return <Skeleton w={{ base: '100%', lg: '400px' }} h={{ base: '48px', lg: '26px' }} mb={{ base: 6, lg: 7 }} mt={{ base: 0, lg: 7 }}/>;
return (
<Skeleton
w={{ base: '100%', lg: '400px' }}
h={{ base: '48px', lg: '24px' }}
mb={{ base: 6, lg: isPaginationVisible ? 0 : 7 }}
mt={{ base: 0, lg: isPaginationVisible ? 0 : 7 }}
/>
);
}
if (countersQuery.isError || isError || data?.items.length === 0) {
......
......@@ -31,7 +31,14 @@ const TxnBatches = () => {
const text = (() => {
if (countersQuery.isLoading || isLoading) {
return <Skeleton w={{ base: '100%', lg: '400px' }} h={{ base: '48px', lg: '26px' }} mb={{ base: 6, lg: 7 }} mt={{ base: 0, lg: 7 }}/>;
return (
<Skeleton
w={{ base: '100%', lg: '400px' }}
h={{ base: '48px', lg: '24px' }}
mb={{ base: 6, lg: isPaginationVisible ? 0 : 7 }}
mt={{ base: 0, lg: isPaginationVisible ? 0 : 7 }}
/>
);
}
if (countersQuery.isError || isError || data.items.length === 0) {
......
......@@ -5,6 +5,7 @@ import React from 'react';
import type { VerifiedContractsFilters } from 'types/api/contracts';
import useDebounce from 'lib/hooks/useDebounce';
import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import { apos } from 'lib/html-entities';
import getQueryParamString from 'lib/router/getQueryParamString';
......@@ -29,6 +30,8 @@ const VerifiedContracts = () => {
const debouncedSearchTerm = useDebounce(searchTerm || '', 300);
const isMobile = useIsMobile();
const { isError, isLoading, data, isPaginationVisible, pagination, onFilterChange } = useQueryWithPages({
resourceName: 'verified_contracts',
filters: { q: debouncedSearchTerm, filter: type },
......@@ -87,13 +90,15 @@ const VerifiedContracts = () => {
{ sortButton }
{ filterInput }
</HStack>
<ActionBar mt={ -6 }>
<HStack spacing={ 3 } display={{ base: 'none', lg: 'flex' }}>
{ typeFilter }
{ filterInput }
</HStack>
{ isPaginationVisible && <Pagination ml="auto" { ...pagination }/> }
</ActionBar>
{ (!isMobile || isPaginationVisible) && (
<ActionBar mt={ -6 }>
<HStack spacing={ 3 } display={{ base: 'none', lg: 'flex' }}>
{ typeFilter }
{ filterInput }
</HStack>
{ isPaginationVisible && <Pagination ml="auto" { ...pagination }/> }
</ActionBar>
) }
</>
);
......
......@@ -31,14 +31,25 @@ const Withdrawals = () => {
const text = (() => {
if (countersQuery.isLoading) {
return <Skeleton w={{ base: '100%', lg: '320px' }} h="26px" mb={ 6 } mt={{ base: 0, lg: 6 }}/>;
return (
<Skeleton
w={{ base: '100%', lg: '320px' }}
h="24px"
mb={{ base: 6, lg: isPaginationVisible ? 0 : 7 }}
mt={{ base: 0, lg: isPaginationVisible ? 0 : 7 }}
/>
);
}
if (countersQuery.isError) {
return null;
}
return <Text mb={{ base: 6, lg: isPaginationVisible ? 0 : 6 }}>A total of { countersQuery.data.toLocaleString('en') } withdrawals found</Text>;
return (
<Text mb={{ base: 6, lg: isPaginationVisible ? 0 : 6 }} lineHeight={{ base: '24px', lg: '32px' }}>
A total of { countersQuery.data.toLocaleString('en') } withdrawals found
</Text>
);
})();
const actionBar = (
......
import { chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type ReactAce from 'react-ace/lib/ace';
interface Props {
id: string;
value: string;
className?: string;
}
const CodeEditorBase = chakra(({ id, value, className }: Props) => {
const [ AceEditor, setAceEditor ] = React.useState<{default: typeof ReactAce} | null>(null);
React.useEffect(() => {
const load = async() => {
const component = await import('react-ace');
await import('ace-builds/src-noconflict/mode-csharp');
await import('ace-builds/src-noconflict/theme-tomorrow');
await import('ace-builds/src-noconflict/theme-tomorrow_night');
await import('ace-builds/src-noconflict/ext-language_tools');
setAceEditor(component);
};
load();
return () => {
setAceEditor(null);
};
}, []);
const theme = useColorModeValue('tomorrow', 'tomorrow_night');
if (!AceEditor) {
return null;
}
return (
<AceEditor.default
className={ className }
mode="csharp" // TODO need to find mode for solidity
theme={ theme }
value={ value }
name={ id }
editorProps={{ $blockScrolling: true }}
readOnly
width="100%"
showPrintMargin={ false }
maxLines={ 25 }
/>
);
});
const CodeEditor = ({ id, value }: Props) => {
// see theme/components/Textarea.ts variantFilledInactive
const bgColor = useColorModeValue('#f5f5f6', '#1a1b1b');
const gutterBgColor = useColorModeValue('gray.100', '#25282c');
return (
<CodeEditorBase
id={ id }
value={ value }
bgColor={ bgColor }
borderRadius="md"
overflow="hidden"
sx={{
'.ace_gutter': {
backgroundColor: gutterBgColor,
},
}}
/>
);
};
export default React.memo(CodeEditor);
......@@ -4,10 +4,10 @@ import React, { useEffect, useState } from 'react';
import CopyIcon from 'icons/copy.svg';
const CopyToClipboard = ({ text, className }: {text: string; className?: string}) => {
const { hasCopied, onCopy } = useClipboard(text, 3000);
const { hasCopied, onCopy } = useClipboard(text, 1000);
const [ copied, setCopied ] = useState(false);
// have to implement controlled tooltip because of the issue - https://github.com/chakra-ui/chakra-ui/issues/7107
const { isOpen, onOpen, onToggle, onClose } = useDisclosure();
const { isOpen, onOpen, onClose } = useDisclosure();
useEffect(() => {
if (hasCopied) {
......@@ -17,13 +17,8 @@ const CopyToClipboard = ({ text, className }: {text: string; className?: string}
}
}, [ hasCopied ]);
const handleClick = React.useCallback(() => {
onToggle();
onCopy();
}, [ onCopy, onToggle ]);
return (
<Tooltip label={ copied ? 'Copied' : 'Copy to clipboard' } isOpen={ isOpen }>
<Tooltip label={ copied ? 'Copied' : 'Copy to clipboard' } isOpen={ isOpen || copied }>
<IconButton
aria-label="copy"
icon={ <CopyIcon/> }
......@@ -32,7 +27,7 @@ const CopyToClipboard = ({ text, className }: {text: string; className?: string}
variant="simple"
display="inline-block"
flexShrink={ 0 }
onClick={ handleClick }
onClick={ onCopy }
className={ className }
onMouseEnter={ onOpen }
onMouseLeave={ onClose }
......
......@@ -25,7 +25,7 @@ interface AsyncSelectProps extends AsyncProps<Option, boolean, GroupBase<Option>
type Props = RegularSelectProps | AsyncSelectProps;
const FancySelect = (props: Props) => {
const FancySelect = (props: Props, ref: React.LegacyRef<HTMLDivElement>) => {
const menuZIndex = useToken('zIndices', 'dropdown');
const { colorMode } = useColorMode();
......@@ -42,6 +42,7 @@ const FancySelect = (props: Props) => {
variant="floating"
size={ props.size || 'md' }
isRequired={ props.isRequired }
ref={ ref }
{ ...(props.error ? { 'aria-invalid': true } : {}) }
{ ...(props.isDisabled ? { 'aria-disabled': true } : {}) }
{ ...(props.value ? { 'data-active': true } : {}) }
......
import { Stat, StatArrow, Text, chakra } from '@chakra-ui/react';
import { Text, Tooltip } from '@chakra-ui/react';
import React from 'react';
type Props = ({
type Props = {
value: number;
} | {
used: number;
target: number;
}) & {
className?: string;
}
const GasUsedToTargetRatio = (props: Props) => {
const percentage = (() => {
if ('value' in props) {
return props.value;
}
return (props.used / props.target - 1) * 100;
})();
const GasUsedToTargetRatio = ({ value }: Props) => {
return (
<Stat className={ props.className }>
<StatArrow type={ percentage >= 0 ? 'increase' : 'decrease' }/>
<Text as="span" color={ percentage >= 0 ? 'green.500' : 'red.500' } fontWeight={ 600 }>
{ Math.abs(percentage).toLocaleString('en', { maximumFractionDigits: 2 }) } %
<Tooltip label="% of Gas Target">
<Text variant="secondary">
{ (value > 0 ? '+' : '') + value.toLocaleString('en', { maximumFractionDigits: 2 }) }%
</Text>
</Stat>
</Tooltip>
);
};
export default React.memo(chakra(GasUsedToTargetRatio));
export default React.memo(GasUsedToTargetRatio);
......@@ -12,7 +12,7 @@ const WIDTH = 50;
const Utilization = ({ className, value, colorScheme = 'green' }: Props) => {
const valueString = (clamp(value * 100 || 0, 0, 100)).toLocaleString('en', { maximumFractionDigits: 2 }) + '%';
const colorGrayScheme = useColorModeValue('gray.500', 'gray.500');
const colorGrayScheme = useColorModeValue('gray.500', 'gray.400');
const color = colorScheme === 'gray' ? colorGrayScheme : 'green.500';
return (
......
......@@ -50,7 +50,7 @@ const AddressLink = (props: Props) => {
} else if (type === 'block') {
url = route({ pathname: '/block/[height]', query: { height: props.blockHeight } });
} else if (type === 'address_token') {
url = route({ pathname: '/address/[hash]', query: { hash, tab: 'token_transfers', token_hash: props.tokenHash, scroll_to_tabs: 'true' } });
url = route({ pathname: '/address/[hash]', query: { hash, tab: 'token_transfers', token: props.tokenHash, scroll_to_tabs: 'true' } });
} else {
url = route({ pathname: '/address/[hash]', query: { hash } });
}
......
import type { SystemStyleObject } from '@chakra-ui/react';
import { Box, useColorMode, Flex } from '@chakra-ui/react';
import type { EditorProps } from '@monaco-editor/react';
import MonacoEditor from '@monaco-editor/react';
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import React from 'react';
import type { File, Monaco } from './types';
import useClientRect from 'lib/hooks/useClientRect';
import useIsMobile from 'lib/hooks/useIsMobile';
import isMetaKey from 'lib/isMetaKey';
import CodeEditorBreadcrumbs from './CodeEditorBreadcrumbs';
import CodeEditorLoading from './CodeEditorLoading';
import CodeEditorSideBar, { CONTAINER_WIDTH as SIDE_BAR_WIDTH } from './CodeEditorSideBar';
import CodeEditorTabs from './CodeEditorTabs';
import addFileImportDecorations from './utils/addFileImportDecorations';
import getFullPathOfImportedFile from './utils/getFullPathOfImportedFile';
import * as themes from './utils/themes';
import useThemeColors from './utils/useThemeColors';
const EDITOR_OPTIONS: EditorProps['options'] = {
readOnly: true,
minimap: { enabled: false },
scrollbar: {
alwaysConsumeMouseWheel: true,
},
dragAndDrop: false,
};
const TABS_HEIGHT = 35;
const BREADCRUMBS_HEIGHT = 22;
const EDITOR_HEIGHT = 500;
interface Props {
data: Array<File>;
}
const CodeEditor = ({ data }: Props) => {
const [ instance, setInstance ] = React.useState<Monaco | undefined>();
const [ editor, setEditor ] = React.useState<monaco.editor.IStandaloneCodeEditor | undefined>();
const [ index, setIndex ] = React.useState(0);
const [ tabs, setTabs ] = React.useState([ data[index].file_path ]);
const [ isMetaPressed, setIsMetaPressed ] = React.useState(false);
const [ containerRect, containerNodeRef ] = useClientRect<HTMLDivElement>();
const { colorMode } = useColorMode();
const isMobile = useIsMobile();
const themeColors = useThemeColors();
const editorWidth = containerRect ? containerRect.width - (isMobile ? 0 : SIDE_BAR_WIDTH) : 0;
React.useEffect(() => {
instance?.editor.setTheme(colorMode === 'light' ? 'blockscout-light' : 'blockscout-dark');
}, [ colorMode, instance?.editor ]);
const handleEditorDidMount = React.useCallback((editor: monaco.editor.IStandaloneCodeEditor, monaco: Monaco) => {
setInstance(monaco);
setEditor(editor);
monaco.editor.defineTheme('blockscout-light', themes.light);
monaco.editor.defineTheme('blockscout-dark', themes.dark);
monaco.editor.setTheme(colorMode === 'light' ? 'blockscout-light' : 'blockscout-dark');
const loadedModels = monaco.editor.getModels();
const loadedModelsPaths = loadedModels.map((model) => model.uri.path);
const newModels = data.slice(1)
.filter((file) => !loadedModelsPaths.includes(file.file_path))
.map((file) => monaco.editor.createModel(file.source_code, 'sol', monaco.Uri.parse(file.file_path)));
loadedModels.concat(newModels).forEach(addFileImportDecorations);
editor.addAction({
id: 'close-tab',
label: 'Close current tab',
keybindings: [
monaco.KeyMod.Alt | monaco.KeyCode.KeyW,
],
contextMenuGroupId: 'navigation',
contextMenuOrder: 1.7,
run: function(editor) {
const model = editor.getModel();
const path = model?.uri.path;
if (path) {
handleTabClose(path, true);
}
},
});
// componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ ]);
const handleSelectFile = React.useCallback((index: number, lineNumber?: number) => {
setIndex(index);
setTabs((prev) => prev.some((item) => item === data[index].file_path) ? prev : ([ ...prev, data[index].file_path ]));
if (lineNumber !== undefined && !Object.is(lineNumber, NaN)) {
window.setTimeout(() => {
editor?.revealLineInCenter(lineNumber);
}, 0);
}
editor?.focus();
}, [ data, editor ]);
const handleTabSelect = React.useCallback((path: string) => {
const index = data.findIndex((item) => item.file_path === path);
if (index > -1) {
setIndex(index);
}
}, [ data ]);
const handleTabClose = React.useCallback((path: string, _isActive?: boolean) => {
setTabs((prev) => {
if (prev.length > 1) {
const tabIndex = prev.findIndex((item) => item === path);
const isActive = _isActive !== undefined ? _isActive : data[index].file_path === path;
if (isActive) {
const nextActiveIndex = data.findIndex((item) => item.file_path === prev[(tabIndex === 0 ? 1 : tabIndex - 1)]);
setIndex(nextActiveIndex);
}
return prev.filter((item) => item !== path);
}
return prev;
});
}, [ data, index ]);
const handleClick = React.useCallback((event: React.MouseEvent) => {
if (!isMetaPressed && !isMobile) {
return;
}
const target = event.target as HTMLSpanElement;
const isImportLink = target.classList.contains('import-link');
if (isImportLink) {
const path = target.innerText;
const fullPath = getFullPathOfImportedFile(data[index].file_path, path);
const fileIndex = data.findIndex((file) => file.file_path === fullPath);
if (fileIndex > -1) {
event.stopPropagation();
handleSelectFile(fileIndex);
}
}
}, [ data, handleSelectFile, index, isMetaPressed, isMobile ]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
isMetaKey(event) && setIsMetaPressed(true);
}, []);
const handleKeyUp = React.useCallback(() => {
setIsMetaPressed(false);
}, []);
const containerSx: SystemStyleObject = React.useMemo(() => ({
'.editor-container': {
position: 'absolute',
top: 0,
left: 0,
width: `${ editorWidth }px`,
height: '100%',
},
'.highlight': {
backgroundColor: themeColors['custom.findMatchHighlightBackground'],
},
'&&.meta-pressed .import-link': {
_hover: {
color: themeColors['custom.fileLink.hoverForeground'],
textDecoration: 'underline',
cursor: 'pointer',
},
},
}), [ editorWidth, themeColors ]);
if (data.length === 1) {
return (
<Box overflow="hidden" borderRadius="md" height={ `${ EDITOR_HEIGHT }px` }>
<MonacoEditor
language="sol"
path={ data[index].file_path }
defaultValue={ data[index].source_code }
options={ EDITOR_OPTIONS }
onMount={ handleEditorDidMount }
loading={ <CodeEditorLoading/> }
/>
</Box>
);
}
return (
<Flex
className={ isMetaPressed ? 'meta-pressed' : undefined }
overflow="hidden"
borderRadius="md"
width="100%"
height={ `${ EDITOR_HEIGHT + TABS_HEIGHT + BREADCRUMBS_HEIGHT }px` }
position="relative"
ref={ containerNodeRef }
sx={ containerSx }
onClick={ handleClick }
onKeyDown={ handleKeyDown }
onKeyUp={ handleKeyUp }
>
<Box flexGrow={ 1 }>
<CodeEditorTabs tabs={ tabs } activeTab={ data[index].file_path } onTabSelect={ handleTabSelect } onTabClose={ handleTabClose }/>
<CodeEditorBreadcrumbs path={ data[index].file_path }/>
<MonacoEditor
className="editor-container"
height={ `${ EDITOR_HEIGHT }px` }
language="sol"
path={ data[index].file_path }
defaultValue={ data[index].source_code }
options={ EDITOR_OPTIONS }
onMount={ handleEditorDidMount }
loading={ <CodeEditorLoading/> }
/>
</Box>
<CodeEditorSideBar
data={ data }
onFileSelect={ handleSelectFile }
monaco={ instance }
editor={ editor }
selectedFile={ data[index].file_path }
/>
</Flex>
);
};
export default React.memo(CodeEditor);
import { Flex, Box } from '@chakra-ui/react';
import React from 'react';
import stripLeadingSlash from 'lib/stripLeadingSlash';
import useThemeColors from 'ui/shared/monaco/utils/useThemeColors';
interface Props {
path: string;
}
const CodeEditorBreadcrumbs = ({ path }: Props) => {
const chunks = stripLeadingSlash(path).split('/');
const themeColors = useThemeColors();
return (
<Flex
color={ themeColors['breadcrumbs.foreground'] }
bgColor={ themeColors['editor.background'] }
pl="16px"
pr="8px"
flexWrap="wrap"
fontSize="13px"
lineHeight="22px"
alignItems="center"
>
{ chunks.map((chunk, index) => {
return (
<React.Fragment key={ index }>
{ index !== 0 && (
<Box
className="codicon codicon-breadcrumb-separator"
boxSize="16px"
_before={{
content: '"\\eab6"',
}}/>
) }
<Box>{ chunk }</Box>
</React.Fragment>
);
}) }
</Flex>
);
};
export default React.memo(CodeEditorBreadcrumbs);
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { File } from './types';
import CodeEditorFileTree from './CodeEditorFileTree';
import CoderEditorCollapseButton from './CoderEditorCollapseButton';
import composeFileTree from './utils/composeFileTree';
interface Props {
data: Array<File>;
onFileSelect: (index: number) => void;
selectedFile: string;
isActive: boolean;
setActionBarRenderer: React.Dispatch<React.SetStateAction<(() => JSX.Element) | undefined>>;
}
const CodeEditorFileExplorer = ({ data, onFileSelect, selectedFile, isActive, setActionBarRenderer }: Props) => {
const [ key, setKey ] = React.useState(0);
const tree = React.useMemo(() => {
return composeFileTree(data);
}, [ data ]);
const handleCollapseButtonClick = React.useCallback(() => {
setKey((prev) => prev + 1);
}, []);
const renderActionBar = React.useCallback(() => {
return (
<CoderEditorCollapseButton onClick={ handleCollapseButtonClick } label="Collapse folders"/>
);
}, [ handleCollapseButtonClick ]);
const handleFileClick = React.useCallback((event: React.MouseEvent) => {
const filePath = (event.currentTarget as HTMLDivElement).getAttribute('data-file-path');
const fileIndex = data.findIndex((item) => item.file_path === filePath);
if (fileIndex > -1) {
onFileSelect(fileIndex);
}
}, [ data, onFileSelect ]);
React.useEffect(() => {
isActive && setActionBarRenderer(() => renderActionBar);
}, [ isActive, renderActionBar, setActionBarRenderer ]);
return (
<Box>
<CodeEditorFileTree key={ key } tree={ tree } onItemClick={ handleFileClick } isCollapsed={ key > 0 } selectedFile={ selectedFile }/>
</Box>
);
};
export default React.memo(CodeEditorFileExplorer);
import type { ChakraProps } from '@chakra-ui/react';
import { Box, Accordion, AccordionButton, AccordionItem, AccordionPanel, Icon, chakra } from '@chakra-ui/react';
import React from 'react';
import type { FileTree } from './types';
import iconFile from './icons/file.svg';
import iconFolderOpen from './icons/folder-open.svg';
import iconFolder from './icons/folder.svg';
import iconSolidity from './icons/solidity.svg';
import useThemeColors from './utils/useThemeColors';
interface Props {
tree: FileTree;
level?: number;
isCollapsed?: boolean;
onItemClick: (event: React.MouseEvent) => void;
selectedFile: string;
}
const CodeEditorFileTree = ({ tree, level = 0, onItemClick, isCollapsed, selectedFile }: Props) => {
const itemProps: ChakraProps = {
borderWidth: '0px',
cursor: 'pointer',
lineHeight: '22px',
_last: {
borderBottomWidth: '0px',
},
};
const themeColors = useThemeColors();
return (
<Accordion allowMultiple defaultIndex={ isCollapsed ? undefined : tree.map((item, index) => index) } reduceMotion>
{
tree.map((leaf, index) => {
const leafName = <chakra.span overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">{ leaf.name }</chakra.span>;
if ('children' in leaf) {
return (
<AccordionItem key={ index } { ...itemProps }>
{ ({ isExpanded }) => (
<>
<AccordionButton
pr="8px"
py="0"
pl={ `${ 8 + 8 * level }px` }
_hover={{ bgColor: themeColors['custom.list.hoverBackground'] }}
fontSize="13px"
lineHeight="22px"
h="22px"
transitionDuration="0"
>
<Box
className="codicon codicon-tree-item-expanded"
transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' }
boxSize="16px"
mr="2px"
/>
<Icon as={ isExpanded ? iconFolderOpen : iconFolder } boxSize="16px" mr="4px"/>
{ leafName }
</AccordionButton>
<AccordionPanel p="0">
<CodeEditorFileTree
tree={ leaf.children }
level={ level + 1 }
onItemClick={ onItemClick }
isCollapsed={ isCollapsed }
selectedFile={ selectedFile }
/>
</AccordionPanel>
</>
) }
</AccordionItem>
);
}
const icon = /.sol|.yul|.vy$/.test(leaf.name) ? iconSolidity : iconFile;
return (
<AccordionItem
key={ index }
{ ...itemProps }
pl={ `${ 26 + (level * 8) }px` }
pr="8px"
onClick={ onItemClick }
data-file-path={ leaf.file_path }
display="flex"
alignItems="center"
overflow="hidden"
_hover={{
bgColor: selectedFile === leaf.file_path ? themeColors['list.inactiveSelectionBackground'] : themeColors['custom.list.hoverBackground'],
}}
bgColor={ selectedFile === leaf.file_path ? themeColors['list.inactiveSelectionBackground'] : 'none' }
>
<Icon as={ icon } boxSize="16px" mr="4px"/>
{ leafName }
</AccordionItem>
);
})
}
</Accordion>
);
};
export default React.memo(CodeEditorFileTree);
import { Center } from '@chakra-ui/react';
import React from 'react';
import ContentLoader from 'ui/shared/ContentLoader';
import useThemeColors from './utils/useThemeColors';
const CodeEditorLoading = () => {
const themeColors = useThemeColors();
return (
<Center bgColor={ themeColors['editor.background'] } w="100%" h="100%">
<ContentLoader/>
</Center>
);
};
export default React.memo(CodeEditorLoading);
import type { ChakraProps } from '@chakra-ui/react';
import { Accordion, Box, Input, InputGroup, InputRightElement, useBoolean } from '@chakra-ui/react';
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import React from 'react';
import type { File, Monaco, SearchResult } from './types';
import useDebounce from 'lib/hooks/useDebounce';
import CodeEditorSearchSection from './CodeEditorSearchSection';
import CoderEditorCollapseButton from './CoderEditorCollapseButton';
import useThemeColors from './utils/useThemeColors';
interface Props {
data: Array<File>;
monaco: Monaco | undefined;
onFileSelect: (index: number, lineNumber?: number) => void;
isInputStuck: boolean;
isActive: boolean;
setActionBarRenderer: React.Dispatch<React.SetStateAction<(() => JSX.Element) | undefined>>;
defaultValue: string;
}
const CodeEditorSearch = ({ monaco, data, onFileSelect, isInputStuck, isActive, setActionBarRenderer, defaultValue }: Props) => {
const [ searchTerm, changeSearchTerm ] = React.useState('');
const [ searchResults, setSearchResults ] = React.useState<Array<SearchResult>>([]);
const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>([]);
const [ isMatchCase, setMatchCase ] = useBoolean();
const [ isMatchWholeWord, setMatchWholeWord ] = useBoolean();
const [ isMatchRegex, setMatchRegex ] = useBoolean();
const decorations = React.useRef<Record<string, Array<string>>>({});
const themeColors = useThemeColors();
const debouncedSearchTerm = useDebounce(searchTerm, 300);
React.useEffect(() => {
changeSearchTerm(defaultValue);
}, [ defaultValue ]);
React.useEffect(() => {
if (!monaco) {
return;
}
if (!debouncedSearchTerm) {
setSearchResults([]);
}
const models = monaco.editor.getModels();
const matches = models.map((model) => model.findMatches(debouncedSearchTerm, false, isMatchRegex, isMatchCase, isMatchWholeWord ? 'true' : null, false));
models.forEach((model, index) => {
const filePath = model.uri.path;
const prevDecorations = decorations.current[filePath] || [];
const newDecorations: Array<monaco.editor.IModelDeltaDecoration> = matches[index].map(({ range }) => ({ range, options: { className: 'highlight' } }));
const newDecorationsIds = model.deltaDecorations(prevDecorations, newDecorations);
decorations.current[filePath] = newDecorationsIds;
});
const result: Array<SearchResult> = matches
.map((match, index) => {
const model = models[index];
return {
file_path: model.uri.path,
matches: match.map(({ range }) => ({ ...range, lineContent: model.getLineContent(range.startLineNumber) })),
};
})
.filter(({ matches }) => matches.length > 0);
setSearchResults(result.length > 0 ? result : []);
}, [ debouncedSearchTerm, isMatchCase, isMatchRegex, isMatchWholeWord, monaco ]);
React.useEffect(() => {
setExpandedSections(searchResults.map((item, index) => index));
}, [ searchResults ]);
const handleSearchTermChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
changeSearchTerm(event.target.value);
}, []);
const handleResultItemClick = React.useCallback((filePath: string, lineNumber: number) => {
const fileIndex = data.findIndex((item) => item.file_path === filePath);
if (fileIndex > -1) {
onFileSelect(fileIndex, Number(lineNumber));
}
}, [ data, onFileSelect ]);
const handleAccordionStateChange = React.useCallback((newValue: Array<number>) => {
setExpandedSections(newValue);
}, []);
const handleToggleCollapseClick = React.useCallback(() => {
if (expandedSections.length === 0) {
setExpandedSections(searchResults.map((item, index) => index));
} else {
setExpandedSections([]);
}
}, [ expandedSections.length, searchResults ]);
const renderActionBar = React.useCallback(() => {
return (
<CoderEditorCollapseButton
onClick={ handleToggleCollapseClick }
label={ expandedSections.length === 0 ? 'Expand all' : 'Collapse all' }
isDisabled={ searchResults.length === 0 }
isCollapsed={ expandedSections.length === 0 }
/>
);
}, [ expandedSections.length, handleToggleCollapseClick, searchResults.length ]);
React.useEffect(() => {
isActive && setActionBarRenderer(() => renderActionBar);
}, [ isActive, renderActionBar, setActionBarRenderer ]);
const buttonProps: ChakraProps = {
boxSize: '20px',
p: '1px',
cursor: 'pointer',
borderRadius: '3px',
borderWidth: '1px',
borderColor: 'transparent',
};
const searchResultNum = (() => {
if (!debouncedSearchTerm) {
return null;
}
const totalResults = searchResults.map(({ matches }) => matches.length).reduce((result, item) => result + item, 0);
if (!totalResults) {
return (
<Box px="8px" fontSize="13px" lineHeight="18px" mb="8px">
No results found. Review your settings for configured exclusions.
</Box>
);
}
return (
<Box px="8px" fontSize="13px" lineHeight="18px" mb="8px">
{ totalResults } result{ totalResults > 1 ? 's' : '' } in { searchResults.length } file{ searchResults.length > 1 ? 's' : '' }
</Box>
);
})();
return (
<Box>
<InputGroup
px="8px"
position="sticky"
top="35px"
left="0"
zIndex="2"
bgColor={ themeColors['sideBar.background'] }
pb="8px"
boxShadow={ isInputStuck ? 'md' : 'none' }
>
<Input
size="xs"
onChange={ handleSearchTermChange }
value={ searchTerm }
placeholder="Search"
variant="unstyled"
color={ themeColors['input.foreground'] }
bgColor={ themeColors['input.background'] }
borderRadius="none"
fontSize="13px"
lineHeight="20px"
borderWidth="1px"
borderColor={ themeColors['input.background'] }
py="2px"
pl="4px"
pr="75px"
transitionDuration="0"
_focus={{
borderColor: themeColors.focusBorder,
}}
/>
<InputRightElement w="auto" h="auto" right="12px" top="3px" columnGap="2px">
<Box
{ ...buttonProps }
className="codicon codicon-case-sensitive"
onClick={ setMatchCase.toggle }
bgColor={ isMatchCase ? themeColors['custom.inputOption.activeBackground'] : 'transparent' }
_hover={{ bgColor: isMatchCase ? themeColors['custom.inputOption.activeBackground'] : themeColors['custom.inputOption.hoverBackground'] }}
title="Match Case"
aria-label="Match Case"
/>
<Box
{ ...buttonProps }
className="codicon codicon-whole-word"
bgColor={ isMatchWholeWord ? themeColors['custom.inputOption.activeBackground'] : 'transparent' }
onClick={ setMatchWholeWord.toggle }
_hover={{ bgColor: isMatchWholeWord ? themeColors['custom.inputOption.activeBackground'] : themeColors['custom.inputOption.hoverBackground'] }}
title="Match Whole Word"
aria-label="Match Whole Word"
/>
<Box
{ ...buttonProps }
className="codicon codicon-regex"
bgColor={ isMatchRegex ? themeColors['custom.inputOption.activeBackground'] : 'transparent' }
onClick={ setMatchRegex.toggle }
_hover={{ bgColor: isMatchRegex ? themeColors['custom.inputOption.activeBackground'] : themeColors['custom.inputOption.hoverBackground'] }}
title="Use Regular Expression"
aria-label="Use Regular Expression"
/>
</InputRightElement>
</InputGroup>
{ searchResultNum }
<Accordion
key={ debouncedSearchTerm }
allowMultiple
index={ expandedSections }
onChange={ handleAccordionStateChange }
reduceMotion
>
{ searchResults.map((item) => <CodeEditorSearchSection key={ item.file_path } data={ item } onItemClick={ handleResultItemClick }/>) }
</Accordion>
</Box>
);
};
export default React.memo(CodeEditorSearch);
import { Box, chakra } from '@chakra-ui/react';
import React from 'react';
import type { SearchResult } from './types';
import type ArrayElement from 'types/utils/ArrayElement';
import useThemeColors from './utils/useThemeColors';
interface Props extends ArrayElement<SearchResult['matches']> {
filePath: string;
onClick: (event: React.MouseEvent) => void;
}
const calculateStartPosition = (lineContent: string, startColumn: number) => {
let start = 0;
for (let index = 0; index < startColumn; index++) {
const element = lineContent[index];
if (element === ' ') {
start = index + 1;
continue;
}
}
return start ? start - 1 : 0;
};
const CodeEditorSearchResultItem = ({ lineContent, filePath, onClick, startLineNumber, startColumn, endColumn }: Props) => {
const start = calculateStartPosition(lineContent, startColumn);
const themeColors = useThemeColors();
return (
<Box
pr="8px"
pl="36px"
fontSize="13px"
lineHeight="22px"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
cursor="pointer"
data-file-path={ filePath }
data-line-number={ startLineNumber }
onClick={ onClick }
transitionDuration="0"
_hover={{ bgColor: themeColors['custom.list.hoverBackground'] }}
>
<span>{ lineContent.slice(start, startColumn - 1) }</span>
<chakra.span bgColor={ themeColors['custom.findMatchHighlightBackground'] }>
{ lineContent.slice(startColumn - 1, endColumn - 1) }
</chakra.span>
<span>{ lineContent.slice(endColumn - 1) }</span>
</Box>
);
};
export default React.memo(CodeEditorSearchResultItem);
import { AccordionButton, AccordionItem, AccordionPanel, Icon, Box } from '@chakra-ui/react';
import React from 'react';
import type { SearchResult } from './types';
import CodeEditorSearchResultItem from './CodeEditorSearchResultItem';
import iconFile from './icons/file.svg';
import iconSolidity from './icons/solidity.svg';
import getFileName from './utils/getFileName';
import useThemeColors from './utils/useThemeColors';
interface Props {
data: SearchResult;
onItemClick: (filePath: string, lineNumber: number) => void;
}
const CodeEditorSearchSection = ({ data, onItemClick }: Props) => {
const fileName = getFileName(data.file_path);
const handleFileLineClick = React.useCallback((event: React.MouseEvent) => {
const lineNumber = Number((event.currentTarget as HTMLDivElement).getAttribute('data-line-number'));
if (!Object.is(lineNumber, NaN)) {
onItemClick(data.file_path, Number(lineNumber));
}
}, [ data.file_path, onItemClick ]);
const icon = /.sol|.yul|.vy$/.test(fileName) ? iconSolidity : iconFile;
const themeColors = useThemeColors();
return (
<AccordionItem borderWidth="0px" _last={{ borderBottomWidth: '0px' }} >
{ ({ isExpanded }) => (
<>
<AccordionButton
py={ 0 }
px={ 2 }
_hover={{ bgColor: themeColors['custom.list.hoverBackground'] }}
fontSize="13px"
transitionDuration="0"
lineHeight="22px"
alignItems="center"
>
<Box
className="codicon codicon-tree-item-expanded"
transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' }
width="20px"
height="22px"
py="3px"
/>
<Icon as={ icon } boxSize="16px" mr="4px"/>
<span>{ fileName }</span>
<Box className="monaco-count-badge" ml="auto" bgColor={ themeColors['badge.background'] }>{ data.matches.length }</Box>
</AccordionButton>
<AccordionPanel p={ 0 }>
{ data.matches.map((match) => (
<CodeEditorSearchResultItem
key={ data.file_path + '_' + match.startLineNumber + '_' + match.startColumn }
filePath={ data.file_path }
onClick={ handleFileLineClick }
{ ...match }
/>
),
) }
</AccordionPanel>
</>
) }
</AccordionItem>
);
};
export default React.memo(CodeEditorSearchSection);
import type { HTMLChakraProps } from '@chakra-ui/react';
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs, useBoolean } from '@chakra-ui/react';
import _throttle from 'lodash/throttle';
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import React from 'react';
import type { File, Monaco } from './types';
import { shift, cmd } from 'lib/html-entities';
import CodeEditorFileExplorer from './CodeEditorFileExplorer';
import CodeEditorSearch from './CodeEditorSearch';
import useThemeColors from './utils/useThemeColors';
interface Props {
monaco: Monaco | undefined;
editor: monaco.editor.IStandaloneCodeEditor | undefined;
data: Array<File>;
onFileSelect: (index: number, lineNumber?: number) => void;
selectedFile: string;
}
export const CONTAINER_WIDTH = 250;
const CodeEditorSideBar = ({ onFileSelect, data, monaco, editor, selectedFile }: Props) => {
const [ isStuck, setIsStuck ] = React.useState(false);
const [ isDrawerOpen, setIsDrawerOpen ] = useBoolean(false);
const [ tabIndex, setTabIndex ] = React.useState(0);
const [ searchValue, setSearchValue ] = React.useState('');
const [ actionBarRenderer, setActionBarRenderer ] = React.useState<() => JSX.Element>();
const themeColors = useThemeColors();
const tabProps: HTMLChakraProps<'button'> = {
fontFamily: 'heading',
textTransform: 'uppercase',
fontSize: '11px',
lineHeight: '35px',
fontWeight: 500,
color: themeColors['tab.inactiveForeground'],
_selected: {
color: themeColors['tab.activeForeground'],
},
px: 0,
letterSpacing: 0.3,
};
const handleScrollThrottled = React.useRef(_throttle((event: React.SyntheticEvent) => {
setIsStuck((event.target as HTMLDivElement).scrollTop > 0);
}, 100));
const handleFileSelect = React.useCallback((index: number, lineNumber?: number) => {
isDrawerOpen && setIsDrawerOpen.off();
onFileSelect(index, lineNumber);
}, [ isDrawerOpen, onFileSelect, setIsDrawerOpen ]);
React.useEffect(() => {
if (editor && monaco) {
editor.addAction({
id: 'file-explorer',
label: 'Show File Explorer',
keybindings: [
monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyE,
],
contextMenuGroupId: 'navigation',
contextMenuOrder: 1.5,
run: function() {
setTabIndex(0);
},
});
editor.addAction({
id: 'search-in-files',
label: 'Show Search in Files',
keybindings: [
monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyF,
],
contextMenuGroupId: 'navigation',
contextMenuOrder: 1.6,
run: function(editor) {
setTabIndex(1);
const selection = editor.getSelection();
const selectedValue = selection ? editor.getModel()?.getValueInRange(selection) : '';
setSearchValue(selectedValue || '');
},
});
}
}, [ editor, monaco ]);
return (
<>
<Box
w={ `${ CONTAINER_WIDTH }px` }
flexShrink={ 0 }
bgColor={ themeColors['sideBar.background'] }
fontSize="13px"
overflowY="scroll"
onScroll={ handleScrollThrottled.current }
position={{ base: 'absolute', lg: 'relative' }}
right={{ base: isDrawerOpen ? '0' : `-${ CONTAINER_WIDTH }px`, lg: '0' }}
top={{ base: 0, lg: undefined }}
h="100%"
pb="22px"
boxShadow={{ base: isDrawerOpen ? 'md' : 'none', lg: 'none' }}
zIndex={{ base: '2', lg: undefined }}
transitionProperty="right"
transitionDuration="normal"
transitionTimingFunction="ease-in-out"
borderTopRightRadius="md"
borderBottomRightRadius="md"
>
<Tabs isLazy lazyBehavior="keepMounted" variant="unstyled" size="13px" index={ tabIndex } onChange={ setTabIndex }>
<TabList
columnGap={ 3 }
position="sticky"
top={ 0 }
left={ 0 }
bgColor={ themeColors['sideBar.background'] }
zIndex="1"
px={ 2 }
boxShadow={ isStuck ? 'md' : 'none' }
borderTopRightRadius="md"
>
<Tab { ...tabProps } title={ `File explorer (${ shift + cmd }E)` }>Explorer</Tab>
<Tab { ...tabProps } title={ `Search in files (${ shift + cmd }F)` }>Search</Tab>
{ actionBarRenderer?.() }
</TabList>
<TabPanels>
<TabPanel p={ 0 }>
<CodeEditorFileExplorer
data={ data }
onFileSelect={ handleFileSelect }
selectedFile={ selectedFile }
isActive={ tabIndex === 0 }
setActionBarRenderer={ setActionBarRenderer }
/>
</TabPanel>
<TabPanel p={ 0 }>
<CodeEditorSearch
data={ data }
onFileSelect={ handleFileSelect }
monaco={ monaco }
isInputStuck={ isStuck }
isActive={ tabIndex === 1 }
setActionBarRenderer={ setActionBarRenderer }
defaultValue={ searchValue }
/>
</TabPanel>
</TabPanels>
</Tabs>
</Box>
<Box
boxSize="24px"
p="4px"
position="absolute"
display={{ base: 'block', lg: 'none' }}
right={ isDrawerOpen ? `${ CONTAINER_WIDTH - 1 }px` : '0' }
top="calc(50% - 12px)"
backgroundColor={ themeColors['sideBar.background'] }
borderTopLeftRadius="4px"
borderBottomLeftRadius="4px"
boxShadow="md"
onClick={ setIsDrawerOpen.toggle }
zIndex="1"
transitionProperty="right"
transitionDuration="normal"
transitionTimingFunction="ease-in-out"
title={ isDrawerOpen ? 'Open sidebar' : 'Close sidebar' }
aria-label={ isDrawerOpen ? 'Open sidebar' : 'Close sidebar' }
>
<Box
className="codicon codicon-tree-item-expanded"
transform={ isDrawerOpen ? 'rotate(-90deg)' : 'rotate(+90deg)' }
boxSize="16px"
/>
</Box>
</>
);
};
export default React.memo(CodeEditorSideBar);
import { Flex, Icon, chakra, Box } from '@chakra-ui/react';
import React from 'react';
import { alt } from 'lib/html-entities';
import useThemeColors from 'ui/shared/monaco/utils/useThemeColors';
import iconFile from './icons/file.svg';
import iconSolidity from './icons/solidity.svg';
import getFilePathParts from './utils/getFilePathParts';
interface Props {
isActive?: boolean;
path: string;
onClick: (path: string) => void;
onClose: (path: string) => void;
isCloseDisabled: boolean;
tabsPathChunks: Array<Array<string>>;
}
const CodeEditorTab = ({ isActive, path, onClick, onClose, isCloseDisabled, tabsPathChunks }: Props) => {
const [ fileName, folderName ] = getFilePathParts(path, tabsPathChunks);
const themeColors = useThemeColors();
const handleClick = React.useCallback(() => {
onClick(path);
}, [ onClick, path ]);
const handleClose = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
!isCloseDisabled && onClose(path);
}, [ isCloseDisabled, onClose, path ]);
const icon = /.sol|.yul|.vy$/.test(fileName) ? iconSolidity : iconFile;
return (
<Flex
pl="10px"
pr="4px"
fontSize="13px"
lineHeight="34px"
bgColor={ isActive ? themeColors['tab.activeBackground'] : themeColors['tab.inactiveBackground'] }
borderRightWidth="1px"
borderRightColor={ themeColors['tab.border'] }
borderBottomWidth="1px"
borderBottomColor={ isActive ? 'transparent' : themeColors['tab.border'] }
color={ isActive ? themeColors['tab.activeForeground'] : themeColors['tab.inactiveForeground'] }
alignItems="center"
fontWeight={ 400 }
cursor="pointer"
onClick={ handleClick }
_hover={{
'.codicon-close': {
visibility: 'visible',
},
}}
userSelect="none"
>
<Icon as={ icon } boxSize="16px" mr="4px"/>
<span>{ fileName }</span>
{ folderName && <chakra.span fontSize="11px" opacity={ 0.8 } ml={ 1 }>{ folderName[0] === '.' ? '' : '...' }{ folderName }</chakra.span> }
<Box
className="codicon codicon-close"
boxSize="20px"
ml="4px"
p="2px"
title={ `Close ${ isActive ? `(${ alt }W)` : '' }` }
aria-label="Close"
onClick={ handleClose }
borderRadius="sm"
opacity={ isCloseDisabled ? 0.3 : 1 }
visibility={{ base: 'visible', lg: isActive ? 'visible' : 'hidden' }}
color={ themeColors['icon.foreground'] }
_hover={{ bgColor: isCloseDisabled ? 'transparent' : themeColors['custom.inputOption.hoverBackground'] }}
/>
</Flex>
);
};
export default React.memo(CodeEditorTab);
import { Flex } from '@chakra-ui/react';
import React from 'react';
import CodeEditorTab from './CodeEditorTab';
import useThemeColors from './utils/useThemeColors';
interface Props {
tabs: Array<string>;
activeTab: string;
onTabSelect: (tab: string) => void;
onTabClose: (tab: string) => void;
}
const CodeEditorTabs = ({ tabs, activeTab, onTabSelect, onTabClose }: Props) => {
const themeColors = useThemeColors();
const tabsPathChunks = React.useMemo(() => {
return tabs.map((tab) => tab.split('/'));
}, [ tabs ]);
return (
<Flex
bgColor={ themeColors['sideBar.background'] }
flexWrap="wrap"
>
{ tabs.map((tab) => (
<CodeEditorTab
key={ tab }
path={ tab }
isActive={ activeTab === tab }
onClick={ onTabSelect }
onClose={ onTabClose }
isCloseDisabled={ tabs.length === 1 }
tabsPathChunks={ tabsPathChunks }
/>
)) }
</Flex>
);
};
export default React.memo(CodeEditorTabs);
import { Box } from '@chakra-ui/react';
import React from 'react';
import useThemeColors from './utils/useThemeColors';
interface Props {
onClick: () => void;
label: string;
isDisabled?: boolean;
isCollapsed?: boolean;
}
const CoderEditorCollapseButton = ({ onClick, label, isDisabled, isCollapsed }: Props) => {
const themeColors = useThemeColors();
return (
<Box
ml="auto"
alignSelf="center"
className={ isCollapsed ? 'codicon codicon-search-expand-results' : 'codicon codicon-collapse-all' }
opacity={ isDisabled ? 0.6 : 1 }
boxSize="20px"
p="2px"
borderRadius="sm"
_before={{
content: isCollapsed ? '"\\eb95"' : '"\\eac5"',
}}
_hover={{
bgColor: themeColors['custom.inputOption.hoverBackground'],
}}
onClick={ onClick }
cursor="pointer"
title={ label }
aria-label={ label }
/>
);
};
export default React.memo(CoderEditorCollapseButton);
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M13 9h5.5L13 3.5V9M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m5 2H6v16h12v-9h-7V4z" fill="#42a5f5"/>
</svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7a2 2 0 0 1 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#90a4ae"/>
</svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M10 4H4c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#90a4ae"/>
</svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="#0288d1">
<path d="m5.747 14.046 6.254 8.61 6.252-8.61-6.254 3.807z"/>
<path d="M11.999 1.343 5.747 11.83l6.252 3.807 6.253-3.807z"/>
</g>
</svg>
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
export interface File {
file_path: string;
source_code: string;
}
export interface FileTreeFile extends File {
name: string;
}
export interface FileTreeFolder {
name: string;
children: Array<FileTreeFile | FileTreeFolder>;
}
export type FileTree = Array<FileTreeFile | FileTreeFolder>;
export type Monaco = typeof monaco;
export interface SearchResult {
file_path: string;
matches: Array<
Pick<monaco.editor.FindMatch['range'], 'startColumn' | 'endColumn' | 'startLineNumber' | 'endLineNumber'> &
{ lineContent: string }
>;
}
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
export default function addFileImportDecorations(model: monaco.editor.ITextModel) {
const matches = model.findMatches('^import "((\\/|\\.)(\\w|\\.|\\/|-)+)"', false, true, false, null, true);
const decorations: Array<monaco.editor.IModelDeltaDecoration> = matches.map(({ range }) => ({
range: {
...range,
startColumn: range.startColumn + 8,
endColumn: range.endColumn - 1,
},
options: {
inlineClassName: 'import-link',
hoverMessage: {
value: 'Cmd/Win + click to open file',
},
},
}));
model.deltaDecorations([], decorations);
}
import composeFileTree from './composeFileTree';
const files = [
{
file_path: 'index.sol',
source_code: 'zero',
},
{
file_path: 'contracts/Zeta.eth.sol',
source_code: 'one',
},
{
file_path: '/_openzeppelin/contracts/utils/Context.sol',
source_code: 'two',
},
{
file_path: '/_openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol',
source_code: 'three',
},
{
file_path: '/_openzeppelin/contracts/token/ERC20/IERC20.sol',
source_code: 'four',
},
{
file_path: '/_openzeppelin/contracts/token/ERC20/ERC20.sol',
source_code: 'five',
},
];
test('builds correct file tree', () => {
const result = composeFileTree(files);
expect(result).toMatchInlineSnapshot(`
[
{
"children": [
{
"children": [
{
"children": [
{
"children": [
{
"children": [
{
"file_path": "/_openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol",
"name": "IERC20Metadata.sol",
"source_code": "three",
},
],
"name": "extensions",
},
{
"file_path": "/_openzeppelin/contracts/token/ERC20/ERC20.sol",
"name": "ERC20.sol",
"source_code": "five",
},
{
"file_path": "/_openzeppelin/contracts/token/ERC20/IERC20.sol",
"name": "IERC20.sol",
"source_code": "four",
},
],
"name": "ERC20",
},
],
"name": "token",
},
{
"children": [
{
"file_path": "/_openzeppelin/contracts/utils/Context.sol",
"name": "Context.sol",
"source_code": "two",
},
],
"name": "utils",
},
],
"name": "contracts",
},
],
"name": "_openzeppelin",
},
{
"children": [
{
"file_path": "contracts/Zeta.eth.sol",
"name": "Zeta.eth.sol",
"source_code": "one",
},
],
"name": "contracts",
},
{
"file_path": "index.sol",
"name": "index.sol",
"source_code": "zero",
},
]
`);
});
import type { File, FileTree } from '../types';
import stripLeadingSlash from 'lib/stripLeadingSlash';
import sortFileTree from './sortFileTree';
export default function composeFileTree(files: Array<File>) {
const result: FileTree = [];
type Level = {
result: FileTree;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} & Record<string, any>;
const level: Level = { result };
files.forEach((file) => {
const path = stripLeadingSlash(file.file_path);
const segments = path.split('/');
segments.reduce((acc, segment, currentIndex, array) => {
if (!acc[segment]) {
acc[segment] = { result: [] };
acc.result.push({
name: segment,
...(currentIndex === array.length - 1 ? file : { children: acc[segment].result }),
});
}
acc.result.sort(sortFileTree);
return acc[segment];
}, level);
});
return result.sort(sortFileTree);
}
// ensure that path always starts with /
export default function formatFilePath(path: string) {
if (path[0] === '.' && path[1] === '/') {
return path.slice(1);
}
if (path[0] === '/') {
return path;
}
return '/' + path;
}
export default function getFileName(path: string) {
const chunks = path.split('/');
return chunks[chunks.length - 1];
}
import getFilePathParts from './getFilePathParts';
it('computes correct chunks if all file name are unique', () => {
const result = getFilePathParts(
'/src/utils/Ownable.sol',
[
'/src/utils/EIP712.sol'.split('/'),
'/src/token/BaseERC20.sol'.split('/'),
],
);
expect(result).toEqual([ 'Ownable.sol', undefined ]);
});
it('computes correct chunks if files with the same name is not in folders with the same name', () => {
const result = getFilePathParts(
'/src/utils/Ownable.sol',
[
'/src/utils/EIP712.sol'.split('/'),
'/lib/_openzeppelin/contracts/contracts/access/Ownable.sol'.split('/'),
],
);
expect(result).toEqual([ 'Ownable.sol', '/utils' ]);
});
it('computes correct chunks if files with the same name is in folders with the same name', () => {
const result = getFilePathParts(
'/src/utils/Ownable.sol',
[
'/src/utils/EIP712.sol'.split('/'),
'/lib/_openzeppelin/contracts/src/utils/Ownable.sol'.split('/'),
],
);
expect(result).toEqual([ 'Ownable.sol', './src/utils' ]);
});
it('computes correct chunks if file is in root directory', () => {
const result = getFilePathParts(
'/Ownable.sol',
[
'/src/utils/EIP712.sol'.split('/'),
'/lib/_openzeppelin/contracts/src/utils/Ownable.sol'.split('/'),
],
);
expect(result).toEqual([ 'Ownable.sol', './' ]);
});
export default function getFilePathParts(path: string, tabsPathChunks: Array<Array<string>>): [string, string | undefined] {
const chunks = path.split('/');
const fileName = chunks[chunks.length - 1];
const folderName = getFolderName(chunks, tabsPathChunks);
return [ fileName, folderName ];
}
function getFolderName(chunks: Array<string>, tabsPathChunks: Array<Array<string>>): string | undefined {
const fileName = chunks[chunks.length - 1];
const otherTabsPathChunks = tabsPathChunks.filter((item) => item.join('/') !== chunks.join('/'));
const tabsWithSameFileName = otherTabsPathChunks.filter((tabChunks) => tabChunks[tabChunks.length - 1] === fileName);
if (tabsWithSameFileName.length === 0 || chunks.length <= 1) {
return;
}
if (chunks.length === 2) {
return './' + chunks[chunks.length - 2];
}
let result = '/' + chunks[chunks.length - 2];
for (let index = 3; index <= chunks.length; index++) {
const element = chunks[chunks.length - index];
if (element === '') {
result = '.' + result;
}
const subFolderNames = tabsWithSameFileName.map((tab) => tab[tab.length - index]);
if (subFolderNames.includes(element)) {
result = '/' + element + result;
} else {
break;
}
}
return result;
}
import getFullPathOfImportedFile from './getFullPathOfImportedFile';
it('construct correct absolute path', () => {
const result = getFullPathOfImportedFile(
'/foo/bar/baz/index.sol',
'./.././../abc/contract.sol',
);
expect(result).toBe('/foo/abc/contract.sol');
});
it('returns undefined if imported file is outside the base file folder', () => {
const result = getFullPathOfImportedFile(
'/index.sol',
'../../abc/contract.sol',
);
expect(result).toBeUndefined();
});
it('returns unmodified path if it is already absolute', () => {
const result = getFullPathOfImportedFile(
'/index.sol',
'/abc/contract.sol',
);
expect(result).toBe('/abc/contract.sol');
});
it('returns undefined for external path', () => {
const result = getFullPathOfImportedFile(
'/index.sol',
'https://github.com/ethereum/dapp/contract.sol',
);
expect(result).toBeUndefined();
});
import stripLeadingSlash from 'lib/stripLeadingSlash';
export default function getFullPathOfImportedFile(baseFilePath: string, importedFilePath: string) {
if (importedFilePath[0] === '/') {
return importedFilePath;
}
if (importedFilePath[0] !== '.') {
return;
}
const baseFileChunks = stripLeadingSlash(baseFilePath).split('/');
const importedFileChunks = importedFilePath.split('/');
const result: Array<string> = baseFileChunks.slice(0, -1);
for (let index = 0; index < importedFileChunks.length - 1; index++) {
const element = importedFileChunks[index];
if (element === '.') {
continue;
}
if (element === '..') {
if (result.length === 0) {
break;
}
result.pop();
continue;
}
result.push(element);
}
if (result.length === 0) {
return;
}
result.push(importedFileChunks[importedFileChunks.length - 1]);
return '/' + result.join('/');
}
import type { FileTree } from '../types';
import type ArrayElement from 'types/utils/ArrayElement';
export default function sortFileTree(a: ArrayElement<FileTree>, b: ArrayElement<FileTree>) {
if ('children' in a && !('children' in b)) {
return -1;
}
if ('children' in b && !('children' in a)) {
return 1;
}
return a.name.localeCompare(b.name);
}
export const light = {
base: 'vs' as const,
inherit: true,
rules: [],
colors: {
'editor.background': '#f5f5f6',
'editorWidget.background': '#f5f5f6',
'tab.activeBackground': '#f5f5f6',
'tab.inactiveBackground': 'rgb(236, 236, 236)',
'tab.activeForeground': '#101112', // black
'tab.inactiveForeground': '#4a5568', // gray.600
'tab.border': 'rgb(243, 243, 243)',
'icon.foreground': '#616161',
'input.foreground': '#616161',
'input.background': '#fff',
'list.inactiveSelectionBackground': '#e4e6f1',
'breadcrumbs.foreground': 'rgb(97, 97, 97)',
'badge.background': '#c4c4c4',
'sideBar.background': '#eee',
focusBorder: '#0090f1',
// not able to use rgba for standard variables, so we use custom prefix here
'custom.list.hoverBackground': 'rgba(16, 17, 18, 0.08)', // blackAlpha.200
'custom.findMatchHighlightBackground': 'rgba(234,92,0,0.33)',
'custom.inputOption.activeBackground': 'rgba(0, 144, 241, 0.2)',
'custom.inputOption.hoverBackground': 'rgba(184, 184, 184, 0.31)',
// don't know the name of this variables in vscode
'custom.fileLink.hoverForeground': '#4299E1', // blue.400
} as const,
};
export const dark = {
base: 'vs-dark' as const,
inherit: true,
rules: [],
colors: {
'editor.background': '#1a1b1b',
'editorWidget.background': '#1a1b1b',
'tab.activeBackground': '#1a1b1b', // black
'tab.inactiveBackground': 'rgb(45, 45, 45)',
'tab.activeForeground': '#fff', // white
'tab.inactiveForeground': '#a0aec0', // gray.400
'tab.border': 'rgb(37, 37, 38)',
'icon.foreground': '#616161',
'input.foreground': '#cccccc',
'input.background': '#3c3c3c',
'list.inactiveSelectionBackground': '#37373d',
'badge.background': '#4d4d4d',
'breadcrumbs.foreground': 'rgb(97, 97, 97)',
'sideBar.background': '#222',
focusBorder: '#007fd4',
// not able to use rgba for standard variables, so we use custom prefix here
'custom.list.hoverBackground': 'rgba(255, 255, 255, 0.08)', // whiteAlpha.200
'custom.findMatchHighlightBackground': 'rgba(234,92,0,0.33)',
'custom.inputOption.activeBackground': 'rgba(0, 127, 212, 0.4)',
'custom.inputOption.hoverBackground': 'rgba(90, 93, 94, 0.31)',
// don't know the name of this variables in vscode
'custom.fileLink.hoverForeground': '#4299E1', // blue.400
} as const,
};
import { useColorModeValue } from '@chakra-ui/react';
import * as themes from './themes';
export default function useThemeColors() {
const theme = useColorModeValue(themes.light, themes.dark);
return theme.colors;
}
import type { ResponsiveValue } from '@chakra-ui/react';
import { AspectRatio, chakra, Icon, Image, shouldForwardProp, Skeleton, useColorModeValue } from '@chakra-ui/react';
import { Box, AspectRatio, chakra, Icon, Image, shouldForwardProp, Skeleton, useColorModeValue } from '@chakra-ui/react';
import type { Property } from 'csstype';
import React from 'react';
......@@ -30,6 +30,7 @@ const Fallback = ({ className, padding }: FallbackProps) => {
const NftImage = ({ url, className, fallbackPadding, objectFit }: Props) => {
const [ isLoading, setIsLoading ] = React.useState(true);
const [ isError, setIsError ] = React.useState(false);
const handleLoad = React.useCallback(() => {
setIsLoading(false);
......@@ -37,10 +38,37 @@ const NftImage = ({ url, className, fallbackPadding, objectFit }: Props) => {
const handleLoadError = React.useCallback(() => {
setIsLoading(false);
setIsError(true);
}, []);
const _objectFit = objectFit || 'contain';
const content = (() => {
// as of ChakraUI v2.5.3
// fallback prop of Image component doesn't work well with loading prop lazy strategy
// so we have to render fallback and loader manually
if (isError || !url) {
return <Fallback className={ className } padding={ fallbackPadding }/>;
}
return (
<Box>
{ isLoading && <Skeleton position="absolute" left={ 0 } top={ 0 } w="100%" h="100%" zIndex="1"/> }
<Image
w="100%"
h="100%"
objectFit={ _objectFit }
src={ url }
opacity={ isLoading ? 0 : 1 }
alt="Token instance image"
onError={ handleLoadError }
onLoad={ handleLoad }
loading={ url ? 'lazy' : undefined }
/>
</Box>
);
})();
return (
<AspectRatio
className={ className }
......@@ -54,17 +82,7 @@ const NftImage = ({ url, className, fallbackPadding, objectFit }: Props) => {
},
}}
>
<Image
w="100%"
h="100%"
objectFit={ _objectFit }
src={ url || undefined }
alt="Token instance image"
fallback={ url && isLoading ? <Skeleton/> : <Fallback className={ className } padding={ fallbackPadding }/> }
onError={ handleLoadError }
onLoad={ handleLoad }
loading={ url ? 'lazy' : undefined }
/>
{ content }
</AspectRatio>
);
};
......
......@@ -32,7 +32,7 @@ const Sort = <Sort extends string>({ sort, setSort, options }: Props<Sort>) => {
return (
<Menu>
<MenuButton>
<MenuButton as="div">
<SortButton
isActive={ isOpen || Boolean(sort) }
onClick={ onToggle }
......
......@@ -74,9 +74,19 @@ const TokenDetails = ({ tokenQuery }: Props) => {
total_supply: totalSupply,
decimals,
symbol,
type,
} = tokenQuery.data;
const totalValue = totalSupply !== null ? getCurrencyValue({ value: totalSupply, accuracy: 3, accuracyUsd: 2, exchangeRate, decimals }) : undefined;
let marketcap;
let totalSupplyValue;
if (type === 'ERC-20') {
const totalValue = totalSupply !== null ? getCurrencyValue({ value: totalSupply, accuracy: 3, accuracyUsd: 2, exchangeRate, decimals }) : undefined;
marketcap = totalValue?.usd;
totalSupplyValue = totalValue?.valueStr;
} else {
totalSupplyValue = Number(totalSupply).toLocaleString('en');
}
return (
<Grid
......@@ -94,13 +104,13 @@ const TokenDetails = ({ tokenQuery }: Props) => {
{ `$${ exchangeRate }` }
</DetailsInfoItem>
) }
{ totalValue?.usd && (
{ marketcap && (
<DetailsInfoItem
title="Fully diluted market cap"
hint="Total supply * Price"
alignSelf="center"
>
{ `$${ totalValue?.usd }` }
{ `$${ marketcap }` }
</DetailsInfoItem>
) }
<DetailsInfoItem
......@@ -112,7 +122,7 @@ const TokenDetails = ({ tokenQuery }: Props) => {
>
<Flex w="100%">
<Box whiteSpace="nowrap" overflow="hidden">
<HashStringShortenDynamic hash={ totalValue?.valueStr || '0' }/>
<HashStringShortenDynamic hash={ totalSupplyValue || '0' }/>
</Box>
<Box flexShrink={ 0 }> { symbol || '' }</Box>
</Flex>
......
......@@ -93,14 +93,12 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
/>
</Flex>
<Grid
mt={ 8 }
mt={ 5 }
columnGap={ 8 }
rowGap={{ base: 1, lg: 3 }}
templateColumns={{ base: 'minmax(0, 1fr)', lg: '200px minmax(0, 1fr)' }}
overflow="hidden"
>
{ divider }
<DetailsSponsoredItem/>
{ hasMetadata && (
<>
{ divider }
......@@ -148,6 +146,8 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
) }
</>
) }
{ divider }
<DetailsSponsoredItem/>
</Grid>
</>
);
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment