diff --git a/apps/remix-ide-e2e/src/commands/currentSelectedFileIs.ts b/apps/remix-ide-e2e/src/commands/currentSelectedFileIs.ts new file mode 100644 index 0000000000..81164cb97a --- /dev/null +++ b/apps/remix-ide-e2e/src/commands/currentSelectedFileIs.ts @@ -0,0 +1,15 @@ +import { NightwatchBrowser } from 'nightwatch' +import EventEmitter from 'events' + +class CurrentSelectedFileIs extends EventEmitter { + command (this: NightwatchBrowser, value: string): NightwatchBrowser { + this.api + .waitForElementContainsText('*[data-id="tabs-component"] *[data-id="tab-active"]', value) + .perform(() => { + this.emit('complete') + }) + return this + } +} + +module.exports = CurrentSelectedFileIs diff --git a/apps/remix-ide-e2e/src/tests/editor.test.ts b/apps/remix-ide-e2e/src/tests/editor.test.ts index 7b714a432b..59c3211e79 100644 --- a/apps/remix-ide-e2e/src/tests/editor.test.ts +++ b/apps/remix-ide-e2e/src/tests/editor.test.ts @@ -147,6 +147,7 @@ module.exports = { .waitForElementContainsText('.contextview .type', 'uint256') .waitForElementContainsText('.contextview .name', 'number') .click('.contextview [data-action="previous"]') // declaration + .pause(1000) .execute(() => { return (document.getElementById('editorView') as any).getCursorPosition() }, [], (result) => { @@ -154,6 +155,7 @@ module.exports = { browser.assert.equal(result.value, '180') }) .click('.contextview [data-action="next"]') // back to the initial state + .pause(1000) .execute(() => { return (document.getElementById('editorView') as any).getCursorPosition() }, [], (result) => { @@ -161,6 +163,7 @@ module.exports = { browser.assert.equal(result.value, '323') }) .click('.contextview [data-action="next"]') // next reference + .pause(1000) .execute(() => { return (document.getElementById('editorView') as any).getCursorPosition() }, [], (result) => { @@ -168,12 +171,74 @@ module.exports = { browser.assert.equal(result.value, '489') }) .click('.contextview [data-action="gotoref"]') // back to the declaration + .pause(1000) .execute(() => { return (document.getElementById('editorView') as any).getCursorPosition() }, [], (result) => { console.log('result', result) browser.assert.equal(result.value, '180') }) + }, + + 'Should display the context view, loop over "Owner" by switching file #group2': function (browser: NightwatchBrowser) { + browser + .clickLaunchIcon('solidity') + .click('[for="autoCompile"]') // disable auto compile + .openFile('contracts') + .openFile('contracts/3_Ballot.sol') + .waitForElementVisible('#editorView') + .setEditorValue(BallotWithARefToOwner) + .clickLaunchIcon('solidity') + .click('*[data-id="compilerContainerCompileBtn"]') // compile + .pause(2000) + .execute(() => { + (document.getElementById('editorView') as any).gotoLine(14, 6) + }, [], () => {}) + .waitForElementVisible('.contextview') + .waitForElementContainsText('.contextview .type', 'ContractDefinition') + .waitForElementContainsText('.contextview .name', 'Owner') + .click('.contextview [data-action="next"]') + .pause(1000) + .execute(() => { + return (document.getElementById('editorView') as any).getCursorPosition() + }, [], (result) => { + console.log('result', result) + browser.assert.equal(result.value, '1061') + }) + .click('.contextview [data-action="next"]') + .pause(1000) + .execute(() => { + return (document.getElementById('editorView') as any).getCursorPosition() + }, [], (result) => { + console.log('result', result) + browser.assert.equal(result.value, '122') + }) + .currentSelectedFileIs('2_Owner.sol') // make sure the current file has been properly changed + .click('.contextview [data-action="next"]') + .pause(1000) + .execute(() => { + return (document.getElementById('editorView') as any).getCursorPosition() + }, [], (result) => { + console.log('result', result) + browser.assert.equal(result.value, '211') + }) + .click('.contextview [data-action="next"]') + .currentSelectedFileIs('3_Ballot.sol') + .pause(1000) + .execute(() => { + return (document.getElementById('editorView') as any).getCursorPosition() + }, [], (result) => { + console.log('result', result) + browser.assert.equal(result.value, '1061') + }) + .click('.contextview [data-action="gotoref"]') // go to the declaration + .pause(1000) + .execute(() => { + return (document.getElementById('editorView') as any).getCursorPosition() + }, [], (result) => { + console.log('result', result) + browser.assert.equal(result.value, '122') + }) .end() } } @@ -281,3 +346,149 @@ contract Storage { return number; } }` + +const BallotWithARefToOwner = ` + + +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity >=0.7.0 <0.9.0; + +import "./2_Owner.sol"; + +/** + * @title Ballot + * @dev Implements voting process along with vote delegation + */ +contract Ballot { + Owner c; + struct Voter { + uint weight; // weight is accumulated by delegation + bool voted; // if true, that person already voted + address delegate; // person delegated to + uint vote; // index of the voted proposal + } + + struct Proposal { + // If you can limit the length to a certain number of bytes, + // always use one of bytes1 to bytes32 because they are much cheaper + bytes32 name; // short name (up to 32 bytes) + uint voteCount; // number of accumulated votes + } + + address public chairperson; + + mapping(address => Voter) public voters; + + Proposal[] public proposals; + + /** + * @dev Create a new ballot to choose one of 'proposalNames'. + * @param proposalNames names of proposals + */ + constructor(bytes32[] memory proposalNames) { + c = new Owner(); + chairperson = msg.sender; + voters[chairperson].weight = 1; + + for (uint i = 0; i < proposalNames.length; i++) { + // 'Proposal({...})' creates a temporary + // Proposal object and 'proposals.push(...)' + // appends it to the end of 'proposals'. + proposals.push(Proposal({ + name: proposalNames[i], + voteCount: 0 + })); + } + } + + /** + * @dev Give 'voter' the right to vote on this ballot. May only be called by 'chairperson'. + * @param voter address of voter + */ + function giveRightToVote(address voter) public { + require( + msg.sender == chairperson, + "Only chairperson can give right to vote." + ); + require( + !voters[voter].voted, + "The voter already voted." + ); + require(voters[voter].weight == 0); + voters[voter].weight = 1; + } + + /** + * @dev Delegate your vote to the voter 'to'. + * @param to address to which vote is delegated + */ + function delegate(address to) public { + Voter storage sender = voters[msg.sender]; + require(!sender.voted, "You already voted."); + require(to != msg.sender, "Self-delegation is disallowed."); + + while (voters[to].delegate != address(0)) { + to = voters[to].delegate; + + // We found a loop in the delegation, not allowed. + require(to != msg.sender, "Found loop in delegation."); + } + sender.voted = true; + sender.delegate = to; + Voter storage delegate_ = voters[to]; + if (delegate_.voted) { + // If the delegate already voted, + // directly add to the number of votes + proposals[delegate_.vote].voteCount += sender.weight; + } else { + // If the delegate did not vote yet, + // add to her weight. + delegate_.weight += sender.weight; + } + } + + /** + * @dev Give your vote (including votes delegated to you) to proposal 'proposals[proposal].name'. + * @param proposal index of proposal in the proposals array + */ + function vote(uint proposal) public { + Voter storage sender = voters[msg.sender]; + require(sender.weight != 0, "Has no right to vote"); + require(!sender.voted, "Already voted."); + sender.voted = true; + sender.vote = proposal; + + // If 'proposal' is out of the range of the array, + // this will throw automatically and revert all + // changes. + proposals[proposal].voteCount += sender.weight; + } + + /** + * @dev Computes the winning proposal taking all previous votes into account. + * @return winningProposal_ index of winning proposal in the proposals array + */ + function winningProposal() public view + returns (uint winningProposal_) + { + uint winningVoteCount = 0; + for (uint p = 0; p < proposals.length; p++) { + if (proposals[p].voteCount > winningVoteCount) { + winningVoteCount = proposals[p].voteCount; + winningProposal_ = p; + } + } + } + + /** + * @dev Calls winningProposal() function to get the index of the winner contained in the proposals array and then + * @return winnerName_ the name of the winner + */ + function winnerName() public view + returns (bytes32 winnerName_) + { + winnerName_ = proposals[winningProposal()].name; + } +} +` diff --git a/apps/remix-ide-e2e/src/types/index.d.ts b/apps/remix-ide-e2e/src/types/index.d.ts index f9a7c35d63..9abe5c2174 100644 --- a/apps/remix-ide-e2e/src/types/index.d.ts +++ b/apps/remix-ide-e2e/src/types/index.d.ts @@ -61,6 +61,7 @@ declare module 'nightwatch' { acceptAndRemember (this: NightwatchBrowser, remember: boolean, accept: boolean): NightwatchBrowser clearConsole (this: NightwatchBrowser): NightwatchBrowser clearTransactions (this: NightwatchBrowser): NightwatchBrowser + currentSelectedFileIs (name: string): NightwatchBrowser } export interface NightwatchBrowser { diff --git a/apps/remix-ide/src/app/editor/editor.js b/apps/remix-ide/src/app/editor/editor.js index 013b1b9853..1bc0a00b82 100644 --- a/apps/remix-ide/src/app/editor/editor.js +++ b/apps/remix-ide/src/app/editor/editor.js @@ -438,7 +438,7 @@ class Editor extends Plugin { if (!filePath) return filePath = await this.call('fileManager', 'getPathFromUrl', filePath) filePath = filePath.file - if (!this.sessions[filePath]) throw new Error('file not found' + filePath) + if (!this.sessions[filePath]) return const path = filePath || this.currentFile const { from } = this.currentRequest diff --git a/libs/remix-core-plugin/src/lib/editor-context-listener.ts b/libs/remix-core-plugin/src/lib/editor-context-listener.ts index 7bde42de11..9e73f6bc01 100644 --- a/libs/remix-core-plugin/src/lib/editor-context-listener.ts +++ b/libs/remix-core-plugin/src/lib/editor-context-listener.ts @@ -84,11 +84,6 @@ export class EditorContextListener extends Plugin { async _highlightItems (cursorPosition, compilationResult, file) { if (this.currentPosition === cursorPosition) return - if (this.currentFile !== file) { - this.currentFile = file - this.currentPosition = cursorPosition - return - } this._stopHighlighting() this.currentPosition = cursorPosition this.currentFile = file @@ -122,9 +117,13 @@ export class EditorContextListener extends Plugin { async _highlight (node, compilationResult) { if (!node) return const position = sourceMappingDecoder.decode(node.src) + const fileTarget = compilationResult.getSourceName(position.file) + const nodeFound = this._activeHighlights.find((el) => el.fileTarget === fileTarget && el.position.file === position.file && el.position.length === position.length && el.position.start === position.start) + if (nodeFound) return // if the content is already highlighted, do nothing. + await this._highlightInternal(position, node, compilationResult) if (compilationResult && compilationResult.languageversion.indexOf('soljson') === 0) { - this._activeHighlights.push({ position, fileTarget: compilationResult.getSourceName(position.file), nodeId: node.id }) + this._activeHighlights.push({ position, fileTarget, nodeId: node.id }) } } @@ -204,13 +203,16 @@ export class EditorContextListener extends Plugin { } _loadContractInfos (node) { + const path = (this.nodes.length && this.nodes[0].absolutePath) || this.results.source.target for (const i in this.nodes) { if (this.nodes[i].id === node.scope) { const contract = this.nodes[i] - this.contract = this.results.data.contracts[this.results.source.target][contract.name] - this.estimationObj = this.contract.evm.gasEstimates - this.creationCost = this.estimationObj === null ? '-' : this.estimationObj.creation.totalCost - this.codeDepositCost = this.estimationObj === null ? '-' : this.estimationObj.creation.codeDepositCost + this.contract = this.results.data.contracts[path][contract.name] + if (contract) { + this.estimationObj = this.contract.evm.gasEstimates + this.creationCost = this.estimationObj === null ? '-' : this.estimationObj.creation.totalCost + this.codeDepositCost = this.estimationObj === null ? '-' : this.estimationObj.creation.codeDepositCost + } } } } diff --git a/libs/remix-ui/editor-context-view/src/lib/remix-ui-editor-context-view.tsx b/libs/remix-ui/editor-context-view/src/lib/remix-ui-editor-context-view.tsx index d2668d57d3..7e25e32592 100644 --- a/libs/remix-ui/editor-context-view/src/lib/remix-ui-editor-context-view.tsx +++ b/libs/remix-ui/editor-context-view/src/lib/remix-ui-editor-context-view.tsx @@ -8,15 +8,26 @@ import './remix-ui-editor-context-view.css' export type astNode = { name: string, id: number, - children: Array, + children?: Array, typeDescriptions: any, nodeType: String, - src: any, - nodeId: any, - position: any + src: string // e.g "142:1361:0" +} + +export type nodePositionLight = { + file: number, + length: number, + start: number +} + +export type astNodeLight = { + fileTarget: String, + nodeId: number, + position: nodePositionLight } export type onContextListenerChangedListener = (nodes: Array) => void +export type ononCurrentFileChangedListener = (name: string) => void export type gasEstimationType = { executionCost: string, @@ -30,8 +41,9 @@ export interface RemixUiEditorContextViewProps { offsetToLineColumn: (position: any, file: any, sources: any, asts: any) => any, getCurrentFileName: () => String onContextListenerChanged: (listener: onContextListenerChangedListener) => void + onCurrentFileChanged: (listener: ononCurrentFileChangedListener) => void referencesOf: (nodes: astNode) => Array - getActiveHighlights: () => Array + getActiveHighlights: () => Array gasEstimation: (node: astNode) => gasEstimationType declarationOf: (node: astNode) => astNode } @@ -48,49 +60,56 @@ function isDefinition (node: any) { type nullableAstNode = astNode | null export function RemixUiEditorContextView (props: RemixUiEditorContextViewProps) { - /* - gotoLineDisableRef is used to temporarily disable the update of the view. - e.g when the user ask the component to "gotoLine" we don't want to rerender the component (but just to put the mouse on the desired line) - */ - const gotoLineDisableRef = useRef(false) + const loopOverReferences = useRef(0) + const currentNodeDeclaration = useRef(null) const [state, setState] = useState<{ nodes: Array, - references: Array, activeHighlights: Array - currentNode: nullableAstNode, gasEstimation: gasEstimationType }>({ nodes: [], - references: [], activeHighlights: [], - currentNode: null, gasEstimation: { executionCost: '', codeDepositCost: '' } }) useEffect(() => { + props.onCurrentFileChanged(() => { + currentNodeDeclaration.current = null + setState(prevState => { + return { ...prevState, nodes: [], activeHighlights: [] } + }) + }) + props.onContextListenerChanged(async (nodes: Array) => { - if (gotoLineDisableRef.current) { - gotoLineDisableRef.current = false - return - } - let currentNode + let nextNodeDeclaration + let nextNode if (!props.hide && nodes && nodes.length) { - currentNode = nodes[nodes.length - 1] - if (!isDefinition(currentNode)) { - currentNode = await props.declarationOf(currentNode) + nextNode = nodes[nodes.length - 1] + if (!isDefinition(nextNode)) { + nextNodeDeclaration = await props.declarationOf(nextNode) + } else { + nextNodeDeclaration = nextNode } } - let references + if (nextNodeDeclaration && currentNodeDeclaration.current && nextNodeDeclaration.id === currentNodeDeclaration.current.id) return + + currentNodeDeclaration.current = nextNodeDeclaration + let gasEstimation - if (currentNode) { - references = await props.referencesOf(currentNode) - if (currentNode.nodeType === 'FunctionDefinition') { - gasEstimation = await props.gasEstimation(currentNode) + if (currentNodeDeclaration.current) { + if (currentNodeDeclaration.current.nodeType === 'FunctionDefinition') { + gasEstimation = await props.gasEstimation(currentNodeDeclaration.current) } } - const activeHighlights = await props.getActiveHighlights() + const activeHighlights: Array = await props.getActiveHighlights() + if (nextNode && activeHighlights && activeHighlights.length) { + loopOverReferences.current = activeHighlights.findIndex((el: astNodeLight) => `${el.position.start}:${el.position.length}:${el.position.file}` === nextNode.src) + loopOverReferences.current = loopOverReferences.current === -1 ? 0 : loopOverReferences.current + } else { + loopOverReferences.current = 0 + } setState(prevState => { - return { ...prevState, nodes, references, activeHighlights, currentNode, gasEstimation } + return { ...prevState, nodes, activeHighlights, gasEstimation } }) }) }, []) @@ -123,8 +142,7 @@ export function RemixUiEditorContextView (props: RemixUiEditorContextViewProps) if (fileName !== await props.getCurrentFileName()) { await props.openFile(fileName) } - if (lineColumn.start && lineColumn.start.line && lineColumn.start.column) { - gotoLineDisableRef.current = true + if (lineColumn.start && lineColumn.start.line >= 0 && lineColumn.start.column >= 0) { props.gotoLine(lineColumn.start.line, lineColumn.end.column + 1) } } @@ -141,14 +159,14 @@ export function RemixUiEditorContextView (props: RemixUiEditorContextViewProps) } } - const _render = (node: nullableAstNode) => { + const _render = () => { + const node = currentNodeDeclaration.current if (!node) return (
) - const references = state.references + const references = state.activeHighlights const type = node.typeDescriptions && node.typeDescriptions.typeString ? node.typeDescriptions.typeString : node.nodeType const referencesCount = `${references ? references.length : '0'} reference(s)` - let ref = 0 - const nodes: Array = state.activeHighlights + const nodes: Array = state.activeHighlights const jumpTo = () => { if (node && node.src) { @@ -161,10 +179,10 @@ export function RemixUiEditorContextView (props: RemixUiEditorContextViewProps) // JUMP BETWEEN REFERENCES const jump = (e: any) => { - e.target.dataset.action === 'next' ? ref++ : ref-- - if (ref < 0) ref = nodes.length - 1 - if (ref >= nodes.length) ref = 0 - _jumpToInternal(nodes[ref].position) + e.target.dataset.action === 'next' ? loopOverReferences.current++ : loopOverReferences.current-- + if (loopOverReferences.current < 0) loopOverReferences.current = nodes.length - 1 + if (loopOverReferences.current >= nodes.length) loopOverReferences.current = 0 + _jumpToInternal(nodes[loopOverReferences.current].position) } return ( @@ -181,7 +199,7 @@ export function RemixUiEditorContextView (props: RemixUiEditorContextViewProps) return ( !props.hide &&
- {_render(state.currentNode)} + {_render()}
) } diff --git a/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx b/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx index 67df7cd443..82a18ef075 100644 --- a/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx +++ b/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx @@ -398,11 +398,12 @@ export const EditorUI = (props: EditorUIProps) => { props.plugin.call('editor', 'gotoLine', line, column)} - openFile={(file) => props.plugin.call('editor', 'openFile', file)} + openFile={(file) => props.plugin.call('fileManager', 'switchFile', file)} getLastCompilationResult={() => { return props.plugin.call('compilerArtefacts', 'getLastCompilationResult') } } offsetToLineColumn={(position, file, sources, asts) => { return props.plugin.call('offsetToLineColumnConverter', 'offsetToLineColumn', position, file, sources, asts) } } getCurrentFileName={() => { return props.plugin.call('fileManager', 'file') } } onContextListenerChanged={(listener) => { props.plugin.on('contextualListener', 'contextChanged', listener) }} + onCurrentFileChanged={(listener) => { props.plugin.on('fileManager', 'currentFileChanged', listener) }} referencesOf={(node: astNode) => { return props.plugin.call('contextualListener', 'referencesOf', node) }} getActiveHighlights={() => { return props.plugin.call('contextualListener', 'getActiveHighlights') }} gasEstimation={(node: astNode) => { return props.plugin.call('contextualListener', 'gasEstimation', node) }} diff --git a/libs/remix-ui/tabs/src/lib/remix-ui-tabs.tsx b/libs/remix-ui/tabs/src/lib/remix-ui-tabs.tsx index 3c791d1c1b..45632fc4de 100644 --- a/libs/remix-ui/tabs/src/lib/remix-ui-tabs.tsx +++ b/libs/remix-ui/tabs/src/lib/remix-ui-tabs.tsx @@ -36,7 +36,7 @@ export const TabsUI = (props: TabsUIProps) => { const classNameImg = 'my-1 mr-1 text-dark ' + tab.iconClass const classNameTab = 'nav-item nav-link d-flex justify-content-center align-items-center px-2 py-1 tab' + (index === currentIndexRef.current ? ' active' : '') return ( -
{ tabsRef.current[index] = el }} className={classNameTab} title={tab.tooltip}> +
{ tabsRef.current[index] = el }} className={classNameTab} data-id={index === currentIndexRef.current ? 'tab-active' : ''} title={tab.tooltip}> {tab.icon ? () : ()} {tab.title} { props.onClose(index); event.stopPropagation() }}> @@ -74,7 +74,7 @@ export const TabsUI = (props: TabsUIProps) => { }, []) return ( -
+
props.onZoomOut()}> diff --git a/libs/remix-ui/workspace/src/lib/actions/index.ts b/libs/remix-ui/workspace/src/lib/actions/index.ts index 390a1ff44d..e9bc5d1a62 100644 --- a/libs/remix-ui/workspace/src/lib/actions/index.ts +++ b/libs/remix-ui/workspace/src/lib/actions/index.ts @@ -179,7 +179,7 @@ export const createNewFolder = async (path: string, rootDir: string) => { const exists = await fileManager.exists(dirName) if (exists) { - return dispatch(displayNotification('Rename File Failed', `A file or folder ${extractNameFromKey(path)} already exists at this location. Please choose a different name.`, 'Close', null, () => {})) + return dispatch(displayNotification('Failed to create folder', `A folder ${extractNameFromKey(path)} already exists at this location. Please choose a different name.`, 'Close', null, () => {})) } await fileManager.mkdir(dirName) path = path.indexOf(rootDir + '/') === 0 ? path.replace(rootDir + '/', '') : path