Unverified Commit 1b78fac5 authored by bunsenstraat's avatar bunsenstraat Committed by GitHub

Merge pull request #1184 from ethereum/multiselectdelete

Multi select & multi context menu actions & deleting multi
parents 74107061 4e4ec8d3
import React, { useRef, useEffect } from 'react' // eslint-disable-line import React, { useRef, useEffect } from 'react' // eslint-disable-line
import { FileExplorerContextMenuProps } from './types' import { action, FileExplorerContextMenuProps } from './types'
import './css/file-explorer-context-menu.css' import './css/file-explorer-context-menu.css'
export const FileExplorerContextMenu = (props: FileExplorerContextMenuProps) => { export const FileExplorerContextMenu = (props: FileExplorerContextMenuProps) => {
const { actions, createNewFile, createNewFolder, deletePath, renamePath, hideContextMenu, pushChangesToGist, publishFileToGist, publishFolderToGist, copy, paste, runScript, emit, pageX, pageY, path, type, ...otherProps } = props const { actions, createNewFile, createNewFolder, deletePath, renamePath, hideContextMenu, pushChangesToGist, publishFileToGist, publishFolderToGist, copy, paste, runScript, emit, pageX, pageY, path, type, focus, ...otherProps } = props
const contextMenuRef = useRef(null) const contextMenuRef = useRef(null)
useEffect(() => { useEffect(() => {
contextMenuRef.current.focus() contextMenuRef.current.focus()
}, []) }, [])
...@@ -22,14 +21,40 @@ export const FileExplorerContextMenu = (props: FileExplorerContextMenuProps) => ...@@ -22,14 +21,40 @@ export const FileExplorerContextMenu = (props: FileExplorerContextMenuProps) =>
} }
}, [pageX, pageY]) }, [pageX, pageY])
const filterItem = (item: action) => {
/**
* if there are multiple elements focused we need to take this and all conditions must be met
* for example : 'downloadAsZip' with type ['file','folder'] will work on files and folders when multiple are selected
**/
const nonRootFocus = focus.filter((el) => { return !(el.key === '' && el.type === 'folder') })
if (nonRootFocus.length > 1) {
for (const element of nonRootFocus) {
if (!itemMatchesCondition(item, element.type, element.key)) return false
}
return true
} else {
return itemMatchesCondition(item, type, path)
}
}
const itemMatchesCondition = (item: action, itemType: string, itemPath: string) => {
if (item.type && Array.isArray(item.type) && (item.type.findIndex(name => name === itemType) !== -1)) return true
else if (item.path && Array.isArray(item.path) && (item.path.findIndex(key => key === itemPath) !== -1)) return true
else if (item.extension && Array.isArray(item.extension) && (item.extension.findIndex(ext => itemPath.endsWith(ext)) !== -1)) return true
else if (item.pattern && Array.isArray(item.pattern) && (item.pattern.filter(value => itemPath.match(new RegExp(value))).length > 0)) return true
else return false
}
const getPath = () => {
if (focus.length > 1) {
return focus.map((element) => element.key)
} else {
return path
}
}
const menu = () => { const menu = () => {
return actions.filter(item => { return actions.filter(item => filterItem(item)).map((item, index) => {
if (item.type && Array.isArray(item.type) && (item.type.findIndex(name => name === type) !== -1)) return true
else if (item.path && Array.isArray(item.path) && (item.path.findIndex(key => key === path) !== -1)) return true
else if (item.extension && Array.isArray(item.extension) && (item.extension.findIndex(ext => path.endsWith(ext)) !== -1)) return true
else if (item.pattern && Array.isArray(item.pattern) && (item.pattern.filter(value => path.match(new RegExp(value))).length > 0)) return true
else return false
}).map((item, index) => {
return <li return <li
id={`menuitem${item.name.toLowerCase()}`} id={`menuitem${item.name.toLowerCase()}`}
key={index} key={index}
...@@ -47,7 +72,7 @@ export const FileExplorerContextMenu = (props: FileExplorerContextMenuProps) => ...@@ -47,7 +72,7 @@ export const FileExplorerContextMenu = (props: FileExplorerContextMenuProps) =>
renamePath(path, type) renamePath(path, type)
break break
case 'Delete': case 'Delete':
deletePath(path) deletePath(getPath())
break break
case 'Push changes to gist': case 'Push changes to gist':
pushChangesToGist(path, type) pushChangesToGist(path, type)
...@@ -67,8 +92,11 @@ export const FileExplorerContextMenu = (props: FileExplorerContextMenuProps) => ...@@ -67,8 +92,11 @@ export const FileExplorerContextMenu = (props: FileExplorerContextMenuProps) =>
case 'Paste': case 'Paste':
paste(path, type) paste(path, type)
break break
case 'Delete All':
deletePath(getPath())
break
default: default:
emit && emit(item.id, path) emit && emit(item.id, getPath())
break break
} }
hideContextMenu() hideContextMenu()
......
...@@ -6,7 +6,7 @@ import { Toaster } from '@remix-ui/toaster' // eslint-disable-line ...@@ -6,7 +6,7 @@ import { Toaster } from '@remix-ui/toaster' // eslint-disable-line
import Gists from 'gists' import Gists from 'gists'
import { FileExplorerMenu } from './file-explorer-menu' // eslint-disable-line import { FileExplorerMenu } from './file-explorer-menu' // eslint-disable-line
import { FileExplorerContextMenu } from './file-explorer-context-menu' // eslint-disable-line import { FileExplorerContextMenu } from './file-explorer-context-menu' // eslint-disable-line
import { FileExplorerProps, File } from './types' import { FileExplorerProps, File, MenuItems } from './types'
import { fileSystemReducer, fileSystemInitialState } from './reducers/fileSystem' import { fileSystemReducer, fileSystemInitialState } from './reducers/fileSystem'
import { fetchDirectory, init, resolveDirectory, addInputField, removeInputField } from './actions/fileSystem' import { fetchDirectory, init, resolveDirectory, addInputField, removeInputField } from './actions/fileSystem'
import * as helper from '../../../../../apps/remix-ide/src/lib/helper' import * as helper from '../../../../../apps/remix-ide/src/lib/helper'
...@@ -33,63 +33,80 @@ export const FileExplorer = (props: FileExplorerProps) => { ...@@ -33,63 +33,80 @@ export const FileExplorer = (props: FileExplorerProps) => {
type: ['folder', 'gist'], type: ['folder', 'gist'],
path: [], path: [],
extension: [], extension: [],
pattern: [] pattern: [],
multiselect: false
}, { }, {
id: 'newFolder', id: 'newFolder',
name: 'New Folder', name: 'New Folder',
type: ['folder', 'gist'], type: ['folder', 'gist'],
path: [], path: [],
extension: [], extension: [],
pattern: [] pattern: [],
multiselect: false
}, { }, {
id: 'rename', id: 'rename',
name: 'Rename', name: 'Rename',
type: ['file', 'folder'], type: ['file', 'folder'],
path: [], path: [],
extension: [], extension: [],
pattern: [] pattern: [],
multiselect: false
}, { }, {
id: 'delete', id: 'delete',
name: 'Delete', name: 'Delete',
type: ['file', 'folder', 'gist'], type: ['file', 'folder', 'gist'],
path: [], path: [],
extension: [], extension: [],
pattern: [] pattern: [],
multiselect: false
}, { }, {
id: 'run', id: 'run',
name: 'Run', name: 'Run',
type: [], type: [],
path: [], path: [],
extension: ['.js'], extension: ['.js'],
pattern: [] pattern: [],
multiselect: false
}, { }, {
id: 'pushChangesToGist', id: 'pushChangesToGist',
name: 'Push changes to gist', name: 'Push changes to gist',
type: ['gist'], type: ['gist'],
path: [], path: [],
extension: [], extension: [],
pattern: [] pattern: [],
multiselect: false
}, { }, {
id: 'publishFolderToGist', id: 'publishFolderToGist',
name: 'Publish folder to gist', name: 'Publish folder to gist',
type: ['folder'], type: ['folder'],
path: [], path: [],
extension: [], extension: [],
pattern: [] pattern: [],
multiselect: false
}, { }, {
id: 'publishFileToGist', id: 'publishFileToGist',
name: 'Publish file to gist', name: 'Publish file to gist',
type: ['file'], type: ['file'],
path: [], path: [],
extension: [], extension: [],
pattern: [] pattern: [],
multiselect: false
}, { }, {
id: 'copy', id: 'copy',
name: 'Copy', name: 'Copy',
type: ['folder', 'file'], type: ['folder', 'file'],
path: [], path: [],
extension: [], extension: [],
pattern: [] pattern: [],
multiselect: false
}, {
id: 'deleteAll',
name: 'Delete All',
type: ['folder', 'file'],
path: [],
extension: [],
pattern: [],
multiselect: true
}], }],
focusContext: { focusContext: {
element: null, element: null,
...@@ -225,6 +242,31 @@ export const FileExplorer = (props: FileExplorerProps) => { ...@@ -225,6 +242,31 @@ export const FileExplorer = (props: FileExplorerProps) => {
}, [state.modals]) }, [state.modals])
useEffect(() => { useEffect(() => {
const keyPressHandler = (e: KeyboardEvent) => {
if (e.shiftKey) {
setState(prevState => {
return { ...prevState, ctrlKey: true }
})
}
}
const keyUpHandler = (e: KeyboardEvent) => {
if (!e.shiftKey) {
setState(prevState => {
return { ...prevState, ctrlKey: false }
})
}
}
document.addEventListener('keydown', keyPressHandler)
document.addEventListener('keyup', keyUpHandler)
return () => {
document.removeEventListener('keydown', keyPressHandler)
document.removeEventListener('keyup', keyUpHandler)
}
}, [])
useEffect(() => {
if (canPaste) { if (canPaste) {
addMenuItems([{ addMenuItems([{
id: 'paste', id: 'paste',
...@@ -232,14 +274,15 @@ export const FileExplorer = (props: FileExplorerProps) => { ...@@ -232,14 +274,15 @@ export const FileExplorer = (props: FileExplorerProps) => {
type: ['folder', 'file'], type: ['folder', 'file'],
path: [], path: [],
extension: [], extension: [],
pattern: [] pattern: [],
multiselect: false
}]) }])
} else { } else {
removeMenuItems(['paste']) removeMenuItems(['paste'])
} }
}, [canPaste]) }, [canPaste])
const addMenuItems = (items: { id: string, name: string, type: string[], path: string[], extension: string[], pattern: string[] }[]) => { const addMenuItems = (items: MenuItems) => {
setState(prevState => { setState(prevState => {
// filter duplicate items // filter duplicate items
const actions = items.filter(({ name }) => prevState.actions.findIndex(action => action.name === name) === -1) const actions = items.filter(({ name }) => prevState.actions.findIndex(action => action.name === name) === -1)
...@@ -325,21 +368,23 @@ export const FileExplorer = (props: FileExplorerProps) => { ...@@ -325,21 +368,23 @@ export const FileExplorer = (props: FileExplorerProps) => {
} }
} }
const deletePath = async (path: string) => { const deletePath = async (path: string | string[]) => {
const filesProvider = fileSystem.provider.provider const filesProvider = fileSystem.provider.provider
if (!Array.isArray(path)) path = [path]
if (filesProvider.isReadOnly(path)) { for (const p of path) {
return toast('cannot delete file. ' + name + ' is a read only explorer') if (filesProvider.isReadOnly(p)) {
return toast('cannot delete file. ' + name + ' is a read only explorer')
}
} }
const isDir = state.fileManager.isDirectory(path) modal(`Delete ${path.length > 1 ? 'items' : 'item'}`, deleteMessage(path), 'OK', async () => {
const fileManager = state.fileManager
modal(`Delete ${isDir ? 'folder' : 'file'}`, `Are you sure you want to delete ${path} ${isDir ? 'folder' : 'file'}?`, 'OK', async () => { for (const p of path) {
try { try {
const fileManager = state.fileManager await fileManager.remove(p)
} catch (e) {
await fileManager.remove(path) const isDir = state.fileManager.isDirectory(p)
} catch (e) { toast(`Failed to remove ${isDir ? 'folder' : 'file'} ${p}.`)
toast(`Failed to remove ${isDir ? 'folder' : 'file'} ${path}.`) }
} }
}, 'Cancel', () => {}) }, 'Cancel', () => {})
} }
...@@ -556,7 +601,7 @@ export const FileExplorer = (props: FileExplorerProps) => { ...@@ -556,7 +601,7 @@ export const FileExplorer = (props: FileExplorerProps) => {
}) })
} }
const emitContextMenuEvent = (id: string, path: string) => { const emitContextMenuEvent = (id: string, path: string | string[]) => {
plugin.emit(id, path) plugin.emit(id, path)
} }
...@@ -566,7 +611,7 @@ export const FileExplorer = (props: FileExplorerProps) => { ...@@ -566,7 +611,7 @@ export const FileExplorer = (props: FileExplorerProps) => {
}) })
} }
const modal = (title: string, message: string, okLabel: string, okFn: () => void, cancelLabel?: string, cancelFn?: () => void) => { const modal = (title: string, message: string | JSX.Element, okLabel: string, okFn: () => void, cancelLabel?: string, cancelFn?: () => void) => {
setState(prevState => { setState(prevState => {
return { return {
...prevState, ...prevState,
...@@ -592,10 +637,25 @@ export const FileExplorer = (props: FileExplorerProps) => { ...@@ -592,10 +637,25 @@ export const FileExplorer = (props: FileExplorerProps) => {
const handleClickFile = (path: string, type: string) => { const handleClickFile = (path: string, type: string) => {
path = path.indexOf(props.name + '/') === 0 ? path.replace(props.name + '/', '') : path path = path.indexOf(props.name + '/') === 0 ? path.replace(props.name + '/', '') : path
state.fileManager.open(path) if (!state.ctrlKey) {
setState(prevState => { state.fileManager.open(path)
return { ...prevState, focusElement: [{ key: path, type }] } setState(prevState => {
}) return { ...prevState, focusElement: [{ key: path, type }] }
})
} else {
if (state.focusElement.findIndex(item => item.key === path) !== -1) {
setState(prevState => {
return { ...prevState, focusElement: prevState.focusElement.filter(item => item.key !== path) }
})
} else {
setState(prevState => {
const nonRootFocus = prevState.focusElement.filter((el) => { return !(el.key === '' && el.type === 'folder') })
nonRootFocus.push({ key: path, type })
return { ...prevState, focusElement: nonRootFocus }
})
}
}
} }
const handleClickFolder = async (path: string, type: string) => { const handleClickFolder = async (path: string, type: string) => {
...@@ -606,7 +666,10 @@ export const FileExplorer = (props: FileExplorerProps) => { ...@@ -606,7 +666,10 @@ export const FileExplorer = (props: FileExplorerProps) => {
}) })
} else { } else {
setState(prevState => { setState(prevState => {
return { ...prevState, focusElement: [...prevState.focusElement, { key: path, type }] } const nonRootFocus = prevState.focusElement.filter((el) => { return !(el.key === '' && el.type === 'folder') })
nonRootFocus.push({ key: path, type })
return { ...prevState, focusElement: nonRootFocus }
}) })
} }
} else { } else {
...@@ -763,6 +826,17 @@ export const FileExplorer = (props: FileExplorerProps) => { ...@@ -763,6 +826,17 @@ export const FileExplorer = (props: FileExplorerProps) => {
}) })
} }
const deleteMessage = (path: string[]) => {
return (
<div>
<div>Are you sure you want to delete {path.length > 1 ? 'these items' : 'this item'}?</div>
{
path.map((item, i) => (<li key={i}>{item}</li>))
}
</div>
)
}
const label = (file: File) => { const label = (file: File) => {
return ( return (
<div <div
...@@ -929,7 +1003,7 @@ export const FileExplorer = (props: FileExplorerProps) => { ...@@ -929,7 +1003,7 @@ export const FileExplorer = (props: FileExplorerProps) => {
<Toaster message={state.toasterMsg} /> <Toaster message={state.toasterMsg} />
{ state.showContextMenu && { state.showContextMenu &&
<FileExplorerContextMenu <FileExplorerContextMenu
actions={state.actions} actions={state.focusElement.length > 1 ? state.actions.filter(item => item.multiselect) : state.actions.filter(item => !item.multiselect)}
hideContextMenu={hideContextMenu} hideContextMenu={hideContextMenu}
createNewFile={handleNewFileInput} createNewFile={handleNewFileInput}
createNewFolder={handleNewFolderInput} createNewFolder={handleNewFolderInput}
...@@ -943,6 +1017,7 @@ export const FileExplorer = (props: FileExplorerProps) => { ...@@ -943,6 +1017,7 @@ export const FileExplorer = (props: FileExplorerProps) => {
pageY={state.focusContext.y} pageY={state.focusContext.y}
path={state.focusContext.element} path={state.focusContext.element}
type={state.focusContext.type} type={state.focusContext.type}
focus={state.focusElement}
onMouseOver={(e) => { onMouseOver={(e) => {
e.stopPropagation() e.stopPropagation()
handleMouseOver(state.focusContext.element) handleMouseOver(state.focusContext.element)
......
...@@ -6,7 +6,7 @@ export interface FileExplorerProps { ...@@ -6,7 +6,7 @@ export interface FileExplorerProps {
menuItems?: string[], menuItems?: string[],
plugin: any, plugin: any,
focusRoot: boolean, focusRoot: boolean,
contextMenuItems: { id: string, name: string, type: string[], path: string[], extension: string[], pattern: string[] }[], contextMenuItems: MenuItems,
displayInput?: boolean, displayInput?: boolean,
externalUploads?: EventTarget & HTMLInputElement externalUploads?: EventTarget & HTMLInputElement
} }
...@@ -29,11 +29,14 @@ export interface FileExplorerMenuProps { ...@@ -29,11 +29,14 @@ export interface FileExplorerMenuProps {
uploadFile: (target: EventTarget & HTMLInputElement) => void uploadFile: (target: EventTarget & HTMLInputElement) => void
} }
export type action = { name: string, type: string[], path: string[], extension: string[], pattern: string[], id: string, multiselect: boolean }
export type MenuItems = action[]
export interface FileExplorerContextMenuProps { export interface FileExplorerContextMenuProps {
actions: { name: string, type: string[], path: string[], extension: string[], pattern: string[], id: string }[], actions: action[],
createNewFile: (folder?: string) => void, createNewFile: (folder?: string) => void,
createNewFolder: (parentFolder?: string) => void, createNewFolder: (parentFolder?: string) => void,
deletePath: (path: string) => void, deletePath: (path: string | string[]) => void,
renamePath: (path: string, type: string) => void, renamePath: (path: string, type: string) => void,
hideContextMenu: () => void, hideContextMenu: () => void,
publishToGist?: (path?: string, type?: string) => void, publishToGist?: (path?: string, type?: string) => void,
...@@ -41,12 +44,13 @@ export interface FileExplorerContextMenuProps { ...@@ -41,12 +44,13 @@ export interface FileExplorerContextMenuProps {
publishFolderToGist?: (path?: string, type?: string) => void, publishFolderToGist?: (path?: string, type?: string) => void,
publishFileToGist?: (path?: string, type?: string) => void, publishFileToGist?: (path?: string, type?: string) => void,
runScript?: (path: string) => void, runScript?: (path: string) => void,
emit?: (id: string, path: string) => void, emit?: (id: string, path: string | string[]) => void,
pageX: number, pageX: number,
pageY: number, pageY: number,
path: string, path: string,
type: string, type: string,
focus: {key:string, type:string}[],
onMouseOver?: (...args) => void, onMouseOver?: (...args) => void,
copy?: (path: string, type: string) => void copy?: (path: string, type: string) => void,
paste?: (destination: string, type: string) => void paste?: (destination: string, type: string) => void
} }
...@@ -14,7 +14,7 @@ export const TreeViewItem = (props: TreeViewItemProps) => { ...@@ -14,7 +14,7 @@ export const TreeViewItem = (props: TreeViewItemProps) => {
return ( return (
<li ref={innerRef} key={`treeViewLi${id}`} data-id={`treeViewLi${id}`} className='li_tv' {...otherProps}> <li ref={innerRef} key={`treeViewLi${id}`} data-id={`treeViewLi${id}`} className='li_tv' {...otherProps}>
<div key={`treeViewDiv${id}`} data-id={`treeViewDiv${id}`} className={`d-flex flex-row align-items-center ${labelClass}`} onClick={() => !controlBehaviour && setIsExpanded(!isExpanded)}> <div key={`treeViewDiv${id}`} data-id={`treeViewDiv${id}`} className={`d-flex flex-row align-items-center ${labelClass}`} onClick={() => !controlBehaviour && setIsExpanded(!isExpanded)}>
{ controlBehaviour ? null : children ? <div className={isExpanded ? `px-1 ${iconY} caret caret_tv` : `px-1 ${iconX} caret caret_tv`} style={{ visibility: children ? 'visible' : 'hidden' }}></div> : icon ? <div className={`pr-3 pl-1 ${icon} caret caret_tv`}></div> : null } { children ? <div className={isExpanded ? `px-1 ${iconY} caret caret_tv` : `px-1 ${iconX} caret caret_tv`} style={{ visibility: children ? 'visible' : 'hidden' }}></div> : icon ? <div className={`pr-3 pl-1 ${icon} caret caret_tv`}></div> : null }
<span className='w-100 pl-1'> <span className='w-100 pl-1'>
{ label } { label }
</span> </span>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment