Merge pull request #2113 from ethereum/fsfallback

Filesystem migration fallback
pull/5370/head
bunsenstraat 3 years ago committed by GitHub
commit 349854342e
  1. 105
      apps/remix-ide-e2e/src/tests/migrateFileSystem.test.ts
  2. 130
      apps/remix-ide/src/app/components/preload.tsx
  3. 23
      apps/remix-ide/src/app/components/styles/preload.css
  4. 72
      apps/remix-ide/src/app/files/fileSystem.ts
  5. 190
      apps/remix-ide/src/app/files/filesystems/fileSystemUtility.ts
  6. 91
      apps/remix-ide/src/app/files/filesystems/indexedDB.ts
  7. 57
      apps/remix-ide/src/app/files/filesystems/localStorage.ts
  8. 45
      apps/remix-ide/src/app/plugins/storage.ts
  9. 20
      apps/remix-ide/src/app/tabs/theme-module.js
  10. 63
      apps/remix-ide/src/assets/js/init.js
  11. 1
      apps/remix-ide/src/assets/js/lightning-fs.min.js
  12. 139
      apps/remix-ide/src/assets/js/migrate.js
  13. 4
      apps/remix-ide/src/index.html
  14. 50
      apps/remix-ide/src/index.tsx
  15. 2
      apps/remix-ide/src/production.index.html
  16. 2
      libs/remix-ui/home-tab/src/lib/remix-ui-home-tab.tsx
  17. 11
      libs/remix-ui/terminal/src/lib/remix-ui-terminal.tsx
  18. 5
      libs/remix-ui/terminal/src/lib/terminalWelcome.tsx
  19. 2
      package.json

@ -1,25 +1,90 @@
'use strict'
import { NightwatchBrowser } from 'nightwatch'
import init from '../helpers/init'
module.exports = {
before: function (browser: NightwatchBrowser, done: VoidFunction) {
init(browser, done, 'http://127.0.0.1:8080?e2e_testmigration=true', false)
'@disabled': true,
'Should load the testmigration url #group1': function (browser: NightwatchBrowser) {
browser.url('http://127.0.0.1:8080?e2e_testmigration=true')
.pause(6000)
.switchBrowserTab(0)
.maximizeWindow()
.waitForElementVisible('*[data-id="skipbackup-btn"]', 5000)
.click('*[data-id="skipbackup-btn"]')
.waitForElementVisible('[id="remixTourSkipbtn"]')
.click('[id="remixTourSkipbtn"]')
},
'Should have README file with TEST README as content': function (browser: NightwatchBrowser) {
browser.waitForElementVisible('*[data-id="remixIdeSidePanel"]', 5000)
'Should load the testmigration url and refresh and still have test data #group7': function (browser: NightwatchBrowser) {
browser.url('http://127.0.0.1:8080?e2e_testmigration=true')
.pause(6000)
.switchBrowserTab(0)
.maximizeWindow()
.waitForElementVisible('*[data-id="skipbackup-btn"]', 5000)
.click('*[data-id="skipbackup-btn"]')
.waitForElementVisible('[id="remixTourSkipbtn"]')
.click('[id="remixTourSkipbtn"]').refresh()
},
'should have indexedDB storage in terminal #group1 #group7': function (browser: NightwatchBrowser) {
browser.assert.containsText('*[data-id="terminalJournal"]', 'indexedDB')
},
'Should fallback to localstorage with default data #group2': function (browser: NightwatchBrowser) {
browser.url('http://127.0.0.1:8080?e2e_testmigration_fallback=true')
.pause(6000)
.switchBrowserTab(0)
.maximizeWindow()
.waitForElementVisible('[id="remixTourSkipbtn"]')
.click('[id="remixTourSkipbtn"]')
.waitForElementVisible('*[data-id="remixIdeSidePanel"]', 5000)
.waitForElementVisible('div[data-id="filePanelFileExplorerTree"]')
.openFile('README.txt')
.getEditorValue((content) => {
browser.assert.ok(content.includes('Output from script will appear in remix terminal.'))
})
.click('*[data-id="treeViewLitreeViewItemcontracts"]')
.openFile('contracts/1_Storage.sol')
.getEditorValue((content) => {
browser.assert.ok(content.includes('function retrieve() public view returns (uint256){'))
})
},
'Should load the testmigration url with local storage anabled #group3': function (browser: NightwatchBrowser) {
browser.url('http://127.0.0.1:8080?e2e_testmigration=true&e2e_testmigration_fallback=true')
.pause(6000)
.switchBrowserTab(0)
.maximizeWindow()
.waitForElementVisible('*[data-id="skipbackup-btn"]', 5000)
.click('*[data-id="skipbackup-btn"]')
.waitForElementVisible('[id="remixTourSkipbtn"]')
.click('[id="remixTourSkipbtn"]')
},
'Should generate error in migration by deleting indexedDB and falling back to local storage with test #group5': function (browser: NightwatchBrowser) {
browser.url('http://127.0.0.1:8080?e2e_testmigration=true')
.pause(6000)
.switchBrowserTab(0)
.maximizeWindow().execute(('delete window.indexedDB'))
.waitForElementVisible('*[data-id="skipbackup-btn"]', 5000)
.click('*[data-id="skipbackup-btn"]')
.waitForElementVisible('[id="remixTourSkipbtn"]')
.click('[id="remixTourSkipbtn"]')
},
'should have localstorage storage in terminal #group2 #group3 #group5': function (browser: NightwatchBrowser) {
browser.assert.containsText('*[data-id="terminalJournal"]', 'localstorage')
},
'Should have README file with TEST README as content #group1 #group3': function (browser: NightwatchBrowser) {
browser
.waitForElementVisible('*[data-id="remixIdeSidePanel"]', 5000)
.waitForElementVisible('div[data-id="filePanelFileExplorerTree"]')
.openFile('TEST_README.txt')
.getEditorValue((content) => {
browser.assert.equal(content, 'TEST README')
})
},
'Should have a workspace_test': function (browser: NightwatchBrowser) {
// these are test data entries
'Should have a workspace_test #group1 #group3 #group5 #group7': function (browser: NightwatchBrowser) {
browser.waitForElementVisible('*[data-id="remixIdeSidePanel"]', 5000)
.click('*[data-id="workspacesSelect"] option[value="workspace_test"]')
.waitForElementVisible('*[data-id="treeViewLitreeViewItemtest_contracts"]')
},
'Should have a sol file with test data': function (browser: NightwatchBrowser) {
'Should have a sol file with test data #group1 #group3 #group5 #group7': function (browser: NightwatchBrowser) {
browser.waitForElementVisible('*[data-id="remixIdeSidePanel"]', 5000)
.click('*[data-id="treeViewLitreeViewItemtest_contracts"]')
.openFile('test_contracts/1_Storage.sol')
@ -27,7 +92,7 @@ module.exports = {
browser.assert.equal(content, 'testing')
})
},
'Should have a artifacts file with JSON test data': function (browser: NightwatchBrowser) {
'Should have a artifacts file with JSON test data #group1 #group3 #group5 #group7': function (browser: NightwatchBrowser) {
browser.waitForElementVisible('*[data-id="remixIdeSidePanel"]', 5000)
.click('*[data-id="treeViewLitreeViewItemtest_contracts/artifacts"]')
.openFile('test_contracts/artifacts/Storage_metadata.json')
@ -35,5 +100,27 @@ module.exports = {
const metadata = JSON.parse(content)
browser.assert.equal(metadata.test, 'data')
})
}
},
'Should have a empty workspace #group1 #group3 #group5 #group7': function (browser: NightwatchBrowser) {
browser.waitForElementVisible('*[data-id="remixIdeSidePanel"]', 5000)
.click('*[data-id="workspacesSelect"] option[value="emptyspace"]')
},
// end of test data entries
'Should load with all storage blocked #group4': function (browser: NightwatchBrowser) {
browser.url('http://127.0.0.1:8080?e2e_testblock_storage=true')
.pause(6000)
.switchBrowserTab(0)
.maximizeWindow()
.assert.containsText('.alert-warning', 'Your browser does not support')
},
'Should with errors #group6': function (browser: NightwatchBrowser) {
browser.url('http://127.0.0.1:8080?e2e_testmigration=true')
.pause(6000)
.switchBrowserTab(0)
.maximizeWindow().execute('delete window.localStorage')
.waitForElementVisible('*[data-id="skipbackup-btn"]', 5000)
.click('*[data-id="skipbackup-btn"]')
.assert.containsText('.alert-danger', 'An unknown error')
},
}

@ -0,0 +1,130 @@
import { RemixApp } from '@remix-ui/app'
import React, { useEffect, useRef, useState } from 'react'
import { render } from 'react-dom'
import * as packageJson from '../../../../../package.json'
import { fileSystem, fileSystems } from '../files/fileSystem'
import { indexedDBFileSystem } from '../files/filesystems/indexedDB'
import { localStorageFS } from '../files/filesystems/localStorage'
import { fileSystemUtility, migrationTestData } from '../files/filesystems/fileSystemUtility'
import './styles/preload.css'
const _paq = window._paq = window._paq || []
export const Preload = () => {
const [supported, setSupported] = useState<boolean>(true)
const [error, setError] = useState<boolean>(false)
const [showDownloader, setShowDownloader] = useState<boolean>(false)
const remixFileSystems = useRef<fileSystems>(new fileSystems())
const remixIndexedDB = useRef<fileSystem>(new indexedDBFileSystem())
const localStorageFileSystem = useRef<fileSystem>(new localStorageFS())
// url parameters to e2e test the fallbacks and error warnings
const testmigrationFallback = useRef<boolean>(window.location.hash.includes('e2e_testmigration_fallback=true') && window.location.host === '127.0.0.1:8080' && window.location.protocol === 'http:')
const testmigrationResult = useRef<boolean>(window.location.hash.includes('e2e_testmigration=true') && window.location.host === '127.0.0.1:8080' && window.location.protocol === 'http:')
const testBlockStorage = useRef<boolean>(window.location.hash.includes('e2e_testblock_storage=true') && window.location.host === '127.0.0.1:8080' && window.location.protocol === 'http:')
function loadAppComponent() {
import('../../app').then((AppComponent) => {
const appComponent = new AppComponent.default()
appComponent.run().then(() => {
render(
<>
<RemixApp app={appComponent} />
</>,
document.getElementById('root')
)
})
}).catch(err => {
_paq.push(['_trackEvent', 'Preload', 'error', err && err.message])
console.log('Error loading Remix:', err)
setError(true)
})
}
const downloadBackup = async () => {
setShowDownloader(false)
const fsUtility = new fileSystemUtility()
await fsUtility.downloadBackup(remixFileSystems.current.fileSystems['localstorage'])
await migrateAndLoad()
}
const migrateAndLoad = async () => {
setShowDownloader(false)
const fsUtility = new fileSystemUtility()
const migrationResult = await fsUtility.migrate(localStorageFileSystem.current, remixIndexedDB.current)
_paq.push(['_trackEvent', 'Migrate', 'result', migrationResult?'success' : 'fail'])
await setFileSystems()
}
const setFileSystems = async() => {
const fsLoaded = await remixFileSystems.current.setFileSystem([(testmigrationFallback.current || testBlockStorage.current)? null: remixIndexedDB.current, testBlockStorage.current? null:localStorageFileSystem.current])
if (fsLoaded) {
console.log(fsLoaded.name + ' activated')
_paq.push(['_trackEvent', 'Storage', 'activate', fsLoaded.name])
loadAppComponent()
} else {
_paq.push(['_trackEvent', 'Storage', 'error', 'no supported storage'])
setSupported(false)
}
}
const testmigration = async() => {
if (testmigrationResult.current) {
const fsUtility = new fileSystemUtility()
fsUtility.populateWorkspace(migrationTestData, remixFileSystems.current.fileSystems['localstorage'].fs)
}
}
useEffect(() => {
async function loadStorage() {
await remixFileSystems.current.addFileSystem(remixIndexedDB.current) || _paq.push(['_trackEvent', 'Storage', 'error', 'indexedDB not supported'])
await remixFileSystems.current.addFileSystem(localStorageFileSystem.current) || _paq.push(['_trackEvent', 'Storage', 'error', 'localstorage not supported'])
await testmigration()
remixIndexedDB.current.loaded && await remixIndexedDB.current.checkWorkspaces()
localStorageFileSystem.current.loaded && await localStorageFileSystem.current.checkWorkspaces()
remixIndexedDB.current.loaded && ( (remixIndexedDB.current.hasWorkSpaces || !localStorageFileSystem.current.hasWorkSpaces)? await setFileSystems():setShowDownloader(true))
!remixIndexedDB.current.loaded && await setFileSystems()
}
loadStorage()
}, [])
return <>
<div className='preload-container'>
<div className='preload-logo pb-4'>
{logo}
<div className="info-secondary splash">
REMIX IDE
<br />
<span className='version'> v{packageJson.version}</span>
</div>
</div>
{!supported ?
<div className='preload-info-container alert alert-warning'>
Your browser does not support any of the filesytems required by Remix.
Either change the settings in your browser or use a supported browser.
</div> : null}
{error ?
<div className='preload-info-container alert alert-danger'>
An unknown error has occured loading the application.
</div> : null}
{showDownloader ?
<div className='preload-info-container alert alert-info'>
This app will be updated now. Please download a backup of your files now to make sure you don't lose your work.
<br></br>
You don't need to do anything else, your files will be available when the app loads.
<div onClick={async () => { await downloadBackup() }} data-id='downloadbackup-btn' className='btn btn-primary mt-1'>download backup</div>
<div onClick={async () => { await migrateAndLoad() }} data-id='skipbackup-btn' className='btn btn-primary mt-1'>skip backup</div>
</div> : null}
{(supported && !error && !showDownloader) ?
<div>
<i className="fas fa-spinner fa-spin fa-2x"></i>
</div> : null}
</div>
</>
}
const logo = <svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 105 100">
<path d="M91.84,35a.09.09,0,0,1-.1-.07,41,41,0,0,0-79.48,0,.09.09,0,0,1-.1.07C9.45,35,1,35.35,1,42.53c0,8.56,1,16,6,20.32,2.16,1.85,5.81,2.3,9.27,2.22a44.4,44.4,0,0,0,6.45-.68.09.09,0,0,0,.06-.15A34.81,34.81,0,0,1,17,45c0-.1,0-.21,0-.31a35,35,0,0,1,70,0c0,.1,0,.21,0,.31a34.81,34.81,0,0,1-5.78,19.24.09.09,0,0,0,.06.15,44.4,44.4,0,0,0,6.45.68c3.46.08,7.11-.37,9.27-2.22,5-4.27,6-11.76,6-20.32C103,35.35,94.55,35,91.84,35Z" />
<path d="M52,74,25.4,65.13a.1.1,0,0,0-.1.17L51.93,91.93a.1.1,0,0,0,.14,0L78.7,65.3a.1.1,0,0,0-.1-.17L52,74A.06.06,0,0,1,52,74Z" />
<path d="M75.68,46.9,82,45a.09.09,0,0,0,.08-.09,29.91,29.91,0,0,0-.87-6.94.11.11,0,0,0-.09-.08l-6.43-.58a.1.1,0,0,1-.06-.18l4.78-4.18a.13.13,0,0,0,0-.12,30.19,30.19,0,0,0-3.65-6.07.09.09,0,0,0-.11,0l-5.91,2a.1.1,0,0,1-.12-.14L72.19,23a.11.11,0,0,0,0-.12,29.86,29.86,0,0,0-5.84-4.13.09.09,0,0,0-.11,0l-4.47,4.13a.1.1,0,0,1-.17-.07l.09-6a.1.1,0,0,0-.07-.1,30.54,30.54,0,0,0-7-1.47.1.1,0,0,0-.1.07l-2.38,5.54a.1.1,0,0,1-.18,0l-2.37-5.54a.11.11,0,0,0-.11-.06,30,30,0,0,0-7,1.48.12.12,0,0,0-.07.1l.08,6.05a.09.09,0,0,1-.16.07L37.8,18.76a.11.11,0,0,0-.12,0,29.75,29.75,0,0,0-5.83,4.13.11.11,0,0,0,0,.12l2.59,5.6a.11.11,0,0,1-.13.14l-5.9-2a.11.11,0,0,0-.12,0,30.23,30.23,0,0,0-3.62,6.08.11.11,0,0,0,0,.12l4.79,4.19a.1.1,0,0,1-.06.17L23,37.91a.1.1,0,0,0-.09.07A29.9,29.9,0,0,0,22,44.92a.1.1,0,0,0,.07.1L28.4,47a.1.1,0,0,1,0,.18l-5.84,3.26a.16.16,0,0,0,0,.11,30.17,30.17,0,0,0,2.1,6.76c.32.71.67,1.4,1,2.08a.1.1,0,0,0,.06,0L52,68.16H52l26.34-8.78a.1.1,0,0,0,.06-.05,30.48,30.48,0,0,0,3.11-8.88.1.1,0,0,0-.05-.11l-5.83-3.26A.1.1,0,0,1,75.68,46.9Z" />
</svg>

@ -0,0 +1,23 @@
.preload-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
}
.preload-info-container {
display: flex;
flex-direction: column;
text-align: center;
max-width: 400px;
}
.preload-info-container .btn {
cursor: pointer;
}
.preload-logo {
min-width: 200px;
padding-bottom: 1.5rem !important;
}

@ -0,0 +1,72 @@
export class fileSystem {
name: string
enabled: boolean
available: boolean
fs: any
fsCallBack: any;
hasWorkSpaces: boolean
loaded: boolean
load: () => Promise<unknown>
test: () => Promise<unknown>
constructor() {
this.available = false
this.enabled = false
this.hasWorkSpaces = false
this.loaded = false
}
checkWorkspaces = async () => {
try {
await this.fs.stat('.workspaces')
this.hasWorkSpaces = true
} catch (e) {
}
}
set = async () => {
const w = (window as any)
if (!this.loaded) return false
w.remixFileSystem = this.fs
w.remixFileSystem.name = this.name
w.remixFileSystemCallback = this.fsCallBack
return true
}
}
export class fileSystems {
fileSystems: Record<string, fileSystem>
constructor() {
this.fileSystems = {}
}
addFileSystem = async (fs: fileSystem): Promise<boolean> => {
try {
this.fileSystems[fs.name] = fs
await fs.test() && await fs.load()
console.log(fs.name + ' is loaded...')
return true
} catch (e) {
console.log(fs.name + ' not available...')
return false
}
}
/**
* sets filesystem using list as fallback
* @param {string[]} names
* @returns {Promise}
*/
setFileSystem = async (filesystems?: fileSystem[]): Promise<fileSystem> => {
for (const fs of filesystems) {
if (fs && this.fileSystems[fs.name]) {
const result = await this.fileSystems[fs.name].set()
if (result) return this.fileSystems[fs.name]
}
}
return null
}
}

@ -0,0 +1,190 @@
import { hashMessage } from "ethers/lib/utils"
import JSZip from "jszip"
import { fileSystem } from "../fileSystem"
const _paq = window._paq = window._paq || []
export class fileSystemUtility {
migrate = async (fsFrom: fileSystem, fsTo: fileSystem) => {
try {
await fsFrom.checkWorkspaces()
await fsTo.checkWorkspaces()
if (fsTo.hasWorkSpaces) {
console.log(`${fsTo.name} already has files`)
return true
}
if (!fsFrom.hasWorkSpaces) {
console.log('no files to migrate')
return true
}
const fromFiles = await this.copyFolderToJson('/', null, null, fsFrom.fs)
await this.populateWorkspace(fromFiles, fsTo.fs)
const toFiles = await this.copyFolderToJson('/', null, null, fsTo.fs)
if (hashMessage(JSON.stringify(toFiles)) === hashMessage(JSON.stringify(fromFiles))) {
console.log('file migration successful')
return true
} else {
_paq.push(['_trackEvent', 'Migrate', 'error', 'hash mismatch'])
console.log('file migration failed falling back to ' + fsFrom.name)
fsTo.loaded = false
return false
}
} catch (err) {
console.log(err)
_paq.push(['_trackEvent', 'Migrate', 'error', err && err.message])
console.log('file migration failed falling back to ' + fsFrom.name)
fsTo.loaded = false
return false
}
}
downloadBackup = async (fs: fileSystem) => {
try {
const zip = new JSZip()
await fs.checkWorkspaces()
await this.copyFolderToJson('/', null, null, fs.fs, ({ path, content }) => {
zip.file(path, content)
})
const blob = await zip.generateAsync({ type: 'blob' })
const today = new Date()
const date = today.getFullYear() + '-' + (today.getMonth() + 1) + '-' + today.getDate()
const time = today.getHours() + 'h' + today.getMinutes() + 'min'
this.saveAs(blob, `remix-backup-at-${time}-${date}.zip`)
_paq.push(['_trackEvent','Backup','download','preload'])
} catch (err) {
_paq.push(['_trackEvent','Backup','error',err && err.message])
console.log(err)
}
}
populateWorkspace = async (json, fs) => {
for (const item in json) {
const isFolder = json[item].content === undefined
if (isFolder) {
await this.createDir(item, fs)
await this.populateWorkspace(json[item].children, fs)
} else {
await fs.writeFile(item, json[item].content, 'utf8')
}
}
}
/**
* copy the folder recursively
* @param {string} path is the folder to be copied over
* @param {Function} visitFile is a function called for each visited files
* @param {Function} visitFolder is a function called for each visited folders
*/
copyFolderToJson = async (path, visitFile, visitFolder, fs, cb = null) => {
visitFile = visitFile || (() => { })
visitFolder = visitFolder || (() => { })
return await this._copyFolderToJsonInternal(path, visitFile, visitFolder, fs, cb)
}
/**
* copy the folder recursively (internal use)
* @param {string} path is the folder to be copied over
* @param {Function} visitFile is a function called for each visited files
* @param {Function} visitFolder is a function called for each visited folders
*/
async _copyFolderToJsonInternal(path, visitFile, visitFolder, fs, cb) {
visitFile = visitFile || function () { /* do nothing. */ }
visitFolder = visitFolder || function () { /* do nothing. */ }
const json = {}
// path = this.removePrefix(path)
if (await fs.exists(path)) {
const items = await fs.readdir(path)
visitFolder({ path })
if (items.length !== 0) {
for (const item of items) {
const file: any = {}
const curPath = `${path}${path.endsWith('/') ? '' : '/'}${item}`
if ((await fs.stat(curPath)).isDirectory()) {
file.children = await this._copyFolderToJsonInternal(curPath, visitFile, visitFolder, fs, cb)
} else {
file.content = await fs.readFile(curPath, 'utf8')
if (cb) cb({ path: curPath, content: file.content })
visitFile({ path: curPath, content: file.content })
}
json[curPath] = file
}
}
}
return json
}
createDir = async (path, fs) => {
const paths = path.split('/')
if (paths.length && paths[0] === '') paths.shift()
let currentCheck = ''
for (const value of paths) {
currentCheck = currentCheck + (currentCheck ? '/' : '') + value
if (!await fs.exists(currentCheck)) {
await fs.mkdir(currentCheck)
}
}
}
saveAs = (blob, name) => {
const node = document.createElement('a')
node.download = name
node.rel = 'noopener'
node.href = URL.createObjectURL(blob)
setTimeout(function () { URL.revokeObjectURL(node.href) }, 4E4) // 40s
setTimeout(function () {
try {
node.dispatchEvent(new MouseEvent('click'))
} catch (e) {
const evt = document.createEvent('MouseEvents')
evt.initMouseEvent('click', true, true, window, 0, 0, 0, 80,
20, false, false, false, false, 0, null)
node.dispatchEvent(evt)
}
}, 0) // 40s
}
}
/* eslint-disable no-template-curly-in-string */
export const migrationTestData = {
'.workspaces': {
children: {
'.workspaces/default_workspace': {
children: {
'.workspaces/default_workspace/README.txt': {
content: 'TEST README'
}
}
},
'.workspaces/emptyspace': {
},
'.workspaces/workspace_test': {
children: {
'.workspaces/workspace_test/TEST_README.txt': {
content: 'TEST README'
},
'.workspaces/workspace_test/test_contracts': {
children: {
'.workspaces/workspace_test/test_contracts/1_Storage.sol': {
content: 'testing'
},
'.workspaces/workspace_test/test_contracts/artifacts': {
children: {
'.workspaces/workspace_test/test_contracts/artifacts/Storage_metadata.json': {
content: '{ "test": "data" }'
}
}
}
}
}
}
}
}
}
}

@ -0,0 +1,91 @@
import LightningFS from "@isomorphic-git/lightning-fs"
import { fileSystem } from "../fileSystem"
export class IndexedDBStorage extends LightningFS {
base: LightningFS.PromisifedFS
addSlash: (file: string) => string
extended: { exists: (path: string) => Promise<unknown>; rmdir: (path: any) => Promise<void>; readdir: (path: any) => Promise<string[]>; unlink: (path: any) => Promise<void>; mkdir: (path: any) => Promise<void>; readFile: (path: any, options: any) => Promise<Uint8Array>; rename: (from: any, to: any) => Promise<void>; writeFile: (path: any, content: any, options: any) => Promise<void>; stat: (path: any) => Promise<import("fs").Stats>; init(name: string, opt?: LightningFS.FSConstructorOptions): void; activate(): Promise<void>; deactivate(): Promise<void>; lstat(filePath: string): Promise<import("fs").Stats>; readlink(filePath: string): Promise<string>; symlink(target: string, filePath: string): Promise<void> }
constructor(name: string) {
super(name)
this.addSlash = (file) => {
if (!file.startsWith('/')) file = '/' + file
return file
}
this.base = this.promises
this.extended = {
...this.promises,
exists: async (path: string) => {
return new Promise((resolve) => {
this.base.stat(this.addSlash(path)).then(() => resolve(true)).catch(() => resolve(false))
})
},
rmdir: async (path) => {
return this.base.rmdir(this.addSlash(path))
},
readdir: async (path) => {
return this.base.readdir(this.addSlash(path))
},
unlink: async (path) => {
return this.base.unlink(this.addSlash(path))
},
mkdir: async (path) => {
return this.base.mkdir(this.addSlash(path))
},
readFile: async (path, options) => {
return this.base.readFile(this.addSlash(path), options)
},
rename: async (from, to) => {
return this.base.rename(this.addSlash(from), this.addSlash(to))
},
writeFile: async (path, content, options) => {
return this.base.writeFile(this.addSlash(path), content, options)
},
stat: async (path) => {
return this.base.stat(this.addSlash(path))
}
}
}
}
export class indexedDBFileSystem extends fileSystem {
constructor() {
super()
this.name = 'indexedDB'
}
load = async () => {
return new Promise((resolve, reject) => {
try {
const fs = new IndexedDBStorage('RemixFileSystem')
fs.init('RemixFileSystem')
this.fs = fs.extended
this.fsCallBack = fs
this.loaded = true
resolve(true)
} catch (e) {
reject(e)
}
})
}
test = async () => {
return new Promise((resolve, reject) => {
if (!window.indexedDB) {
this.available = false
reject('No indexedDB on window')
}
const request = window.indexedDB.open("RemixTestDataBase");
request.onerror = () => {
this.available = false
reject('Error creating test database')
};
request.onsuccess = () => {
window.indexedDB.deleteDatabase("RemixTestDataBase");
this.available = true
resolve(true)
};
})
}
}

@ -0,0 +1,57 @@
import { fileSystem } from "../fileSystem";
export class localStorageFS extends fileSystem {
constructor() {
super()
this.name = 'localstorage'
}
load = async () => {
const me = this
return new Promise((resolve, reject) => {
try {
const w = window as any
w.BrowserFS.install(window)
w.BrowserFS.configure({
fs: 'LocalStorage'
}, async function (e) {
if (e) {
console.log('BrowserFS Error: ' + e)
reject(e)
} else {
me.fs = { ...window.require('fs') }
me.fsCallBack = window.require('fs')
me.fs.readdir = me.fs.readdirSync
me.fs.readFile = me.fs.readFileSync
me.fs.writeFile = me.fs.writeFileSync
me.fs.stat = me.fs.statSync
me.fs.unlink = me.fs.unlinkSync
me.fs.rmdir = me.fs.rmdirSync
me.fs.mkdir = me.fs.mkdirSync
me.fs.rename = me.fs.renameSync
me.fs.exists = me.fs.existsSync
me.loaded = true
resolve(true)
}
})
} catch (e) {
console.log('BrowserFS is not ready!')
reject(e)
}
})
}
test = async () => {
return new Promise((resolve, reject) => {
const test = 'test';
try {
localStorage.setItem(test, test);
localStorage.removeItem(test);
resolve(true)
} catch(e) {
reject(e)
}
})
}
}

@ -4,7 +4,7 @@ const profile = {
name: 'storage',
displayName: 'Storage',
description: 'Storage',
methods: ['getStorage']
methods: ['getStorage', 'formatString']
};
export class StoragePlugin extends Plugin {
@ -13,10 +13,47 @@ export class StoragePlugin extends Plugin {
}
async getStorage() {
if ('storage' in navigator && 'estimate' in navigator.storage) {
return navigator.storage.estimate()
let storage = null
if ('storage' in navigator && 'estimate' in navigator.storage && (window as any).remixFileSystem.name !== 'localstorage') {
storage = navigator.storage.estimate()
} else {
throw new Error("Can't get storage quota");
storage ={
usage: parseFloat(this.calculateLocalStorage()) * 1000,
quota: 5000000,
}
}
const _paq = window._paq = window._paq || []
_paq.push(['trackEvent', 'Storage', 'used', this.formatString(storage)]);
return storage
}
formatString(storage) {
return `${this.formatBytes(storage.usage)} / ${this.formatBytes(storage.quota)}`;
}
calculateLocalStorage() {
var _lsTotal = 0
var _xLen; var _x
for (_x in localStorage) {
// eslint-disable-next-line no-prototype-builtins
if (!localStorage.hasOwnProperty(_x)) {
continue
}
_xLen = ((localStorage[_x].length + _x.length) * 2)
_lsTotal += _xLen
}
return (_lsTotal / 1024).toFixed(2)
}
formatBytes(bytes: number, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
}

@ -31,17 +31,20 @@ export class ThemeModule extends Plugin {
super(profile)
this.events = new EventEmitter()
this._deps = {
config: Registry.getInstance().get('config').api
config: Registry.getInstance().get('config') && Registry.getInstance().get('config').api
}
this.themes = themes.reduce((acc, theme) => {
theme.url = window.location.origin + window.location.pathname + theme.url
return { ...acc, [theme.name.toLocaleLowerCase()]: theme }
}, {})
this.themes = {}
themes.map((theme) => {
this.themes[theme.name.toLocaleLowerCase()] = {
...theme,
url: window.location.origin + window.location.pathname + theme.url
}
})
this._paq = _paq
let queryTheme = (new QueryParams()).get().theme
queryTheme = queryTheme && queryTheme.toLocaleLowerCase()
queryTheme = this.themes[queryTheme] ? queryTheme : null
let currentTheme = this._deps.config.get('settings/theme')
let currentTheme = (this._deps.config && this._deps.config.get('settings/theme')) || null
currentTheme = currentTheme && currentTheme.toLocaleLowerCase()
currentTheme = this.themes[currentTheme] ? currentTheme : null
this.currentThemeState = { queryTheme, currentTheme }
@ -65,6 +68,7 @@ export class ThemeModule extends Plugin {
initTheme (callback) { // callback is setTimeOut in app.js which is always passed
if (callback) this.initCallback = callback
if (this.active) {
document.getElementById('theme-link') ? document.getElementById('theme-link').remove():null
const nextTheme = this.themes[this.active] // Theme
document.documentElement.style.setProperty('--theme', nextTheme.quality)
@ -93,9 +97,9 @@ export class ThemeModule extends Plugin {
_paq.push(['trackEvent', 'themeModule', 'switchTo', next])
const nextTheme = this.themes[next] // Theme
if (!this.forced) this._deps.config.set('settings/theme', next)
document.getElementById('theme-link').remove()
const theme = document.createElement('link')
document.getElementById('theme-link') ? document.getElementById('theme-link').remove():null
const theme = document.createElement('link')
theme.setAttribute('rel', 'stylesheet')
theme.setAttribute('href', nextTheme.url)
theme.setAttribute('id', 'theme-link')

@ -1,5 +1,5 @@
/* eslint-disable prefer-promise-reject-errors */
function urlParams () {
function urlParams() {
var qs = window.location.hash.substr(1)
if (window.location.search.length > 0) {
@ -41,67 +41,12 @@ for (const k in assets[versionToLoad]) {
}
window.onload = () => {
// eslint-disable-next-line no-undef
class RemixFileSystem extends LightningFS {
constructor (...t) {
super(...t)
this.addSlash = (file) => {
if (!file.startsWith('/')) file = '/' + file
return file
}
this.base = this.promises
this.promises = {
...this.promises,
exists: async (path) => {
return new Promise((resolve, reject) => {
this.base.stat(this.addSlash(path)).then(() => resolve(true)).catch(() => resolve(false))
})
},
rmdir: async (path) => {
return this.base.rmdir(this.addSlash(path))
},
readdir: async (path) => {
return this.base.readdir(this.addSlash(path))
},
unlink: async (path) => {
return this.base.unlink(this.addSlash(path))
},
mkdir: async (path) => {
return this.base.mkdir(this.addSlash(path))
},
readFile: async (path, options) => {
return this.base.readFile(this.addSlash(path), options)
},
rename: async (from, to) => {
return this.base.rename(this.addSlash(from), this.addSlash(to))
},
writeFile: async (path, content, options) => {
return this.base.writeFile(this.addSlash(path), content, options)
},
stat: async (path) => {
return this.base.stat(this.addSlash(path))
}
}
}
}
function loadApp () {
function loadApp() {
const app = document.createElement('script')
app.setAttribute('src', versions[versionToLoad])
document.body.appendChild(app)
}
window.remixFileSystemCallback = new RemixFileSystem()
window.remixFileSystemCallback.init('RemixFileSystem').then(() => {
window.remixFileSystem = window.remixFileSystemCallback.promises
// check if .workspaces is present in indexeddb
window.remixFileSystem.stat('.workspaces').then((dir) => {
if (dir.isDirectory()) loadApp()
}).catch(() => {
// no indexeddb .workspaces -> run migration
// eslint-disable-next-line no-undef
migrateFilesFromLocalStorage(loadApp)
})
})
loadApp()
return
}

File diff suppressed because one or more lines are too long

@ -1,139 +0,0 @@
// eslint-disable-next-line no-unused-vars
async function migrateFilesFromLocalStorage (cb) {
let testmigration = false // migration loads test data into localstorage with browserfs
// indexeddb will be empty by this point, so there is no danger but do a check for the origin to load test data so it runs only locally
testmigration = window.location.hash.includes('e2e_testmigration=true') && window.location.host === '127.0.0.1:8080' && window.location.protocol === 'http:'
// eslint-disable-next-line no-undef
BrowserFS.install(window)
// eslint-disable-next-line no-undef
BrowserFS.configure({
fs: 'LocalStorage'
}, async function (e) {
if (e) console.log(e)
const browserFS = window.require('fs')
/**
* copy the folder recursively (internal use)
* @param {string} path is the folder to be copied over
* @param {Function} visitFile is a function called for each visited files
* @param {Function} visitFolder is a function called for each visited folders
*/
async function _copyFolderToJsonInternal (path, visitFile, visitFolder, fs) {
visitFile = visitFile || (() => { })
visitFolder = visitFolder || (() => { })
return new Promise((resolve, reject) => {
const json = {}
if (fs.existsSync(path)) {
try {
const items = fs.readdirSync(path)
visitFolder({ path })
if (items.length !== 0) {
items.forEach(async (item, index) => {
const file = {}
const curPath = `${path}${path.endsWith('/') ? '' : '/'}${item}`
if (fs.statSync(curPath).isDirectory()) {
file.children = await _copyFolderToJsonInternal(curPath, visitFile, visitFolder, fs)
} else {
file.content = fs.readFileSync(curPath, 'utf8')
visitFile({ path: curPath, content: file.content })
}
json[curPath] = file
})
}
} catch (e) {
console.log(e)
return reject(e)
}
}
return resolve(json)
})
}
/**
* copy the folder recursively
* @param {string} path is the folder to be copied over
* @param {Function} visitFile is a function called for each visited files
* @param {Function} visitFolder is a function called for each visited folders
*/
async function copyFolderToJson (path, visitFile, visitFolder, fs) {
visitFile = visitFile || (() => { })
visitFolder = visitFolder || (() => { })
return _copyFolderToJsonInternal(path, visitFile, visitFolder, fs)
}
const populateWorkspace = async (json, fs) => {
for (const item in json) {
const isFolder = json[item].content === undefined
if (isFolder) {
await createDir(item, fs)
await populateWorkspace(json[item].children, fs)
} else {
try {
await fs.writeFile(item, json[item].content, 'utf8')
} catch (error) {
console.log(error)
}
}
}
}
const createDir = async (path, fs) => {
const paths = path.split('/')
if (paths.length && paths[0] === '') paths.shift()
let currentCheck = ''
for (const value of paths) {
currentCheck = currentCheck + (currentCheck ? '/' : '') + value
if (!await fs.exists(currentCheck)) {
try {
await fs.mkdir(currentCheck)
} catch (error) {
console.log(error)
}
}
}
}
//
if (testmigration) await populateWorkspace(testData, browserFS)
const files = await copyFolderToJson('/', null, null, browserFS)
await populateWorkspace(files, window.remixFileSystem)
// eslint-disable-next-line no-undef
if (cb) cb()
})
}
/* eslint-disable no-template-curly-in-string */
const testData = {
'.workspaces': {
children: {
'.workspaces/default_workspace': {
children: {
'.workspaces/default_workspace/README.txt': {
content: 'TEST README'
}
}
},
'.workspaces/workspace_test': {
children: {
'.workspaces/workspace_test/TEST_README.txt': {
content: 'TEST README'
},
'.workspaces/workspace_test/test_contracts': {
children: {
'.workspaces/workspace_test/test_contracts/1_Storage.sol': {
content: 'testing'
},
'.workspaces/workspace_test/test_contracts/artifacts': {
children: {
'.workspaces/workspace_test/test_contracts/artifacts/Storage_metadata.json': {
content: '{ "test": "data" }'
}
}
}
}
}
}
}
}
}
}

@ -28,8 +28,6 @@
<link rel="icon" type="x-icon" href="assets/img/icon.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/intro.js/4.1.0/introjs.min.css">
<script src="assets/js/browserfs.min.js"></script>
<script src="assets/js/migrate.js"></script>
<script src="assets/js/lightning-fs.min.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<!-- Matomo -->
<script type="text/javascript">
@ -42,7 +40,7 @@
var _paq = window._paq = window._paq || []
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(['disableCookies']);
_paq.push(['enableJSErrorTracking']);
_paq.push(['enableJSErrorTracking']);
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {

@ -1,45 +1,27 @@
// eslint-disable-next-line no-use-before-define
import React from 'react'
import { render } from 'react-dom'
// eslint-disable-next-line no-unused-vars
import { RemixApp } from '@remix-ui/app'
import * as packageJson from '../../../package.json'
import './index.css'
import { ThemeModule } from './app/tabs/theme-module'
import { Preload } from './app/components/preload'
import Config from './config'
import Registry from './app/state/registry'
import { Storage } from '@remix-project/remix-lib'
(async function() {
// load current theme befor anything else
try {
const configStorage = new Storage('config-v0.8:')
const config = new Config(configStorage);
Registry.getInstance().put({ api: config, name: 'config' })
} catch (e) {}
const theme = new ThemeModule()
theme.initTheme()
(function () {
render(
<React.StrictMode>
<div style={{ display: 'block' }} className='centered'>
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 105 100">
<path d="M91.84,35a.09.09,0,0,1-.1-.07,41,41,0,0,0-79.48,0,.09.09,0,0,1-.1.07C9.45,35,1,35.35,1,42.53c0,8.56,1,16,6,20.32,2.16,1.85,5.81,2.3,9.27,2.22a44.4,44.4,0,0,0,6.45-.68.09.09,0,0,0,.06-.15A34.81,34.81,0,0,1,17,45c0-.1,0-.21,0-.31a35,35,0,0,1,70,0c0,.1,0,.21,0,.31a34.81,34.81,0,0,1-5.78,19.24.09.09,0,0,0,.06.15,44.4,44.4,0,0,0,6.45.68c3.46.08,7.11-.37,9.27-2.22,5-4.27,6-11.76,6-20.32C103,35.35,94.55,35,91.84,35Z"/>
<path d="M52,74,25.4,65.13a.1.1,0,0,0-.1.17L51.93,91.93a.1.1,0,0,0,.14,0L78.7,65.3a.1.1,0,0,0-.1-.17L52,74A.06.06,0,0,1,52,74Z"/>
<path d="M75.68,46.9,82,45a.09.09,0,0,0,.08-.09,29.91,29.91,0,0,0-.87-6.94.11.11,0,0,0-.09-.08l-6.43-.58a.1.1,0,0,1-.06-.18l4.78-4.18a.13.13,0,0,0,0-.12,30.19,30.19,0,0,0-3.65-6.07.09.09,0,0,0-.11,0l-5.91,2a.1.1,0,0,1-.12-.14L72.19,23a.11.11,0,0,0,0-.12,29.86,29.86,0,0,0-5.84-4.13.09.09,0,0,0-.11,0l-4.47,4.13a.1.1,0,0,1-.17-.07l.09-6a.1.1,0,0,0-.07-.1,30.54,30.54,0,0,0-7-1.47.1.1,0,0,0-.1.07l-2.38,5.54a.1.1,0,0,1-.18,0l-2.37-5.54a.11.11,0,0,0-.11-.06,30,30,0,0,0-7,1.48.12.12,0,0,0-.07.1l.08,6.05a.09.09,0,0,1-.16.07L37.8,18.76a.11.11,0,0,0-.12,0,29.75,29.75,0,0,0-5.83,4.13.11.11,0,0,0,0,.12l2.59,5.6a.11.11,0,0,1-.13.14l-5.9-2a.11.11,0,0,0-.12,0,30.23,30.23,0,0,0-3.62,6.08.11.11,0,0,0,0,.12l4.79,4.19a.1.1,0,0,1-.06.17L23,37.91a.1.1,0,0,0-.09.07A29.9,29.9,0,0,0,22,44.92a.1.1,0,0,0,.07.1L28.4,47a.1.1,0,0,1,0,.18l-5.84,3.26a.16.16,0,0,0,0,.11,30.17,30.17,0,0,0,2.1,6.76c.32.71.67,1.4,1,2.08a.1.1,0,0,0,.06,0L52,68.16H52l26.34-8.78a.1.1,0,0,0,.06-.05,30.48,30.48,0,0,0,3.11-8.88.1.1,0,0,0-.05-.11l-5.83-3.26A.1.1,0,0,1,75.68,46.9Z"/>
</svg>
<div className="info-secondary splash">
REMIX IDE
<br />
<span className='version'> v{ packageJson.version }</span>
</div>
<div style={{ marginTop: '50%', textAlign: 'center' }}>
<i className="fas fa-spinner fa-spin fa-2x"></i>
</div>
</div>
<Preload></Preload>
</React.StrictMode>,
document.getElementById('root')
)
})()
import ('./app').then((AppComponent) => {
const appComponent = new AppComponent.default()
appComponent.run().then(() => {
render(
<>
<RemixApp app={appComponent} />
</>,
document.getElementById('root')
)
})
}).catch(err => {
console.log('Error on loading Remix:', err)
})

@ -28,8 +28,6 @@
<link rel="icon" type="x-icon" href="assets/img/icon.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/intro.js/4.1.0/introjs.min.css">
<script src="assets/js/browserfs.min.js"></script>
<script src="assets/js/migrate.js"></script>
<script src="assets/js/lightning-fs.min.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<!-- Matomo -->
<script type="text/javascript">

@ -206,7 +206,9 @@ export const RemixUiHomeTab = (props: RemixUiHomeTabProps) => {
const date = today.getFullYear() + '-' + (today.getMonth() + 1) + '-' + today.getDate()
const time = today.getHours() + 'h' + today.getMinutes() + 'min'
saveAs(blob, `remix-backup-at-${time}-${date}.zip`)
_paq.push(['_trackEvent', 'Backup', 'download', 'home'])
}).catch((e) => {
_paq.push(['_trackEvent', 'Backup', 'error', e.message])
plugin.call('notification', 'toast', e.message)
})
} catch (e) {

@ -46,6 +46,7 @@ export const RemixUiTerminal = (props: RemixUiTerminalProps) => {
const [clearConsole, setClearConsole] = useState(false)
const [paste, setPaste] = useState(false)
const [storage, setStorage] = useState<any>(null)
const [autoCompletState, setAutoCompleteState] = useState({
activeSuggestion: 0,
data: {
@ -419,10 +420,18 @@ export const RemixUiTerminal = (props: RemixUiTerminalProps) => {
}
useEffect(() => {
(async()=>{
const storage = await props.plugin.call('storage', 'formatString', await props.plugin.call('storage','getStorage'))
setStorage(storage)
})()
props.plugin.on('layout', 'change', (panels) => {
setIsOpen(!panels.terminal.minimized)
})
return () => {
props.plugin.off('layout', 'change')
}
@ -477,7 +486,7 @@ export const RemixUiTerminal = (props: RemixUiTerminalProps) => {
}
<div className="position-relative d-flex flex-column-reverse h-100">
<div id='journal' className='remix_ui_terminal_journal d-flex flex-column pt-3 pb-4 px-2 mx-2 mr-0' data-id='terminalJournal'>
{!clearConsole && <TerminalWelcomeMessage packageJson={version}/>}
{!clearConsole && <TerminalWelcomeMessage storage={storage} packageJson={version}/>}
{newstate.journalBlocks && newstate.journalBlocks.map((x, index) => {
if (x.name === EMPTY_BLOCK) {
return (

@ -1,9 +1,10 @@
import React from 'react' // eslint-disable-line
import React, { useEffect } from 'react' // eslint-disable-line
const TerminalWelcomeMessage = ({ packageJson }) => {
const TerminalWelcomeMessage = ({ packageJson, storage }) => {
return (
<div className="remix_ui_terminal_block px-4 " data-id="block_null">
<div className="remix_ui_terminal_welcome"> Welcome to Remix {packageJson} </div><br />
<div className="">Your files are stored in {(window as any).remixFileSystem.name}, {storage} used</div><br />
<div>You can use this terminal to: </div>
<ul className='ml-0 mr-4'>
<li>Check transactions details and start debugging.</li>

@ -148,6 +148,7 @@
"@ethereumjs/common": "^2.5.0",
"@ethereumjs/tx": "^3.3.2",
"@ethereumjs/vm": "^5.5.3",
"@isomorphic-git/lightning-fs": "^4.4.1",
"@monaco-editor/react": "^4.3.1",
"@remixproject/engine": "^0.3.28",
"@remixproject/engine-web": "^0.3.28",
@ -229,6 +230,7 @@
"@types/axios": "^0.14.0",
"@types/chai": "^4.2.11",
"@types/fs-extra": "^9.0.1",
"@types/isomorphic-git__lightning-fs": "^4.4.2",
"@types/jest": "^27.0.2",
"@types/lodash": "^4.14.172",
"@types/mocha": "^7.0.2",

Loading…
Cancel
Save