diff --git a/.changeset/tidy-trains-tap.md b/.changeset/tidy-trains-tap.md new file mode 100644 index 00000000..103d4b7a --- /dev/null +++ b/.changeset/tidy-trains-tap.md @@ -0,0 +1,15 @@ +--- +'@1hive/evmcrispr': patch +--- + +- New `print` command. +- New autocompletion suggestions for Aragon agents and tokens retrieved via the `@token` helper. +- New arithmetic operator `^` for exponentiation in mathematical calculations. +- Add support to the `goerli` network on the `aragonos` module. +- Add support to `payable` functions on the `exec` commands. +- Reintroduce `raw` command. +- Fix line endings in Windows. +- Fix `BigNumber` edge case operations. +- Fix underscored view function calls when using the call operator (`::`). For example: `token-manager::MINT_ROLE()`. +- Add improvements to `set` command. +- Add improvements to `@get` helper. diff --git a/packages/evmcrispr-terminal/package.json b/packages/evmcrispr-terminal/package.json index 1faa2a28..61fce521 100644 --- a/packages/evmcrispr-terminal/package.json +++ b/packages/evmcrispr-terminal/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@1hive/evmcrispr": "0.8.0", + "@chakra-ui/icons": "^2.0.11", "@chakra-ui/react": "^1.8.8", "@emotion/react": "^11", "@emotion/styled": "^11", diff --git a/packages/evmcrispr-terminal/src/App.tsx b/packages/evmcrispr-terminal/src/App.tsx index 71a0ed24..96f10d4c 100644 --- a/packages/evmcrispr-terminal/src/App.tsx +++ b/packages/evmcrispr-terminal/src/App.tsx @@ -1,124 +1,15 @@ import '@fontsource/ubuntu-mono'; import { HashRouter, Redirect, Route, Switch } from 'react-router-dom'; -import type { ComponentStyleConfig } from '@chakra-ui/react'; import { ChakraProvider, DarkMode, extendTheme } from '@chakra-ui/react'; +import { theme } from './theme'; + import Wagmi from './providers/Wagmi'; import Header from './components/header'; import Landing from './pages/landing'; -import { Terminal } from './pages/terminal'; - -const Modal: ComponentStyleConfig = { - // The styles all button have in common - parts: ['dialog'], - baseStyle: { - dialog: { - bg: 'black', - border: '3px solid', - borderColor: 'brand.green.300', - }, - }, -}; - -const Button: ComponentStyleConfig = { - // The styles all button have in common - baseStyle: { - borderRadius: 'none', // <-- border radius is same for all variants and sizes - textDecoration: 'none', - _hover: { - transition: 'all 0.5s', - }, - _focus: { - boxShadow: 'rgba(66, 153, 225, 0.6) 0px 0px 0px 2px', - }, - fontWeight: 'normal', - }, - // Two sizes: sm and md - sizes: { - sm: { - fontSize: 'sm', - px: 4, // <-- px is short for paddingLeft and paddingRight - py: 3, // <-- py is short for paddingTop and paddingBottom - }, - md: { - fontSize: 'md', - px: 6, // <-- these values are tokens from the design system - py: 4, // <-- these values are tokens from the design system - }, - lg: { - fontSize: '2xl', - px: 6, - py: 3, - }, - }, - // Two variants: outline and solid - variants: { - outline: { - border: '2px solid', - borderColor: 'brand.green.300', - color: 'brand.green.300', - }, - blue: { - color: 'brand.green.300', - bgColor: 'brand.blue.600', - _hover: { - bgColor: 'gray.900', - }, - }, - lime: { - color: 'gray.900', - bgColor: 'brand.green.300', - _hover: { - bgColor: 'gray.900', - color: 'brand.green.300', - }, - }, - warning: { - color: 'brand.warning.50', - bgColor: 'brand.warning.400', - _hover: { - bgColor: 'brand.warning.50', - color: 'brand.warning.400', - }, - }, - }, - // The default size and variant values - defaultProps: { - variant: 'solid', - size: 'lg', - }, -}; - -const theme = { - initialColorMode: 'dark', - useSystemColorMode: false, - colors: { - brand: { - green: { - 300: '#92ed5e', - 900: '#041800', - }, - warning: { - 50: '#ffe8df', - 400: '#ed6f2c', - }, - blue: { - 600: '#16169d', - 900: '#02071c', - }, - }, - }, - fonts: { - heading: 'Ubuntu Mono, monospace, sans-serif', - body: 'Ubuntu Mono, monospace, sans-serif', - }, - components: { - Modal, - Button, - }, -}; +import Terminal from './pages/terminal'; const App = () => { return ( diff --git a/packages/evmcrispr-terminal/src/components/action-buttons/error-msg.tsx b/packages/evmcrispr-terminal/src/components/action-buttons/error-msg.tsx new file mode 100644 index 00000000..66a4d62f --- /dev/null +++ b/packages/evmcrispr-terminal/src/components/action-buttons/error-msg.tsx @@ -0,0 +1,59 @@ +import { + Alert, + AlertDescription, + AlertIcon, + Box, + Button, + Collapse, +} from '@chakra-ui/react'; +import { useEffect, useRef, useState } from 'react'; + +const COLLAPSE_THRESHOLD = 30; + +export default function ErrorMsg({ errors }: { errors: string[] }) { + const [showCollapse, setShowCollapse] = useState(false); + const [showExpandBtn, setShowExpandBtn] = useState(false); + const contentRef = useRef(null); + + useEffect(() => { + if (!errors?.length) { + setShowExpandBtn(false); + } else if (contentRef.current) { + setShowExpandBtn(contentRef.current.clientHeight > COLLAPSE_THRESHOLD); + } + }, [errors]); + + return ( + + {errors.map((e, index) => ( + + + + + +
{e}
+
+
+
+
+ ))} + {showExpandBtn && ( + + )} +
+ ); +} diff --git a/packages/evmcrispr-terminal/src/components/action-buttons/index.tsx b/packages/evmcrispr-terminal/src/components/action-buttons/index.tsx new file mode 100644 index 00000000..b7934255 --- /dev/null +++ b/packages/evmcrispr-terminal/src/components/action-buttons/index.tsx @@ -0,0 +1,233 @@ +import { useState } from 'react'; +import { EVMcrispr, isProviderAction, parseScript } from '@1hive/evmcrispr'; +import { useConnect, useDisconnect } from 'wagmi'; +import { InjectedConnector } from 'wagmi/connectors/injected'; + +import type { Action, ForwardOptions } from '@1hive/evmcrispr'; +import type { Connector } from 'wagmi'; +import type { providers } from 'ethers'; + +import { + Button, + FormLabel, + HStack, + Switch, + VStack, + useBoolean, + useDisclosure, +} from '@chakra-ui/react'; + +import SelectWalletModal from '../wallet-modal'; +import LogModal from '../log-modal'; +import ErrorMsg from './error-msg'; + +// TODO: Migrate logic to evmcrispr +const executeActions = async ( + actions: Action[], + connector: Connector, + options?: ForwardOptions, +): Promise => { + const txs = []; + + if (!(connector instanceof InjectedConnector)) { + throw new Error( + `Provider action-returning commands are only supported by injected wallets (e.g. Metamask)`, + ); + } + + for (const action of actions) { + if (isProviderAction(action)) { + const [chainParam] = action.params; + + await connector.switchChain(Number(chainParam.chainId)); + } else { + const signer = await connector.getSigner(); + txs.push( + await ( + await signer.sendTransaction({ + ...action, + gasPrice: options?.gasPrice, + gasLimit: options?.gasLimit, + }) + ).wait(), + ); + } + } + return txs; +}; + +type ActionButtonsType = { + address: string; + terminalStoreActions: Record; + terminalStoreState: { + errors?: string[]; + isLoading: boolean; + script: any; + }; +}; + +export default function ActionButtons({ + address, + terminalStoreActions, + terminalStoreState, +}: ActionButtonsType) { + const [maximizeGasLimit, setMaximizeGasLimit] = useBoolean(false); + const [logs, setLogs] = useState([]); + const [url] = useState(''); + + const { + isOpen: isWalletModalOpen, + onOpen: onWalletModalOpen, + onClose: onWalletModalClose, + } = useDisclosure(); + const { + isOpen: isLogModalOpen, + onOpen: onLogModalOpen, + onClose: _onLogModalClose, + } = useDisclosure(); + + const { disconnect } = useDisconnect(); + const { activeConnector, isConnecting } = useConnect(); + + const { errors, isLoading, script } = terminalStoreState; + const addressShortened = `${address.slice(0, 6)}..${address.slice(-4)}`; + + async function onDisconnect() { + terminalStoreActions.errors([]); + disconnect(); + } + + function logListener(message: string, prevMessages: string[]) { + if (!isLogModalOpen) { + onLogModalOpen(); + } + console.log(message); + setLogs([...prevMessages, message]); + } + + function onLogModalClose() { + _onLogModalClose(); + setLogs([]); + } + + async function onExecute() { + terminalStoreActions.errors([]); + terminalStoreActions.isLoading(true); + + try { + const signer = await activeConnector?.getSigner(); + if (!activeConnector || signer === undefined || signer === null) + throw new Error('Account not connected'); + + const { ast, errors } = parseScript(script); + + if (errors.length) { + terminalStoreActions.isLoading(false); + terminalStoreActions.errors(errors); + return; + } + + const actions = await new EVMcrispr(ast, signer) + .registerLogListener(logListener) + .interpret(); + + await executeActions( + actions, + activeConnector, + maximizeGasLimit ? { gasLimit: 10_000_000 } : {}, + ); + + // TODO: adapt to cas11 changes + // const chainId = (await signer.provider?.getNetwork())?.chainId; + // setUrl(`https://${client(chainId)}/#/${connectedDAO.kernel.address}/${}`); + } catch (err: any) { + const e = err as Error; + console.error(e); + if ( + e.message.startsWith('transaction failed') && + /^0x[0-9a-f]{64}$/.test(e.message.split('"')[1]) + ) { + terminalStoreActions.errors([ + `Transaction failed, watch in block explorer ${ + e.message.split('"')[1] + }`, + ]); + } else { + terminalStoreActions.errors([e.message]); + } + } finally { + terminalStoreActions.isLoading(false); + } + } + + return ( + <> + + + + Maximize gas limit? + + + + + {!address ? ( + + ) : ( + <> + {url ? ( + + ) : null} + + + + + )} + + {errors ? : null} + + + + + + ); +} diff --git a/packages/evmcrispr-terminal/src/components/log-modal/index.tsx b/packages/evmcrispr-terminal/src/components/log-modal/index.tsx new file mode 100644 index 00000000..268652ad --- /dev/null +++ b/packages/evmcrispr-terminal/src/components/log-modal/index.tsx @@ -0,0 +1,62 @@ +import { + Alert, + AlertIcon, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Stack, +} from '@chakra-ui/react'; + +const status = (log: string) => { + return log.startsWith(':success:') + ? 'success' + : log.startsWith(':error:') + ? 'error' + : 'info'; +}; + +const stripString = (log: string): string => { + return log.startsWith(':success:') + ? log.slice(':success:'.length) + : log.startsWith(':error:') + ? log.slice(':error:'.length) + : log; +}; + +export default function LogModal({ + isOpen, + closeModal, + logs, +}: { + isOpen: boolean; + closeModal: () => void; + logs: string[]; +}) { + return ( + + + + Logs + + + + {logs.map((log, i) => ( + + + {stripString(log)} + + ))} + + + + + ); +} diff --git a/packages/evmcrispr-terminal/src/components/modal/index.tsx b/packages/evmcrispr-terminal/src/components/wallet-modal/index.tsx similarity index 100% rename from packages/evmcrispr-terminal/src/components/modal/index.tsx rename to packages/evmcrispr-terminal/src/components/wallet-modal/index.tsx diff --git a/packages/evmcrispr-terminal/src/pages/terminal/use-terminal-store.ts b/packages/evmcrispr-terminal/src/hooks/use-terminal-store.ts similarity index 97% rename from packages/evmcrispr-terminal/src/pages/terminal/use-terminal-store.ts rename to packages/evmcrispr-terminal/src/hooks/use-terminal-store.ts index abb37649..ca456f52 100644 --- a/packages/evmcrispr-terminal/src/pages/terminal/use-terminal-store.ts +++ b/packages/evmcrispr-terminal/src/hooks/use-terminal-store.ts @@ -9,8 +9,8 @@ import { BindingsManager, NodeType, parseScript } from '@1hive/evmcrispr'; import { createStore } from '@udecode/zustood'; import type { providers } from 'ethers'; -import { runEagerExecutions } from '../../editor/autocompletion'; -import { DEFAULT_MODULE_BINDING } from '../../utils'; +import { runEagerExecutions } from '../editor/autocompletion'; +import { DEFAULT_MODULE_BINDING } from '../utils'; const scriptPlaceholder = `# Available commands: # Standard commands: diff --git a/packages/evmcrispr-terminal/src/pages/terminal.tsx b/packages/evmcrispr-terminal/src/pages/terminal.tsx new file mode 100644 index 00000000..9cd672a0 --- /dev/null +++ b/packages/evmcrispr-terminal/src/pages/terminal.tsx @@ -0,0 +1,190 @@ +import { useEffect, useState } from 'react'; + +import { IPFSResolver } from '@1hive/evmcrispr'; +import { useAccount, useConnect, useProvider } from 'wagmi'; +import type { Monaco } from '@monaco-editor/react'; + +import MonacoEditor, { useMonaco } from '@monaco-editor/react'; +import { useChain, useSpringRef } from '@react-spring/web'; +import { Container } from '@chakra-ui/react'; + +import { + conf, + contribution, + createLanguage, + getModulesKeywords, +} from '../editor/evmcl'; +import { createProvideCompletionItemsFn } from '../editor/autocompletion'; +import { theme } from '../editor/theme'; + +import { + terminalStoreActions, + useTerminalStore, +} from '../hooks/use-terminal-store'; +import { useDebounce } from '../hooks/useDebounce'; + +import FadeIn from '../components/animations/fade-in'; +import Footer from '../components/footer'; +import ActionButtons from '../components/action-buttons'; + +const ipfsResolver = new IPFSResolver(); + +export default function Terminal() { + const monaco = useMonaco(); + const { bindingsCache, errors, isLoading, script, ast, currentModuleNames } = + useTerminalStore(); + + const { data: account } = useAccount(); + const { connectors, connect, isConnected } = useConnect(); + const provider = useProvider(); + + const terminalRef = useSpringRef(); + const buttonsRef = useSpringRef(); + const footerRef = useSpringRef(); + const [firstTry, setFirstTry] = useState(true); + + const address = account?.address ?? ''; + + const debouncedScript = useDebounce(script, 200); + + useChain([terminalRef, buttonsRef, footerRef]); + + /** + * Try to connect as soon as page mounts + * to have access to a provider to use on + * auto-completion + */ + useEffect(() => { + if (!firstTry || isConnected) { + return; + } + connect(connectors[0]); + setFirstTry(false); + }, [firstTry, connect, connectors, isConnected]); + + useEffect(() => { + terminalStoreActions.processScript(); + }, [debouncedScript]); + + useEffect(() => { + if (!monaco) { + return; + } + const { commandKeywords, helperKeywords } = getModulesKeywords( + currentModuleNames, + bindingsCache, + ); + + const tokensProvider = monaco.languages.setMonarchTokensProvider( + 'evmcl', + createLanguage(commandKeywords, helperKeywords), + ); + + return () => { + tokensProvider.dispose(); + }; + }, [monaco, currentModuleNames, bindingsCache]); + + useEffect(() => { + if (!monaco || !provider) { + return; + } + const completionProvider = monaco.languages.registerCompletionItemProvider( + 'evmcl', + { + provideCompletionItems: createProvideCompletionItemsFn( + bindingsCache, + { provider, ipfsResolver }, + ast, + ), + }, + ); + + return () => { + completionProvider.dispose(); + }; + }, [bindingsCache, monaco, provider, ast]); + + // Set up a script if we have one in the URL + useEffect(() => { + const encodedScript = new URLSearchParams( + window.location.hash.split('?')[1], + ).get('script'); + if (encodedScript) { + terminalStoreActions.script(encodedScript); + terminalStoreActions.processScript(); + } + }, []); + + function handleOnChangeEditor(str: string | undefined, ev: any) { + terminalStoreActions.script(str ?? ''); + const change = ev.changes[0]; + const startLineNumber = change.range.startLineNumber; + const newLine = change.text + ? change.text.split('\n').length + + startLineNumber - + // Substract current line + 1 + : startLineNumber; + terminalStoreActions.updateCurrentLine(newLine); + } + + function handleBeforeMountEditor(monaco: Monaco) { + monaco.editor.defineTheme('theme', theme); + monaco.languages.register(contribution); + monaco.languages.setLanguageConfiguration('evmcl', conf); + } + + function handleOnMountEditor(editor: any) { + editor.setPosition({ lineNumber: 10000, column: 0 }); + editor.focus(); + } + + return ( + <> + + + + + + + + + +