From 3550cda6996e2e39178d697a275738bbb7270ecc Mon Sep 17 00:00:00 2001 From: ioedeveloper Date: Mon, 20 Sep 2021 11:45:54 +0100 Subject: [PATCH] Move file-explorer to workspace directory --- .../components/file-explorer-context-menu.tsx | 135 ++++ .../src/lib/components/file-explorer-menu.tsx | 98 +++ .../src/lib/components/file-explorer.tsx | 654 ++++++++++++++++++ .../lib/css/file-explorer-context-menu.css | 28 + .../workspace/src/lib/css/file-explorer.css | 56 ++ .../src/lib/css/remix-ui-workspace.css | 61 ++ .../src/lib/providers/FileSystemProvider.tsx | 4 +- .../remix-ui/workspace/src/lib/types/index.ts | 85 ++- .../remix-ui/workspace/src/lib/utils/index.ts | 61 ++ 9 files changed, 1179 insertions(+), 3 deletions(-) create mode 100644 libs/remix-ui/workspace/src/lib/components/file-explorer-context-menu.tsx create mode 100644 libs/remix-ui/workspace/src/lib/components/file-explorer-menu.tsx create mode 100644 libs/remix-ui/workspace/src/lib/components/file-explorer.tsx create mode 100644 libs/remix-ui/workspace/src/lib/css/file-explorer-context-menu.css create mode 100644 libs/remix-ui/workspace/src/lib/css/file-explorer.css create mode 100644 libs/remix-ui/workspace/src/lib/css/remix-ui-workspace.css create mode 100644 libs/remix-ui/workspace/src/lib/utils/index.ts diff --git a/libs/remix-ui/workspace/src/lib/components/file-explorer-context-menu.tsx b/libs/remix-ui/workspace/src/lib/components/file-explorer-context-menu.tsx new file mode 100644 index 0000000000..0c4e24b3e1 --- /dev/null +++ b/libs/remix-ui/workspace/src/lib/components/file-explorer-context-menu.tsx @@ -0,0 +1,135 @@ +import React, { useRef, useEffect } from 'react' // eslint-disable-line +import { action, FileExplorerContextMenuProps } from '../types' + +import './css/file-explorer-context-menu.css' +import { customAction } from '@remixproject/plugin-api/lib/file-system/file-panel' + +declare global { + interface Window { + _paq: any + } +} +const _paq = window._paq = window._paq || [] //eslint-disable-line + +export const FileExplorerContextMenu = (props: FileExplorerContextMenuProps) => { + 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) + useEffect(() => { + contextMenuRef.current.focus() + }, []) + + useEffect(() => { + const menuItemsContainer = contextMenuRef.current + const boundary = menuItemsContainer.getBoundingClientRect() + + if (boundary.bottom > (window.innerHeight || document.documentElement.clientHeight)) { + menuItemsContainer.style.position = 'fixed' + menuItemsContainer.style.bottom = '10px' + menuItemsContainer.style.top = null + } + }, [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 = () => { + return actions.filter(item => filterItem(item)).map((item, index) => { + return
  • { + e.stopPropagation() + switch (item.name) { + case 'New File': + createNewFile(path) + break + case 'New Folder': + createNewFolder(path) + break + case 'Rename': + renamePath(path, type) + break + case 'Delete': + deletePath(getPath()) + break + case 'Push changes to gist': + _paq.push(['trackEvent', 'fileExplorer', 'pushToChangesoGist']) + pushChangesToGist(path, type) + break + case 'Publish folder to gist': + _paq.push(['trackEvent', 'fileExplorer', 'publishFolderToGist']) + publishFolderToGist(path, type) + break + case 'Publish file to gist': + _paq.push(['trackEvent', 'fileExplorer', 'publishFileToGist']) + publishFileToGist(path, type) + break + case 'Run': + _paq.push(['trackEvent', 'fileExplorer', 'runScript']) + runScript(path) + break + case 'Copy': + copy(path, type) + break + case 'Paste': + paste(path, type) + break + case 'Delete All': + deletePath(getPath()) + break + default: + _paq.push(['trackEvent', 'fileExplorer', 'customAction', `${item.id}/${item.name}`]) + emit && emit({ ...item, path: [path] } as customAction) + break + } + hideContextMenu() + }}>{item.label || item.name}
  • + }) + } + + return ( + + ) +} + +export default FileExplorerContextMenu diff --git a/libs/remix-ui/workspace/src/lib/components/file-explorer-menu.tsx b/libs/remix-ui/workspace/src/lib/components/file-explorer-menu.tsx new file mode 100644 index 0000000000..69418cd3c7 --- /dev/null +++ b/libs/remix-ui/workspace/src/lib/components/file-explorer-menu.tsx @@ -0,0 +1,98 @@ +import React, { useState, useEffect } from 'react' //eslint-disable-line +import { FileExplorerMenuProps } from '../types' + +export const FileExplorerMenu = (props: FileExplorerMenuProps) => { + const [state, setState] = useState({ + menuItems: [ + { + action: 'createNewFile', + title: 'Create New File', + icon: 'far fa-file' + }, + { + action: 'createNewFolder', + title: 'Create New Folder', + icon: 'far fa-folder' + }, + { + action: 'publishToGist', + title: 'Publish all the current workspace files (only root) to a github gist', + icon: 'fab fa-github' + }, + { + action: 'uploadFile', + title: 'Load a local file into current workspace', + icon: 'fa fa-upload' + }, + { + action: 'updateGist', + title: 'Update the current [gist] explorer', + icon: 'fab fa-github' + } + ].filter(item => props.menuItems && props.menuItems.find((name) => { return name === item.action })), + actions: {} + }) + + useEffect(() => { + const actions = { + updateGist: () => {} + } + + setState(prevState => { + return { ...prevState, actions } + }) + }, []) + + return ( + <> + { props.title } + { + state.menuItems.map(({ action, title, icon }, index) => { + if (action === 'uploadFile') { + return ( + + ) + } else { + return ( + { + e.stopPropagation() + if (action === 'createNewFile') { + props.createNewFile() + } else if (action === 'createNewFolder') { + props.createNewFolder() + } else if (action === 'publishToGist') { + props.publishToGist() + } else { + state.actions[action]() + } + }} + className={'newFile ' + icon + ' remixui_newFile'} + title={title} + key={index} + > + + ) + } + })} + + + ) +} + +export default FileExplorerMenu diff --git a/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx b/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx new file mode 100644 index 0000000000..d60caa91d8 --- /dev/null +++ b/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx @@ -0,0 +1,654 @@ +import React, { useEffect, useState, useRef, useContext } 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 { FileExplorerMenu } from './file-explorer-menu' // eslint-disable-line +import { FileExplorerContextMenu } from './file-explorer-context-menu' // eslint-disable-line +import { FileExplorerProps, File, MenuItems, FileExplorerState } from '../types' +import { FileSystemContext } from '@remix-ui/workspace' +import { customAction } from '@remixproject/plugin-api/lib/file-system/file-panel' +import { contextMenuActions } from '../utils' + +import './css/file-explorer.css' +import { checkSpecialChars, extractParentFromKey, getPathIcon, joinPath } from '@remix-ui/helper' + +export const FileExplorer = (props: FileExplorerProps) => { + const { name, focusRoot, contextMenuItems, externalUploads, removedContextMenuItems, resetFocus, files } = props + const [state, setState] = useState({ + ctrlKey: false, + newFileName: '', + actions: contextMenuActions, + focusContext: { + element: null, + x: null, + y: null, + type: '' + }, + focusEdit: { + element: null, + type: '', + isNew: false, + lastEdit: '' + }, + expandPath: [name], + mouseOverElement: null, + showContextMenu: false, + reservedKeywords: [name, 'gist-'], + copyElement: [] + }) + const [canPaste, setCanPaste] = useState(false) + const editRef = useRef(null) + const global = useContext(FileSystemContext) + + useEffect(() => { + if (global.fs.mode === 'browser') { + setState(prevState => { + return { ...prevState, expandPath: [...new Set([...prevState.expandPath, ...global.fs.browser.expandPath])] } + }) + } else if (global.fs.mode === 'localhost') { + setState(prevState => { + return { ...prevState, expandPath: [...new Set([...prevState.expandPath, ...global.fs.localhost.expandPath])] } + }) + } + }, [global.fs.browser.expandPath, global.fs.localhost.expandPath]) + + useEffect(() => { + if (state.focusEdit.element) { + setTimeout(() => { + if (editRef && editRef.current) { + editRef.current.focus() + } + }, 0) + } + }, [state.focusEdit.element]) + + useEffect(() => { + if (focusRoot) { + global.dispatchSetFocusElement([{ key: '', type: 'folder' }]) + resetFocus(false) + } + }, [focusRoot]) + + useEffect(() => { + if (contextMenuItems) { + addMenuItems(contextMenuItems) + } + }, [contextMenuItems]) + + useEffect(() => { + if (removedContextMenuItems) { + removeMenuItems(removedContextMenuItems) + } + }, [contextMenuItems]) + + useEffect(() => { + if (global.fs.focusEdit) { + setState(prevState => { + return { ...prevState, focusEdit: { element: global.fs.focusEdit, type: 'file', isNew: true, lastEdit: null } } + }) + } + }, [global.fs.focusEdit]) + + useEffect(() => { + if (externalUploads) { + uploadFile(externalUploads) + } + }, [externalUploads]) + + 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) { + addMenuItems([{ + id: 'paste', + name: 'Paste', + type: ['folder', 'file'], + path: [], + extension: [], + pattern: [], + multiselect: false, + label: '' + }]) + } else { + removeMenuItems([{ + id: 'paste', + name: 'Paste', + type: ['folder', 'file'], + path: [], + extension: [], + pattern: [], + multiselect: false, + label: '' + }]) + } + }, [canPaste]) + + const addMenuItems = (items: MenuItems) => { + setState(prevState => { + // filter duplicate items + const actions = items.filter(({ name }) => prevState.actions.findIndex(action => action.name === name) === -1) + + return { ...prevState, actions: [...prevState.actions, ...actions] } + }) + } + + const removeMenuItems = (items: MenuItems) => { + setState(prevState => { + const actions = prevState.actions.filter(({ id, name }) => items.findIndex(item => id === item.id && name === item.name) === -1) + return { ...prevState, actions } + }) + } + + const extractNameFromKey = (key: string):string => { + const keyPath = key.split('/') + + return keyPath[keyPath.length - 1] + } + + const hasReservedKeyword = (content: string): boolean => { + if (state.reservedKeywords.findIndex(value => content.startsWith(value)) !== -1) return true + else return false + } + + const getFocusedFolder = () => { + if (global.fs.focusElement[0]) { + if (global.fs.focusElement[0].type === 'folder' && global.fs.focusElement[0].key) return global.fs.focusElement[0].key + else if (global.fs.focusElement[0].type === 'gist' && global.fs.focusElement[0].key) return global.fs.focusElement[0].key + else if (global.fs.focusElement[0].type === 'file' && global.fs.focusElement[0].key) return extractParentFromKey(global.fs.focusElement[0].key) ? extractParentFromKey(global.fs.focusElement[0].key) : name + else return name + } + } + + const createNewFile = async (newFilePath: string) => { + try { + global.dispatchCreateNewFile(newFilePath, props.name) + } catch (error) { + return global.modal('File Creation Failed', typeof error === 'string' ? error : error.message, 'Close', async () => {}) + } + } + + const createNewFolder = async (newFolderPath: string) => { + try { + global.dispatchCreateNewFolder(newFolderPath, props.name) + } catch (e) { + return global.modal('Folder Creation Failed', typeof e === 'string' ? e : e.message, 'Close', async () => {}) + } + } + + const deletePath = async (path: string[]) => { + if (global.fs.readonly) return global.toast('cannot delete file. ' + name + ' is a read only explorer') + if (!Array.isArray(path)) path = [path] + + global.modal(`Delete ${path.length > 1 ? 'items' : 'item'}`, deleteMessage(path), 'OK', () => { global.dispatchDeletePath(path) }, 'Cancel', () => {}) + } + + const renamePath = async (oldPath: string, newPath: string) => { + try { + global.dispatchRenamePath(oldPath, newPath) + } catch (error) { + global.modal('Rename File Failed', 'Unexpected error while renaming: ' + typeof error === 'string' ? error : error.message, 'Close', async () => {}) + } + } + + const uploadFile = (target) => { + const parentFolder = getFocusedFolder() + const expandPath = [...new Set([...state.expandPath, parentFolder])] + + setState(prevState => { + return { ...prevState, expandPath } + }) + global.dispatchUploadFile(target, parentFolder) + } + + const copyFile = (src: string, dest: string) => { + try { + global.dispatchCopyFile(src, dest) + } catch (error) { + global.modal('Copy File Failed', 'Unexpected error while copying file: ' + src, 'Close', async () => {}) + } + } + + const copyFolder = (src: string, dest: string) => { + try { + global.dispatchCopyFolder(src, dest) + } catch (error) { + global.modal('Copy Folder Failed', 'Unexpected error while copying folder: ' + src, 'Close', async () => {}) + } + } + + const publishToGist = (path?: string, type?: string) => { + global.modal('Create a public gist', `Are you sure you want to anonymously publish all your files in the ${name} workspace as a public gist on github.com?`, 'OK', () => toGist(path, type), 'Cancel', () => {}) + } + + const pushChangesToGist = (path?: string, type?: string) => { + global.modal('Create a public gist', 'Are you sure you want to push changes to remote gist file on github.com?', 'OK', () => toGist(path, type), 'Cancel', () => {}) + } + + const publishFolderToGist = (path?: string, type?: string) => { + global.modal('Create a public gist', `Are you sure you want to anonymously publish all your files in the ${path} folder as a public gist on github.com?`, 'OK', () => toGist(path, type), 'Cancel', () => {}) + } + + const publishFileToGist = (path?: string, type?: string) => { + global.modal('Create a public gist', `Are you sure you want to anonymously publish ${path} file as a public gist on github.com?`, 'OK', () => toGist(path, type), 'Cancel', () => {}) + } + + const toGist = (path?: string, type?: string) => { + global.dispatchPublishToGist(path, type) + } + + const runScript = async (path: string) => { + try { + global.dispatchRunScript(path) + } catch (error) { + global.toast('Run script failed') + } + } + + const emitContextMenuEvent = (cmd: customAction) => { + try { + global.dispatchEmitContextMenuEvent(cmd) + } catch (error) { + global.toast(error) + } + } + + const handleClickFile = (path: string, type: 'folder' | 'file' | 'gist') => { + path = path.indexOf(props.name + '/') === 0 ? path.replace(props.name + '/', '') : path + if (!state.ctrlKey) { + global.dispatchHandleClickFile(path, type) + } else { + if (global.fs.focusElement.findIndex(item => item.key === path) !== -1) { + const focusElement = global.fs.focusElement.filter(item => item.key !== path) + + global.dispatchSetFocusElement(focusElement) + } else { + const nonRootFocus = global.fs.focusElement.filter((el) => { return !(el.key === '' && el.type === 'folder') }) + + nonRootFocus.push({ key: path, type }) + global.dispatchSetFocusElement(nonRootFocus) + } + } + } + + const handleClickFolder = async (path: string, type: 'folder' | 'file' | 'gist') => { + if (state.ctrlKey) { + if (global.fs.focusElement.findIndex(item => item.key === path) !== -1) { + const focusElement = global.fs.focusElement.filter(item => item.key !== path) + + global.dispatchSetFocusElement(focusElement) + } else { + const nonRootFocus = global.fs.focusElement.filter((el) => { return !(el.key === '' && el.type === 'folder') }) + + nonRootFocus.push({ key: path, type }) + global.dispatchSetFocusElement(nonRootFocus) + } + } else { + let expandPath = [] + + if (!state.expandPath.includes(path)) { + expandPath = [...new Set([...state.expandPath, path])] + global.dispatchFetchDirectory(path) + } else { + expandPath = [...new Set(state.expandPath.filter(key => key && (typeof key === 'string') && !key.startsWith(path)))] + } + + global.dispatchSetFocusElement([{ key: path, type }]) + setState(prevState => { + return { ...prevState, expandPath } + }) + } + } + + const handleContextMenuFile = (pageX: number, pageY: number, path: string, content: string, type: string) => { + if (!content) return + setState(prevState => { + return { ...prevState, focusContext: { element: path, x: pageX, y: pageY, type }, focusEdit: { ...prevState.focusEdit, lastEdit: content }, showContextMenu: prevState.focusEdit.element !== path } + }) + } + + const handleContextMenuFolder = (pageX: number, pageY: number, path: string, content: string, type: string) => { + if (!content) return + setState(prevState => { + return { ...prevState, focusContext: { element: path, x: pageX, y: pageY, type }, focusEdit: { ...prevState.focusEdit, lastEdit: content }, showContextMenu: prevState.focusEdit.element !== path } + }) + } + + const hideContextMenu = () => { + setState(prevState => { + return { ...prevState, focusContext: { element: null, x: 0, y: 0, type: '' }, showContextMenu: false } + }) + } + + const editModeOn = (path: string, type: string, isNew: boolean = false) => { + if (global.fs.readonly) return + setState(prevState => { + return { ...prevState, focusEdit: { ...prevState.focusEdit, element: path, isNew, type } } + }) + } + + const editModeOff = async (content: string) => { + if (typeof content === 'string') content = content.trim() + const parentFolder = extractParentFromKey(state.focusEdit.element) + + if (!content || (content.trim() === '')) { + if (state.focusEdit.isNew) { + global.dispatchRemoveInputField(parentFolder) + setState(prevState => { + return { ...prevState, focusEdit: { element: null, isNew: false, type: '', lastEdit: '' } } + }) + } else { + editRef.current.textContent = state.focusEdit.lastEdit + setState(prevState => { + return { ...prevState, focusEdit: { element: null, isNew: false, type: '', lastEdit: '' } } + }) + } + } else { + if (state.focusEdit.lastEdit === content) { + editRef.current.textContent = content + return setState(prevState => { + return { ...prevState, focusEdit: { element: null, isNew: false, type: '', lastEdit: '' } } + }) + } + if (checkSpecialChars(content)) { + global.modal('Validation Error', 'Special characters are not allowed', 'OK', () => {}) + } else { + if (state.focusEdit.isNew) { + if (hasReservedKeyword(content)) { + global.dispatchRemoveInputField(parentFolder) + global.modal('Reserved Keyword', `File name contains remix reserved keywords. '${content}'`, 'Close', () => {}) + } else { + state.focusEdit.type === 'file' ? createNewFile(joinPath(parentFolder, content)) : createNewFolder(joinPath(parentFolder, content)) + global.dispatchRemoveInputField(parentFolder) + } + } else { + if (hasReservedKeyword(content)) { + editRef.current.textContent = state.focusEdit.lastEdit + global.modal('Reserved Keyword', `File name contains remix reserved keywords. '${content}'`, 'Close', () => {}) + } else { + const oldPath: string = state.focusEdit.element + const oldName = extractNameFromKey(oldPath) + const newPath = oldPath.replace(oldName, content) + + editRef.current.textContent = extractNameFromKey(oldPath) + renamePath(oldPath, newPath) + } + } + setState(prevState => { + return { ...prevState, focusEdit: { element: null, isNew: false, type: '', lastEdit: '' } } + }) + } + } + } + + const handleNewFileInput = async (parentFolder?: string) => { + if (!parentFolder) parentFolder = getFocusedFolder() + const expandPath = [...new Set([...state.expandPath, parentFolder])] + + await global.dispatchAddInputField(parentFolder, 'file') + setState(prevState => { + return { ...prevState, expandPath } + }) + editModeOn(parentFolder + '/blank', 'file', true) + } + + const handleNewFolderInput = async (parentFolder?: string) => { + if (!parentFolder) parentFolder = getFocusedFolder() + else if ((parentFolder.indexOf('.sol') !== -1) || (parentFolder.indexOf('.js') !== -1)) parentFolder = extractParentFromKey(parentFolder) + const expandPath = [...new Set([...state.expandPath, parentFolder])] + + await global.dispatchAddInputField(parentFolder, 'folder') + setState(prevState => { + return { ...prevState, expandPath } + }) + editModeOn(parentFolder + '/blank', 'folder', true) + } + + const handleEditInput = (event) => { + if (event.which === 13) { + event.preventDefault() + editModeOff(editRef.current.innerText) + } + } + + const handleMouseOver = (path: string) => { + setState(prevState => { + return { ...prevState, mouseOverElement: path } + }) + } + + const handleMouseOut = () => { + setState(prevState => { + return { ...prevState, mouseOverElement: null } + }) + } + + const handleCopyClick = (path: string, type: 'folder' | 'gist' | 'file') => { + setState(prevState => { + return { ...prevState, copyElement: [{ key: path, type }] } + }) + setCanPaste(true) + global.toast(`Copied to clipboard ${path}`) + } + + const handlePasteClick = (dest: string, destType: string) => { + dest = destType === 'file' ? extractParentFromKey(dest) || props.name : dest + state.copyElement.map(({ key, type }) => { + type === 'file' ? copyFile(key, dest) : copyFolder(key, dest) + }) + } + + const deleteMessage = (path: string[]) => { + return ( +
    +
    Are you sure you want to delete {path.length > 1 ? 'these items' : 'this item'}?
    + { + path.map((item, i) => (
  • {item}
  • )) + } +
    + ) + } + + const label = (file: File) => { + const isEditable = (state.focusEdit.element === file.path) || (global.fs.focusEdit === file.path) + + return ( +
    { + e.stopPropagation() + editModeOff(editRef.current.innerText) + }} + > + + { file.name } + +
    + ) + } + + 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' : global.fs.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 = getPathIcon(file.path) + const spreadProps = { + onClick: (e) => e.stopPropagation() + } + + if (file.isDirectory) { + return ( + { + e.stopPropagation() + if (state.focusEdit.element !== file.path) handleClickFolder(file.path, file.type) + }} + onContextMenu={(e) => { + e.preventDefault() + e.stopPropagation() + handleContextMenuFolder(e.pageX, e.pageY, file.path, e.target.textContent, file.type) + }} + labelClass={labelClass} + controlBehaviour={ state.ctrlKey } + expand={state.expandPath.includes(file.path)} + onMouseOver={(e) => { + e.stopPropagation() + handleMouseOver(file.path) + }} + onMouseOut={(e) => { + e.stopPropagation() + if (state.mouseOverElement === file.path) handleMouseOut() + }} + > + { + file.child ? { + Object.keys(file.child).map((key, index) => { + return renderFiles(file.child[key], index) + }) + } + : + } + + ) + } else { + return ( + { + e.stopPropagation() + if (state.focusEdit.element !== file.path) handleClickFile(file.path, file.type) + }} + onContextMenu={(e) => { + e.preventDefault() + e.stopPropagation() + handleContextMenuFile(e.pageX, e.pageY, file.path, e.target.textContent, file.type) + }} + icon={icon} + labelClass={labelClass} + onMouseOver={(e) => { + e.stopPropagation() + handleMouseOver(file.path) + }} + onMouseOut={(e) => { + e.stopPropagation() + if (state.mouseOverElement === file.path) handleMouseOut() + }} + /> + ) + } + } + + return ( +
    + + { + e.stopPropagation() + if (e && (e.target as any).getAttribute('data-id') === 'fileExplorerUploadFileuploadFile') return // we don't want to let propagate the input of type file + if (e && (e.target as any).getAttribute('data-id') === 'fileExplorerFileUpload') return // we don't want to let propagate the input of type file + let expandPath = [] + + if (!state.expandPath.includes(props.name)) { + expandPath = [props.name, ...new Set([...state.expandPath])] + } else { + expandPath = [...new Set(state.expandPath.filter(key => key && (typeof key === 'string') && !key.startsWith(props.name)))] + } + setState(prevState => { + return { ...prevState, expandPath } + }) + resetFocus(true) + }}> + +
    + } + expand={true}> +
    + + { + files[props.name] && Object.keys(files[props.name]).map((key, index) => { + return renderFiles(files[props.name][key], index) + }) + } + +
    +
    + + { state.showContextMenu && + 1 ? state.actions.filter(item => item.multiselect) : state.actions.filter(item => !item.multiselect)} + hideContextMenu={hideContextMenu} + createNewFile={handleNewFileInput} + createNewFolder={handleNewFolderInput} + deletePath={deletePath} + renamePath={editModeOn} + runScript={runScript} + copy={handleCopyClick} + paste={handlePasteClick} + emit={emitContextMenuEvent} + pageX={state.focusContext.x} + pageY={state.focusContext.y} + path={state.focusContext.element} + type={state.focusContext.type} + focus={global.fs.focusElement} + onMouseOver={(e) => { + e.stopPropagation() + handleMouseOver(state.focusContext.element) + }} + pushChangesToGist={pushChangesToGist} + publishFolderToGist={publishFolderToGist} + publishFileToGist={publishFileToGist} + /> + } + + ) +} + +export default FileExplorer diff --git a/libs/remix-ui/workspace/src/lib/css/file-explorer-context-menu.css b/libs/remix-ui/workspace/src/lib/css/file-explorer-context-menu.css new file mode 100644 index 0000000000..f88c8825a8 --- /dev/null +++ b/libs/remix-ui/workspace/src/lib/css/file-explorer-context-menu.css @@ -0,0 +1,28 @@ +.remixui_contextContainer +{ + display: block; + position: fixed; + border-radius: 2px; + z-index: 1000; + box-shadow: 0 0 4px var(--dark); +} +.remixui_contextContainer:focus { + outline: none; +} +.remixui_liitem +{ + padding: 2px; + padding-left: 6px; + cursor: pointer; + color: var(--text-dark); + background-color: var(--light); +} +.remixui_liitem:hover +{ + background-color: var(--secondary); +} +#remixui_menuitems +{ + list-style: none; + margin: 0px; +} \ No newline at end of file diff --git a/libs/remix-ui/workspace/src/lib/css/file-explorer.css b/libs/remix-ui/workspace/src/lib/css/file-explorer.css new file mode 100644 index 0000000000..4da076bc44 --- /dev/null +++ b/libs/remix-ui/workspace/src/lib/css/file-explorer.css @@ -0,0 +1,56 @@ +.remixui_label { + margin-top : 4px; +} +.remixui_leaf { + overflow : hidden; + text-overflow : ellipsis; + width : 90%; + margin-bottom : 0px; +} +.remixui_fileexplorer { + box-sizing : border-box; + user-select : none; +} +input[type="file"] { + display: none; +} +.remixui_folder, +.remixui_file { + font-size : 14px; + cursor : pointer; +} +.remixui_file { + padding : 4px; +} +.remixui_newFile { + padding-right : 10px; +} +.remixui_newFile i { + cursor : pointer; +} +.remixui_newFile:hover { + transform : scale(1.3); +} +.remixui_menu { + margin-left : 20px; +} +.remixui_items { + display : inline +} +.remixui_remove { + margin-left : auto; + padding-left : 5px; + padding-right : 5px; +} +.remixui_activeMode { + display : flex; + width : 100%; + margin-right : 10px; + padding-right : 19px; +} +.remixui_activeMode > div { + min-width : 10px; +} +ul { + padding : 0; +} diff --git a/libs/remix-ui/workspace/src/lib/css/remix-ui-workspace.css b/libs/remix-ui/workspace/src/lib/css/remix-ui-workspace.css new file mode 100644 index 0000000000..534da8bca6 --- /dev/null +++ b/libs/remix-ui/workspace/src/lib/css/remix-ui-workspace.css @@ -0,0 +1,61 @@ +.remixui_container { + display : flex; + flex-direction : row; + width : 100%; + height : 100%; + box-sizing : border-box; + } + .remixui_fileexplorer { + display : flex; + flex-direction : column; + position : relative; + width : 100%; + padding-left : 6px; + padding-right : 6px; + padding-top : 6px; + } + .remixui_fileExplorerTree { + cursor : default; + } + .remixui_gist { + padding : 10px; + } + .remixui_gist i { + cursor : pointer; + } + .remixui_gist i:hover { + color : orange; + } + .remixui_connectToLocalhost { + padding : 10px; + } + .remixui_connectToLocalhost i { + cursor : pointer; + } + .remixui_connectToLocalhost i:hover { + color : var(--secondary) + } + .remixui_uploadFile { + padding : 10px; + } + .remixui_uploadFile label:hover { + color : var(--secondary) + } + .remixui_uploadFile label { + cursor : pointer; + } + .remixui_treeview { + overflow-y : auto; + } + .remixui_dialog { + display: flex; + flex-direction: column; + } + .remixui_dialogParagraph { + margin-bottom: 2em; + word-break: break-word; + } + .remixui_menuicon { + padding-right : 10px; + } + \ No newline at end of file diff --git a/libs/remix-ui/workspace/src/lib/providers/FileSystemProvider.tsx b/libs/remix-ui/workspace/src/lib/providers/FileSystemProvider.tsx index e3f8bf7db6..57742ad834 100644 --- a/libs/remix-ui/workspace/src/lib/providers/FileSystemProvider.tsx +++ b/libs/remix-ui/workspace/src/lib/providers/FileSystemProvider.tsx @@ -104,11 +104,11 @@ export const FileSystemProvider = (props: WorkspaceProps) => { } const dispatchEmitContextMenuEvent = async (cmd: customAction) => { - await emitContextMenuEvent(cmd) + await emitContextMenuEvent(cmd)() } const dispatchHandleClickFile = async (path: string, type: 'file' | 'folder' | 'gist') => { - await handleClickFile(path, type) + await handleClickFile(path, type)(fsDispatch) } useEffect(() => { diff --git a/libs/remix-ui/workspace/src/lib/types/index.ts b/libs/remix-ui/workspace/src/lib/types/index.ts index bc6662f872..74e3ed477e 100644 --- a/libs/remix-ui/workspace/src/lib/types/index.ts +++ b/libs/remix-ui/workspace/src/lib/types/index.ts @@ -1,4 +1,4 @@ -import { MenuItems } from '@remix-ui/file-explorer' +import { customAction } from '@remixproject/plugin-api/lib/file-system/file-panel' export interface WorkspaceProps { plugin: { setWorkspace: ({ name: string, isLocalhost: boolean }, setEvent: boolean) => void, @@ -53,3 +53,86 @@ export interface File { type: 'folder' | 'file' | 'gist', child?: File[] } + +/* eslint-disable-next-line */ +export interface FileExplorerProps { + name: string, + menuItems?: string[], + focusRoot: boolean, + contextMenuItems: MenuItems, + removedContextMenuItems: MenuItems, + displayInput?: boolean, + externalUploads?: EventTarget & HTMLInputElement, + resetFocus?: (value: boolean) => void, + files: { [x: string]: Record } +} + +export interface FileExplorerMenuProps { + title: string, + menuItems: string[], + createNewFile: (folder?: string) => void, + createNewFolder: (parentFolder?: string) => void, + publishToGist: (path?: string) => void, + uploadFile: (target: EventTarget & HTMLInputElement) => void +} + +export type action = { name: string, type?: Array<'folder' | 'gist' | 'file'>, path?: string[], extension?: string[], pattern?: string[], id: string, multiselect: boolean, label: string } + +export type MenuItems = action[] +export interface FileExplorerContextMenuProps { + actions: action[], + createNewFile: (folder?: string) => void, + createNewFolder: (parentFolder?: string) => void, + deletePath: (path: string | string[]) => void, + renamePath: (path: string, type: string) => void, + hideContextMenu: () => void, + publishToGist?: (path?: string, type?: string) => void, + pushChangesToGist?: (path?: string, type?: string) => void, + publishFolderToGist?: (path?: string, type?: string) => void, + publishFileToGist?: (path?: string, type?: string) => void, + runScript?: (path: string) => void, + emit?: (cmd: customAction) => void, + pageX: number, + pageY: number, + path: string, + type: string, + focus: {key:string, type:string}[], + onMouseOver?: (...args) => void, + copy?: (path: string, type: string) => void, + paste?: (destination: string, type: string) => void +} + +export interface FileExplorerState { + ctrlKey: boolean + newFileName: string + actions: { + id: string + name: string + type?: Array<'folder' | 'gist' | 'file'> + path?: string[] + extension?: string[] + pattern?: string[] + multiselect: boolean + label: string + }[] + focusContext: { + element: string + x: number + y: number + type: string + } + focusEdit: { + element: string + type: string + isNew: boolean + lastEdit: string + } + expandPath: string[] + mouseOverElement: string + showContextMenu: boolean + reservedKeywords: string[] + copyElement: { + key: string + type: 'folder' | 'gist' | 'file' + }[] + } diff --git a/libs/remix-ui/workspace/src/lib/utils/index.ts b/libs/remix-ui/workspace/src/lib/utils/index.ts new file mode 100644 index 0000000000..a39a4be7b4 --- /dev/null +++ b/libs/remix-ui/workspace/src/lib/utils/index.ts @@ -0,0 +1,61 @@ +export const contextMenuActions: MenuItems = [{ + id: 'newFile', + name: 'New File', + type: ['folder', 'gist'], + multiselect: false, + label: '' +}, { + id: 'newFolder', + name: 'New Folder', + type: ['folder', 'gist'], + multiselect: false, + label: '' +}, { + id: 'rename', + name: 'Rename', + type: ['file', 'folder'], + multiselect: false, + label: '' +}, { + id: 'delete', + name: 'Delete', + type: ['file', 'folder', 'gist'], + multiselect: false, + label: '' +}, { + id: 'run', + name: 'Run', + extension: ['.js'], + multiselect: false, + label: '' +}, { + id: 'pushChangesToGist', + name: 'Push changes to gist', + type: ['gist'], + multiselect: false, + label: '' +}, { + id: 'publishFolderToGist', + name: 'Publish folder to gist', + type: ['folder'], + multiselect: false, + label: '' +}, { + id: 'publishFileToGist', + name: 'Publish file to gist', + type: ['file'], + multiselect: false, + label: '' +}, { + id: 'copy', + name: 'Copy', + type: ['folder', 'file'], + multiselect: false, + label: '' +}, { + id: 'deleteAll', + name: 'Delete All', + type: ['folder', 'file'], + multiselect: true, + label: '' +}]