├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── demo ├── .gitignore ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── src │ ├── App.js │ ├── index.css │ ├── index.js │ └── serviceWorker.js └── yarn.lock ├── package.json ├── src ├── fs-branch.js ├── fs-node.js ├── icons.js ├── index.js ├── module.js ├── shapes.js └── utils.js ├── webpack_config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | presets: ["es2017", "stage-2", "react"], 3 | plugins: ["transform-decorators-legacy"], 4 | env: { 5 | test: { 6 | plugins: ["transform-es2015-modules-commonjs"] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { "es6": true, "browser": true }, 3 | "extends": ["eslint:recommended", "plugin:react/recommended"], 4 | "parser": "babel-eslint", 5 | "settings": { 6 | "react": { 7 | "version": "15.0" 8 | }, 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | !build 2 | webpack_config.js 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2018 Eytan Manor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-fs-tree 2 | 3 | I am a beautiful React.Component tree and I will help you present FS hierarchy within a React App. No dependencies, just pure awesomeness! My creator has created me after testing few alternatives for [tortilla.academy](https://tortilla.academy), and has found nothing which is as stylish and can show git-diff annotations right out of the box next to each file (here's [an example page](https://tortilla.academy/tutorial/chatty/version/2-0-0/diff/3-0-0) which was built with me). 4 | 5 |

react-fs-tree

6 | 7 | This is how I look like behind the scenes: 8 | 9 | ```js 10 | import React from 'react' 11 | import FSRoot from 'react-fs-tree' 12 | 13 | const FSTree = () => ( 14 | 25 | ) 26 | ``` 27 | 28 | > A small demonstration app of me can be found in the [`demo` dir](https://github.com/DAB0mB/react-fs-tree/tree/master/demo). 29 | 30 | My nodes will pop and glow once you click on them. I'll be sure to notify you whenever it happens ;-) 31 | 32 | ```js 33 | const onSelect = (node) => { 34 | console.log(`node ${node.path} selected`) 35 | } 36 | 37 | const onOpen = (node) => { 38 | console.log(`node ${node.path} opened`) 39 | } 40 | 41 | 44 | ``` 45 | 46 | I can also notify you on... 47 | 48 | - **onSelect(node)** 49 | 50 | - **onDeselect(node)** 51 | 52 | - **onSelectChange(node)** 53 | 54 | - **onOpen(node)** 55 | 56 | - **onClose(node)** 57 | 58 | - **onOpenChange(node)** 59 | 60 | If you really want, you can change my colors with the **theme** prop, using the following variables: 61 | 62 | - **theme.primary** 63 | 64 | - **theme.selectedBackground** 65 | 66 | - **theme.selectedText** 67 | 68 | - **theme.modeM** 69 | 70 | - **theme.modeA** 71 | 72 | - **theme.modeD** 73 | 74 | Each of my nodes is an entity of its own and will do exactly what you tell it to: 75 | 76 | - **node.open()** - Will open the node. 77 | 78 | - **node.close()** - Will close the node. 79 | 80 | - **node.toggleOpen()** - Will open the node if closed and will close the node if opened. 81 | 82 | - **node.select()** - Will select the node. 83 | 84 | - **node.deselect()** - Will deselect the node. 85 | 86 | - **node.toggleSelect()** - Will select the node if deselected and will deselect the node if selected. 87 | 88 | You can also ask for any of its details, like... 89 | 90 | - **node.name** - The name of the node e.g. `foo`. 91 | 92 | - **node.path** - The full path of the node starting from the root e.g. `~/folder/foo`. 93 | 94 | - **node.childNodes** - An array of the direct children of the node. 95 | 96 | - **node.parentNode** - The parent of the node. 97 | 98 | - **node.root** - The greatest ancestor of the node. 99 | 100 | - **node.opened** - Whether the node is opened or not. 101 | 102 | - **node.selected** - Whether the node is selected or not. 103 | 104 | - **node.branchedOut** - Whether the node has offspring or not. 105 | 106 | - **node.virtual** - Whether the node is currently rendered or is just a virtual representation of the its data*. 107 | 108 | > \* A node is an actual instance of its belonging React.Component. 109 | 110 | You should also know that... 111 | 112 | - You can make me non interactive by passing me the `noninteractive` flag. When that happens my nodes won't respond to your clicks, and neither do I. You can ask each of my nodes whether it's interactive or not with `node.noninteractive`. 113 | 114 | - You can pass a git-diff mode that will signify whether the node was added (`'a'`), deleted (`'d'`) or modified (`'m'`). You can ask the node what's its mode at anytime using `node.mode`. 115 | 116 | If you like what you see you can install me with `npm`: 117 | 118 | $ npm install react-fs-tree 119 | 120 | Feel free to use me (MIT license)! 121 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # react-fs-tree example -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-fs-tree-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "prop-types": "^15.7.2", 7 | "react": "^16.13.1", 8 | "react-dom": "^16.13.1", 9 | "react-fs-tree": "link:..", 10 | "react-scripts": "2.0.5" 11 | }, 12 | "scripts": { 13 | "start": "react-scripts start", 14 | "build": "react-scripts build", 15 | "eject": "react-scripts eject" 16 | }, 17 | "eslintConfig": { 18 | "extends": "react-app" 19 | }, 20 | "browserslist": [ 21 | ">0.2%", 22 | "not dead", 23 | "not ie <= 11", 24 | "not op_mini all" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DAB0mB/react-fs-tree/4279938b2d8bbbb4aff3ae5e4a746853da6ffd4a/demo/public/favicon.ico -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /demo/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /demo/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import FSRoot from 'react-fs-tree' 3 | 4 | class App extends React.Component { 5 | render() { 6 | return ( 7 |
8 | 19 |
20 | ) 21 | } 22 | } 23 | 24 | export default App 25 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 5 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: http://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /demo/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read http://bit.ly/CRA-PWA. 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit http://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | installingWorker.onstatechange = () => { 64 | if (installingWorker.state === 'installed') { 65 | if (navigator.serviceWorker.controller) { 66 | // At this point, the updated precached content has been fetched, 67 | // but the previous service worker will still serve the older 68 | // content until all client tabs are closed. 69 | console.log( 70 | 'New content is available and will be used when all ' + 71 | 'tabs for this page are closed. See http://bit.ly/CRA-PWA.' 72 | ); 73 | 74 | // Execute callback 75 | if (config && config.onUpdate) { 76 | config.onUpdate(registration); 77 | } 78 | } else { 79 | // At this point, everything has been precached. 80 | // It's the perfect time to display a 81 | // "Content is cached for offline use." message. 82 | console.log('Content is cached for offline use.'); 83 | 84 | // Execute callback 85 | if (config && config.onSuccess) { 86 | config.onSuccess(registration); 87 | } 88 | } 89 | } 90 | }; 91 | }; 92 | }) 93 | .catch(error => { 94 | console.error('Error during service worker registration:', error); 95 | }); 96 | } 97 | 98 | function checkValidServiceWorker(swUrl, config) { 99 | // Check if the service worker can be found. If it can't reload the page. 100 | fetch(swUrl) 101 | .then(response => { 102 | // Ensure service worker exists, and that we really are getting a JS file. 103 | if ( 104 | response.status === 404 || 105 | response.headers.get('content-type').indexOf('javascript') === -1 106 | ) { 107 | // No service worker found. Probably a different app. Reload the page. 108 | navigator.serviceWorker.ready.then(registration => { 109 | registration.unregister().then(() => { 110 | window.location.reload(); 111 | }); 112 | }); 113 | } else { 114 | // Service worker found. Proceed as normal. 115 | registerValidSW(swUrl, config); 116 | } 117 | }) 118 | .catch(() => { 119 | console.log( 120 | 'No internet connection found. App is running in offline mode.' 121 | ); 122 | }); 123 | } 124 | 125 | export function unregister() { 126 | if ('serviceWorker' in navigator) { 127 | navigator.serviceWorker.ready.then(registration => { 128 | registration.unregister(); 129 | }); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-fs-tree", 3 | "version": "0.2.0", 4 | "description": "A recursive FS tree React.Component", 5 | "main": "build/react-fs-tree.js", 6 | "scripts": { 7 | "build": "webpack --config webpack_config.js", 8 | "prepublish": "yarn build" 9 | }, 10 | "devDependencies": { 11 | "babel-core": "^6.26.3", 12 | "babel-eslint": "^9.0.0", 13 | "babel-loader": "^7.1.5", 14 | "babel-plugin-transform-decorators-legacy": "^1.3.5", 15 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", 16 | "babel-polyfill": "^6.26.0", 17 | "babel-preset-es2017": "^6.24.1", 18 | "babel-preset-react": "^6.24.1", 19 | "babel-preset-stage-2": "^6.24.1", 20 | "eslint": "^5.1.0", 21 | "eslint-loader": "^2.1.0", 22 | "eslint-plugin-react": "^7.11.1", 23 | "react": "^16.5.2", 24 | "react-dom": "^16.5.2", 25 | "webpack": "^4.16.1", 26 | "webpack-cli": "^3.1.0", 27 | "webpack-node-externals": "^1.7.2" 28 | }, 29 | "peerDependencies": { 30 | "prop-types": "*", 31 | "react": "*", 32 | "react-dom": "*" 33 | }, 34 | "dependencies": {} 35 | } 36 | -------------------------------------------------------------------------------- /src/fs-branch.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import React from 'react' 3 | 4 | import { exports } from './module' 5 | import Shapes from './shapes' 6 | 7 | class FSBranch extends React.Component { 8 | static propTypes = { 9 | childNodes: PropTypes.arrayOf(Shapes.Node).isRequired, 10 | parentNode: PropTypes.instanceOf(React.Component).isRequired, 11 | root: PropTypes.instanceOf(React.Component).isRequired, 12 | depth: PropTypes.number, 13 | noninteractive: PropTypes.bool, 14 | onSelect: PropTypes.func, 15 | onDeselect: PropTypes.func, 16 | onSelectChange: PropTypes.func, 17 | onClose: PropTypes.func, 18 | onOpen: PropTypes.func, 19 | onOpenChange: PropTypes.func, 20 | } 21 | 22 | static defaultProps = { 23 | depth: 0, 24 | noninteractive: false, 25 | onSelect: () => {}, 26 | onDeselect: () => {}, 27 | onSelectChange: () => {}, 28 | onClose: () => {}, 29 | onOpen: () => {}, 30 | onOpenChange: () => {}, 31 | } 32 | 33 | get depth() { 34 | return this.props.depth 35 | } 36 | 37 | get parentNode() { 38 | return this.props.parentNode 39 | } 40 | 41 | get root() { 42 | return this.props.root 43 | } 44 | 45 | get noninteractive() { 46 | return this.props.noninteractive 47 | } 48 | 49 | get childNodes() { 50 | return [...this._childNodes] 51 | } 52 | 53 | get path() { 54 | return this._path 55 | } 56 | 57 | constructor(props) { 58 | super(props) 59 | 60 | this.state = { 61 | childNodes: props.childNodes, 62 | } 63 | 64 | this._path = props.parentNode.path + '/' 65 | this._childNodes = [] 66 | } 67 | 68 | UNSAFE_componentWillReceiveProps(nextProps) { 69 | this.setState({ 70 | childNodes: nextProps.childNodes, 71 | }) 72 | } 73 | 74 | UNSAFE_componentWillUpdate() { 75 | this._childNodes = [] 76 | } 77 | 78 | render() { 79 | return ( 80 |
81 |
    82 | {this.state.childNodes.map((node, i) => ( 83 |
  • 84 | ref && this._childNodes.push(ref)} 86 | node={node} 87 | branch={this} 88 | parentNode={this.props.parentNode} 89 | root={this.props.root} 90 | noninteractive={this.props.noninteractive} 91 | depth={this.props.depth + 1} 92 | onSelect={this.props.onSelect} 93 | onDeselect={this.props.onDeselect} 94 | onSelectChange={this.props.onSelectChange} 95 | onClose={this.props.onClose} 96 | onOpen={this.props.onOpen} 97 | onOpenChange={this.props.onOpenChange} 98 | /> 99 |
  • 100 | ))} 101 |
102 |
103 | ) 104 | } 105 | } 106 | 107 | exports.FSBranch = FSBranch 108 | -------------------------------------------------------------------------------- /src/fs-node.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import React from 'react' 3 | 4 | import Icons from './icons' 5 | import { exports } from './module' 6 | import Shapes from './shapes' 7 | import { someNodes } from './utils' 8 | 9 | class FSNode extends React.Component { 10 | static propTypes = { 11 | node: Shapes.Node.isRequired, 12 | branch: PropTypes.instanceOf(React.Component).isRequired, 13 | parentNode: PropTypes.instanceOf(React.Component).isRequired, 14 | root: PropTypes.instanceOf(React.Component).isRequired, 15 | depth: PropTypes.number, 16 | virtual: PropTypes.bool, 17 | noninteractive: PropTypes.bool, 18 | onSelect: PropTypes.func, 19 | onDeselect: PropTypes.func, 20 | onSelectChange: PropTypes.func, 21 | onClose: PropTypes.func, 22 | onOpen: PropTypes.func, 23 | onOpenChange: PropTypes.func, 24 | } 25 | 26 | static defaultProps = { 27 | depth: 0, 28 | virtual: false, 29 | noninteractive: false, 30 | onSelect: () => {}, 31 | onDeselect: () => {}, 32 | onSelectChange: () => {}, 33 | onClose: () => {}, 34 | onOpen: () => {}, 35 | onOpenChange: () => {}, 36 | } 37 | 38 | get depth() { 39 | return this.props.depth 40 | } 41 | 42 | get virtual() { 43 | return this.props.virtual 44 | } 45 | 46 | get parentNode() { 47 | return this.props.parentNode 48 | } 49 | 50 | get root() { 51 | return this.props.root 52 | } 53 | 54 | get noninteractive() { 55 | return this.props.noninteractive 56 | } 57 | 58 | get childNodes() { 59 | return [...this._childNodes] 60 | } 61 | 62 | get branchedOut() { 63 | return !!this.state.node.childNodes 64 | } 65 | 66 | get path() { 67 | return this._path 68 | } 69 | 70 | get name() { 71 | return this.state.node.name 72 | } 73 | 74 | get opened() { 75 | return this.state.node.opened 76 | } 77 | 78 | get selected() { 79 | return this.state.node.selected 80 | } 81 | 82 | constructor(props) { 83 | super(props) 84 | 85 | this._path = props.branch._path + props.node.name 86 | this._childNodes = [] 87 | 88 | this.state = { 89 | node: props.node 90 | } 91 | 92 | if (props.virtual) { 93 | this._createVirtualChildNodes() 94 | } 95 | } 96 | 97 | componentDidMount() { 98 | this._mounted = true 99 | } 100 | 101 | UNSAFE_componentWillReceiveProps(nextProps) { 102 | this.setState({ 103 | node: nextProps.node, 104 | }) 105 | } 106 | 107 | UNSAFE_componentWillUpdate() { 108 | this._childNodes = [] 109 | } 110 | 111 | componentDidUpdate() { 112 | if (!this.state.opened) { 113 | this._createVirtualChildNodes() 114 | } 115 | } 116 | 117 | componentWillUnmount() { 118 | this._mounted = false 119 | } 120 | 121 | render() { 122 | return ( 123 |
124 |
125 |
126 |
127 |
this.toggleOpen())}>{this._getIcon()}
128 |
this.toggleSelect())}>{this.state.node.name}
129 |
this.toggleSelect())}>{this._getMode()}
130 |
131 | {this.state.node.childNodes && this.state.node.opened && ( 132 | ref && (this._childNodes = ref._childNodes)} 134 | childNodes={this.state.node.childNodes} 135 | parentNode={this} 136 | root={this.props.root} 137 | depth={this.props.depth} 138 | noninteractive={this.props.noninteractive} 139 | onSelect={this.props.onSelect} 140 | onDeselect={this.props.onDeselect} 141 | onSelectChange={this.props.onSelectChange} 142 | onOpen={this.props.onOpen} 143 | onClose={this.props.onClose} 144 | onOpenChange={this.props.onOpenChange} 145 | /> 146 | )} 147 |
148 |
149 |
150 | ) 151 | } 152 | 153 | select(onSelect = () => {}) { 154 | const callback = (resolve = Promise.resolve.bind(Promise)) => { 155 | this.props.onSelect(this) 156 | this.props.onSelectChange(this) 157 | onSelect(this) 158 | 159 | return resolve(this) 160 | } 161 | 162 | if (this.state.node.selected) return callback() 163 | 164 | if (!this._mounted) { 165 | const node = this.state.node 166 | node.selected = true 167 | 168 | return callback() 169 | } 170 | 171 | return new Promise((resolve) => { 172 | this.setState({ 173 | node: Object.assign(this.state.node, { 174 | selected: true 175 | }) 176 | }, () => { 177 | callback(resolve) 178 | }) 179 | }) 180 | } 181 | 182 | deselect(onDeselect = () => {}) { 183 | const callback = (resolve = Promise.resolve.bind(Promise)) => { 184 | this.props.onDeselect(this) 185 | this.props.onSelectChange(this) 186 | onDeselect(this) 187 | 188 | return resolve(this) 189 | } 190 | 191 | if (!this.state.node.selected) return callback() 192 | 193 | if (!this._mounted) { 194 | const node = this.state.node 195 | node.selected = false 196 | 197 | return callback() 198 | } 199 | 200 | return new Promise((resolve) => { 201 | this.setState({ 202 | node: Object.assign(this.state.node, { 203 | selected: false 204 | }) 205 | }, () => { 206 | callback(resolve) 207 | }) 208 | }) 209 | } 210 | 211 | toggleSelect(onToggle) { 212 | return this.state.node.selected ? this.deselect(onToggle) : this.select(onToggle) 213 | } 214 | 215 | close(onClose = () => {}) { 216 | const callback = (resolve = Promise.resolve.bind(Promise)) => { 217 | this.props.onClose(this) 218 | this.props.onOpenChange(this) 219 | onClose(this) 220 | 221 | return resolve(this) 222 | } 223 | 224 | if (!this.state.node.childNodes) return callback() 225 | if (!this.state.node.opened) return callback() 226 | 227 | return new Promise((resolve) => { 228 | this.setState({ 229 | node: Object.assign(this.state.node, { 230 | opened: false 231 | }) 232 | }, () => { 233 | callback(resolve) 234 | }) 235 | }) 236 | } 237 | 238 | open(onOpen = () => {}) { 239 | const callback = (resolve = Promise.resolve.bind(Promise)) => { 240 | this.props.onOpen(this) 241 | this.props.onOpenChange(this) 242 | onOpen(this) 243 | 244 | return resolve(this) 245 | } 246 | 247 | if (!this.state.node.childNodes) return callback() 248 | if (this.state.node.opened) return callback() 249 | 250 | return new Promise((resolve) => { 251 | this.setState({ 252 | node: Object.assign(this.state.node, { 253 | opened: true 254 | }) 255 | }, () => { 256 | callback(resolve) 257 | }) 258 | }) 259 | } 260 | 261 | toggleOpen(onToggle) { 262 | return this.state.node.opened ? this.close(onToggle) : this.open(onToggle) 263 | } 264 | 265 | _getWrapClass = () => { 266 | const selected = this.state.node.selected ? 'FSNode-selected' : 'FSNode-deselected' 267 | const type = this.state.node.childNodes ? 'FSNode-dir' : 'FSNode-file' 268 | 269 | return `FSNode-wrap ${selected} ${type}` 270 | } 271 | 272 | _getDepthSize = (depth = this.depth) => { 273 | let padding = 23 * depth 274 | 275 | if (!this.state.node.childNodes) { 276 | padding += 14 277 | } 278 | 279 | return padding + 'px' 280 | } 281 | 282 | _getWrapStyle = () => { 283 | const translateX = this._getDepthSize(this.depth - 1) 284 | 285 | return { 286 | transform: `translateX(-${translateX})`, 287 | width: `calc(100% + ${translateX})`, 288 | } 289 | } 290 | 291 | _getNodeStyle = () => { 292 | return { 293 | paddingLeft: this._getDepthSize(this.depth), 294 | } 295 | } 296 | 297 | _getIcon = () => { 298 | if (!this.state.node.childNodes) { 299 | return ( 300 |
this.toggleSelect())}> 301 | 302 |
303 | ) 304 | } 305 | 306 | return ( 307 |
308 | {this.state.node.opened ? : } 309 | 310 |
311 | ) 312 | } 313 | 314 | _getMode = () => { 315 | if (!this.state.node.childNodes) { 316 | switch (this.state.node.mode) { 317 | case 'm': return ( 318 |
±
319 | ) 320 | case 'a': return ( 321 |
+
322 | ) 323 | case 'd': return ( 324 |
-
325 | ) 326 | default: return null 327 | } 328 | } 329 | 330 | const someAdditions = someNodes(this.state.node.childNodes, n => n.mode === 'a') 331 | const someDeletions = someNodes(this.state.node.childNodes, n => n.mode === 'd') 332 | const someModifications = (someAdditions && someDeletions) || someNodes(this.state.node.childNodes, n => n.mode === 'm') 333 | 334 | return someModifications ? ( 335 |
±
336 | ) : someAdditions ? ( 337 |
+
338 | ) : someDeletions ? ( 339 |
-
340 | ) : null; 341 | } 342 | 343 | _createVirtualChildNodes() { 344 | if (!this.state.node.childNodes) return 345 | 346 | this.state.node.childNodes.forEach((node) => { 347 | const ref = new FSNode({ 348 | node, 349 | virtual: true, 350 | branch: this.props.branch, 351 | parentNode: this, 352 | root: this.props.root, 353 | depth: this.props.depth + 1, 354 | noninteractive: this.props.noninteractive, 355 | onSelect: this.props.onSelect, 356 | onDeselect: this.props.onDeselect, 357 | onSelectChange: this.props.onSelectChange, 358 | onOpen: this.props.onOpen, 359 | onClose: this.props.onClose, 360 | onOpenChange: this.props.onOpenChange, 361 | }) 362 | 363 | this._childNodes.push(ref) 364 | }) 365 | } 366 | } 367 | 368 | exports.FSNode = FSNode 369 | -------------------------------------------------------------------------------- /src/icons.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const Folder = props => ( 4 | 5 | ) 6 | 7 | export const FolderOpen = props => ( 8 | 9 | ) 10 | 11 | export const File = props => ( 12 | 13 | ) 14 | 15 | export const CaretRight = props => ( 16 | 17 | ) 18 | 19 | export const CaretDown = props => ( 20 | 21 | ) 22 | 23 | export default { 24 | Folder, 25 | FolderOpen, 26 | File, 27 | CaretRight, 28 | CaretDown, 29 | } 30 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import React from 'react' 3 | 4 | import './fs-branch' 5 | import './fs-node' 6 | import { exports } from './module' 7 | import Shapes from './shapes' 8 | import { walkTogether } from './utils' 9 | 10 | export const FSBranch = exports.FSBranch 11 | export const FSNode = exports.FSNode 12 | 13 | export class FSRoot extends React.Component { 14 | static propTypes = { 15 | childNodes: PropTypes.arrayOf(Shapes.Node), 16 | theme: Shapes.Theme, 17 | noninteractive: PropTypes.bool, 18 | onSelect: PropTypes.func, 19 | onDeselect: PropTypes.func, 20 | onClose: PropTypes.func, 21 | onOpen: PropTypes.func, 22 | } 23 | 24 | static defaultProps = { 25 | noninteractive: false, 26 | onSelect: () => {}, 27 | onDeselect: () => {}, 28 | onClose: () => {}, 29 | onOpen: () => {}, 30 | } 31 | 32 | get noninteractive() { 33 | return this.props.noninteractive 34 | } 35 | 36 | get childNodes() { 37 | return this._childNodes 38 | } 39 | 40 | get path() { 41 | return this._path 42 | } 43 | 44 | constructor(props) { 45 | super(props) 46 | 47 | this.state = { 48 | childNodes: this.props.childNodes, 49 | } 50 | 51 | this._path = '~' 52 | this._childNodes = [] 53 | } 54 | 55 | UNSAFE_componentWillReceiveProps(nextProps) { 56 | const nextChildNodesStr = JSON.stringify(nextProps.childNodes) 57 | const currChildNodesStr = JSON.stringify(this.props.childNodes) 58 | 59 | if (nextChildNodesStr === currChildNodesStr) { 60 | return; 61 | } 62 | 63 | const state = { childNodes: JSON.parse(nextChildNodesStr) } 64 | 65 | walkTogether(state.childNodes, this._childNodes, (target, source) => { 66 | if (!source) return 67 | 68 | target.opened = source.opened 69 | target.selected = source.selected 70 | }) 71 | 72 | this.setState(state) 73 | } 74 | 75 | UNSAFE_componentWillUpdate() { 76 | this._childNodes = [] 77 | } 78 | 79 | render() { 80 | const { 81 | primary = '#5b6f9d', 82 | selectedBackground = '#4c84ff', 83 | selectedText = '#ffffff', 84 | modeM = '#5b6f9d', 85 | modeA = '#356611', 86 | modeD = '#951b1b', 87 | } = this.props.theme || {} 88 | 89 | return ( 90 |
91 |