diff --git a/apps/circuit-compiler/src/app/services/circomPluginClient.ts b/apps/circuit-compiler/src/app/services/circomPluginClient.ts index 50cb710d68..93a2445861 100644 --- a/apps/circuit-compiler/src/app/services/circomPluginClient.ts +++ b/apps/circuit-compiler/src/app/services/circomPluginClient.ts @@ -10,8 +10,6 @@ import * as compilerV215 from 'circom_wasm/v2.1.5' import { extractNameFromKey, extractParentFromKey } from '@remix-ui/helper' import { CompilationConfig, CompilerReport, PrimeValue, ResolverOutput } from '../types' -// @ts-ignore -const _paq = (window._paq = window._paq || []) export class CircomPluginClient extends PluginClient { public internalEvents: EventManager private _compilationConfig: CompilationConfig = { @@ -22,6 +20,11 @@ export class CircomPluginClient extends PluginClient { private lastParsedFiles: Record = {} private lastCompiledFile: string = '' private compiler: typeof compilerV215 & typeof compilerV216 & typeof compilerV217 & typeof compilerV218 + private _paq = { + push: (args) => { + this.call('matomo' as any, 'track', args) + } + } constructor() { super() @@ -164,7 +167,7 @@ export class CircomPluginClient extends PluginClient { const circuitErrors = circuitApi.report() this.logCompilerReport(circuitErrors) - _paq.push(['trackEvent', 'circuit-compiler', 'compile', 'Compilation failed']) + this._paq.push(['trackEvent', 'circuit-compiler', 'compile', 'Compilation failed']) throw new Error(circuitErrors) } else { this.lastCompiledFile = path @@ -184,7 +187,7 @@ export class CircomPluginClient extends PluginClient { } else { this.internalEvents.emit('circuit_compiling_done', []) } - _paq.push(['trackEvent', 'circuit-compiler', 'compile', 'Compilation successful']) + this._paq.push(['trackEvent', 'circuit-compiler', 'compile', 'Compilation successful']) circuitApi.log().map(log => { log && this.call('terminal', 'log', { type: 'log', value: log }) }) @@ -226,7 +229,7 @@ export class CircomPluginClient extends PluginClient { const r1csErrors = r1csApi.report() this.logCompilerReport(r1csErrors) - _paq.push(['trackEvent', 'circuit-compiler', 'generateR1cs', 'R1CS Generation failed']) + this._paq.push(['trackEvent', 'circuit-compiler', 'generateR1cs', 'R1CS Generation failed']) throw new Error(r1csErrors) } else { this.internalEvents.emit('circuit_generating_r1cs_done') @@ -235,7 +238,7 @@ export class CircomPluginClient extends PluginClient { // @ts-ignore await this.call('fileManager', 'writeFile', writePath, r1csProgram, true) - _paq.push(['trackEvent', 'circuit-compiler', 'generateR1cs', 'R1CS Generation successful']) + this._paq.push(['trackEvent', 'circuit-compiler', 'generateR1cs', 'R1CS Generation successful']) r1csApi.log().map(log => { log && this.call('terminal', 'log', { type: 'log', value: log }) }) @@ -256,7 +259,7 @@ export class CircomPluginClient extends PluginClient { const witness = this.compiler ? await this.compiler.generate_witness(dataRead, input) : await generate_witness(dataRead, input) // @ts-ignore await this.call('fileManager', 'writeFile', wasmPath.replace('.wasm', '.wtn'), witness, true) - _paq.push(['trackEvent', 'circuit-compiler', 'computeWitness', 'Witness computing successful']) + this._paq.push(['trackEvent', 'circuit-compiler', 'computeWitness', 'Witness computing successful']) this.internalEvents.emit('circuit_computing_witness_done') this.emit('statusChanged', { key: 'succeed', title: 'witness computed successfully', type: 'success' }) } diff --git a/apps/remix-ide-e2e/src/commands/selectFiles.ts b/apps/remix-ide-e2e/src/commands/selectFiles.ts index d24ec1e42b..544f36ba3d 100644 --- a/apps/remix-ide-e2e/src/commands/selectFiles.ts +++ b/apps/remix-ide-e2e/src/commands/selectFiles.ts @@ -8,15 +8,14 @@ class SelectFiles extends EventEmitter { browser.perform(function () { const actions = this.actions({ async: true }) actions.keyDown(this.Keys.SHIFT) - for(let i = 0; i < selectedElements.length; i++) { + for (let i = 0; i < selectedElements.length; i++) { actions.click(selectedElements[i].value) } - return actions.contextClick(selectedElements[0].value) + return actions//.contextClick(selectedElements[0].value) }) this.emit('complete') return this } } - module.exports = SelectFiles diff --git a/apps/remix-ide-e2e/src/tests/file_explorer_multiselect.test.ts b/apps/remix-ide-e2e/src/tests/file_explorer_multiselect.test.ts index 1d31d6a9b5..214217af38 100644 --- a/apps/remix-ide-e2e/src/tests/file_explorer_multiselect.test.ts +++ b/apps/remix-ide-e2e/src/tests/file_explorer_multiselect.test.ts @@ -2,6 +2,7 @@ import { NightwatchBrowser } from 'nightwatch' import init from '../helpers/init' module.exports = { + "@disabled": true, before: function (browser: NightwatchBrowser, done: VoidFunction) { init(browser, done) }, @@ -10,11 +11,11 @@ module.exports = { const selectedElements = [] browser .openFile('contracts') - .click({ selector: '//*[@data-id="treeViewLitreeViewItemcontracts/1_Storage.sol"]', locateStrategy: 'xpath' }) - .findElement({ selector: '//*[@data-id="treeViewLitreeViewItemcontracts/2_Owner.sol"]', locateStrategy: 'xpath' }, (el) => { + .click({ selector: '//*[@data-id="treeViewDivtreeViewItemcontracts/1_Storage.sol"]', locateStrategy: 'xpath' }) + .findElement({ selector: '//*[@data-id="treeViewDivtreeViewItemcontracts/2_Owner.sol"]', locateStrategy: 'xpath' }, (el) => { selectedElements.push(el) }) - browser.findElement({ selector: '//*[@data-id="treeViewLitreeViewItemtests"]', locateStrategy: 'xpath' }, + browser.findElement({ selector: '//*[@data-id="treeViewDivtreeViewItemtests"]', locateStrategy: 'xpath' }, (el: any) => { selectedElements.push(el) }) @@ -22,6 +23,74 @@ module.exports = { .assert.visible('.bg-secondary[data-id="treeViewLitreeViewItemcontracts/1_Storage.sol"]') .assert.visible('.bg-secondary[data-id="treeViewLitreeViewItemcontracts/2_Owner.sol"]') .assert.visible('.bg-secondary[data-id="treeViewLitreeViewItemtests"]') - .end() + }, + 'Should drag and drop multiple files in file explorer to tests folder #group1': function (browser: NightwatchBrowser) { + const selectedElements = [] + if (browser.options.desiredCapabilities?.browserName === 'firefox') { + console.log('Skipping test for firefox') + browser.end() + return; + } else { + browser + .click({ selector: '//*[@data-id="treeViewUltreeViewMenu"]', locateStrategy: 'xpath' }) + .click({ selector: '//*[@data-id="treeViewLitreeViewItemcontracts/1_Storage.sol"]', locateStrategy: 'xpath' }) + .findElement({ selector: '//*[@data-id="treeViewLitreeViewItemcontracts/2_Owner.sol"]', locateStrategy: 'xpath' }, (el) => { + selectedElements.push(el) + }) + browser.selectFiles(selectedElements) + .perform((done) => { + browser.findElement({ selector: '//*[@data-id="treeViewLitreeViewItemtests"]', locateStrategy: 'xpath' }, + (el: any) => { + const id = (el as any).value.getId() + browser + .waitForElementVisible('li[data-id="treeViewLitreeViewItemtests"]') + .dragAndDrop('li[data-id="treeViewLitreeViewItemcontracts/1_Storage.sol"]', id) + .waitForElementPresent('[data-id="fileSystemModalDialogModalFooter-react"] .modal-ok') + .execute(function () { (document.querySelector('[data-id="fileSystemModalDialogModalFooter-react"] .modal-ok') as HTMLElement).click() }) + .waitForElementVisible('li[data-id="treeViewLitreeViewItemtests/1_Storage.sol"]') + .waitForElementVisible('li[data-id="treeViewLitreeViewItemtests/2_Owner.sol"]') + .waitForElementNotPresent('li[data-id="treeViewLitreeViewItemcontracts/1_Storage.sol"]') + .waitForElementNotPresent('li[data-id="treeViewLitreeViewItemcontracts/2_Owner.sol"]') + .perform(() => done()) + }) + }) + } + }, + 'should drag and drop multiple files and folders in file explorer to contracts folder #group3': function (browser: NightwatchBrowser) { + const selectedElements = [] + if (browser.options.desiredCapabilities?.browserName === 'firefox') { + console.log('Skipping test for firefox') + browser.end() + return; + } else { + browser + .clickLaunchIcon('filePanel') + .click({ selector: '//*[@data-id="treeViewLitreeViewItemtests"]', locateStrategy: 'xpath' }) + .findElement({ selector: '//*[@data-id="treeViewDivtreeViewItemscripts"]', locateStrategy: 'xpath' }, (el) => { + selectedElements.push(el) + }) + browser.findElement({ selector: '//*[@data-id="treeViewDivtreeViewItemREADME.txt"]', locateStrategy: 'xpath' }, + (el: any) => { + selectedElements.push(el) + }) + browser.selectFiles(selectedElements) + .perform((done) => { + browser.findElement({ selector: '//*[@data-id="treeViewLitreeViewItemcontracts"]', locateStrategy: 'xpath' }, + (el: any) => { + const id = (el as any).value.getId() + browser + .waitForElementVisible('li[data-id="treeViewLitreeViewItemcontracts"]') + .dragAndDrop('li[data-id="treeViewLitreeViewItemtests"]', id) + .waitForElementPresent('[data-id="fileSystemModalDialogModalFooter-react"] .modal-ok') + .execute(function () { (document.querySelector('[data-id="fileSystemModalDialogModalFooter-react"] .modal-ok') as HTMLElement).click() }) + .waitForElementVisible('li[data-id="treeViewLitreeViewItemcontracts/tests"]', 5000) + .waitForElementVisible('li[data-id="treeViewLitreeViewItemcontracts/README.txt"]', 5000) + .waitForElementVisible('li[data-id="treeViewLitreeViewItemcontracts/scripts"]', 5000) + .waitForElementNotPresent('li[data-id="treeViewLitreeViewItemtests"]') + .waitForElementNotPresent('li[data-id="treeViewLitreeViewItemREADME.txt"]') + .perform(() => done()) + }) + }) + } } } diff --git a/apps/remix-ide/ci/deploy_from_travis_remix-alpha.sh b/apps/remix-ide/ci/deploy_from_travis_remix-alpha.sh index c57de7fea5..964da156b1 100755 --- a/apps/remix-ide/ci/deploy_from_travis_remix-alpha.sh +++ b/apps/remix-ide/ci/deploy_from_travis_remix-alpha.sh @@ -5,6 +5,11 @@ SHA=`git rev-parse --short --verify HEAD` cd dist/apps/remix-ide +# this gh action is used to deploy the build to the gh pages +mkdir dist/apps/remix-ide/.github +mkdir dist/apps/remix-ide/.github/workflows +cp apps/remix-ide/ci/gh-actions-deploy.yml dist/apps/remix-ide/.github/workflows + git init git checkout -b gh-pages git config user.name "$COMMIT_AUTHOR" diff --git a/apps/remix-ide/ci/deploy_from_travis_remix-beta.sh b/apps/remix-ide/ci/deploy_from_travis_remix-beta.sh index 134d9c2090..98f7af21c8 100755 --- a/apps/remix-ide/ci/deploy_from_travis_remix-beta.sh +++ b/apps/remix-ide/ci/deploy_from_travis_remix-beta.sh @@ -5,6 +5,11 @@ SHA=`git rev-parse --short --verify HEAD` cd dist/apps/remix-ide +# this gh action is used to deploy the build to the gh pages +mkdir dist/apps/remix-ide/.github +mkdir dist/apps/remix-ide/.github/workflows +cp apps/remix-ide/ci/gh-actions-deploy.yml dist/apps/remix-ide/.github/workflows + git init git checkout -b gh-pages git config user.name "$COMMIT_AUTHOR" diff --git a/apps/remix-ide/ci/deploy_from_travis_remix-live.sh b/apps/remix-ide/ci/deploy_from_travis_remix-live.sh index 1376cda6f0..0aadafc75c 100755 --- a/apps/remix-ide/ci/deploy_from_travis_remix-live.sh +++ b/apps/remix-ide/ci/deploy_from_travis_remix-live.sh @@ -5,6 +5,11 @@ SHA=`git rev-parse --short --verify HEAD` cd dist/apps/remix-ide +# this gh action is used to deploy the build to the gh pages +mkdir dist/apps/remix-ide/.github +mkdir dist/apps/remix-ide/.github/workflows +cp apps/remix-ide/ci/gh-actions-deploy.yml dist/apps/remix-ide/.github/workflows/gh-actions-deploy.yml + git init git checkout -b gh-pages git config user.name "$COMMIT_AUTHOR" diff --git a/apps/remix-ide/ci/gh-actions-deploy.yml b/apps/remix-ide/ci/gh-actions-deploy.yml new file mode 100644 index 0000000000..a25c381cb9 --- /dev/null +++ b/apps/remix-ide/ci/gh-actions-deploy.yml @@ -0,0 +1,43 @@ +# Simple workflow for deploying static content to GitHub Pages +name: Deploy static content to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["gh-pages"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Single deploy job since we're just deploying + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + # Upload entire repository + path: '.' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/apps/remix-ide/src/app/files/fileManager.ts b/apps/remix-ide/src/app/files/fileManager.ts index e232c2a132..229c15ce33 100644 --- a/apps/remix-ide/src/app/files/fileManager.ts +++ b/apps/remix-ide/src/app/files/fileManager.ts @@ -962,6 +962,12 @@ class FileManager extends Plugin { return exists } + /** + * Check if a file can be moved + * @param src source file + * @param dest destination file + * @returns {boolean} true if the file is allowed to be moved + */ async moveFileIsAllowed (src: string, dest: string) { try { src = this.normalize(src) @@ -984,6 +990,12 @@ class FileManager extends Plugin { } } + /** + * Check if a folder can be moved + * @param src source folder + * @param dest destination folder + * @returns {boolean} true if the folder is allowed to be moved + */ async moveDirIsAllowed (src: string, dest: string) { try { src = this.normalize(src) diff --git a/apps/remix-ide/src/app/plugins/git.tsx b/apps/remix-ide/src/app/plugins/git.tsx index 35129dfceb..73331d87ed 100644 --- a/apps/remix-ide/src/app/plugins/git.tsx +++ b/apps/remix-ide/src/app/plugins/git.tsx @@ -6,6 +6,7 @@ import * as packageJson from '../../../../../package.json' const profile = { name: 'dgit', + displayName: 'Git', desciption: 'Git plugin for Remix', methods: ['pull', 'track', 'diff', 'clone', 'open'], events: [''], diff --git a/apps/remix-ide/src/app/tabs/locales/en/terminal.json b/apps/remix-ide/src/app/tabs/locales/en/terminal.json index 431f54fbba..9ce7d78d6f 100644 --- a/apps/remix-ide/src/app/tabs/locales/en/terminal.json +++ b/apps/remix-ide/src/app/tabs/locales/en/terminal.json @@ -40,5 +40,6 @@ "terminal.input": "input", "terminal.decodedInput": "decoded input", "terminal.decodedOutput": "decoded output", + "terminal.rawlogs": "raw logs", "terminal.logs": "logs" } diff --git a/apps/remix-ide/src/app/tabs/locales/es/terminal.json b/apps/remix-ide/src/app/tabs/locales/es/terminal.json index 9c59e05ada..17eb3d6a40 100644 --- a/apps/remix-ide/src/app/tabs/locales/es/terminal.json +++ b/apps/remix-ide/src/app/tabs/locales/es/terminal.json @@ -39,5 +39,6 @@ "terminal.input": "entrada", "terminal.decodedInput": "entrada descodificada", "terminal.decodedOutput": "salida descodificada", + "terminal.rawlogs": "registros sin procesar", "terminal.logs": "registros" } diff --git a/apps/remix-ide/src/app/tabs/locales/fr/terminal.json b/apps/remix-ide/src/app/tabs/locales/fr/terminal.json index 8183140a97..e67a0c7b4f 100644 --- a/apps/remix-ide/src/app/tabs/locales/fr/terminal.json +++ b/apps/remix-ide/src/app/tabs/locales/fr/terminal.json @@ -39,5 +39,6 @@ "terminal.input": "entrée", "terminal.decodedInput": "entrée décodée", "terminal.decodedOutput": "sortie décodée", - "terminal.logs": "logs" + "terminal.rawlogs": "logs bruts", + "terminal.logs": "Log" } diff --git a/apps/remix-ide/src/app/tabs/locales/it/terminal.json b/apps/remix-ide/src/app/tabs/locales/it/terminal.json index 8bdc6c7dc8..19653883b1 100644 --- a/apps/remix-ide/src/app/tabs/locales/it/terminal.json +++ b/apps/remix-ide/src/app/tabs/locales/it/terminal.json @@ -39,5 +39,6 @@ "terminal.input": "input", "terminal.decodedInput": "input decodificato", "terminal.decodedOutput": "output decodificato", + "terminal.rawlogs": "log grezzi", "terminal.logs": "Log" } diff --git a/apps/remix-ide/src/app/tabs/locales/ko/terminal.json b/apps/remix-ide/src/app/tabs/locales/ko/terminal.json index 171a981094..23db143c61 100644 --- a/apps/remix-ide/src/app/tabs/locales/ko/terminal.json +++ b/apps/remix-ide/src/app/tabs/locales/ko/terminal.json @@ -39,5 +39,6 @@ "terminal.input": "입력", "terminal.decodedInput": "디코드된 입력", "terminal.decodedOutput": "디코드된 출력", + "terminal.rawlogs": "원시 로그", "terminal.logs": "로그" } diff --git a/apps/remix-ide/src/app/tabs/locales/ru/terminal.json b/apps/remix-ide/src/app/tabs/locales/ru/terminal.json index 2778fb35de..6d10549196 100644 --- a/apps/remix-ide/src/app/tabs/locales/ru/terminal.json +++ b/apps/remix-ide/src/app/tabs/locales/ru/terminal.json @@ -39,5 +39,6 @@ "terminal.input": "ввод", "terminal.decodedInput": "декодированный ввод", "terminal.decodedOutput": "декодированный вывод", + "terminal.rawlogs": "необработанные журналы", "terminal.logs": "журналы" } diff --git a/apps/remix-ide/src/app/tabs/locales/zh/terminal.json b/apps/remix-ide/src/app/tabs/locales/zh/terminal.json index 40e6d3791c..eac8de2d0b 100644 --- a/apps/remix-ide/src/app/tabs/locales/zh/terminal.json +++ b/apps/remix-ide/src/app/tabs/locales/zh/terminal.json @@ -39,5 +39,6 @@ "terminal.input": "输入", "terminal.decodedInput": "解码输入", "terminal.decodedOutput": "解码输出", + "terminal.rawlogs": "原始日志", "terminal.logs": "日志" } diff --git a/libs/remix-lib/package.json b/libs/remix-lib/package.json index fac86e3732..dffc187d0a 100644 --- a/libs/remix-lib/package.json +++ b/libs/remix-lib/package.json @@ -57,4 +57,4 @@ "typings": "src/index.d.ts", "gitHead": "35e1469e94bb370f5427d4ab230fcbd47c665e55", "types": "./src/index.d.ts" -} \ No newline at end of file +} diff --git a/libs/remix-ui/app/src/lib/remix-app/style/remix-app.css b/libs/remix-ui/app/src/lib/remix-app/style/remix-app.css index 8c56059bfb..a1d447e8a5 100644 --- a/libs/remix-ui/app/src/lib/remix-app/style/remix-app.css +++ b/libs/remix-ui/app/src/lib/remix-app/style/remix-app.css @@ -43,6 +43,7 @@ pre { .pinnedpanel { width : 320px; transition : width 0.25s; + padding-bottom : 1.4rem; } .highlightcode { position : absolute; diff --git a/libs/remix-ui/terminal/src/lib/components/Table.tsx b/libs/remix-ui/terminal/src/lib/components/Table.tsx index 5aa3823f00..90a2b40032 100644 --- a/libs/remix-ui/terminal/src/lib/components/Table.tsx +++ b/libs/remix-ui/terminal/src/lib/components/Table.tsx @@ -188,7 +188,18 @@ const showTable = (opts, showTableHash) => { {JSON.stringify(stringified, null, '\t')} - + + + + ) : null} + {opts.logs ? ( + + + + + +
{JSON.stringify(opts.logs.raw || '0', null, 2)}
+ ) : null} diff --git a/libs/remix-ui/workspace/src/lib/actions/index.ts b/libs/remix-ui/workspace/src/lib/actions/index.ts index b4480e2019..772bdff1a6 100644 --- a/libs/remix-ui/workspace/src/lib/actions/index.ts +++ b/libs/remix-ui/workspace/src/lib/actions/index.ts @@ -656,3 +656,21 @@ export const moveFolderIsAllowed = async (src: string, dest: string) => { return isAllowed } +export const moveFilesIsAllowed = async (src: string[], dest: string) => { + const fileManager = plugin.fileManager + const boolArray: boolean[] = [] + for (const srcFile of src) { + boolArray.push(await fileManager.moveFileIsAllowed(srcFile, dest)) + } + return boolArray.every(p => p === true) || false +} + +export const moveFoldersIsAllowed = async (src: string[], dest: string) => { + const fileManager = plugin.fileManager + const boolArray: boolean[] = [] + for (const srcFile of src) { + boolArray.push(await fileManager.moveDirIsAllowed(srcFile, dest)) + } + return boolArray.every(p => p === true) || false +} + diff --git a/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx b/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx index e3b301901c..950c2810ff 100644 --- a/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx +++ b/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef, SyntheticEvent } from 'react' // eslint-disable-line +import React, { useEffect, useState, useRef, SyntheticEvent, useContext } from 'react' // eslint-disable-line import { useIntl } from 'react-intl' import { TreeView } from '@remix-ui/tree-view' // eslint-disable-line import { FileExplorerMenu } from './file-explorer-menu' // eslint-disable-line @@ -9,8 +9,9 @@ import '../css/file-explorer.css' import { checkSpecialChars, extractNameFromKey, extractParentFromKey, getPathIcon, joinPath } from '@remix-ui/helper' // eslint-disable-next-line @typescript-eslint/no-unused-vars import { ROOT_PATH } from '../utils/constants' -import { moveFileIsAllowed, moveFolderIsAllowed } from '../actions' +import { moveFileIsAllowed, moveFilesIsAllowed, moveFolderIsAllowed, moveFoldersIsAllowed } from '../actions' import { FlatTree } from './flat-tree' +import { FileSystemContext } from '../contexts' export const FileExplorer = (props: FileExplorerProps) => { const intl = useIntl() @@ -36,6 +37,11 @@ export const FileExplorer = (props: FileExplorerProps) => { // const [isPending, startTransition] = useTransition(); const treeRef = useRef(null) + const { plugin } = useContext(FileSystemContext) + const [feTarget, setFeTarget] = useState<{ key: string, type: 'file' | 'folder' }[]>({} as { key: string, type: 'file' | 'folder' }[]) + const [filesSelected, setFilesSelected] = useState([]) + const feWindow = (window as any) + useEffect(() => { if (contextMenuItems) { addMenuItems(contextMenuItems) @@ -96,6 +102,100 @@ export const FileExplorer = (props: FileExplorerProps) => { } }, [treeRef.current]) + useEffect(() => { + const performDeletion = async () => { + const path: string[] = [] + if (feTarget?.length > 0 && feTarget[0]?.key.length > 0) { + feTarget.forEach((one) => { + path.push(one.key) + }) + await deletePath(path) + } + } + + if (treeRef.current) { + const deleteKeyPressHandler = async (eve: KeyboardEvent) => { + if (eve.key === 'Delete' ) { + feWindow._paq.push(['trackEvent', 'fileExplorer', 'deleteKey', 'deletePath']) + setState((prevState) => { + return { ...prevState, deleteKey: true } + }) + performDeletion() + return + } + if (eve.metaKey) { + if (eve.key === 'Backspace') { + feWindow._paq.push(['trackEvent', 'fileExplorer', 'osxDeleteKey', 'deletePath']) + setState((prevState) => { + return { ...prevState, deleteKey: true } + }) + performDeletion() + return + } + } + } + const deleteKeyPressUpHandler = async (eve: KeyboardEvent) => { + if (eve.key === 'Delete' ) { + setState((prevState) => { + return { ...prevState, deleteKey: false } + }) + return + } + if (eve.metaKey) { + if (eve.key === 'Backspace') { + setState((prevState) => { + return { ...prevState, deleteKey: false } + }) + return + } + } + } + + treeRef.current?.addEventListener('keydown', deleteKeyPressHandler) + treeRef.current?.addEventListener('keyup', deleteKeyPressUpHandler) + return () => { + treeRef.current?.removeEventListener('keydown', deleteKeyPressHandler) + treeRef.current?.removeEventListener('keyup', deleteKeyPressUpHandler) + } + } + }, [treeRef.current, feTarget]) + + useEffect(() => { + const performRename = async () => { + if (feTarget?.length > 1 && feTarget[0]?.key.length > 1) { + await plugin.call('notification', 'alert', { id: 'renameAlert', message: 'You cannot rename multiple files at once!' }) + } + props.editModeOn(feTarget[0].key, feTarget[0].type, false) + } + if (treeRef.current) { + const F2KeyPressHandler = async (eve: KeyboardEvent) => { + if (eve.key === 'F2' ) { + feWindow._paq.push(['trackEvent', 'fileExplorer', 'f2ToRename', 'RenamePath']) + await performRename() + setState((prevState) => { + return { ...prevState, F2Key: true } + }) + return + } + } + const F2KeyPressUpHandler = async (eve: KeyboardEvent) => { + if (eve.key === 'F2' ) { + setState((prevState) => { + return { ...prevState, F2Key: false } + }) + return + } + } + + treeRef.current?.addEventListener('keydown', F2KeyPressHandler) + treeRef.current?.addEventListener('keyup', F2KeyPressUpHandler) + return () => { + treeRef.current?.removeEventListener('keydown', F2KeyPressHandler) + treeRef.current?.removeEventListener('keyup', F2KeyPressUpHandler) + } + } + }, [treeRef.current, feTarget]) + const hasReservedKeyword = (content: string): boolean => { if (state.reservedKeywords.findIndex((value) => content.startsWith(value)) !== -1) return true else return false @@ -292,17 +392,18 @@ export const FileExplorer = (props: FileExplorerProps) => { props.dispatchHandleExpandPath(expandPath) } - const handleFileMove = async (dest: string, src: string) => { + /** + * This offers the ability to move a file to a new location + * without showing a modal dialong to the user. + * @param dest path of the destination + * @param src path of the source + * @returns {Promise} + */ + const moveFileSilently = async (dest: string, src: string) => { + if (dest.length === 0 || src.length === 0) return if (await moveFileIsAllowed(src, dest) === false) return try { - props.modal( - intl.formatMessage({ id: 'filePanel.moveFile' }), - intl.formatMessage({ id: 'filePanel.moveFileMsg1' }, { src, dest }), - intl.formatMessage({ id: 'filePanel.yes' }), - () => props.dispatchMoveFile(src, dest), - intl.formatMessage({ id: 'filePanel.cancel' }), - () => { } - ) + props.dispatchMoveFile(src, dest) } catch (error) { props.modal( intl.formatMessage({ id: 'filePanel.movingFileFailed' }), @@ -313,17 +414,24 @@ export const FileExplorer = (props: FileExplorerProps) => { } } - const handleFolderMove = async (dest: string, src: string) => { + const resetMultiselect = () => { + setState((prevState) => { + return { ...prevState, ctrlKey: false } + }) + } + + /** + * This offers the ability to move a folder to a new location + * without showing a modal dialong to the user. + * @param dest path of the destination + * @param src path of the source + * @returns {Promise} + */ + const moveFolderSilently = async (dest: string, src: string) => { + if (dest.length === 0 || src.length === 0) return if (await moveFolderIsAllowed(src, dest) === false) return try { - props.modal( - intl.formatMessage({ id: 'filePanel.moveFile' }), - intl.formatMessage({ id: 'filePanel.moveFileMsg1' }, { src, dest }), - intl.formatMessage({ id: 'filePanel.yes' }), - () => props.dispatchMoveFolder(src, dest), - intl.formatMessage({ id: 'filePanel.cancel' }), - () => { } - ) + props.dispatchMoveFolder(src, dest) } catch (error) { props.modal( intl.formatMessage({ id: 'filePanel.movingFolderFailed' }), @@ -334,6 +442,19 @@ export const FileExplorer = (props: FileExplorerProps) => { } } + const warnMovingItems = async (src: string[], dest: string): Promise => { + return new Promise((resolve, reject) => { + props.modal( + intl.formatMessage({ id: 'filePanel.moveFile' }), + intl.formatMessage({ id: 'filePanel.moveFileMsg1' }, { src: src.join(', '), dest }), + intl.formatMessage({ id: 'filePanel.yes' }), + () => resolve(null), + intl.formatMessage({ id: 'filePanel.cancel' }), + () => reject() + ) + }) + } + const handleTreeClick = (event: SyntheticEvent) => { let target = event.target as HTMLElement while (target && target.getAttribute && !target.getAttribute('data-path')) { @@ -401,13 +522,18 @@ export const FileExplorer = (props: FileExplorerProps) => { fileState={fileState} expandPath={props.expandPath} handleContextMenu={handleContextMenu} - moveFile={handleFileMove} - moveFolder={handleFolderMove} + warnMovingItems={warnMovingItems} + moveFolderSilently={moveFolderSilently} + moveFileSilently={moveFileSilently} + resetMultiselect={resetMultiselect} + setFilesSelected={setFilesSelected} handleClickFolder={handleClickFolder} createNewFile={props.createNewFile} createNewFolder={props.createNewFolder} deletePath={deletePath} editPath={props.editModeOn} + fileTarget={feTarget} + setTargetFiles={setFeTarget} /> diff --git a/libs/remix-ui/workspace/src/lib/components/flat-tree-drop.tsx b/libs/remix-ui/workspace/src/lib/components/flat-tree-drop.tsx index 92de1ff8f1..d049a2bb6c 100644 --- a/libs/remix-ui/workspace/src/lib/components/flat-tree-drop.tsx +++ b/libs/remix-ui/workspace/src/lib/components/flat-tree-drop.tsx @@ -1,19 +1,12 @@ -import React, { SyntheticEvent, useEffect, useRef, useState } from 'react' -import { FileType } from '../types' -import { getEventTarget } from '../utils/getEventTarget' +import React, { SyntheticEvent, useContext, useEffect, useRef, useState } from 'react' +import { DragStructure, FileType, FlatTreeDropProps } from '../types' +import { buildMultiSelectedItemProfiles, getEventTarget } from '../utils/getEventTarget' import { extractParentFromKey } from '@remix-ui/helper' -interface FlatTreeDropProps { - moveFile: (dest: string, src: string) => void - moveFolder: (dest: string, src: string) => void - getFlatTreeItem: (path: string) => FileType - handleClickFolder: (path: string, type: string) => void - dragSource: FileType - children: React.ReactNode - expandPath: string[] -} +import { FileSystemContext } from '../contexts' + export const FlatTreeDrop = (props: FlatTreeDropProps) => { - const { getFlatTreeItem, dragSource, moveFile, moveFolder, handleClickFolder, expandPath } = props + const { getFlatTreeItem, dragSource, handleClickFolder, expandPath } = props // delay timer const [timer, setTimer] = useState() // folder to open @@ -21,7 +14,9 @@ export const FlatTreeDrop = (props: FlatTreeDropProps) => { const onDragOver = async (e: SyntheticEvent) => { e.preventDefault() + const target = await getEventTarget(e) + if (!target || !target.path) { clearTimeout(timer) setFolderToOpen(null) @@ -50,6 +45,8 @@ export const FlatTreeDrop = (props: FlatTreeDropProps) => { event.preventDefault() const target = await getEventTarget(event) + const filePaths = [] + let dragDestination: any if (!target || !target.path) { dragDestination = { @@ -59,23 +56,39 @@ export const FlatTreeDrop = (props: FlatTreeDropProps) => { } else { dragDestination = getFlatTreeItem(target.path) } + + props.selectedItems.forEach((item) => filePaths.push(item.path)) + props.setFilesSelected(filePaths) + if (dragDestination.isDirectory) { - if (dragSource.isDirectory) { - moveFolder(dragDestination.path, dragSource.path) - } else { - moveFile(dragDestination.path, dragSource.path) - } + await props.warnMovingItems(filePaths, dragDestination.path) + await moveItemsSilently(props.selectedItems, dragDestination.path) } else { const path = extractParentFromKey(dragDestination.path) || '/' - - if (dragSource.isDirectory) { - moveFolder(path, dragSource.path) - } else { - moveFile(path, dragSource.path) - } + await props.warnMovingItems(filePaths, path) + await moveItemsSilently(props.selectedItems, path) } } + /** + * Moves items silently without showing a confirmation dialog. + * @param items MultiSelected items built into a DragStructure profile + * @param dragSource source FileExplorer item being dragged. + * @returns Promise + */ + const moveItemsSilently = async (items: DragStructure[], targetPath: string) => { + const promises = items.filter(item => item.path !== targetPath) + .map(async (item) => { + if (item.type === 'file') { + await props.moveFileSilently(targetPath, item.path) + } else if (item.type === 'folder') { + await props.moveFolderSilently(targetPath, item.path) + } + }) + await Promise.all(promises) + props.resetMultiselect() + } + return (
) { @@ -25,6 +26,8 @@ export default function useOnScreen(ref: RefObject) { return isIntersecting } interface FlatTreeProps { + fileTarget: any + setTargetFiles: React.Dispatch files: { [x: string]: Record }, flatTree: FileType[], expandPath: string[], @@ -35,13 +38,16 @@ interface FlatTreeProps { handleContextMenu: (pageX: number, pageY: number, path: string, content: string, type: string) => void handleTreeClick: (e: SyntheticEvent) => void handleClickFolder: (path: string, type: string) => void - moveFile: (dest: string, src: string) => void - moveFolder: (dest: string, src: string) => void + moveFolderSilently: (dest: string, src: string) => Promise + moveFileSilently: (dest: string, src: string) => Promise + resetMultiselect: () => void + setFilesSelected: Dispatch> fileState: fileDecoration[] createNewFile?: any createNewFolder?: any deletePath?: (path: string | string[]) => void | Promise editPath?: (path: string, type: string, isNew?: boolean) => void + warnMovingItems: (srcs: string[], dests: string) => Promise } let mouseTimer: any = { @@ -50,7 +56,7 @@ let mouseTimer: any = { } export const FlatTree = (props: FlatTreeProps) => { - const { files, flatTree, expandPath, focusEdit, editModeOff, handleTreeClick, moveFile, moveFolder, fileState, focusElement, handleClickFolder, deletePath, editPath } = props + const { files, flatTree, expandPath, focusEdit, editModeOff, handleTreeClick, warnMovingItems, fileState, focusElement, handleClickFolder, deletePath, moveFileSilently, moveFolderSilently, setFilesSelected } = props const [hover, setHover] = useState('') const [mouseOverTarget, setMouseOverTarget] = useState<{ path: string, @@ -67,18 +73,25 @@ export const FlatTree = (props: FlatTreeProps) => { const ref = useRef(null) const containerRef = useRef(null) const virtuoso = useRef(null) + const [selectedItems, setSelectedItems] = useState([]) const labelClass = (file: FileType) => props.focusEdit.element === file.path ? 'bg-light' : props.focusElement.findIndex((item) => item.key === file.path) !== -1 - ? 'bg-secondary' + ? 'bg-secondary remixui_selected' : hover == file.path ? 'bg-light border-no-shift' : props.focusContext.element === file.path && props.focusEdit.element !== file.path ? 'bg-light border-no-shift' : '' + useEffect(() => { + if (props.focusElement && props.focusElement.length > 0) { + props.setTargetFiles(props.focusElement) + } + }, [props.focusElement, props.focusElement.length]) + const getIndentLevelDiv = (path: string) => { // remove double slash path = path.replace(/\/\//g, '/') @@ -103,6 +116,9 @@ export const FlatTree = (props: FlatTreeProps) => { const target = await getEventTarget(event) setDragSource(flatTree.find((item) => item.path === target.path)) setIsDragging(true) + const items = buildMultiSelectedItemProfiles(target) + setSelectedItems(items) + setFilesSelected(items.map((item) => item.path)) } useEffect(() => { @@ -116,6 +132,12 @@ export const FlatTree = (props: FlatTreeProps) => { const onDragEnd = (event: SyntheticEvent) => { setIsDragging(false) + document.querySelectorAll('li.remixui_selected').forEach(item => { + item.classList.remove('remixui_selected') + item.classList.remove('bg-secondary') + }) + props.setFilesSelected([]) + setSelectedItems([]) } const getFlatTreeItem = (path: string) => { @@ -247,17 +269,23 @@ export const FlatTree = (props: FlatTreeProps) => {
+ onContextMenu={handleContextMenu} + > { showMouseOverTarget && mouseOverTarget && !isDragging && Promise, dispatchMoveFile: (src: string, dest: string) => Promise, dispatchMoveFolder: (src: string, dest: string) => Promise, + dispatchMoveFiles: (src: string[], dest: string) => Promise, + dispatchMoveFolders: (src: string[], dest: string) => Promise, dispatchShowAllBranches: () => Promise, dispatchSwitchToBranch: (branch: branch) => Promise, dispatchCreateNewBranch: (name: string) => Promise, diff --git a/libs/remix-ui/workspace/src/lib/css/file-explorer.css b/libs/remix-ui/workspace/src/lib/css/file-explorer.css index 16f0ffca51..4ccf2096c7 100644 --- a/libs/remix-ui/workspace/src/lib/css/file-explorer.css +++ b/libs/remix-ui/workspace/src/lib/css/file-explorer.css @@ -69,3 +69,7 @@ ul { .remixui_icons:hover { color: var(--text); } + +.remixui_selected { + +} diff --git a/libs/remix-ui/workspace/src/lib/providers/FileSystemProvider.tsx b/libs/remix-ui/workspace/src/lib/providers/FileSystemProvider.tsx index af2caab30f..7494e39e3d 100644 --- a/libs/remix-ui/workspace/src/lib/providers/FileSystemProvider.tsx +++ b/libs/remix-ui/workspace/src/lib/providers/FileSystemProvider.tsx @@ -202,10 +202,22 @@ export const FileSystemProvider = (props: WorkspaceProps) => { await moveFile(src, dest) } + const dispatchMoveFiles = async (src: string[], dest: string) => { + for (const path of src) { + await moveFile(path, dest) + } + } + const dispatchMoveFolder = async (src: string, dest: string) => { await moveFolder(src, dest) } + const dispatchMoveFolders = async (src: string[], dest: string) => { + for (const path of src) { + await moveFolder(path, dest) + } + } + const dispatchShowAllBranches = async () => { await showAllBranches() } @@ -368,7 +380,9 @@ export const FileSystemProvider = (props: WorkspaceProps) => { dispatchHandleRestoreBackup, dispatchCloneRepository, dispatchMoveFile, + dispatchMoveFiles, dispatchMoveFolder, + dispatchMoveFolders, dispatchShowAllBranches, dispatchSwitchToBranch, dispatchCreateNewBranch, diff --git a/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx b/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx index 03d807808a..5b924b9697 100644 --- a/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx +++ b/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx @@ -1253,7 +1253,9 @@ export function Workspace() { dispatchAddInputField={global.dispatchAddInputField} dispatchHandleExpandPath={global.dispatchHandleExpandPath} dispatchMoveFile={global.dispatchMoveFile} + dispatchMoveFiles={global.dispatchMoveFiles} dispatchMoveFolder={global.dispatchMoveFolder} + dispatchMoveFolders={global.dispatchMoveFolders} handleCopyClick={handleCopyClick} handlePasteClick={handlePasteClick} addMenuItems={addMenuItems} @@ -1319,7 +1321,9 @@ export function Workspace() { dispatchAddInputField={global.dispatchAddInputField} dispatchHandleExpandPath={global.dispatchHandleExpandPath} dispatchMoveFile={global.dispatchMoveFile} + dispatchMoveFiles={global.dispatchMoveFiles} dispatchMoveFolder={global.dispatchMoveFolder} + dispatchMoveFolders={global.dispatchMoveFolders} handleCopyClick={handleCopyClick} handlePasteClick={handlePasteClick} addMenuItems={addMenuItems} diff --git a/libs/remix-ui/workspace/src/lib/types/index.ts b/libs/remix-ui/workspace/src/lib/types/index.ts index aff1b70bd8..dbd0d68f41 100644 --- a/libs/remix-ui/workspace/src/lib/types/index.ts +++ b/libs/remix-ui/workspace/src/lib/types/index.ts @@ -1,5 +1,5 @@ /* eslint-disable @nrwl/nx/enforce-module-boundaries */ -import React from 'react' +import React, { Dispatch } from 'react' import { customAction } from '@remixproject/plugin-api' import { fileDecoration } from '@remix-ui/file-decorators' import { RemixAppManager } from 'libs/remix-ui/plugin-manager/src/types' @@ -136,6 +136,8 @@ export interface FileExplorerProps { dispatchAddInputField:(path: string, type: 'file' | 'folder') => Promise, dispatchHandleExpandPath: (paths: string[]) => Promise, dispatchMoveFile: (src: string, dest: string) => Promise, + dispatchMoveFiles: (src: string[], dest: string) => Promise, + dispatchMoveFolders: (src: string[], dest: string) => Promise, dispatchMoveFolder: (src: string, dest: string) => Promise, handlePasteClick: (dest: string, destType: string) => void handleCopyClick: (path: string, type: WorkspaceElement) => void @@ -201,6 +203,8 @@ export interface FileExplorerContextMenuProps { export interface WorkSpaceState { ctrlKey: boolean + deleteKey?: boolean + F2Key?: boolean newFileName: string actions: { id: string @@ -343,3 +347,28 @@ export interface Action { export type Actions = {[A in keyof ActionPayloadTypes]: Action}[keyof ActionPayloadTypes] export type WorkspaceElement = 'folder' | 'file' | 'workspace' + +export interface FlatTreeDropProps { + resetMultiselect: () => void + moveFolderSilently: (dest: string, src: string) => Promise + moveFileSilently: (dest: string, src: string) => Promise + setFilesSelected: Dispatch> + getFlatTreeItem: (path: string) => FileType + handleClickFolder: (path: string, type: string) => void + dragSource: FileType + children: React.ReactNode + expandPath: string[] + selectedItems: DragStructure[] + setSelectedItems: Dispatch> + warnMovingItems: (srcs: string[], dest: string) => Promise +} + +export type DragStructure = { + position: { + top: number + left: number + } + path: string + type: string + content: string +} diff --git a/libs/remix-ui/workspace/src/lib/utils/getEventTarget.ts b/libs/remix-ui/workspace/src/lib/utils/getEventTarget.ts index e61cc7a226..ea663acf11 100644 --- a/libs/remix-ui/workspace/src/lib/utils/getEventTarget.ts +++ b/libs/remix-ui/workspace/src/lib/utils/getEventTarget.ts @@ -22,4 +22,35 @@ export const getEventTarget = async (e: any, useLabel: boolean = false) => { position: endPosition } } -} \ No newline at end of file +} + +/** + * When multiple files are selected in FileExplorer, + * and these files are dragged to a target folder, + * this function will build the profile of each selected item + * in FileExplorer so they can be moved when dropped + * @param target - Initial target item in FileExplorer + * @returns - {DragStructure} Array of selected items + */ +export const buildMultiSelectedItemProfiles = (target: { + path: string + type: string + content: string + position: { + top: number + left: number + } + }) => { + const selectItems = [] + selectItems.push(target) + document.querySelectorAll('li.remixui_selected').forEach(item => { + const dragTarget = { + position: { top: target?.position.top || 0, left: target?.position.left || 0 }, + path: item.getAttribute('data-path') || item.getAttribute('data-label-path') || '', + type: item.getAttribute('data-type') || item.getAttribute('data-label-type') || '', + content: item.textContent || '' + } + if (dragTarget.path !== target.path) selectItems.push(dragTarget) + }) + return selectItems +}