feat: add CRUD to file explorer

This commit is contained in:
Anthony Laibe 2018-11-07 14:21:17 +00:00
parent c8d6f18648
commit f82d3de1b0
14 changed files with 389 additions and 77 deletions

View File

@ -293,10 +293,17 @@ export const saveFile = {
failure: (error) => action(SAVE_FILE[FAILURE], {error}) failure: (error) => action(SAVE_FILE[FAILURE], {error})
}; };
export const SAVE_FOLDER = createRequestTypes('SAVE_FOLDER');
export const saveFolder = {
request: ({path}) => action(SAVE_FOLDER[REQUEST], {path}),
success: () => action(SAVE_FOLDER[SUCCESS]),
failure: (error) => action(SAVE_FOLDER[FAILURE], {error})
};
export const REMOVE_FILE = createRequestTypes('REMOVE_FILE'); export const REMOVE_FILE = createRequestTypes('REMOVE_FILE');
export const removeFile = { export const removeFile = {
request: ({name, path, content}) => action(REMOVE_FILE[REQUEST], {name, path, content}), request: ({name, path, content}) => action(REMOVE_FILE[REQUEST], {name, path, content}),
success: () => action(REMOVE_FILE[SUCCESS]), success: (_, file) => action(REMOVE_FILE[SUCCESS], {file}),
failure: (error) => action(REMOVE_FILE[FAILURE], {error}) failure: (error) => action(REMOVE_FILE[FAILURE], {error})
}; };

View File

@ -0,0 +1,51 @@
import React from 'react';
import {Button, Modal, ModalHeader, ModalBody, ModalFooter, Input} from 'reactstrap';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {isDarkTheme} from '../utils/utils';
class AddFileModal extends React.Component {
constructor(props) {
super(props)
this.state = {modal: false, filename: ''};
}
toggle() {
this.setState({modal: !this.state.modal});
}
handleChange(event) {
this.setState({filename: event.target.value});
}
addFile() {
this.props.saveFile({path: `${this.props.node.path}/${this.state.filename}`, content: ''});
this.toggle();
}
render() {
return (
<Modal contentClassName={classNames({'dark-theme': isDarkTheme(this.props.theme)})}
isOpen={this.state.modal}
toggle={() => this.toggle()}>
<ModalHeader toggle={() => this.toggle()}>Please give the file a name</ModalHeader>
<ModalBody>
<Input autofocus="true" value={this.state.filename} onChange={e => this.handleChange(e)} />
</ModalBody>
<ModalFooter>
<Button color="primary" onClick={() => this.addFile()}>Add File</Button>{' '}
<Button color="secondary" onClick={() => this.toggle()}>Cancel</Button>
</ModalFooter>
</Modal>
)
}
}
AddFileModal.propTypes = {
saveFile: PropTypes.func,
node: PropTypes.object,
theme: PropTypes.string
};
export default AddFileModal;

View File

@ -0,0 +1,51 @@
import React from 'react';
import {Button, Modal, ModalHeader, ModalBody, ModalFooter, Input} from 'reactstrap';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {isDarkTheme} from '../utils/utils';
class AddFolderModal extends React.Component {
constructor(props) {
super(props)
this.state = {modal: false, folder: ''};
}
toggle() {
this.setState({modal: !this.state.modal});
}
handleChange(event) {
this.setState({folder: event.target.value});
}
addFolder() {
this.props.saveFolder({path: `${this.props.node.path}/${this.state.folder}`});
this.toggle();
}
render() {
return (
<Modal contentClassName={classNames({'dark-theme': isDarkTheme(this.props.theme)})}
isOpen={this.state.modal}
toggle={() => this.toggle()}>
<ModalHeader toggle={() => this.toggle()}>Please give the folder a name</ModalHeader>
<ModalBody>
<Input autofocus="true" value={this.state.filename} onChange={e => this.handleChange(e)} />
</ModalBody>
<ModalFooter>
<Button color="primary" onClick={() => this.addFolder()}>Add Folder</Button>{' '}
<Button color="secondary" onClick={() => this.toggle()}>Cancel</Button>
</ModalFooter>
</Modal>
)
}
}
AddFolderModal.propTypes = {
saveFolder: PropTypes.func,
node: PropTypes.object,
theme: PropTypes.string
};
export default AddFolderModal;

View File

@ -1,12 +1,12 @@
import React from 'react';
import {AppSwitch} from '@coreui/react'; import {AppSwitch} from '@coreui/react';
import {Label} from 'reactstrap'; import {Label} from 'reactstrap';
import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {Treebeard, decorators} from 'react-treebeard'; import {Treebeard, decorators} from 'react-treebeard';
import classNames from 'classnames'; import classNames from 'classnames';
import {DARK_THEME} from '../constants';
const isDarkTheme= (theme) => theme === DARK_THEME; import FileExplorerRowContainer from '../containers/FileExplorerRowContainer';
import {isDarkTheme} from '../utils/utils';
const style = (theme) => ({ const style = (theme) => ({
tree: { tree: {
@ -77,67 +77,73 @@ const style = (theme) => ({
} }
} }
}); });
class Header extends React.Component {
resolveIcon() {
let icon;
let {node} = this.props;
if (!node.children) {
const Header = ({style, node}) => { const extension = node.path.split('.').pop();
let icon; switch(extension) {
case 'html':
if (!node.children) { icon = 'text-danger fa fa-html5';
const extension = node.path.split('.').pop(); break;
switch(extension) { case 'css':
case 'html': icon = 'text-warning fa fa-css3';
icon = 'text-danger fa fa-html5'; break;
break; case 'js':
case 'css': case 'jsx':
icon = 'text-warning fa fa-css3'; icon = 'text-primary icon js-icon';
break; break;
case 'js': case 'json':
case 'jsx': icon = 'text-success icon hjson-icon';
icon = 'text-primary icon js-icon'; break;
break; case 'sol':
case 'json': icon = 'text-warning icon solidity-icon';
icon = 'text-success icon hjson-icon'; break;
break; default:
case 'sol': icon = 'fa fa-file-o';
icon = 'text-warning icon solidity-icon'; }
break; } else {
default: switch(node.name) {
icon = 'fa fa-file-o'; case 'dist':
} icon = 'text-danger icon easybuild-icon';
} else { break;
switch(node.name) { case 'config':
case 'dist': icon = 'text-warning fa fa-cogs';
icon = 'text-danger icon easybuild-icon'; break;
break; case 'contracts':
case 'config': icon = 'text-success fa fa-file-text';
icon = 'text-warning fa fa-cogs'; break;
break; case 'app':
case 'contracts': icon = 'text-primary fa fa-code';
icon = 'text-success fa fa-file-text'; break;
break; case 'test':
case 'app': icon = 'icon test-dir-icon';
icon = 'text-primary fa fa-code'; break;
break; case 'node_modules':
case 'test': icon = 'fa fa-folder-o';
icon = 'icon test-dir-icon'; break;
break; default:
case 'node_modules': icon = 'fa fa-folder';
icon = 'fa fa-folder-o'; }
break;
default:
icon = 'fa fa-folder';
} }
return icon;
} }
return ( render() {
<div className="mb-1" style={style.base}> let {node, style} = this.props;
<div style={style.title}> return (
<i className={classNames('mr-1', icon)} /> <div className="mb-1 d-inline-block"
{node.name} style={style.base}>
<div style={style.title}>
<i className={classNames('mr-1', this.resolveIcon())} />
{node.name}
</div>
</div> </div>
</div> );
); }
}; };
Header.propTypes = { Header.propTypes = {
@ -146,6 +152,7 @@ Header.propTypes = {
}; };
decorators.Header = Header; decorators.Header = Header;
decorators.Container = FileExplorerRowContainer;
class FileExplorer extends React.Component { class FileExplorer extends React.Component {
constructor(props) { constructor(props) {

View File

@ -4,6 +4,9 @@ import {Button, Nav, NavLink} from 'reactstrap';
import classnames from 'classnames'; import classnames from 'classnames';
import FontAwesomeIcon from 'react-fontawesome'; import FontAwesomeIcon from 'react-fontawesome';
import AddFileModal from '../components/AddFileModal';
import AddFolderModal from '../components/AddFolderModal';
export const TextEditorToolbarTabs = { export const TextEditorToolbarTabs = {
Interact: { label: 'Interact', icon: 'bolt' }, Interact: { label: 'Interact', icon: 'bolt' },
Details: { label: 'Details', icon: 'info-circle' }, Details: { label: 'Details', icon: 'info-circle' },
@ -13,6 +16,11 @@ export const TextEditorToolbarTabs = {
}; };
class TextEditorToolbar extends Component { class TextEditorToolbar extends Component {
constructor(props) {
super(props);
this.addFileModal = React.createRef();
this.addFolderModal = React.createRef();
}
isActiveTab(tab) { isActiveTab(tab) {
return this.props.activeTab === tab; return this.props.activeTab === tab;
@ -38,6 +46,16 @@ class TextEditorToolbar extends Component {
return ( return (
<ol className="breadcrumb mb-0"> <ol className="breadcrumb mb-0">
<li className="breadcrumb-item"> <li className="breadcrumb-item">
<Button color="success" size="sm" className="mr-1" onClick={() => this.addFileModal.current.toggle()}>
<FontAwesomeIcon className="mr-2" name="plus"/>
Add File
</Button>
<AddFileModal theme={this.props.theme} node={{path: this.props.rootDirname}} saveFile={this.props.saveFile} ref={this.addFileModal} />
<Button color="success" size="sm" className="mr-1" onClick={() => this.addFolderModal.current.toggle()}>
<FontAwesomeIcon className="mr-2" name="folder-open"/>
Add Folder
</Button>
<AddFolderModal theme={this.props.theme} node={{path: this.props.rootDirname}} saveFolder={this.props.saveFolder} ref={this.addFolderModal} />
<Button color="success" size="sm" className="mr-1" onClick={this.props.save}> <Button color="success" size="sm" className="mr-1" onClick={this.props.save}>
<FontAwesomeIcon className="mr-2" name="save"/> <FontAwesomeIcon className="mr-2" name="save"/>
Save Save
@ -61,7 +79,11 @@ class TextEditorToolbar extends Component {
TextEditorToolbar.propTypes = { TextEditorToolbar.propTypes = {
isContract: PropTypes.bool, isContract: PropTypes.bool,
theme: PropTypes.string,
save: PropTypes.func, save: PropTypes.func,
saveFile: PropTypes.func,
rootDirname: PropTypes.string,
saveFolder: PropTypes.func,
remove: PropTypes.func, remove: PropTypes.func,
toggleShowHiddenFiles: PropTypes.func, toggleShowHiddenFiles: PropTypes.func,
openAsideTab: PropTypes.func, openAsideTab: PropTypes.func,

View File

@ -2,7 +2,6 @@ import React, {Component} from 'react';
import {connect} from 'react-redux'; import {connect} from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {files as filesAction, file as fileAction} from "../actions"; import {files as filesAction, file as fileAction} from "../actions";
import FileExplorer from '../components/FileExplorer'; import FileExplorer from '../components/FileExplorer';
import DataWrapper from "../components/DataWrapper"; import DataWrapper from "../components/DataWrapper";
import {getFiles, getTheme} from "../reducers/selectors"; import {getFiles, getTheme} from "../reducers/selectors";
@ -14,13 +13,13 @@ class FileExplorerContainer extends Component {
render() { render() {
return ( return (
<DataWrapper shouldRender={this.props.files.length > 0} {...this.props} render={({files, fetchFile, showHiddenFiles, toggleShowHiddenFiles, theme}) => ( <DataWrapper shouldRender={this.props.files.length > 0} {...this.props} render={({files, fetchFile, showHiddenFiles, toggleShowHiddenFiles, theme}) => (
<FileExplorer files={files} <FileExplorer files={files}
fetchFile={fetchFile} fetchFile={fetchFile}
showHiddenFiles={showHiddenFiles} showHiddenFiles={showHiddenFiles}
toggleShowHiddenFiles={toggleShowHiddenFiles} toggleShowHiddenFiles={toggleShowHiddenFiles}
theme={theme} /> theme={theme} />
)} /> )} />
); );
} }
} }

View File

@ -0,0 +1,103 @@
import React from 'react';
import {UncontrolledTooltip} from 'reactstrap';
import {connect} from 'react-redux';
import PropTypes from 'prop-types';
import FontAwesome from 'react-fontawesome';
import {removeFile as removeFileAction, saveFile as saveFileAction, saveFolder as saveFolderAction} from '../actions';
import AddFileModal from '../components/AddFileModal';
import AddFolderModal from '../components/AddFolderModal';
import { getTheme } from '../reducers/selectors';
class FileExplorerRowContainer extends React.Component {
constructor(props) {
super(props)
this.state = {active: false};
this.addFileModal = React.createRef();
this.addFolderModal = React.createRef();
}
activateNode() {
this.setState({active : true});
}
deactivateNode() {
this.setState({active : false});
}
renderAction() {
return (
<span className="float-right mr-2">
{this.props.node.children &&
<React.Fragment>
<span id="add-file"
className="pointer"
onClick={() => this.addFileModal.current.toggle()}>
<FontAwesome name="plus" className="text-success mr-2" />
</span>
<span id="add-folder"
className="pointer"
onClick={() => this.addFolderModal.current.toggle()}>
<FontAwesome name="folder-open" className="text-success mr-2" />
</span>
<UncontrolledTooltip placement="bottom" target="add-file">
Add File
</UncontrolledTooltip>
<UncontrolledTooltip placement="bottom" target="add-folder">
Add Folder
</UncontrolledTooltip>
<AddFileModal theme={this.props.theme} node={this.props.node} saveFile={this.props.saveFile} ref={this.addFileModal} />
<AddFolderModal theme={this.props.theme} node={this.props.node} saveFolder={this.props.saveFolder} ref={this.addFolderModal} />
</React.Fragment>
}
<span id="delete"
style={{cursor: "pointer"}}
onClick={() => this.props.removeFile(this.props.node)}>
<FontAwesome name="trash" className="text-danger" />
</span>
<UncontrolledTooltip placement="bottom" target="delete">
Delete
</UncontrolledTooltip>
</span>
)
}
render() {
return (
<div style={this.props.style.container}
onMouseEnter={() => this.activateNode()}
onMouseLeave={() => this.deactivateNode()}>
<span onClick={this.props.onClick}>
<this.props.decorators.Toggle style={this.props.style.toggle}/>
<this.props.decorators.Header node={this.props.node} style={this.props.style.header}/>
</span>
{this.state.active && this.renderAction()}
</div>
)
}
}
FileExplorerRowContainer.propTypes = {
onClick: PropTypes.func,
removeFile: PropTypes.func,
saveFile: PropTypes.func,
saveFolder: PropTypes.func,
style: PropTypes.object,
node: PropTypes.object,
theme: PropTypes.string
};
const mapStateToProps = (state) => {
return {
theme: getTheme(state)
}
}
export default connect(
mapStateToProps,
{
removeFile: removeFileAction.request,
saveFile: saveFileAction.request,
saveFolder: saveFolderAction.request
}
)(FileExplorerRowContainer);

View File

@ -5,8 +5,10 @@ import TextEditorToolbar from '../components/TextEditorToolbar';
import { import {
saveFile as saveFileAction, saveFile as saveFileAction,
removeFile as removeFileAction removeFile as removeFileAction,
saveFolder as saveFolderAction
} from '../actions'; } from '../actions';
import { getRootDirname, getTheme } from '../reducers/selectors';
class TextEditorToolbarContainer extends Component { class TextEditorToolbarContainer extends Component {
save() { save() {
@ -22,6 +24,10 @@ class TextEditorToolbarContainer extends Component {
toggleShowHiddenFiles={this.props.toggleShowHiddenFiles} toggleShowHiddenFiles={this.props.toggleShowHiddenFiles}
openAsideTab={this.props.openAsideTab} openAsideTab={this.props.openAsideTab}
save={() => this.save()} save={() => this.save()}
saveFile={this.props.saveFile}
theme={this.props.theme}
saveFolder={this.props.saveFolder}
rootDirname={this.props.rootDirname}
remove={() => this.remove()} remove={() => this.remove()}
activeTab={this.props.activeTab} />; activeTab={this.props.activeTab} />;
} }
@ -29,18 +35,29 @@ class TextEditorToolbarContainer extends Component {
TextEditorToolbarContainer.propTypes = { TextEditorToolbarContainer.propTypes = {
currentFile: PropTypes.object, currentFile: PropTypes.object,
theme: PropTypes.string,
isContract: PropTypes.bool, isContract: PropTypes.bool,
saveFile: PropTypes.func, saveFile: PropTypes.func,
saveFolder: PropTypes.func,
removeFile: PropTypes.func, removeFile: PropTypes.func,
rootDirname: PropTypes.string,
toggleShowHiddenFiles: PropTypes.func, toggleShowHiddenFiles: PropTypes.func,
openAsideTab: PropTypes.func, openAsideTab: PropTypes.func,
activeTab: PropTypes.object activeTab: PropTypes.object
}; };
const mapStateToProps = (state) => {
return {
rootDirname: getRootDirname(state),
theme: getTheme(state)
}
}
export default connect( export default connect(
null, mapStateToProps,
{ {
saveFile: saveFileAction.request, saveFile: saveFileAction.request,
saveFolder: saveFolderAction.request,
removeFile: removeFileAction.request removeFile: removeFileAction.request
}, },
)(TextEditorToolbarContainer); )(TextEditorToolbarContainer);

View File

@ -81,3 +81,11 @@
.bg-black { .bg-black {
background-color: #1C1C1C; background-color: #1C1C1C;
} }
.dark-theme .modal-header span {
color: #ffffff;
}
.pointer {
cursor: pointer;
}

View File

@ -169,6 +169,10 @@ export function getFiles(state) {
return state.entities.files; return state.entities.files;
} }
export function getRootDirname(state) {
return state.entities.files[0] && state.entities.files[0].dirname;
}
export function getCurrentFile(state) { export function getCurrentFile(state) {
return state.editorTabs.find(file => file.active) || {}; return state.editorTabs.find(file => file.active) || {};
} }

View File

@ -67,6 +67,7 @@ export const postEnsRecord = doRequest.bind(null, actions.ensRecords, api.postEn
export const fetchFiles = doRequest.bind(null, actions.files, api.fetchFiles); export const fetchFiles = doRequest.bind(null, actions.files, api.fetchFiles);
export const fetchFile = doRequest.bind(null, actions.file, api.fetchFile); export const fetchFile = doRequest.bind(null, actions.file, api.fetchFile);
export const postFile = doRequest.bind(null, actions.saveFile, api.postFile); export const postFile = doRequest.bind(null, actions.saveFile, api.postFile);
export const postFolder = doRequest.bind(null, actions.saveFolder, api.postFolder);
export const deleteFile = doRequest.bind(null, actions.removeFile, api.deleteFile); export const deleteFile = doRequest.bind(null, actions.removeFile, api.deleteFile);
export const fetchEthGas = doRequest.bind(null, actions.gasOracle, api.getEthGasAPI); export const fetchEthGas = doRequest.bind(null, actions.gasOracle, api.getEthGasAPI);
export const startDebug = doRequest.bind(null, actions.startDebug, api.startDebug); export const startDebug = doRequest.bind(null, actions.startDebug, api.startDebug);
@ -209,9 +210,18 @@ export function *watchPostFile() {
} }
export function *watchPostFileSuccess() { export function *watchPostFileSuccess() {
yield takeEvery(actions.SAVE_FILE[actions.SUCCESS], fetchFiles);
yield takeEvery(actions.SAVE_FILE[actions.SUCCESS], addEditorTabs); yield takeEvery(actions.SAVE_FILE[actions.SUCCESS], addEditorTabs);
} }
export function *watchPostFolder() {
yield takeEvery(actions.SAVE_FOLDER[actions.REQUEST], postFolder);
}
export function *watchPostFolderSuccess() {
yield takeEvery(actions.SAVE_FOLDER[actions.SUCCESS], fetchFiles);
}
export function *watchDeleteFile() { export function *watchDeleteFile() {
yield takeEvery(actions.REMOVE_FILE[actions.REQUEST], deleteFile); yield takeEvery(actions.REMOVE_FILE[actions.REQUEST], deleteFile);
} }
@ -511,6 +521,7 @@ export default function *root() {
fork(watchFetchFiles), fork(watchFetchFiles),
fork(watchFetchFile), fork(watchFetchFile),
fork(watchPostFile), fork(watchPostFile),
fork(watchPostFolder),
fork(watchDeleteFile), fork(watchDeleteFile),
fork(watchDeleteFileSuccess), fork(watchDeleteFileSuccess),
fork(watchFetchFileSuccess), fork(watchFetchFileSuccess),
@ -543,6 +554,7 @@ export default function *root() {
fork(watchRemoveEditorTabs), fork(watchRemoveEditorTabs),
fork(watchAddEditorTabsSuccess), fork(watchAddEditorTabsSuccess),
fork(watchRemoveEditorTabsSuccess), fork(watchRemoveEditorTabsSuccess),
fork(watchPostFileSuccess) fork(watchPostFileSuccess),
fork(watchPostFolderSuccess)
]); ]);
} }

View File

@ -29,7 +29,7 @@ function request(type, path, params = {}) {
'X-Embark-Cnonce': cnonce 'X-Embark-Cnonce': cnonce
}, },
...(type === 'post' ? { data: params } : {}), ...(type === 'post' ? { data: params } : {}),
...(type === 'get' ? { params: params.params } : {}) ...(['get', 'delete'].includes(type) ? { params: params.params } : {})
} }
return axios(req) return axios(req)
@ -169,6 +169,10 @@ export function postFile() {
return post('/files', ...arguments); return post('/files', ...arguments);
} }
export function postFolder() {
return post('/folders', ...arguments);
}
export function deleteFile(payload) { export function deleteFile(payload) {
return destroy('/file', {params: payload, credentials: payload.credentials}); return destroy('/file', {params: payload, credentials: payload.credentials});
} }

View File

@ -1,6 +1,8 @@
import Convert from 'ansi-to-html'; import Convert from 'ansi-to-html';
import qs from 'qs'; import qs from 'qs';
import {DARK_THEME} from '../constants';
export function last(array) { export function last(array) {
return array && array.length ? array[array.length - 1] : undefined; return array && array.length ? array[array.length - 1] : undefined;
} }
@ -37,3 +39,5 @@ export function stripQueryToken(location) {
); );
return _location; return _location;
} }
export const isDarkTheme = (theme) => theme === DARK_THEME;

View File

@ -29,9 +29,12 @@ class Pipeline {
'get', 'get',
'/embark-api/file', '/embark-api/file',
(req, res) => { (req, res) => {
if (!fs.existsSync(req.query.path) || !req.query.path.startsWith(fs.dappPath())) { try {
return res.send({error: 'Path is invalid'}); this.apiGuardBadFile(req.query.path);
} catch (error) {
return res.send({error: error.message});
} }
const name = path.basename(req.query.path); const name = path.basename(req.query.path);
const content = fs.readFileSync(req.query.path, 'utf8'); const content = fs.readFileSync(req.query.path, 'utf8');
res.send({name, content, path: req.query.path}); res.send({name, content, path: req.query.path});
@ -39,6 +42,22 @@ class Pipeline {
} }
); );
plugin.registerAPICall(
'post',
'/embark-api/folders',
(req, res) => {
try {
this.apiGuardBadFile(req.body.path);
} catch (error) {
return res.send({error: error.message});
}
fs.mkdirpSync(req.body.path);
const name = path.basename(req.body.path);
res.send({name, path: req.body.path});
}
);
plugin.registerAPICall( plugin.registerAPICall(
'post', 'post',
'/embark-api/files', '/embark-api/files',
@ -60,7 +79,7 @@ class Pipeline {
'/embark-api/file', '/embark-api/file',
(req, res) => { (req, res) => {
try { try {
this.apiGuardBadFile(req.query.path); this.apiGuardBadFile(req.query.path, {ensureExists: true});
} catch (error) { } catch (error) {
return res.send({error: error.message}); return res.send({error: error.message});
} }
@ -100,10 +119,14 @@ class Pipeline {
); );
} }
apiGuardBadFile(pathToCheck) { apiGuardBadFile(pathToCheck, options = {ensureExists: false}) {
const dir = path.dirname(pathToCheck); const dir = path.dirname(pathToCheck);
if (!fs.existsSync(pathToCheck) || !dir.startsWith(fs.dappPath())) { const error = new Error('Path is invalid');
throw new Error('Path is invalid'); if (options.ensureExists && !fs.existsSync(pathToCheck)) {
throw error;
}
if (!dir.startsWith(fs.dappPath())) {
throw error;
} }
} }