Merge pull request #944 from ethereum/file-exlorer

add support for large directories to file-explorer
pull/1/head
yann300 7 years ago committed by GitHub
commit 419dcc71d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      ci/browser_tests.sh
  2. 7
      package.json
  3. 13
      src/app.js
  4. 2
      src/app/compiler/compiler.js
  5. 5
      src/app/files/basicReadOnlyExplorer.js
  6. 56
      src/app/files/browser-files.js
  7. 42
      src/app/files/file-explorer.js
  8. 64
      src/app/files/fileManager.js
  9. 222
      src/app/files/shared-folder.js
  10. 2
      src/app/panels/file-panel.js

@ -3,7 +3,6 @@
set -e set -e
setupRemixd () { setupRemixd () {
npm install remixd
mkdir remixdSharedfolder mkdir remixdSharedfolder
cd remixdSharedfolder cd remixdSharedfolder
echo "contract test1 { function get () returns (uint) { return 8; }}" > contract1.sol echo "contract test1 { function get () returns (uint) { return 8; }}" > contract1.sol
@ -15,7 +14,7 @@ setupRemixd () {
cd .. cd ..
echo 'sharing folder: ' echo 'sharing folder: '
echo $PWD echo $PWD
./../node_modules/.bin/remixd -S $PWD & node ../node_modules/remixd/src/main.js -s $PWD &
cd .. cd ..
} }

@ -9,6 +9,7 @@
"babel-plugin-transform-object-assign": "^6.22.0", "babel-plugin-transform-object-assign": "^6.22.0",
"babel-plugin-yo-yoify": "^0.3.3", "babel-plugin-yo-yoify": "^0.3.3",
"babel-polyfill": "^6.22.0", "babel-polyfill": "^6.22.0",
"babel-preset-env": "^1.6.1",
"babel-preset-es2015": "^6.24.0", "babel-preset-es2015": "^6.24.0",
"babel-preset-stage-0": "^6.24.1", "babel-preset-stage-0": "^6.24.1",
"babelify": "^7.3.0", "babelify": "^7.3.0",
@ -44,7 +45,7 @@
"remix-debugger": "latest", "remix-debugger": "latest",
"remix-lib": "latest", "remix-lib": "latest",
"remix-solidity": "latest", "remix-solidity": "latest",
"remixd": "^0.1.2", "remixd": "git+https://github.com/ethereum/remixd.git",
"rimraf": "^2.6.1", "rimraf": "^2.6.1",
"selenium-standalone": "^6.0.1", "selenium-standalone": "^6.0.1",
"solc": "https://github.com/ethereum/solc-js", "solc": "https://github.com/ethereum/solc-js",
@ -155,12 +156,12 @@
"nightwatch_remote_safari": "nightwatch --config nightwatch.js --env safari", "nightwatch_remote_safari": "nightwatch --config nightwatch.js --env safari",
"onchange": "onchange build/app.js -- npm-run-all lint", "onchange": "onchange build/app.js -- npm-run-all lint",
"prepublish": "mkdirp build; npm-run-all -ls downloadsolc build", "prepublish": "mkdirp build; npm-run-all -ls downloadsolc build",
"remixd": "remixd -S ./contracts", "remixd": "node ./node_modules/remixd/src/main.js -s ./contracts",
"selenium": "execr --silent selenium-standalone start", "selenium": "execr --silent selenium-standalone start",
"selenium-install": "selenium-standalone install", "selenium-install": "selenium-standalone install",
"serve": "execr --silent http-server .", "serve": "execr --silent http-server .",
"sourcemap": "exorcist --root ../ build/app.js.map > build/app.js", "sourcemap": "exorcist --root ../ build/app.js.map > build/app.js",
"start": "npm-run-all -lpr serve watch onchange", "start": "npm-run-all -lpr serve watch onchange remixd",
"test": "npm run csslint; standard && node test/index.js", "test": "npm run csslint; standard && node test/index.js",
"test-browser": "npm-run-all -lpr selenium downloadsolc make-mock-compiler serve browsertest", "test-browser": "npm-run-all -lpr selenium downloadsolc make-mock-compiler serve browsertest",
"watch": "watchify src/index.js -dv -p browserify-reload -o build/app.js" "watch": "watchify src/index.js -dv -p browserify-reload -o build/app.js"

@ -453,10 +453,15 @@ function run () {
}) })
// insert ballot contract if there are no files available // insert ballot contract if there are no files available
if (!loadingFromGist && Object.keys(filesProviders['browser'].list()).length === 0) { if (!loadingFromGist) {
if (!filesProviders['browser'].set(examples.ballot.name, examples.ballot.content)) { filesProviders['browser'].resolveDirectory('', (error, filesList) => {
modalDialogCustom.alert('Failed to store example contract in browser. Remix will not work properly. Please ensure Remix has access to LocalStorage. Safari in Private mode is known not to work.') if (error) console.error(error)
} if (Object.keys(filesList).length === 0) {
if (!filesProviders['browser'].set(examples.ballot.name, examples.ballot.content)) {
modalDialogCustom.alert('Failed to store example contract in browser. Remix will not work properly. Please ensure Remix has access to LocalStorage. Safari in Private mode is known not to work.')
}
}
})
} }
window.syncStorage = chromeCloudStorageSync window.syncStorage = chromeCloudStorageSync

@ -233,7 +233,7 @@ function Compiler (handleImportCall) {
// Set a safe fallback until the new one is loaded // Set a safe fallback until the new one is loaded
setCompileJSON(function (source, optimize) { setCompileJSON(function (source, optimize) {
compilationFinished({error: 'Compiler not yet loaded.'}) compilationFinished({ error: { formattedMessage: 'Compiler not yet loaded.' } })
}) })
var newScript = document.createElement('script') var newScript = document.createElement('script')

@ -80,7 +80,8 @@ class BasicReadOnlyExplorer {
// } // }
// } // }
// //
listAsTree () { resolveDirectory (path, callback /* (error, filesList) => { } */) {
// path = '' + (path || '')
function hashmapize (obj, path, val) { function hashmapize (obj, path, val) {
var nodes = path.split('/') var nodes = path.split('/')
var i = 0 var i = 0
@ -107,7 +108,7 @@ class BasicReadOnlyExplorer {
'/content': self.get(path) '/content': self.get(path)
}) })
}) })
return tree callback(null, tree)
} }
removePrefix (path) { removePrefix (path) {

@ -11,9 +11,7 @@ function Files (storage) {
this.exists = function (path) { this.exists = function (path) {
var unprefixedpath = this.removePrefix(path) var unprefixedpath = this.removePrefix(path)
// NOTE: ignore the config file // NOTE: ignore the config file
if (path === '.remix.config') { if (path === '.remix.config') return false
return false
}
return this.isReadOnly(unprefixedpath) || storage.exists(unprefixedpath) return this.isReadOnly(unprefixedpath) || storage.exists(unprefixedpath)
} }
@ -105,29 +103,6 @@ function Files (storage) {
return false return false
} }
this.list = function () {
var files = {}
// add r/w files to the list
storage.keys().forEach((path) => {
// NOTE: as a temporary measure do not show the config file
if (path !== '.remix.config') {
files[this.type + '/' + path] = false
}
})
// add r/o files to the list
Object.keys(readonly).forEach((path) => {
files[this.type + '/' + path] = true
})
return files
}
this.removePrefix = function (path) {
return path.indexOf(this.type + '/') === 0 ? path.replace(this.type + '/', '') : path
}
// //
// Tree model for files // Tree model for files
// { // {
@ -140,11 +115,12 @@ function Files (storage) {
// } // }
// } // }
// //
this.listAsTree = function () { this.resolveDirectory = function (path, callback) {
var self = this
// path = '' + (path || '')
function hashmapize (obj, path, val) { function hashmapize (obj, path, val) {
var nodes = path.split('/') var nodes = path.split('/')
var i = 0 var i = 0
for (; i < nodes.length - 1; i++) { for (; i < nodes.length - 1; i++) {
var node = nodes[i] var node = nodes[i]
if (obj[node] === undefined) { if (obj[node] === undefined) {
@ -152,22 +128,34 @@ function Files (storage) {
} }
obj = obj[node] obj = obj[node]
} }
obj[nodes[i]] = val obj[nodes[i]] = val
} }
var filesList = {}
// add r/w filesList to the list
storage.keys().forEach((path) => {
// NOTE: as a temporary measure do not show the config file
if (path !== '.remix.config') {
filesList[self.type + '/' + path] = false
}
})
// add r/o files to the list
Object.keys(readonly).forEach((path) => {
filesList[self.type + '/' + path] = true
})
var tree = {} var tree = {}
var self = this
// This does not include '.remix.config', because it is filtered // This does not include '.remix.config', because it is filtered
// inside list(). // inside list().
Object.keys(this.list()).forEach(function (path) { Object.keys(filesList).forEach(function (path) {
hashmapize(tree, path, { hashmapize(tree, path, {
'/readonly': self.isReadOnly(path), '/readonly': self.isReadOnly(path),
'/content': self.get(path) '/content': self.get(path)
}) })
}) })
return tree callback(null, tree)
}
this.removePrefix = function (path) {
return path.indexOf(this.type + '/') === 0 ? path.replace(this.type + '/', '') : path
} }
// rename .browser-solidity.json to .remix.config // rename .browser-solidity.json to .remix.config

@ -99,13 +99,13 @@ function fileExplorer (appAPI, files) {
})) : undefined })) : undefined
} }
}, },
formatSelf: function (key, data) { formatSelf: function (key, data, li) {
var isRoot = data.path.indexOf('/') === -1 var isRoot = data.path.indexOf('/') === -1
return yo`<label class="${data.children ? css.folder : css.file}" return yo`<label class="${data.children ? css.folder : css.file}"
data-path="${data.path}" data-path="${data.path}"
style="${isRoot ? 'font-weight:bold;' : ''}" style="${isRoot ? 'font-weight:bold;' : ''}"
onload=${function (el) { adaptEnvironment(el, focus, hover) }} onload=${function (el) { adaptEnvironment(el, focus, hover, li) }}
onunload=${function (el) { unadaptEnvironment(el, focus, hover) }} onunload=${function (el) { unadaptEnvironment(el, focus, hover, li) }}
onclick=${editModeOn} onclick=${editModeOn}
onkeydown=${editModeOff} onkeydown=${editModeOff}
onblur=${editModeOff} onblur=${editModeOff}
@ -346,10 +346,13 @@ function fileExplorer (appAPI, files) {
} }
function fileAdded (filepath) { function fileAdded (filepath) {
var el = treeView.render(files.listAsTree()) self.files.resolveDirectory('./', (error, files) => {
el.className = css.fileexplorer if (error) console.error(error)
self.element.parentElement.replaceChild(el, self.element) var element = self.treeView.render(files)
self.element = el element.className = css.fileexplorer
self.element.parentElement.replaceChild(element, self.element)
self.element = element
})
} }
} }
@ -357,7 +360,7 @@ function fileExplorer (appAPI, files) {
HELPER FUNCTIONS HELPER FUNCTIONS
*/ */
function adaptEnvironment (label, focus, hover) { function adaptEnvironment (label, focus, hover) {
var li = getLiFrom(label) var li = getLiFrom(label) // @TODO: maybe this gets refactored?
li.style.position = 'relative' li.style.position = 'relative'
var span = li.firstChild var span = li.firstChild
// add focus // add focus
@ -369,7 +372,7 @@ function adaptEnvironment (label, focus, hover) {
} }
function unadaptEnvironment (label, focus, hover) { function unadaptEnvironment (label, focus, hover) {
var li = getLiFrom(label) var li = getLiFrom(label) // @TODO: maybe this gets refactored?
var span = li.firstChild var span = li.firstChild
li.style.position = undefined li.style.position = undefined
// remove focus // remove focus
@ -413,11 +416,18 @@ function expandPathTo (li) {
} }
fileExplorer.prototype.init = function () { fileExplorer.prototype.init = function () {
var files = this.files.listAsTree() var self = this
var element = this.treeView.render(files) self.files.resolveDirectory('/', (error, files) => {
element.className = css.fileexplorer if (error) console.error(error)
element.events = this.events var element = self.treeView.render(files)
element.api = this.api element.className = css.fileexplorer
this.element = element element.events = self.events
return element element.api = self.api
setTimeout(function () {
self.element.parentElement.replaceChild(element, self.element)
self.element = element
}, 0)
})
self.element = yo`<div></div>`
return self.element
} }

@ -120,28 +120,34 @@ class FileManager {
} }
switchFile (file) { switchFile (file) {
var self = this
if (!file) { if (!file) {
var fileList = Object.keys(this.opt.filesProviders['browser'].list()) self.opt.filesProviders['browser'].resolveDirectory('', (error, filesTree) => {
if (fileList.length) { if (error) console.error(error)
file = fileList[0] var fileList = Object.keys(flatten(filesTree))
} if (fileList.length) {
} file = fileList[0]
if (!file) return if (file) _switchFile(file)
this.saveCurrentFile() }
this.opt.config.set('currentFile', file) })
this.refreshTabs(file) } else _switchFile(file)
this.fileProviderOf(file).get(file, (error, content) => { function _switchFile () {
if (error) { self.saveCurrentFile()
console.log(error) self.opt.config.set('currentFile', file)
} else { self.refreshTabs(file)
if (this.fileProviderOf(file).isReadOnly(file)) { self.fileProviderOf(file).get(file, (error, content) => {
this.opt.editor.openReadOnly(file, content) if (error) {
console.log(error)
} else { } else {
this.opt.editor.open(file, content) if (self.fileProviderOf(file).isReadOnly(file)) {
self.opt.editor.openReadOnly(file, content)
} else {
self.opt.editor.open(file, content)
}
self.event.trigger('currentFileChanged', [file, self.fileProviderOf(file)])
} }
this.event.trigger('currentFileChanged', [file, this.fileProviderOf(file)]) })
} }
})
} }
fileProviderOf (file) { fileProviderOf (file) {
@ -173,3 +179,23 @@ class FileManager {
} }
module.exports = FileManager module.exports = FileManager
function flatten (tree) {
var flat = {}
var names = Object.keys(tree || {})
if (!names.length) return
else {
names.forEach(name => {
if ('/content' in tree[name]) flat[name] = false
else {
var subflat = flatten(tree[name])
if (!subflat) {
// empty folder
} else {
Object.keys(subflat).forEach(path => { flat[name + '/' + path] = false })
}
}
})
return flat
}
}

@ -1,19 +1,63 @@
'use strict' 'use strict'
var async = require('async')
var EventManager = require('remix-lib').EventManager var EventManager = require('remix-lib').EventManager
var pathtool = require('path')
function buildList (self, path = '', callback) {
path = '' + (path || '')
self.remixd.dir(path, (error, filesList) => {
if (error) console.error(error)
var list = Object.keys(filesList)
var counter = list.length
var fileTree = {}
if (!counter) callback(null, fileTree)
for (var i = 0, name, len = counter; i < len; i++) {
name = list[i]
if (filesList[name].isDirectory) {
setFolder(self, path, name, fileTree, finish)
} else {
setFileContent(self, path, name, fileTree, finish)
}
}
function finish (error) {
if (error) console.error(error)
counter--
if (!counter) callback(null, fileTree)
}
})
}
function setFolder (self, path, name, fileTree, done) {
buildList(self, name, (error, subFileTree) => {
if (error) console.error(error)
name = name.replace(path, '')
if (name[0] === '/') name = name.substring(1)
fileTree[name] = subFileTree
done(null)
})
}
function setFileContent (self, path, name, fileTree, done) {
self.remixd.read(name, (error, result) => {
if (error) console.error(error)
name = name.replace(path, '')
if (name[0] === '/') name = name.substring(1)
fileTree[name] = {
'/content': result.content,
'/readonly': result.readonly
}
done(null)
})
}
class SharedFolder { module.exports = class SharedFolder {
constructor (remixd) { constructor (remixd) {
this.event = new EventManager() this.event = new EventManager()
this.remixd = remixd this._remixd = remixd
this.files = null this.remixd = remixapi(remixd, this)
this.filesContent = {}
this.filesTree = null
this.type = 'localhost' this.type = 'localhost'
this.error = { this.error = { 'EEXIST': 'File already exists' }
'EEXIST': 'File already exists' this._isReady = false
} this.filesContent = {}
this.remixd.event.register('notified', (data) => {
remixd.event.register('notified', (data) => {
if (data.scope === 'sharedfolder') { if (data.scope === 'sharedfolder') {
if (data.name === 'created') { if (data.name === 'created') {
this.init(() => { this.init(() => {
@ -24,7 +68,7 @@ class SharedFolder {
this.event.trigger('fileRemoved', [this.type + '/' + data.value.path]) this.event.trigger('fileRemoved', [this.type + '/' + data.value.path])
}) })
} else if (data.name === 'changed') { } else if (data.name === 'changed') {
this.remixd.call('sharedfolder', 'get', {path: data.value}, (error, content) => { this._remixd.call('sharedfolder', 'get', {path: data.value}, (error, content) => {
if (error) { if (error) {
console.log(error) console.log(error)
} else { } else {
@ -38,41 +82,46 @@ class SharedFolder {
}) })
} }
isConnected () {
return this._isReady
}
close (cb) { close (cb) {
this.remixd.close() this.remixd.exit()
this.files = null this._isReady = false
this.filesTree = null
cb() cb()
} }
init (cb) { init (cb) {
this.remixd.call('sharedfolder', 'list', {}, (error, filesList) => { this._isReady = true
if (error) { cb()
cb(error)
} else {
this.files = {}
for (var k in filesList) {
this.files[this.type + '/' + k] = filesList[k]
}
listAsTree(this, this.files, (error, tree) => {
this.filesTree = tree
cb(error)
})
}
})
} }
// @TODO: refactor all `this._remixd.call(....)` uses into `this.remixd[api](...)`
// where `api = ...`:
// this.remixd.read(path, (error, content) => {})
// this.remixd.write(path, content, (error, result) => {})
// this.remixd.rename(path1, path2, (error, result) => {})
// this.remixd.remove(path, (error, result) => {})
// this.remixd.dir(path, (error, filesList) => {})
//
// this.remixd.exists(path, (error, isValid) => {})
exists (path) { exists (path) {
// @TODO: add new remixd.exists() method
// we remove the this.files = null at the beggining
// modify the exists() (cause it is using the this.files) to use remixd
// yes for the exists I think you might need another remixd function
if (!this.files) return false if (!this.files) return false
return this.files[path] !== undefined return this.files[path] !== undefined
} }
get (path, cb) { get (path, cb) {
var unprefixedpath = this.removePrefix(path) var unprefixedpath = this.removePrefix(path)
this.remixd.call('sharedfolder', 'get', {path: unprefixedpath}, (error, content) => { this._remixd.call('sharedfolder', 'get', {path: unprefixedpath}, (error, file) => {
if (!error) { if (!error) {
this.filesContent[path] = content this.filesContent[path] = file.content
cb(error, content) cb(error, file.content)
} else { } else {
// display the last known content. // display the last known content.
// TODO should perhaps better warn the user that the file is not synced. // TODO should perhaps better warn the user that the file is not synced.
@ -83,10 +132,9 @@ class SharedFolder {
set (path, content, cb) { set (path, content, cb) {
var unprefixedpath = this.removePrefix(path) var unprefixedpath = this.removePrefix(path)
this.remixd.call('sharedfolder', 'set', {path: unprefixedpath, content: content}, (error, result) => { this._remixd.call('sharedfolder', 'set', {path: unprefixedpath, content: content}, (error, result) => {
if (cb) cb(error, result) if (cb) cb(error, result)
var path = this.type + '/' + unprefixedpath var path = this.type + '/' + unprefixedpath
this.filesContent[path]
this.event.trigger('fileChanged', [path]) this.event.trigger('fileChanged', [path])
}) })
return true return true
@ -103,7 +151,7 @@ class SharedFolder {
remove (path) { remove (path) {
var unprefixedpath = this.removePrefix(path) var unprefixedpath = this.removePrefix(path)
this.remixd.call('sharedfolder', 'remove', {path: unprefixedpath}, (error, result) => { this._remixd.call('sharedfolder', 'remove', {path: unprefixedpath}, (error, result) => {
if (error) console.log(error) if (error) console.log(error)
var path = this.type + '/' + unprefixedpath var path = this.type + '/' + unprefixedpath
delete this.filesContent[path] delete this.filesContent[path]
@ -116,7 +164,7 @@ class SharedFolder {
rename (oldPath, newPath, isFolder) { rename (oldPath, newPath, isFolder) {
var unprefixedoldPath = this.removePrefix(oldPath) var unprefixedoldPath = this.removePrefix(oldPath)
var unprefixednewPath = this.removePrefix(newPath) var unprefixednewPath = this.removePrefix(newPath)
this.remixd.call('sharedfolder', 'rename', {oldPath: unprefixedoldPath, newPath: unprefixednewPath}, (error, result) => { this._remixd.call('sharedfolder', 'rename', {oldPath: unprefixedoldPath, newPath: unprefixednewPath}, (error, result) => {
if (error) { if (error) {
console.log(error) console.log(error)
if (this.error[error.code]) error = this.error[error.code] if (this.error[error.code]) error = this.error[error.code]
@ -134,12 +182,26 @@ class SharedFolder {
return true return true
} }
list () { //
return this.files // Tree model for files
} // {
// 'a': { }, // empty directory 'a'
listAsTree () { // 'b': {
return this.filesTree // 'c': {}, // empty directory 'b/c'
// 'd': { '/readonly': true, '/content': 'Hello World' } // files 'b/c/d'
// 'e': { '/readonly': false, '/path': 'b/c/d' } // symlink to 'b/c/d'
// 'f': { '/readonly': false, '/content': '<executable>', '/mode': 0755 }
// }
// }
//
resolveDirectory (path, callback) {
var self = this
path = '' + (path || '')
path = pathtool.join('./', path)
buildList(self, path, (error, fileTree) => {
if (error) return callback(error)
callback(null, { [self.type]: fileTree })
})
} }
removePrefix (path) { removePrefix (path) {
@ -147,55 +209,33 @@ class SharedFolder {
} }
} }
// function remixapi (remixd, self) {
// Tree model for files const read = (path, callback) => {
// { path = '' + (path || '')
// 'a': { }, // empty directory 'a' path = pathtool.join('./', path)
// 'b': { remixd.call('sharedfolder', 'get', { path }, (error, content) => callback(error, content))
// 'c': {}, // empty directory 'b/c'
// 'd': { '/readonly': true, '/content': 'Hello World' } // files 'b/c/d'
// 'e': { '/readonly': false, '/path': 'b/c/d' } // symlink to 'b/c/d'
// 'f': { '/readonly': false, '/content': '<executable>', '/mode': 0755 }
// }
// }
//
function listAsTree (self, filesList, callback) {
function hashmapize (obj, path, val) {
var nodes = path.split('/')
var i = 0
for (; i < nodes.length - 1; i++) {
var node = nodes[i]
if (obj[node] === undefined) {
obj[node] = {}
}
obj = obj[node]
}
obj[nodes[i]] = val
} }
const write = (path, content, callback) => {
var tree = {} path = '' + (path || '')
path = pathtool.join('./', path)
// This does not include '.remix.config', because it is filtered remixd.call('sharedfolder', 'set', { path, content }, (error, result) => callback(error, result))
// inside list(). }
async.eachSeries(Object.keys(filesList), function (path, cb) { const rename = (path, newpath, callback) => {
self.get(path, (error, content) => { path = '' + (path || '')
if (error) { path = pathtool.join('./', path)
console.log(error) remixd.call('sharedfolder', 'rename', { oldPath: path, newPath: newpath }, (error, result) => callback(error, result))
cb(error) }
} else { const remove = (path, callback) => {
self.filesContent[path] = content path = '' + (path || '')
hashmapize(tree, path, { path = pathtool.join('./', path)
'/readonly': filesList[path], remixd.call('sharedfolder', 'remove', { path }, (error, result) => callback(error, result))
'/content': content }
}) const dir = (path, callback) => {
cb() path = '' + (path || '')
} path = pathtool.join('./', path)
}) remixd.call('sharedfolder', 'resolveDirectory', { path }, (error, filesList) => callback(error, filesList))
}, (error) => { }
callback(error, tree) const exit = () => { remixd.close() }
}) const api = { read, write, rename, remove, dir, exit, event: remixd.event }
return api
} }
module.exports = SharedFolder

@ -305,7 +305,7 @@ function filepanel (appAPI, filesProvider) {
*/ */
function connectToLocalhost () { function connectToLocalhost () {
var container = document.querySelector('.filesystemexplorer') var container = document.querySelector('.filesystemexplorer')
if (filesProvider['localhost'].files !== null) { if (filesProvider['localhost'].isConnected()) {
filesProvider['localhost'].close((error) => { filesProvider['localhost'].close((error) => {
if (error) console.log(error) if (error) console.log(error)
}) })

Loading…
Cancel
Save