diff --git a/src/components/ContentWrapFiles.jsx b/src/components/ContentWrapFiles.jsx index 079f8e2..dc8751b 100644 --- a/src/components/ContentWrapFiles.jsx +++ b/src/components/ContentWrapFiles.jsx @@ -1,7 +1,10 @@ import { h, Component } from 'preact'; import UserCodeMirror from './UserCodeMirror'; import { modes, HtmlModes, CssModes, JsModes } from '../codeModes'; -import { log, loadJS, linearizeFiles } from '../utils'; +import { log, loadJS } from '../utils'; + +import { linearizeFiles, assignFilePaths } from '../fileUtils'; + import { SplitPane } from './SplitPane'; import { trackEvent } from '../analytics'; import CodeMirror from '../CodeMirror'; @@ -161,17 +164,6 @@ export default class ContentWrapFiles extends Component { }, this.updateDelay); } - constructFilePaths(files, parentPath = '/user') { - files.forEach(file => { - if (file.isFolder) { - this.constructFilePaths(file.children, `${parentPath}/${file.name}`); - } else { - file.path = `${parentPath}/${file.name}`; - } - }); - return files; - } - createPreviewFile(html, css, js) { // Track if people have written code. if (!trackEvent.hasTrackedCode && (html || css || js)) { @@ -183,7 +175,7 @@ export default class ContentWrapFiles extends Component { const duplicateFiles = JSON.parse( JSON.stringify(this.props.currentItem.files) ); - const files = linearizeFiles(this.constructFilePaths(duplicateFiles)); + const files = linearizeFiles(assignFilePaths(duplicateFiles, '/user')); files.forEach(file => { obj[file.path] = file.content || ''; @@ -589,6 +581,7 @@ export default class ContentWrapFiles extends Component { onAddFile={this.props.onAddFile} onRemoveFile={this.props.onRemoveFile} onRenameFile={this.props.onRenameFile} + onFileDrop={this.props.onFileDrop} />
diff --git a/src/components/SidePane.jsx b/src/components/SidePane.jsx index 7c0035e..692f122 100644 --- a/src/components/SidePane.jsx +++ b/src/components/SidePane.jsx @@ -3,83 +3,86 @@ const ENTER_KEY = 13; const ESCAPE_KEY = 27; function FileIcon({ file }) { + let path; if (file.isFolder) { - return ( - - {file.isCollapsed ? ( - - ) : ( - - )} - - ); - } - const type = file.name.match(/.(\w+)$/)[1]; - switch (type) { - case 'html': - return ( - + if (!file.children.length) { + path = ( + + ); + } else { + path = file.isCollapsed ? ( + + ) : ( + + ); + } + } else { + const type = file.name.match(/.(\w+)$/)[1]; + switch (type) { + case 'html': + path = ( - - ); - /* return ( - - - - ); */ - case 'js': - return ( - + ); + break; + + case 'js': + path = ( - - ); - case 'css': - return ( - + ); + break; + + case 'css': + path = ( - - ); - case 'md': - case 'markdown': - return ( - + ); + break; + + case 'md': + case 'markdown': + path = ( - - ); - case 'jpg': - case 'jpeg': - case 'svg': - case 'png': - return ( - + ); + break; + + case 'jpg': + case 'jpeg': + case 'svg': + case 'png': + path = ( - - ); + ); + break; + } } + return ( + + {path} + + ); } function File({ @@ -90,7 +93,8 @@ function File({ onRenameBtnClick, onRemoveBtnClick, onNameInputBlur, - onNameInputKeyUp + onNameInputKeyUp, + onFileDrop }) { function focusInput(el) { el && @@ -98,8 +102,36 @@ function File({ el.focus(); }, 1); } + function dragStartHandler(e) { + console.log(file.path); + e.dataTransfer.setData('text/plain', file.path); + } + function dragOverHandler(e) { + if (file.isFolder) { + e.preventDefault(); + e.target.classList.add('is-being-dragged-over'); + e.target.style.outline = '1px dashed'; + } + } + function dragLeaveHandler(e) { + if (file.isFolder) { + e.preventDefault(); + e.target.style.outline = null; + } + } + function dropHandler(e) { + if (file.isFolder) { + e.preventDefault(); + onFileDrop(e.dataTransfer.getData('text/plain'), file); + e.target.style.outline = null; + } + } return ( -
+
{file === fileBeingRenamed ? (
diff --git a/src/components/UserCodeMirror.jsx b/src/components/UserCodeMirror.jsx index 102e4e1..ed58f34 100644 --- a/src/components/UserCodeMirror.jsx +++ b/src/components/UserCodeMirror.jsx @@ -74,7 +74,6 @@ export default class UserCodeMirror extends Component { initEditor() { const { options, prefs } = this.props; - console.log(100, options); this.cm = CodeMirror.fromTextArea(this.textarea, { mode: options.mode, lineNumbers: true, diff --git a/src/components/app.jsx b/src/components/app.jsx index 8ce933c..407afdc 100644 --- a/src/components/app.jsx +++ b/src/components/app.jsx @@ -20,9 +20,14 @@ import { handleDownloadsPermission, downloadFile, getCompleteHtml, - getFilenameFromUrl, - linearizeFiles + getFilenameFromUrl } from '../utils'; +import { + linearizeFiles, + assignFilePaths, + getFileFromPath, + removeFileAtPath +} from '../fileUtils'; import { itemService } from '../itemService'; import '../db'; import { Notifications } from './Notifications'; @@ -284,15 +289,20 @@ export default class App extends Component { if (isFileMode) { item = { ...item, - files: [ + files: assignFilePaths([ { name: 'index.html', content: '' }, { name: 'styles', isFolder: true, children: [{ name: 'style.css', content: '' }] }, - { name: 'script.js', content: '' } - ] + { name: 'script.js', content: '' }, + { + name: 'tempo', + isFolder: true, + children: [{ name: 'main.css', content: '' }] + } + ]) }; } else { item = { @@ -1198,13 +1208,12 @@ export default class App extends Component { isCollapsed: true }; } - - this.setState({ - currentItem: { - ...this.state.currentItem, - files: [...this.state.currentItem.files, newEntry] - } - }); + let currentItem = { + ...this.state.currentItem, + files: [...this.state.currentItem.files, newEntry] + }; + assignFilePaths(currentItem.files); + this.setState({ currentItem }); } removeFileHandler(fileToRemove) { this.setState({ @@ -1217,17 +1226,34 @@ export default class App extends Component { }); } renameFileHandler(oldFileName, newFileName) { - this.setState({ - currentItem: { - ...this.state.currentItem, - files: this.state.currentItem.files.map(file => { - if (file.name === oldFileName) { - return { ...file, name: newFileName }; - } - return file; - }) - } - }); + let currentItem = { + ...this.state.currentItem, + files: this.state.currentItem.files.map(file => { + if (file.name === oldFileName) { + return { ...file, name: newFileName }; + } + return file; + }) + }; + assignFilePaths(currentItem.files); + + this.setState({ currentItem }); + } + fileDropHandler(sourceFilePath, destinationFolder) { + let { currentItem } = this.state; + const { file } = getFileFromPath(currentItem.files, sourceFilePath); + + if (file) { + destinationFolder.children.push(file); + removeFileAtPath(currentItem.files, sourceFilePath); + + currentItem = { + ...currentItem, + files: [...currentItem.files] + }; + assignFilePaths(currentItem.files); + this.setState({ currentItem }); + } } folderSelectHandler(folder) { @@ -1278,6 +1304,7 @@ export default class App extends Component { onAddFile={this.addFileHandler.bind(this)} onRemoveFile={this.removeFileHandler.bind(this)} onRenameFile={this.renameFileHandler.bind(this)} + onFileDrop={this.fileDropHandler.bind(this)} onFolderSelect={this.folderSelectHandler.bind(this)} /> ) : ( diff --git a/src/fileUtils.js b/src/fileUtils.js new file mode 100644 index 0000000..f00842d --- /dev/null +++ b/src/fileUtils.js @@ -0,0 +1,87 @@ +import { deferred } from './deferred'; +const esprima = require('esprima'); + +/** + * Returns a linear file list from a nested file strcuture. + * It excludes the folders from the returned list. + * @param {array} files Nested file structure + */ +export function linearizeFiles(files) { + function reduceToLinearFiles(files) { + return files.reduce((list, currentFile) => { + if (currentFile.isFolder) { + return [...list, ...reduceToLinearFiles(currentFile.children)]; + } else { + return [...list, currentFile]; + } + }, []); + } + return reduceToLinearFiles(files); +} + +/** + * Recursively iterates and assigns the `path` property to the files in passed files + * array. + * @param {array} files files structure for an item + * @param {string} parentPath Parent path to prefix with all processed files + */ +export function assignFilePaths(files, parentPath = '') { + files.forEach(file => { + file.path = parentPath ? `${parentPath}/${file.name}` : file.name; + if (file.isFolder) { + assignFilePaths( + file.children, + parentPath ? `${parentPath}/${file.name}` : file.name + ); + } + }); + return files; +} + +/** + * Returns the file object and it's index that is direct child of passed files array with name as passed fileName. + * If not found, returns -1 + * @param {array} files files structure for an item + * @param {string} fileName File/folder name + */ +export function getChildFileFromName(files, fileName) { + const index = files.findIndex(file => file.name === fileName); + return { index, file: files[index] }; +} + +/** + * Returns the file object and it's index in its parent for the passed path. + * If not found, returns {index:-1} + * @param {array} files files structure for an item + * @param {string} path Path of file to search + */ +export function getFileFromPath(files, path) { + let currentFolder = files; + const pathPieces = path.split('/'); + while (pathPieces.length > 1) { + let folderName = pathPieces.shift(); + currentFolder = getChildFileFromName(currentFolder, folderName).file + .children; + } + // now we should be left with just one value in the pathPieces array - the actual file name + return getChildFileFromName(currentFolder, pathPieces[0]); +} + +/** + * Returns the file object and it's index in its parent for the passed path. + * If not found, returns {index:-1} + * @param {array} files files structure for an item + * @param {string} path Path of file to search + */ +export function removeFileAtPath(files, path) { + let currentFolder = files; + const pathPieces = path.split('/'); + while (pathPieces.length > 1) { + let folderName = pathPieces.shift(); + currentFolder = getChildFileFromName(currentFolder, folderName).file + .children; + } + // now we should be left with just one value in the pathPieces array - the actual file name + const { index } = getChildFileFromName(currentFolder, pathPieces[0]); + currentFolder.splice(index, 1); +} diff --git a/src/style.css b/src/style.css index 7a98961..f58115f 100644 --- a/src/style.css +++ b/src/style.css @@ -1622,15 +1622,28 @@ body:not(.is-app) .show-when-app { width: 100%; text-align: left; display: flex; - padding: 5px 4px; + padding: 5px 5px; align-items: center; justify-content: space-between; position: relative; cursor: pointer; } -.sidebar__folder-wrap > div > .sidebar__file { +/* 1st level nesting */ +.sidebar__folder-wrap > div .sidebar__file { padding-left: 1rem; } +/* 2nd level nesting */ +.sidebar__folder-wrap .sidebar__folder-wrap > div > .sidebar__file { + padding-left: 2rem; +} +/* 3rd level nesting */ +.sidebar__folder-wrap + .sidebar__folder-wrap + .sidebar__folder-wrap + > div + > .sidebar__file { + padding-left: 3rem; +} .sidebar__file:hover, .sidebar__file:focus { diff --git a/src/tests/fileUtils.test.js b/src/tests/fileUtils.test.js new file mode 100644 index 0000000..2b2d858 --- /dev/null +++ b/src/tests/fileUtils.test.js @@ -0,0 +1,83 @@ +// See: https://github.com/mzgoddard/preact-render-spy +import { shallow, deep } from 'preact-render-spy'; +import { + assignFilePaths, + getFileFromPath, + removeFileAtPath +} from '../fileUtils'; + +function getNestedFiles() { + return [ + { name: 'index.html' }, + { + name: 'styles', + isFolder: true, + children: [{ name: 'main.css' }, { name: 'style.css' }] + }, + { name: 'script.js' } + ]; +} +describe('assignFilePaths', () => { + test('should assign path on linear file system', () => { + const files = [{ name: 'index.html' }, { name: 'main.css' }]; + assignFilePaths(files); + expect(files[0].path).toBe('index.html'); + expect(files[1].path).toBe('main.css'); + }); + test('should assign path on nested file system', () => { + const files = getNestedFiles(); + assignFilePaths(files); + expect(files[0].path).toBe('index.html'); + expect(files[1].children[0].path).toBe('styles/main.css'); + }); +}); + +describe('getFileFromPath', () => { + test('should return file and correct index', () => { + const files = getNestedFiles(); + assignFilePaths(files); + const { index, file } = getFileFromPath(files, 'index.html'); + expect(index).toBe(0); + expect(file).toBe(files[index]); + }); + test('should return empty object for non-existent path', () => { + const files = getNestedFiles(); + assignFilePaths(files); + const { index, file } = getFileFromPath(files, 'style.css'); + expect(index).toBe(-1); + expect(file).toBe(undefined); + }); + test('should return file and correct index for a nested file', () => { + const files = getNestedFiles(); + assignFilePaths(files); + const { index, file } = getFileFromPath(files, 'styles/style.css'); + expect(index).toBe(1); + expect(file).toBe(files[1].children[index]); + }); +}); + +describe('removeFileAtPath', () => { + test('should remove direct child file', () => { + const files = getNestedFiles(); + assignFilePaths(files); + + expect(files.length).toBe(3); + + removeFileAtPath(files, 'index.html'); + expect(files.length).toBe(2); + expect(files[0].name).toBe('styles'); + }); + + test('should remove a nested file', () => { + const files = getNestedFiles(); + assignFilePaths(files); + + expect(files.length).toBe(3); + + removeFileAtPath(files, 'styles/style.css'); + expect(files.length).toBe(3); + expect(files[1].children.length).toBe(1); + expect(files[0].name).toBe('index.html'); + expect(files[2].name).toBe('script.js'); + }); +}); diff --git a/src/utils.js b/src/utils.js index de4c730..4f5e495 100644 --- a/src/utils.js +++ b/src/utils.js @@ -465,21 +465,3 @@ if (window.IS_EXTENSION) { } else { document.body.classList.add('is-app'); } - -/** - * Returns a linear file list from a nested file strcuture. - * It excludes the folders from the returned list. - * @param {array} files Nested file structure - */ -export function linearizeFiles(files) { - function reduceToLinearFiles(files) { - return files.reduce((list, currentFile) => { - if (currentFile.isFolder) { - return [...list, ...reduceToLinearFiles(currentFile.children)]; - } else { - return [...list, currentFile]; - } - }, []); - } - return reduceToLinearFiles(files); -}