diff --git a/apps/remix-ide-e2e/src/commands/addFile.ts b/apps/remix-ide-e2e/src/commands/addFile.ts index 80838ff9d7..aa1f8d7f6e 100644 --- a/apps/remix-ide-e2e/src/commands/addFile.ts +++ b/apps/remix-ide-e2e/src/commands/addFile.ts @@ -18,9 +18,9 @@ function addFile (browser: NightwatchBrowser, name: string, content: NightwatchC .clickLaunchIcon('filePanel') .click('li[data-id="treeViewLitreeViewItemREADME.txt"]') // focus on root directory .click('.newFile') - .waitForElementContainsText('*[data-id="treeViewLitreeViewItem/blank"]', '', 60000) - .sendKeys('*[data-id="treeViewLitreeViewItem/blank"] .remixui_items', name) - .sendKeys('*[data-id="treeViewLitreeViewItem/blank"] .remixui_items', browser.Keys.ENTER) + .waitForElementContainsText('*[data-id$="/blank"]', '', 60000) + .sendKeys('*[data-id$="/blank"] .remixui_items', name) + .sendKeys('*[data-id$="/blank"] .remixui_items', browser.Keys.ENTER) .pause(2000) .waitForElementVisible(`li[data-id="treeViewLitreeViewItem${name}"]`, 60000) .setEditorValue(content.content) diff --git a/apps/remix-ide-e2e/src/helpers/init.ts b/apps/remix-ide-e2e/src/helpers/init.ts index 484645b391..8782c7a44b 100644 --- a/apps/remix-ide-e2e/src/helpers/init.ts +++ b/apps/remix-ide-e2e/src/helpers/init.ts @@ -2,11 +2,18 @@ import { NightwatchBrowser } from 'nightwatch' require('dotenv').config() -export default function (browser: NightwatchBrowser, callback: VoidFunction, url?: string, preloadPlugins = true): void { +export default function (browser: NightwatchBrowser, callback: VoidFunction, url?: string, preloadPlugins = true, closeWorkspaceAlert = true): void { browser .url(url || 'http://127.0.0.1:8080') .pause(5000) .switchBrowserTab(0) + .perform((done) => { + if (closeWorkspaceAlert) { + browser.waitForElementVisible('*[data-id="modalDialogModalBody"]', 60000) + .modalFooterOKClick() + } + done() + }) .fullscreenWindow(() => { if (preloadPlugins) { initModules(browser, () => { diff --git a/apps/remix-ide-e2e/src/tests/fileExplorer.test.ts b/apps/remix-ide-e2e/src/tests/fileExplorer.test.ts index 0400dc6394..4eb8d156b6 100644 --- a/apps/remix-ide-e2e/src/tests/fileExplorer.test.ts +++ b/apps/remix-ide-e2e/src/tests/fileExplorer.test.ts @@ -22,9 +22,9 @@ module.exports = { .click('li[data-id="treeViewLitreeViewItemREADME.txt"]') // focus on root directory .click('*[data-id="fileExplorerNewFilecreateNewFile"]') .pause(1000) - .waitForElementVisible('*[data-id="treeViewLitreeViewItem/blank"]') - .sendKeys('*[data-id="treeViewLitreeViewItem/blank"] .remixui_items', '5_New_contract.sol') - .sendKeys('*[data-id="treeViewLitreeViewItem/blank"] .remixui_items', browser.Keys.ENTER) + .waitForElementVisible('*[data-id$="/blank"]') + .sendKeys('*[data-id$="/blank"] .remixui_items', '5_New_contract.sol') + .sendKeys('*[data-id$="/blank"] .remixui_items', browser.Keys.ENTER) .waitForElementVisible('*[data-id="treeViewLitreeViewItem5_New_contract.sol"]', 7000) }, @@ -49,9 +49,9 @@ module.exports = { .click('li[data-id="treeViewLitreeViewItemREADME.txt"]') // focus on root directory .click('[data-id="fileExplorerNewFilecreateNewFolder"]') .pause(1000) - .waitForElementVisible('*[data-id="treeViewLitreeViewItem/blank"]') - .sendKeys('*[data-id="treeViewLitreeViewItem/blank"] .remixui_items', 'Browser_Tests') - .sendKeys('*[data-id="treeViewLitreeViewItem/blank"] .remixui_items', browser.Keys.ENTER) + .waitForElementVisible('*[data-id$="/blank"]') + .sendKeys('*[data-id$="/blank"] .remixui_items', 'Browser_Tests') + .sendKeys('*[data-id$="/blank"] .remixui_items', browser.Keys.ENTER) .waitForElementVisible('*[data-id="treeViewLitreeViewItemBrowser_Tests"]') }, diff --git a/apps/remix-ide-e2e/src/tests/gist.spec.ts b/apps/remix-ide-e2e/src/tests/gist.spec.ts index dbc2f3a096..ad98cb6f19 100644 --- a/apps/remix-ide-e2e/src/tests/gist.spec.ts +++ b/apps/remix-ide-e2e/src/tests/gist.spec.ts @@ -29,9 +29,9 @@ module.exports = { .waitForElementVisible('*[data-id="fileExplorerNewFilecreateNewFolder"]') .click('[data-id="fileExplorerNewFilecreateNewFolder"]') .pause(1000) - .waitForElementVisible('*[data-id="treeViewLitreeViewItem/blank"]') - .sendKeys('*[data-id="treeViewLitreeViewItem/blank"] .remixui_items', 'Browser_Tests') - .sendKeys('*[data-id="treeViewLitreeViewItem/blank"] .remixui_items', browser.Keys.ENTER) + .waitForElementVisible('*[data-id$="/blank"]') + .sendKeys('*[data-id$="/blank"] .remixui_items', 'Browser_Tests') + .sendKeys('*[data-id$="/blank"] .remixui_items', browser.Keys.ENTER) .waitForElementVisible('*[data-id="treeViewLitreeViewItemBrowser_Tests"]') .addFile('File.sol', { content: '' }) .click('*[data-id="fileExplorerNewFilepublishToGist"]') diff --git a/apps/remix-ide-e2e/src/tests/solidityUnittests.spec.ts b/apps/remix-ide-e2e/src/tests/solidityUnittests.spec.ts index 3601539f3d..9223ae1728 100644 --- a/apps/remix-ide-e2e/src/tests/solidityUnittests.spec.ts +++ b/apps/remix-ide-e2e/src/tests/solidityUnittests.spec.ts @@ -179,9 +179,8 @@ function runTests (browser: NightwatchBrowser) { .click('*[data-id="treeViewLitreeViewItemcontracts"]') .openFile('contracts/3_Ballot.sol') .clickLaunchIcon('solidityUnitTesting') - .pause(500) - .setValue('*[data-id="uiPathInput"]', 'tests') .pause(2000) + .verify.attributeEquals('*[data-id="uiPathInput"]', 'value', 'tests') .scrollAndClick('#runTestsTabRunAction') .waitForElementVisible('*[data-id="testTabSolidityUnitTestsOutputheader"]', 120000) .waitForElementPresent('#solidityUnittestsOutput div[class^="testPass"]', 60000) diff --git a/apps/remix-ide-e2e/src/tests/url.spec.ts b/apps/remix-ide-e2e/src/tests/url.spec.ts index ec273cab4c..40a52bd073 100644 --- a/apps/remix-ide-e2e/src/tests/url.spec.ts +++ b/apps/remix-ide-e2e/src/tests/url.spec.ts @@ -10,7 +10,7 @@ const sources = [ module.exports = { before: function (browser: NightwatchBrowser, done: VoidFunction) { - init(browser, done, 'http://127.0.0.1:8080/#optimize=true&runs=300&evmVersion=istanbul&version=soljson-v0.7.4+commit.3f05b770.js&code=cHJhZ21hIHNvbGlkaXR5ID49MC42LjAgPDAuNy4wOwoKaW1wb3J0ICJodHRwczovL2dpdGh1Yi5jb20vT3BlblplcHBlbGluL29wZW56ZXBwZWxpbi1jb250cmFjdHMvYmxvYi9tYXN0ZXIvY29udHJhY3RzL2FjY2Vzcy9Pd25hYmxlLnNvbCI7Cgpjb250cmFjdCBHZXRQYWlkIGlzIE93bmFibGUgewogIGZ1bmN0aW9uIHdpdGhkcmF3KCkgZXh0ZXJuYWwgb25seU93bmVyIHsKICB9Cn0') + init(browser, done, 'http://127.0.0.1:8080/#optimize=true&runs=300&evmVersion=istanbul&version=soljson-v0.7.4+commit.3f05b770.js&code=cHJhZ21hIHNvbGlkaXR5ID49MC42LjAgPDAuNy4wOwoKaW1wb3J0ICJodHRwczovL2dpdGh1Yi5jb20vT3BlblplcHBlbGluL29wZW56ZXBwZWxpbi1jb250cmFjdHMvYmxvYi9tYXN0ZXIvY29udHJhY3RzL2FjY2Vzcy9Pd25hYmxlLnNvbCI7Cgpjb250cmFjdCBHZXRQYWlkIGlzIE93bmFibGUgewogIGZ1bmN0aW9uIHdpdGhkcmF3KCkgZXh0ZXJuYWwgb25seU93bmVyIHsKICB9Cn0', true, false) }, '@sources': function () { diff --git a/apps/remix-ide/src/app/compiler/compiler-imports.js b/apps/remix-ide/src/app/compiler/compiler-imports.js index 89fa1aa988..0e72bb425d 100644 --- a/apps/remix-ide/src/app/compiler/compiler-imports.js +++ b/apps/remix-ide/src/app/compiler/compiler-imports.js @@ -121,9 +121,7 @@ module.exports = class CompilerImports extends Plugin { if (provider.type === 'localhost' && !provider.isConnected()) { return reject(new Error(`file provider ${provider.type} not available while trying to resolve ${url}`)) } - provider.exists(url, (error, exist) => { - if (error) return reject(error) - + provider.exists(url).then(exist => { /* if the path is absolute and the file does not exist, we can stop here Doesn't make sense to try to resolve "localhost/node_modules/localhost/node_modules/" and we'll end in an infinite loop. @@ -162,6 +160,8 @@ module.exports = class CompilerImports extends Plugin { if (error) return reject(error) resolve(content) }) + }).catch(error => { + return reject(error) }) } }) diff --git a/apps/remix-ide/src/app/editor/contextView.js b/apps/remix-ide/src/app/editor/contextView.js index dbf1bbf933..da01321762 100644 --- a/apps/remix-ide/src/app/editor/contextView.js +++ b/apps/remix-ide/src/app/editor/contextView.js @@ -109,10 +109,11 @@ class ContextView { if (filename !== this._deps.config.get('currentFile')) { const provider = this._deps.fileManager.fileProviderOf(filename) if (provider) { - provider.exists(filename, (error, exist) => { - if (error) return console.log(error) + provider.exists(filename).then(exist => { this._deps.fileManager.open(filename) jumpToLine(lineColumn) + }).catch(error => { + if (error) return console.log(error) }) } } else { diff --git a/apps/remix-ide/src/app/files/fileManager.js b/apps/remix-ide/src/app/files/fileManager.js index cc73ba0942..fd74b1722d 100644 --- a/apps/remix-ide/src/app/files/fileManager.js +++ b/apps/remix-ide/src/app/files/fileManager.js @@ -121,10 +121,7 @@ class FileManager extends Plugin { try { path = this.limitPluginScope(path) const provider = this.fileProviderOf(path) - const result = provider.exists(path, (err, result) => { - if (err) return false - return result - }) + const result = provider.exists(path) return result } catch (e) { diff --git a/apps/remix-ide/src/app/files/fileProvider.js b/apps/remix-ide/src/app/files/fileProvider.js index 544a1b0bab..521dcec453 100644 --- a/apps/remix-ide/src/app/files/fileProvider.js +++ b/apps/remix-ide/src/app/files/fileProvider.js @@ -63,11 +63,11 @@ class FileProvider { }) } - exists (path, cb) { + async exists (path) { // todo check the type (directory/file) as well #2386 // currently it is not possible to have a file and folder with same path const ret = this._exists(path) - if (cb) cb(null, ret) + return ret } diff --git a/apps/remix-ide/src/app/files/remixDProvider.js b/apps/remix-ide/src/app/files/remixDProvider.js index b20a097ccd..60337c11e4 100644 --- a/apps/remix-ide/src/app/files/remixDProvider.js +++ b/apps/remix-ide/src/app/files/remixDProvider.js @@ -74,16 +74,15 @@ module.exports = class RemixDProvider extends FileProvider { }) } - exists (path, cb) { - if (!this._isReady) return cb && cb('provider not ready') + exists (path) { + if (!this._isReady) throw new Error('provider not ready') const unprefixedpath = this.removePrefix(path) return this._appManager.call('remixd', 'exists', { path: unprefixedpath }) .then((result) => { - if (cb) return cb(null, result) return result - }).catch((error) => { - if (cb) return cb(error) + }) + .catch((error) => { throw new Error(error) }) } diff --git a/apps/remix-ide/src/app/files/workspaceFileProvider.js b/apps/remix-ide/src/app/files/workspaceFileProvider.js index 742d85a775..156723b622 100644 --- a/apps/remix-ide/src/app/files/workspaceFileProvider.js +++ b/apps/remix-ide/src/app/files/workspaceFileProvider.js @@ -33,7 +33,7 @@ class WorkspaceFileProvider extends FileProvider { if (!this.workspace) this.createWorkspace() path = path.replace(/^\/|\/$/g, '') // remove first and last slash if (path.startsWith(this.workspacesPath + '/' + this.workspace)) return path - if (path.startsWith(this.workspace)) return this.workspacesPath + '/' + this.workspace + if (path.startsWith(this.workspace)) return path.replace(this.workspace, this.workspacesPath + '/' + this.workspace) path = super.removePrefix(path) let ret = this.workspacesPath + '/' + this.workspace + '/' + (path === '/' ? '' : path) diff --git a/apps/remix-ide/src/app/panels/file-panel.js b/apps/remix-ide/src/app/panels/file-panel.js index 893ec084f9..991108285c 100644 --- a/apps/remix-ide/src/app/panels/file-panel.js +++ b/apps/remix-ide/src/app/panels/file-panel.js @@ -188,8 +188,11 @@ module.exports = class Filepanel extends ViewPlugin { const browserProvider = this._deps.fileProviders.browser const workspacePath = 'browser/' + workspaceProvider.workspacesPath + '/' + name const workspaceRootPath = 'browser/' + workspaceProvider.workspacesPath - if (!browserProvider.exists(workspaceRootPath)) browserProvider.createDir(workspaceRootPath) - if (!browserProvider.exists(workspacePath)) browserProvider.createDir(workspacePath) + const workspaceRootPathExists = await browserProvider.exists(workspaceRootPath) + const workspacePathExists = await browserProvider.exists(workspacePath) + + if (!workspaceRootPathExists) browserProvider.createDir(workspaceRootPath) + if (!workspacePathExists) browserProvider.createDir(workspacePath) } async workspaceExists (name) { @@ -209,11 +212,13 @@ module.exports = class Filepanel extends ViewPlugin { workspaceProvider.setWorkspace(workspaceName) await this.request.setWorkspace(workspaceName) // tells the react component to switch to that workspace for (const file in examples) { - try { - await workspaceProvider.set(examples[file].name, examples[file].content) - } catch (error) { - console.error(error) - } + setTimeout(async () => { // space creation of files to give react ui time to update. + try { + await workspaceProvider.set(examples[file].name, examples[file].content) + } catch (error) { + console.error(error) + } + }, 10) } } } diff --git a/apps/remix-ide/src/app/tabs/testTab/testTab.js b/apps/remix-ide/src/app/tabs/testTab/testTab.js index 1cfdff91be..1f915d9ee4 100644 --- a/apps/remix-ide/src/app/tabs/testTab/testTab.js +++ b/apps/remix-ide/src/app/tabs/testTab/testTab.js @@ -18,7 +18,9 @@ class TestTabLogic { // Checking to ignore the value which contains only whitespaces if (!path || !(/\S/.test(path))) return const fileProvider = this.fileManager.fileProviderOf(path.split('/')[0]) - fileProvider.exists(path, (e, res) => { if (!res) fileProvider.createDir(path) }) + fileProvider.exists(path).then(res => { + if (!res) fileProvider.createDir(path) + }) } pathExists (path) { diff --git a/apps/remix-ide/src/app/ui/renderer.js b/apps/remix-ide/src/app/ui/renderer.js index 35cf8395ec..97107c19d2 100644 --- a/apps/remix-ide/src/app/ui/renderer.js +++ b/apps/remix-ide/src/app/ui/renderer.js @@ -39,10 +39,11 @@ Renderer.prototype._errorClick = function (errFile, errLine, errCol) { // TODO: refactor with this._components.contextView.jumpTo var provider = self._deps.fileManager.fileProviderOf(errFile) if (provider) { - provider.exists(errFile, (error, exist) => { - if (error) return console.log(error) + provider.exists(errFile).then(exist => { self._deps.fileManager.open(errFile) editor.gotoLine(errLine, errCol) + }).catch(error => { + if (error) return console.log(error) }) } } else { diff --git a/apps/remix-ide/src/lib/helper.js b/apps/remix-ide/src/lib/helper.js index ac3f71a0e5..2d8bb19cfb 100644 --- a/apps/remix-ide/src/lib/helper.js +++ b/apps/remix-ide/src/lib/helper.js @@ -36,14 +36,12 @@ module.exports = { async.whilst( () => { return exist }, (callback) => { - fileProvider.exists(name + counter + prefix + '.' + ext, (error, currentExist) => { - if (error) { - callback(error) - } else { - exist = currentExist - if (exist) counter = (counter | 0) + 1 - callback() - } + fileProvider.exists(name + counter + prefix + '.' + ext).then(currentExist => { + exist = currentExist + if (exist) counter = (counter | 0) + 1 + callback() + }).catch(error => { + if (error) console.log(error) }) }, (error) => { cb(error, name + counter + prefix + '.' + ext) } @@ -52,6 +50,27 @@ module.exports = { createNonClashingName (name, fileProvider, cb) { this.createNonClashingNameWithPrefix(name, fileProvider, '', cb) }, + async createNonClashingNameAsync (name, fileManager, prefix = '') { + if (!name) name = 'Undefined' + let counter = '' + let ext = 'sol' + const reg = /(.*)\.([^.]+)/g + const split = reg.exec(name) + if (split) { + name = split[1] + ext = split[2] + } + let exist = true + + do { + const isDuplicate = await fileManager.exists(name + counter + prefix + '.' + ext) + + if (isDuplicate) counter = (counter | 0) + 1 + else exist = false + } while (exist) + + return name + counter + prefix + '.' + ext + }, checkSpecialChars (name) { return name.match(/[:*?"<>\\'|]/) != null }, diff --git a/libs/remix-ui/file-explorer/src/lib/actions/fileSystem.ts b/libs/remix-ui/file-explorer/src/lib/actions/fileSystem.ts new file mode 100644 index 0000000000..8e3bc9f1c4 --- /dev/null +++ b/libs/remix-ui/file-explorer/src/lib/actions/fileSystem.ts @@ -0,0 +1,293 @@ +import React from 'react' +import { File } from '../types' +import { extractNameFromKey, extractParentFromKey } from '../utils' + +export const fetchDirectoryError = (error: any) => { + return { + type: 'FETCH_DIRECTORY_ERROR', + payload: error + } +} + +export const fetchDirectoryRequest = (promise: Promise) => { + return { + type: 'FETCH_DIRECTORY_REQUEST', + payload: promise + } +} + +export const fetchDirectorySuccess = (path: string, files: File[]) => { + return { + type: 'FETCH_DIRECTORY_SUCCESS', + payload: { path, files } + } +} + +export const fileSystemReset = () => { + return { + type: 'FILESYSTEM_RESET' + } +} + +const normalize = (parent, filesList, newInputType?: string): any => { + const folders = {} + const files = {} + + Object.keys(filesList || {}).forEach(key => { + key = key.replace(/^\/|\/$/g, '') // remove first and last slash + let path = key + path = path.replace(/^\/|\/$/g, '') // remove first and last slash + + if (filesList[key].isDirectory) { + folders[extractNameFromKey(key)] = { + path, + name: extractNameFromKey(path), + isDirectory: filesList[key].isDirectory + } + } else { + files[extractNameFromKey(key)] = { + path, + name: extractNameFromKey(path), + isDirectory: filesList[key].isDirectory + } + } + }) + + if (newInputType === 'folder') { + const path = parent + '/blank' + + folders[path] = { + path: path, + name: '', + isDirectory: true + } + } else if (newInputType === 'file') { + const path = parent + '/blank' + + files[path] = { + path: path, + name: '', + isDirectory: false + } + } + + return Object.assign({}, folders, files) +} + +const fetchDirectoryContent = async (provider, folderPath: string, newInputType?: string): Promise => { + return new Promise((resolve) => { + provider.resolveDirectory(folderPath, (error, fileTree) => { + if (error) console.error(error) + const files = normalize(folderPath, fileTree, newInputType) + + resolve({ [extractNameFromKey(folderPath)]: files }) + }) + }) +} + +export const fetchDirectory = (provider, path: string) => (dispatch: React.Dispatch) => { + const promise = fetchDirectoryContent(provider, path) + + dispatch(fetchDirectoryRequest(promise)) + promise.then((files) => { + dispatch(fetchDirectorySuccess(path, files)) + }).catch((error) => { + dispatch(fetchDirectoryError({ error })) + }) + return promise +} + +export const resolveDirectoryError = (error: any) => { + return { + type: 'RESOLVE_DIRECTORY_ERROR', + payload: error + } +} + +export const resolveDirectoryRequest = (promise: Promise) => { + return { + type: 'RESOLVE_DIRECTORY_REQUEST', + payload: promise + } +} + +export const resolveDirectorySuccess = (path: string, files: File[]) => { + return { + type: 'RESOLVE_DIRECTORY_SUCCESS', + payload: { path, files } + } +} + +export const resolveDirectory = (provider, path: string) => (dispatch: React.Dispatch) => { + const promise = fetchDirectoryContent(provider, path) + + dispatch(resolveDirectoryRequest(promise)) + promise.then((files) => { + dispatch(resolveDirectorySuccess(path, files)) + }).catch((error) => { + dispatch(resolveDirectoryError({ error })) + }) + return promise +} + +export const fetchProviderError = (error: any) => { + return { + type: 'FETCH_PROVIDER_ERROR', + payload: error + } +} + +export const fetchProviderRequest = (promise: Promise) => { + return { + type: 'FETCH_PROVIDER_REQUEST', + payload: promise + } +} + +export const fetchProviderSuccess = (provider: any) => { + return { + type: 'FETCH_PROVIDER_SUCCESS', + payload: provider + } +} + +export const fileAddedSuccess = (path: string, files) => { + return { + type: 'FILE_ADDED', + payload: { path, files } + } +} + +export const folderAddedSuccess = (path: string, files) => { + return { + type: 'FOLDER_ADDED', + payload: { path, files } + } +} + +export const fileRemovedSuccess = (path: string, removePath: string) => { + return { + type: 'FILE_REMOVED', + payload: { path, removePath } + } +} + +export const fileRenamedSuccess = (path: string, removePath: string, files) => { + return { + type: 'FILE_RENAMED', + payload: { path, removePath, files } + } +} + +export const init = (provider, workspaceName: string, plugin, registry) => (dispatch: React.Dispatch) => { + if (provider) { + provider.event.register('fileAdded', async (filePath) => { + if (extractParentFromKey(filePath) === '/.workspaces') return + const path = extractParentFromKey(filePath) || provider.workspace || provider.type || '' + const data = await fetchDirectoryContent(provider, path) + + dispatch(fileAddedSuccess(path, data)) + if (filePath.includes('_test.sol')) { + plugin.event.trigger('newTestFileCreated', [filePath]) + } + }) + provider.event.register('folderAdded', async (folderPath) => { + if (extractParentFromKey(folderPath) === '/.workspaces') return + const path = extractParentFromKey(folderPath) || provider.workspace || provider.type || '' + const data = await fetchDirectoryContent(provider, path) + + dispatch(folderAddedSuccess(path, data)) + }) + provider.event.register('fileRemoved', async (removePath) => { + const path = extractParentFromKey(removePath) || provider.workspace || provider.type || '' + + dispatch(fileRemovedSuccess(path, removePath)) + }) + provider.event.register('fileRenamed', async (oldPath) => { + const path = extractParentFromKey(oldPath) || provider.workspace || provider.type || '' + const data = await fetchDirectoryContent(provider, path) + + dispatch(fileRenamedSuccess(path, oldPath, data)) + }) + provider.event.register('fileExternallyChanged', async (path: string, file: { content: string }) => { + const config = registry.get('config').api + const editor = registry.get('editor').api + + if (config.get('currentFile') === path && editor.currentContent() !== file.content) { + if (provider.isReadOnly(path)) return editor.setText(file.content) + dispatch(displayNotification( + path + ' changed', + 'This file has been changed outside of Remix IDE.', + 'Replace by the new content', 'Keep the content displayed in Remix', + () => { + editor.setText(file.content) + } + )) + } + }) + provider.event.register('fileRenamedError', async () => { + dispatch(displayNotification('File Renamed Failed', '', 'Ok', 'Cancel')) + }) + provider.event.register('rootFolderChanged', async () => { + workspaceName = provider.workspace || provider.type || '' + fetchDirectory(provider, workspaceName)(dispatch) + }) + dispatch(fetchProviderSuccess(provider)) + dispatch(setCurrentWorkspace(workspaceName)) + } else { + dispatch(fetchProviderError('No provider available')) + } +} + +export const setCurrentWorkspace = (name: string) => { + return { + type: 'SET_CURRENT_WORKSPACE', + payload: name + } +} + +export const addInputFieldSuccess = (path: string, files: File[]) => { + return { + type: 'ADD_INPUT_FIELD', + payload: { path, files } + } +} + +export const addInputField = (provider, type: string, path: string) => (dispatch: React.Dispatch) => { + const promise = fetchDirectoryContent(provider, path, type) + + promise.then((files) => { + dispatch(addInputFieldSuccess(path, files)) + }).catch((error) => { + console.error(error) + }) + return promise +} + +export const removeInputFieldSuccess = (path: string) => { + return { + type: 'REMOVE_INPUT_FIELD', + payload: { path } + } +} + +export const removeInputField = (path: string) => (dispatch: React.Dispatch) => { + return dispatch(removeInputFieldSuccess(path)) +} + +export const displayNotification = (title: string, message: string, labelOk: string, labelCancel: string, actionOk?: (...args) => void, actionCancel?: (...args) => void) => { + return { + type: 'DISPLAY_NOTIFICATION', + payload: { title, message, labelOk, labelCancel, actionOk, actionCancel } + } +} + +export const hideNotification = () => { + return { + type: 'DISPLAY_NOTIFICATION' + } +} + +export const closeNotificationModal = () => (dispatch: React.Dispatch) => { + dispatch(hideNotification()) +} diff --git a/libs/remix-ui/file-explorer/src/lib/file-explorer.tsx b/libs/remix-ui/file-explorer/src/lib/file-explorer.tsx index 17f78d79fc..fc8c89450a 100644 --- a/libs/remix-ui/file-explorer/src/lib/file-explorer.tsx +++ b/libs/remix-ui/file-explorer/src/lib/file-explorer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef } from 'react' // eslint-disable-line +import React, { useEffect, useState, useRef, useReducer } from 'react' // eslint-disable-line // import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd' // eslint-disable-line import { TreeView, TreeViewItem } from '@remix-ui/tree-view' // eslint-disable-line import { ModalDialog } from '@remix-ui/modal-dialog' // eslint-disable-line @@ -7,6 +7,8 @@ import Gists from 'gists' import { FileExplorerMenu } from './file-explorer-menu' // eslint-disable-line import { FileExplorerContextMenu } from './file-explorer-context-menu' // eslint-disable-line import { FileExplorerProps, File } from './types' +import { fileSystemReducer, fileSystemInitialState } from './reducers/fileSystem' +import { fetchDirectory, init, resolveDirectory, addInputField, removeInputField } from './actions/fileSystem' import * as helper from '../../../../../apps/remix-ide/src/lib/helper' import QueryParams from '../../../../../apps/remix-ide/src/lib/query-params' @@ -15,7 +17,7 @@ import './css/file-explorer.css' const queryParams = new QueryParams() export const FileExplorer = (props: FileExplorerProps) => { - const { filesProvider, name, registry, plugin, focusRoot, contextMenuItems, displayInput, externalUploads } = props + const { name, registry, plugin, focusRoot, contextMenuItems, displayInput, externalUploads } = props const [state, setState] = useState({ focusElement: [{ key: '', @@ -24,10 +26,51 @@ export const FileExplorer = (props: FileExplorerProps) => { focusPath: null, files: [], fileManager: null, - filesProvider, ctrlKey: false, newFileName: '', - actions: [], + actions: [{ + id: 'newFile', + name: 'New File', + type: ['folder'], + path: [], + extension: [], + pattern: [] + }, { + id: 'newFolder', + name: 'New Folder', + type: ['folder'], + path: [], + extension: [], + pattern: [] + }, { + id: 'rename', + name: 'Rename', + type: ['file', 'folder'], + path: [], + extension: [], + pattern: [] + }, { + id: 'delete', + name: 'Delete', + type: ['file', 'folder'], + path: [], + extension: [], + pattern: [] + }, { + id: 'pushChangesToGist', + name: 'Push changes to gist', + type: [], + path: [], + extension: [], + pattern: ['^browser/gists/([0-9]|[a-z])*$'] + }, { + id: 'run', + name: 'Run', + type: [], + path: [], + extension: ['.js'], + pattern: [] + }], focusContext: { element: null, x: null, @@ -60,8 +103,43 @@ export const FileExplorer = (props: FileExplorerProps) => { mouseOverElement: null, showContextMenu: false }) + const [fileSystem, dispatch] = useReducer(fileSystemReducer, fileSystemInitialState) const editRef = useRef(null) + useEffect(() => { + if (props.filesProvider) { + init(props.filesProvider, props.name, props.plugin, props.registry)(dispatch) + } + }, [props.filesProvider, props.name]) + + useEffect(() => { + const provider = fileSystem.provider.provider + + if (provider) { + fetchDirectory(provider, props.name)(dispatch) + } + }, [fileSystem.provider.provider, props.name]) + + useEffect(() => { + if (fileSystem.notification.message) { + modal(fileSystem.notification.title, fileSystem.notification.message, { + label: fileSystem.notification.labelOk, + fn: fileSystem.notification.actionOk + }, { + label: fileSystem.notification.labelCancel, + fn: fileSystem.notification.actionCancel + }) + } + }, [fileSystem.notification.message]) + + useEffect(() => { + if (fileSystem.files.expandPath.length > 0) { + setState(prevState => { + return { ...prevState, expandPath: [...new Set([...prevState.expandPath, ...fileSystem.files.expandPath])] } + }) + } + }, [fileSystem.files.expandPath]) + useEffect(() => { if (state.focusEdit.element) { setTimeout(() => { @@ -75,95 +153,13 @@ export const FileExplorer = (props: FileExplorerProps) => { useEffect(() => { (async () => { const fileManager = registry.get('filemanager').api - const files = await fetchDirectoryContent(name) - const actions = [{ - id: 'newFile', - name: 'New File', - type: ['folder'], - path: [], - extension: [], - pattern: [] - }, { - id: 'newFolder', - name: 'New Folder', - type: ['folder'], - path: [], - extension: [], - pattern: [] - }, { - id: 'rename', - name: 'Rename', - type: ['file', 'folder'], - path: [], - extension: [], - pattern: [] - }, { - id: 'delete', - name: 'Delete', - type: ['file', 'folder'], - path: [], - extension: [], - pattern: [] - }, { - id: 'pushChangesToGist', - name: 'Push changes to gist', - type: [], - path: [], - extension: [], - pattern: ['^browser/gists/([0-9]|[a-z])*$'] - }, { - id: 'run', - name: 'Run', - type: [], - path: [], - extension: ['.js'], - pattern: [] - }] setState(prevState => { - return { ...prevState, fileManager, files, actions, expandPath: [name] } + return { ...prevState, fileManager, expandPath: [name] } }) })() }, [name]) - useEffect(() => { - if (state.fileManager) { - filesProvider.event.register('fileExternallyChanged', fileExternallyChanged) - filesProvider.event.register('fileRenamedError', fileRenamedError) - filesProvider.event.register('rootFolderChanged', rootFolderChanged) - } - }, [state.fileManager]) - - useEffect(() => { - const { expandPath } = state - const expandFn = async () => { - let files = state.files - - for (let i = 0; i < expandPath.length; i++) { - files = await resolveDirectory(expandPath[i], files) - await setState(prevState => { - return { ...prevState, files } - }) - } - } - - if (expandPath && expandPath.length > 0) { - expandFn() - } - }, [state.expandPath]) - - useEffect(() => { - // unregister event to update state in callback - if (filesProvider.event.registered.fileAdded) filesProvider.event.unregister('fileAdded', fileAdded) - if (filesProvider.event.registered.folderAdded) filesProvider.event.unregister('folderAdded', folderAdded) - if (filesProvider.event.registered.fileRemoved) filesProvider.event.unregister('fileRemoved', fileRemoved) - if (filesProvider.event.registered.fileRenamed) filesProvider.event.unregister('fileRenamed', fileRenamed) - filesProvider.event.register('fileAdded', fileAdded) - filesProvider.event.register('folderAdded', folderAdded) - filesProvider.event.register('fileRemoved', fileRemoved) - filesProvider.event.register('fileRenamed', fileRenamed) - }, [state.files]) - useEffect(() => { if (focusRoot) { setState(prevState => { @@ -220,82 +216,6 @@ export const FileExplorer = (props: FileExplorerProps) => { } }, [state.modals]) - const resolveDirectory = async (folderPath, dir: File[], isChild = false): Promise => { - if (!isChild && (state.focusEdit.element === '/blank') && state.focusEdit.isNew && (dir.findIndex(({ path }) => path === '/blank') === -1)) { - dir = state.focusEdit.type === 'file' ? [...dir, { - path: state.focusEdit.element, - name: '', - isDirectory: false - }] : [{ - path: state.focusEdit.element, - name: '', - isDirectory: true - }, ...dir] - } - dir = await Promise.all(dir.map(async (file) => { - if (file.path === folderPath) { - if ((extractParentFromKey(state.focusEdit.element) === folderPath) && state.focusEdit.isNew) { - file.child = state.focusEdit.type === 'file' ? [...await fetchDirectoryContent(folderPath), { - path: state.focusEdit.element, - name: '', - isDirectory: false - }] : [{ - path: state.focusEdit.element, - name: '', - isDirectory: true - }, ...await fetchDirectoryContent(folderPath)] - } else { - file.child = await fetchDirectoryContent(folderPath) - } - return file - } else if (file.child) { - file.child = await resolveDirectory(folderPath, file.child, true) - return file - } else { - return file - } - })) - - return dir - } - - const fetchDirectoryContent = async (folderPath: string): Promise => { - return new Promise((resolve) => { - filesProvider.resolveDirectory(folderPath, (_error, fileTree) => { - const files = normalize(fileTree) - - resolve(files) - }) - }) - } - - const normalize = (filesList): File[] => { - const folders = [] - const files = [] - - Object.keys(filesList || {}).forEach(key => { - key = key.replace(/^\/|\/$/g, '') // remove first and last slash - let path = key - path = path.replace(/^\/|\/$/g, '') // remove first and last slash - - if (filesList[key].isDirectory) { - folders.push({ - path, - name: extractNameFromKey(path), - isDirectory: filesList[key].isDirectory - }) - } else { - files.push({ - path, - name: extractNameFromKey(path), - isDirectory: filesList[key].isDirectory - }) - } - }) - - return [...folders, ...files] - } - const extractNameFromKey = (key: string):string => { const keyPath = key.split('/') @@ -310,29 +230,23 @@ export const FileExplorer = (props: FileExplorerProps) => { return keyPath.join('/') } - const createNewFile = (newFilePath: string) => { + const createNewFile = async (newFilePath: string) => { const fileManager = state.fileManager try { - helper.createNonClashingName(newFilePath, filesProvider, async (error, newName) => { - if (error) { - modal('Create File Failed', error, { - label: 'Close', - fn: async () => {} - }, null) - } else { - const createFile = await fileManager.writeFile(newName, '') + const newName = await helper.createNonClashingNameAsync(newFilePath, fileManager) + const createFile = await fileManager.writeFile(newName, '') - if (!createFile) { - return toast('Failed to create file ' + newName) - } else { - await fileManager.open(newName) - setState(prevState => { - return { ...prevState, focusElement: [{ key: newName, type: 'file' }] } - }) - } - } - }) + if (!createFile) { + return toast('Failed to create file ' + newName) + } else { + const path = newName.indexOf(props.name + '/') === 0 ? newName.replace(props.name + '/', '') : newName + + await fileManager.open(path) + setState(prevState => { + return { ...prevState, focusElement: [{ key: newName, type: 'file' }] } + }) + } } catch (error) { return modal('File Creation Failed', typeof error === 'string' ? error : error.message, { label: 'Close', @@ -367,6 +281,8 @@ export const FileExplorer = (props: FileExplorerProps) => { } const deletePath = async (path: string) => { + const filesProvider = fileSystem.provider.provider + if (filesProvider.isReadOnly(path)) { return toast('cannot delete file. ' + name + ' is a read only explorer') } @@ -410,106 +326,8 @@ export const FileExplorer = (props: FileExplorerProps) => { } } - const removePath = (path: string, files: File[]): File[] => { - return files.map(file => { - if (file.path === path) { - return null - } else if (file.child) { - const childFiles = removePath(path, file.child) - - file.child = childFiles.filter(file => file) - return file - } else { - return file - } - }) - } - - const fileAdded = async (filePath: string) => { - const pathArr = filePath.split('/') - const expandPath = pathArr.map((path, index) => { - return [...pathArr.slice(0, index)].join('/') - }).filter(path => path && (path !== props.name)) - const files = await fetchDirectoryContent(props.name) - - setState(prevState => { - const uniquePaths = [...new Set([...prevState.expandPath, ...expandPath])] - - return { ...prevState, files, expandPath: uniquePaths } - }) - if (filePath.includes('_test.sol')) { - plugin.event.trigger('newTestFileCreated', [filePath]) - } - } - - const folderAdded = async (folderPath: string) => { - const pathArr = folderPath.split('/') - const expandPath = pathArr.map((path, index) => { - return [...pathArr.slice(0, index)].join('/') - }).filter(path => path && (path !== props.name)) - const files = await fetchDirectoryContent(props.name) - - setState(prevState => { - const uniquePaths = [...new Set([...prevState.expandPath, ...expandPath])] - - return { ...prevState, files, expandPath: uniquePaths } - }) - } - - const fileExternallyChanged = (path: string, file: { content: string }) => { - const config = registry.get('config').api - const editor = registry.get('editor').api - - if (config.get('currentFile') === path && editor.currentContent() !== file.content) { - if (filesProvider.isReadOnly(path)) return editor.setText(file.content) - modal(path + ' changed', 'This file has been changed outside of Remix IDE.', { - label: 'Replace by the new content', - fn: () => { - editor.setText(file.content) - } - }, { - label: 'Keep the content displayed in Remix', - fn: () => {} - }) - } - } - - const fileRemoved = (filePath) => { - const files = removePath(filePath, state.files) - const updatedFiles = files.filter(file => file) - - setState(prevState => { - return { ...prevState, files: updatedFiles } - }) - } - - const fileRenamed = async () => { - const files = await fetchDirectoryContent(props.name) - - setState(prevState => { - return { ...prevState, files, expandPath: [...prevState.expandPath] } - }) - } - - // register to event of the file provider - // files.event.register('fileRenamed', fileRenamed) - const fileRenamedError = (error: string) => { - modal('File Renamed Failed', error, { - label: 'Close', - fn: () => {} - }, null) - } - - // register to event of the file provider - // files.event.register('rootFolderChanged', rootFolderChanged) - const rootFolderChanged = async () => { - const files = await fetchDirectoryContent(name) - setState(prevState => { - return { ...prevState, files } - }) - } - const uploadFile = (target) => { + const filesProvider = fileSystem.provider.provider // TODO The file explorer is merely a view on the current state of // the files module. Please ask the user here if they want to overwrite // a file and then just use `files.add`. The file explorer will @@ -552,8 +370,7 @@ export const FileExplorer = (props: FileExplorerProps) => { } const name = `${parentFolder}/${file.name}` - filesProvider.exists(name, (error, exist) => { - if (error) console.log(error) + filesProvider.exists(name).then(exist => { if (!exist) { loadFile(name) } else { @@ -567,6 +384,8 @@ export const FileExplorer = (props: FileExplorerProps) => { fn: () => {} }) } + }).catch(error => { + if (error) console.log(error) }) }) } @@ -582,6 +401,7 @@ export const FileExplorer = (props: FileExplorerProps) => { } const toGist = (id?: string) => { + const filesProvider = fileSystem.provider.provider const proccedResult = function (error, data) { if (error) { modal('Publish to gist Failed', 'Failed to manage gist: ' + error, { @@ -698,6 +518,8 @@ export const FileExplorer = (props: FileExplorerProps) => { } const runScript = async (path: string) => { + const filesProvider = fileSystem.provider.provider + filesProvider.get(path, (error, content: string) => { if (error) return console.log(error) plugin.call('scriptRunner', 'execute', content) @@ -737,6 +559,7 @@ export const FileExplorer = (props: FileExplorerProps) => { } const handleClickFile = (path: string) => { + path = path.indexOf(props.name + '/') === 0 ? path.replace(props.name + '/', '') : path state.fileManager.open(path) setState(prevState => { return { ...prevState, focusElement: [{ key: path, type: 'file' }] } @@ -759,6 +582,7 @@ export const FileExplorer = (props: FileExplorerProps) => { if (!state.expandPath.includes(path)) { expandPath = [...new Set([...state.expandPath, path])] + resolveDirectory(fileSystem.provider.provider, path)(dispatch) } else { expandPath = [...new Set(state.expandPath.filter(key => key && (typeof key === 'string') && !key.startsWith(path)))] } @@ -790,7 +614,7 @@ export const FileExplorer = (props: FileExplorerProps) => { } const editModeOn = (path: string, type: string, isNew: boolean = false) => { - if (filesProvider.isReadOnly(path)) return + if (fileSystem.provider.provider.isReadOnly(path)) return setState(prevState => { return { ...prevState, focusEdit: { ...prevState.focusEdit, element: path, isNew, type } } }) @@ -802,11 +626,9 @@ export const FileExplorer = (props: FileExplorerProps) => { if (!content || (content.trim() === '')) { if (state.focusEdit.isNew) { - const files = removePath(state.focusEdit.element, state.files) - const updatedFiles = files.filter(file => file) - + removeInputField(parentFolder)(dispatch) setState(prevState => { - return { ...prevState, files: updatedFiles, focusEdit: { element: null, isNew: false, type: '', lastEdit: '' } } + return { ...prevState, focusEdit: { element: null, isNew: false, type: '', lastEdit: '' } } }) } else { editRef.current.textContent = state.focusEdit.lastEdit @@ -829,12 +651,7 @@ export const FileExplorer = (props: FileExplorerProps) => { } else { if (state.focusEdit.isNew) { state.focusEdit.type === 'file' ? createNewFile(joinPath(parentFolder, content)) : createNewFolder(joinPath(parentFolder, content)) - const files = removePath(state.focusEdit.element, state.files) - const updatedFiles = files.filter(file => file) - - setState(prevState => { - return { ...prevState, files: updatedFiles } - }) + removeInputField(parentFolder)(dispatch) } else { const oldPath: string = state.focusEdit.element const oldName = extractNameFromKey(oldPath) @@ -851,9 +668,10 @@ export const FileExplorer = (props: FileExplorerProps) => { } const handleNewFileInput = async (parentFolder?: string) => { - if (!parentFolder) parentFolder = state.focusElement[0] ? state.focusElement[0].type === 'folder' ? state.focusElement[0].key : extractParentFromKey(state.focusElement[0].key) : name + if (!parentFolder) parentFolder = state.focusElement[0] ? state.focusElement[0].type === 'folder' ? state.focusElement[0].key ? state.focusElement[0].key : name : extractParentFromKey(state.focusElement[0].key) ? extractParentFromKey(state.focusElement[0].key) : name : name const expandPath = [...new Set([...state.expandPath, parentFolder])] + await addInputField(fileSystem.provider.provider, 'file', parentFolder)(dispatch) setState(prevState => { return { ...prevState, expandPath } }) @@ -861,10 +679,11 @@ export const FileExplorer = (props: FileExplorerProps) => { } const handleNewFolderInput = async (parentFolder?: string) => { - if (!parentFolder) parentFolder = state.focusElement[0] ? state.focusElement[0].type === 'folder' ? state.focusElement[0].key : extractParentFromKey(state.focusElement[0].key) : name + if (!parentFolder) parentFolder = state.focusElement[0] ? state.focusElement[0].type === 'folder' ? state.focusElement[0].key ? state.focusElement[0].key : name : extractParentFromKey(state.focusElement[0].key) ? extractParentFromKey(state.focusElement[0].key) : name : name else if ((parentFolder.indexOf('.sol') !== -1) || (parentFolder.indexOf('.js') !== -1)) parentFolder = extractParentFromKey(parentFolder) const expandPath = [...new Set([...state.expandPath, parentFolder])] + await addInputField(fileSystem.provider.provider, 'folder', parentFolder)(dispatch) setState(prevState => { return { ...prevState, expandPath } }) @@ -915,12 +734,16 @@ export const FileExplorer = (props: FileExplorerProps) => { } const renderFiles = (file: File, index: number) => { + if (!file || !file.path || typeof file === 'string' || typeof file === 'number' || typeof file === 'boolean') return const labelClass = state.focusEdit.element === file.path ? 'bg-light' : state.focusElement.findIndex(item => item.key === file.path) !== -1 ? 'bg-secondary' : state.mouseOverElement === file.path ? 'bg-light border' : (state.focusContext.element === file.path) && (state.focusEdit.element !== file.path) ? 'bg-light border' : '' const icon = helper.getPathIcon(file.path) + const spreadProps = { + onClick: (e) => e.stopPropagation() + } if (file.isDirectory) { return ( @@ -952,12 +775,12 @@ export const FileExplorer = (props: FileExplorerProps) => { }} > { - file.child ? { - file.child.map((file, index) => { - return renderFiles(file, index) + file.child ? { + Object.keys(file.child).map((key, index) => { + return renderFiles(file.child[key], index) }) } - : + : } ) @@ -965,7 +788,7 @@ export const FileExplorer = (props: FileExplorerProps) => { return ( { e.stopPropagation() @@ -1028,8 +851,8 @@ export const FileExplorer = (props: FileExplorerProps) => {
{ - state.files.map((file, index) => { - return renderFiles(file, index) + fileSystem.files.files[props.name] && Object.keys(fileSystem.files.files[props.name]).map((key, index) => { + return renderFiles(fileSystem.files.files[props.name][key], index) }) } diff --git a/libs/remix-ui/file-explorer/src/lib/reducers/fileSystem.ts b/libs/remix-ui/file-explorer/src/lib/reducers/fileSystem.ts new file mode 100644 index 0000000000..dc1628ebf9 --- /dev/null +++ b/libs/remix-ui/file-explorer/src/lib/reducers/fileSystem.ts @@ -0,0 +1,344 @@ +import * as _ from 'lodash' +import { extractNameFromKey } from '../utils' +interface Action { + type: string; + payload: Record; +} + +export const fileSystemInitialState = { + files: { + files: [], + expandPath: [], + workspaceName: null, + blankPath: null, + isRequesting: false, + isSuccessful: false, + error: null + }, + provider: { + provider: null, + isRequesting: false, + isSuccessful: false, + error: null + }, + notification: { + title: null, + message: null, + actionOk: () => {}, + actionCancel: () => {}, + labelOk: null, + labelCancel: null + } +} + +export const fileSystemReducer = (state = fileSystemInitialState, action: Action) => { + switch (action.type) { + case 'FETCH_DIRECTORY_REQUEST': { + return { + ...state, + files: { + ...state.files, + isRequesting: true, + isSuccessful: false, + error: null + } + } + } + case 'FETCH_DIRECTORY_SUCCESS': { + return { + ...state, + files: { + ...state.files, + files: action.payload.files, + isRequesting: false, + isSuccessful: true, + error: null + } + } + } + case 'FETCH_DIRECTORY_ERROR': { + return { + ...state, + files: { + ...state.files, + isRequesting: false, + isSuccessful: false, + error: action.payload + } + } + } + case 'RESOLVE_DIRECTORY_REQUEST': { + return { + ...state, + files: { + ...state.files, + isRequesting: true, + isSuccessful: false, + error: null + } + } + } + case 'RESOLVE_DIRECTORY_SUCCESS': { + return { + ...state, + files: { + ...state.files, + files: resolveDirectory(state.files.workspaceName, action.payload.path, state.files.files, action.payload.files), + isRequesting: false, + isSuccessful: true, + error: null + } + } + } + case 'RESOLVE_DIRECTORY_ERROR': { + return { + ...state, + files: { + ...state.files, + isRequesting: false, + isSuccessful: false, + error: action.payload + } + } + } + case 'FETCH_PROVIDER_REQUEST': { + return { + ...state, + provider: { + ...state.provider, + isRequesting: true, + isSuccessful: false, + error: null + } + } + } + case 'FETCH_PROVIDER_SUCCESS': { + return { + ...state, + provider: { + ...state.provider, + provider: action.payload, + isRequesting: false, + isSuccessful: true, + error: null + } + } + } + case 'FETCH_PROVIDER_ERROR': { + return { + ...state, + provider: { + ...state.provider, + isRequesting: false, + isSuccessful: false, + error: action.payload + } + } + } + case 'SET_CURRENT_WORKSPACE': { + return { + ...state, + files: { + ...state.files, + workspaceName: action.payload + } + } + } + case 'ADD_INPUT_FIELD': { + return { + ...state, + files: { + ...state.files, + files: addInputField(state.files.workspaceName, action.payload.path, state.files.files, action.payload.files), + blankPath: action.payload.path, + isRequesting: false, + isSuccessful: true, + error: null + } + } + } + case 'REMOVE_INPUT_FIELD': { + return { + ...state, + files: { + ...state.files, + files: removeInputField(state.files.workspaceName, state.files.blankPath, state.files.files), + blankPath: null, + isRequesting: false, + isSuccessful: true, + error: null + } + } + } + case 'FILE_ADDED': { + return { + ...state, + files: { + ...state.files, + files: fileAdded(state.files.workspaceName, action.payload.path, state.files.files, action.payload.files), + expandPath: [...new Set([...state.files.expandPath, action.payload.path])], + isRequesting: false, + isSuccessful: true, + error: null + } + } + } + case 'FOLDER_ADDED': { + return { + ...state, + files: { + ...state.files, + files: folderAdded(state.files.workspaceName, action.payload.path, state.files.files, action.payload.files), + expandPath: [...new Set([...state.files.expandPath, action.payload.path])], + isRequesting: false, + isSuccessful: true, + error: null + } + } + } + case 'FILE_REMOVED': { + return { + ...state, + files: { + ...state.files, + files: fileRemoved(state.files.workspaceName, action.payload.path, action.payload.removePath, state.files.files), + isRequesting: false, + isSuccessful: true, + error: null + } + } + } + case 'FILE_RENAMED': { + return { + ...state, + files: { + ...state.files, + files: fileRenamed(state.files.workspaceName, action.payload.path, action.payload.removePath, state.files.files, action.payload.files), + isRequesting: false, + isSuccessful: true, + error: null + } + } + } + case 'DISPLAY_NOTIFICATION': { + return { + ...state, + notification: { + title: action.payload.title, + message: action.payload.message, + actionOk: action.payload.actionOk || fileSystemInitialState.notification.actionOk, + actionCancel: action.payload.actionCancel || fileSystemInitialState.notification.actionCancel, + labelOk: action.payload.labelOk, + labelCancel: action.payload.labelCancel + } + } + } + case 'HIDE_NOTIFICATION': { + return { + ...state, + notification: fileSystemInitialState.notification + } + } + default: + throw new Error() + } +} + +const resolveDirectory = (root, path: string, files, content) => { + if (path === root) return { [root]: { ...content[root], ...files[root] } } + const pathArr: string[] = path.split('/').filter(value => value) + + if (pathArr[0] !== root) pathArr.unshift(root) + const _path = pathArr.map((key, index) => index > 1 ? ['child', key] : key).reduce((acc: string[], cur) => { + return Array.isArray(cur) ? [...acc, ...cur] : [...acc, cur] + }, []) + + const prevFiles = _.get(files, _path) + + files = _.set(files, _path, { + isDirectory: true, + path, + name: extractNameFromKey(path), + child: { ...content[pathArr[pathArr.length - 1]], ...(prevFiles ? prevFiles.child : {}) } + }) + + return files +} + +const removePath = (root, path: string, pathName, files) => { + const pathArr: string[] = path.split('/').filter(value => value) + + if (pathArr[0] !== root) pathArr.unshift(root) + const _path = pathArr.map((key, index) => index > 1 ? ['child', key] : key).reduce((acc: string[], cur) => { + return Array.isArray(cur) ? [...acc, ...cur] : [...acc, cur] + }, []) + const prevFiles = _.get(files, _path) + + prevFiles && prevFiles.child && prevFiles.child[pathName] && delete prevFiles.child[pathName] + files = _.set(files, _path, { + isDirectory: true, + path, + name: extractNameFromKey(path), + child: prevFiles ? prevFiles.child : {} + }) + + return files +} + +const addInputField = (root, path: string, files, content) => { + if (path === root) return { [root]: { ...content[root], ...files[root] } } + const result = resolveDirectory(root, path, files, content) + + return result +} + +const removeInputField = (root, path: string, files) => { + if (path === root) { + delete files[root][path + '/' + 'blank'] + return files + } + return removePath(root, path, path + '/' + 'blank', files) +} + +const fileAdded = (root, path: string, files, content) => { + return resolveDirectory(root, path, files, content) +} + +const folderAdded = (root, path: string, files, content) => { + return resolveDirectory(root, path, files, content) +} + +const fileRemoved = (root, path: string, removedPath: string, files) => { + if (path === root) { + delete files[root][removedPath] + + return files + } + return removePath(root, path, extractNameFromKey(removedPath), files) +} + +const fileRenamed = (root, path: string, removePath: string, files, content) => { + if (path === root) { + const allFiles = { [root]: { ...content[root], ...files[root] } } + + delete allFiles[root][extractNameFromKey(removePath) || removePath] + return allFiles + } + const pathArr: string[] = path.split('/').filter(value => value) + + if (pathArr[0] !== root) pathArr.unshift(root) + const _path = pathArr.map((key, index) => index > 1 ? ['child', key] : key).reduce((acc: string[], cur) => { + return Array.isArray(cur) ? [...acc, ...cur] : [...acc, cur] + }, []) + const prevFiles = _.get(files, _path) + + delete prevFiles.child[extractNameFromKey(removePath)] + files = _.set(files, _path, { + isDirectory: true, + path, + name: extractNameFromKey(path), + child: { ...content[pathArr[pathArr.length - 1]], ...prevFiles.child } + }) + + return files +} diff --git a/libs/remix-ui/file-explorer/src/lib/types/index.ts b/libs/remix-ui/file-explorer/src/lib/types/index.ts index 61c554dc3a..17d6975414 100644 --- a/libs/remix-ui/file-explorer/src/lib/types/index.ts +++ b/libs/remix-ui/file-explorer/src/lib/types/index.ts @@ -6,7 +6,7 @@ export interface FileExplorerProps { menuItems?: string[], plugin: any, focusRoot: boolean, - contextMenuItems: { name: string, type: string[], path: string[], extension: string[], pattern: string[] }[], + contextMenuItems: { id: string, name: string, type: string[], path: string[], extension: string[], pattern: string[] }[], displayInput?: boolean, externalUploads?: EventTarget & HTMLInputElement } diff --git a/libs/remix-ui/file-explorer/src/lib/utils/index.ts b/libs/remix-ui/file-explorer/src/lib/utils/index.ts new file mode 100644 index 0000000000..3db8f00902 --- /dev/null +++ b/libs/remix-ui/file-explorer/src/lib/utils/index.ts @@ -0,0 +1,13 @@ +export const extractNameFromKey = (key: string): string => { + const keyPath = key.split('/') + + return keyPath[keyPath.length - 1] +} + +export const extractParentFromKey = (key: string):string => { + if (!key) return + const keyPath = key.split('/') + keyPath.pop() + + return keyPath.join('/') +} diff --git a/libs/remixd/src/services/remixdClient.ts b/libs/remixd/src/services/remixdClient.ts index 09f78b7dec..31588628ea 100644 --- a/libs/remixd/src/services/remixdClient.ts +++ b/libs/remixd/src/services/remixdClient.ts @@ -85,11 +85,10 @@ export class RemixdClient extends PluginClient { } } - set (args: SharedFolderArgs): Promise { + set (args: SharedFolderArgs) { try { return new Promise((resolve, reject) => { if (this.readOnly) return reject(new Error('Cannot write file: read-only mode selected')) - const isFolder = args.path.endsWith('/') const path = utils.absolutePath(args.path, this.currentSharedFolder) const exists = fs.existsSync(path) @@ -99,31 +98,25 @@ export class RemixdClient extends PluginClient { return reject(new Error('trying to write "undefined" ! stopping.')) } this.trackDownStreamUpdate[path] = path - if (isFolder) { - fs.mkdirp(path).then(() => { - let splitPath = args.path.split('/') - - splitPath = splitPath.filter(dir => dir) - const dir = '/' + splitPath.join('/') - - this.emit('folderAdded', dir) - resolve() - }).catch((e: Error) => reject(e)) + if (!exists && args.path.indexOf('/') !== -1) { + // the last element is the filename and we should remove it + this.createDir({ path: args.path.substr(0, args.path.lastIndexOf('/')) }) + } + try { + fs.writeFile(path, args.content, 'utf8', (error: Error) => { + if (error) { + console.log(error) + return reject(error) + } + resolve(true) + }) + } catch (e) { + return reject(e) + } + if (!exists) { + this.emit('fileAdded', args.path) } else { - fs.ensureFile(path).then(() => { - fs.writeFile(path, args.content, 'utf8', (error: Error) => { - if (error) { - console.log(error) - return reject(error) - } - resolve() - }) - }).catch((e: Error) => reject(e)) - if (!exists) { - this.emit('fileAdded', args.path) - } else { - this.emit('fileChanged', args.path) - } + this.emit('fileChanged', args.path) } }) } catch (error) { @@ -131,24 +124,22 @@ export class RemixdClient extends PluginClient { } } - createDir (args: SharedFolderArgs): Promise { + createDir (args: SharedFolderArgs) { try { return new Promise((resolve, reject) => { if (this.readOnly) return reject(new Error('Cannot create folder: read-only mode selected')) - const path = utils.absolutePath(args.path, this.currentSharedFolder) - const exists = fs.existsSync(path) - - if (exists && !isRealPath(path)) return reject(new Error('')) - this.trackDownStreamUpdate[path] = path - fs.mkdirp(path).then(() => { - let splitPath = args.path.split('/') - - splitPath = splitPath.filter(dir => dir) - const dir = '/' + splitPath.join('/') - - this.emit('folderAdded', dir) - resolve() - }).catch((e: Error) => reject(e)) + const paths = args.path.split('/').filter(value => value) + if (paths.length && paths[0] === '') paths.shift() + let currentCheck = '' + paths.forEach((value) => { + currentCheck = currentCheck ? currentCheck + '/' + value : value + const path = utils.absolutePath(currentCheck, this.currentSharedFolder) + if (!fs.existsSync(path)) { + fs.mkdirp(path) + this.emit('folderAdded', currentCheck) + } + }) + resolve(true) }) } catch (error) { throw new Error(error) diff --git a/package-lock.json b/package-lock.json index 4f92e09fa8..d74204bd8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25139,9 +25139,9 @@ } }, "lodash": { - "version": "4.17.19", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", - "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==" + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash-es": { "version": "4.17.15", diff --git a/package.json b/package.json index 2587e3bfbf..a7ae4d3288 100644 --- a/package.json +++ b/package.json @@ -160,6 +160,7 @@ "jquery": "^3.3.1", "jszip": "^3.6.0", "latest-version": "^5.1.0", + "lodash": "^4.17.21", "merge": "^1.2.0", "npm-install-version": "^6.0.2", "react": "16.13.1",