Building File tree view in React and Electron

2020-02-11

THe goal is to build file panel similar to what you see in Visual Studio code on the left.

File tree vsCode

We'll try to avoid ready-to-use components like material-ui and build it from scratch.

Requirements:

  • different icons for different file types
  • folders can be expanded and collapsed
  • entire file tree can be massive so we only render what is visible to users (think node_modules folder)
  • selected and hover state for folders and files
  • fires 'selected' event

Here is what we'll cover:

  • setup development environment
  • build basic tree view component that uses static json
  • read folders/files to update the json used by previous step
  • polish and style it

Setup Environment

I used electron react boilerplate and it was easy to get started.

TBD more steps here and some picture.


Basic Tree View component that uses static JSON structure

Let's assume this is a structure we want to use to represent file tree:

var sampleTreeStructure = {
    "name": "src",
    "fullpath": "/project/src",
    "type": "folder",
    "files": [
        {
            "name": "index.html",
            "fullpath": "/project/src/index.html",
            "type": "file",
            "filetype": "html"
        },
        {
          "name": "main.css",
          "fullpath": "/project/src/main.css",
          "type": "file",
          "filetype": "css"
        },
        {
          "name": "pages",
          "fullpath": "/project/src/pages",
          "type": "folder",
          "files": [
            {
              "name": "about.html",
              "fullpath": "/project/src/pages/about.html",
              "type": "file",
              "filetype": "html"
            }
          ]
        }
    ]
}

Simple react component to render this into a tree could look like this:

export default function TreeView({file}) {

    var files = file.files || [];

    return <div>
        {file.name}
        <ul>
            {files.map(f => <li>
                <TreeView file={f} />
            </li>)}
        </ul>
    </div>
}

which looks like this.

File tree vsCode

let's add simple UI icons, add hover state and make lines clickable and folders expandable.

wrapping the filename into a separate div with icon inside:

export default function TreeView({file}) {

    var files = file.files || [];

    var iconClass = file.type === 'folder' ? 'icon-folder' : `icon-${file.filetype}`;

    const [open, setOpen] = useState(false);

    const onLineClicked = function() {
        if (file.type === 'folder') {
            setOpen(!open);
        }
    }

    return <div>
        <div className="file-row" onClick={onLineClicked}>
            <span className={`${iconClass} file-icon`}></span>
            {file.name}
        </div>
        {open &&
            <ul>
                {files.map(f => <li>
                    <TreeView file={f} />
                </li>)}
            </ul>
        }
    </div>
}

setup icons in CSS:

.file-icon {
  display: inline-block;
  width: 20px;
}

.icon-folder:before {
  content: "\1F4C1";
}

.icon-html:before {
  content: "<>";
}

.icon-js:before {
  content: "JS";
}
.icon-css:before {
  content: "#";
}

.file-row:hover {
  background-color:green;
  cursor:pointer;
}

File tree vsCode

let's make child files loaded lazily. we'll have fetchChildren children to load files from a folder.

for now let's use local state

    const [files, setFiles] = useState(null);
    const onLineClicked = function() {
        if (file.type === 'folder') {
            if (!open && !files) {
                fetchChildren(file).then(childFiles => {
                    setFiles(childFiles);
                });
            }
            setOpen(!open);
        }
    }

now the props the component takes are:

<TreeView
    file={rootFolder}
    fetchChildren={fetchChildren}
/>

file system into structure

I'll use fs sync APIs just to keep code simpler.

As we mentioned above, we need to implement 2 things for our component to work:

file - root folder JSON
fetchChildren - function that reads files from folder and returns json.

function toFileObj(file, path) {
  const fullpath = `${path}/${file.name}`;
  const f = parse(fullpath);
  return {
    name: file.name, 
    fullpath: fullpath,
    type: file.isDirectory() ? 'folder' : 'file',
    filetype: !file.isDirectory() && f.ext ? f.ext.substring(1) : ''
  }
}


function fetchChildren(file) {
  return new Promise(function(resolve, reject){
      readdir(file.fullpath, { withFileTypes: true }, (err, files) => {
        const fileObjs = files.map(f => toFileObj(f, file.fullpath));
        resolve(fileObjs);
      });
  });
}

function constructOriginalFile(path) {
  const f = parse(path);
  return {
    fullpath: path,
    name: f.base,
    type: 'folder',
  }
}

prettify it

could things we want to do:

  • set boundearies + scroll bar
  • cute icons for each file type
  • folder open/closed icons
  • nice indentations

.file-icon {
  background-repeat: no-repeat;
  padding-left:20px; /*for bg icon*/
  padding-top:3px;
  padding-bottom:3px;
  background-position: 0 50%;
  background-size: 20px 20px;
}

.icon-folder {
  background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMC4wNzIyIDguMDIzOTdMNS43MTQ4NCAzLjY2NjY2TDYuMzMzNTYgMy4wNDc5NEwxMS4wMDAyIDcuNzE0NjFWOC4zMzMzM0w2LjMzMzU2IDEzTDUuNzE0ODQgMTIuMzgxM0wxMC4wNzIyIDguMDIzOTdaIiBmaWxsPSIjNDI0MjQyIi8+Cjwvc3ZnPg==);
}

.icon-folder-open {
  background-image: url(data:image/svg+xml;base64,Cjxzdmcgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiB2aWV3Qm94PSIwIDAgMTYgMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy45NzYzNyAxMC4wNzE5TDEyLjMzMzcgNS43MTQ2TDEyLjk1MjQgNi4zMzMzMkw4LjI4NTczIDExTDcuNjY3MDEgMTFMMy4wMDAzNCA2LjMzMzMyTDMuNjE5MDYgNS43MTQ2TDcuOTc2MzcgMTAuMDcxOVoiIGZpbGw9IiM0MjQyNDIiLz4KPC9zdmc+);
}

.icon-json {
  background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik02LjAwMDI0IDIuOTgzNjFWMi45NzE4NFYySDUuOTExMDdDNS41OTc2NyAyIDUuMjk0MzEgMi4wNjE2MSA1LjAwMTUyIDIuMTg0NzNDNC43MDg0MiAyLjMwNzk4IDQuNDQ5NjcgMi40ODQ3NCA0LjIyNjAyIDIuNzE0OThDNC4wMDMzNiAyLjk0NDIyIDMuODM4MTYgMy4xOTQ5OCAzLjczMzA2IDMuNDY3NjZMMy43MzI1NyAzLjQ2ODk4QzMuNjM0MDYgMy43MzUyIDMuNTY4MzkgNC4wMTIwMSAzLjUzNTU3IDQuMjk5MTdMMy41MzU0MyA0LjMwMDUzQzMuNTA3MDIgNC41ODA1IDMuNDk4OTQgNC44Njg0NCAzLjUxMTA4IDUuMTY0MjhDMy41MjI5NyA1LjQ1Mzc5IDMuNTI4OTEgNS43NDMyOSAzLjUyODkxIDYuMDMyNzlDMy41Mjg5MSA2LjIzNTU2IDMuNDg5OTkgNi40MjU5NCAzLjQxMjI1IDYuNjA1MDdMMy40MTE4NSA2LjYwNjAxQzMuMzM3MTIgNi43ODI5NiAzLjIzNDQ3IDYuOTM4NjYgMy4xMDM0MSA3LjA3MzU5QzIuOTc2NjkgNy4yMDQwNSAyLjgyNDkgNy4zMTA1NSAyLjY0Njk2IDcuMzkyNUMyLjQ3MDg0IDcuNDY5NTQgMi4yODUyMiA3LjUwODIgMi4wODk0MiA3LjUwODJIMi4wMDAyNFY3LjZWOC40VjguNDkxOEgyLjA4OTQyQzIuMjg0OSA4LjQ5MTggMi40NzAyNiA4LjUzMjM4IDIuNjQ2MjUgOC42MTMzNEwyLjY0NzY2IDguNjEzOTZDMi44MjQ4MiA4LjY5MTU3IDIuOTc2MDIgOC43OTc2MiAzLjEwMjQ1IDguOTMxNjFMMy4xMDQzNiA4LjkzMzUyQzMuMjM0NTIgOS4wNjM3IDMuMzM2ODQgOS4yMTg3MSAzLjQxMTUzIDkuMzk5NDJMMy40MTIyNSA5LjQwMTA4QzMuNDkwMTEgOS41ODA0NyAzLjUyODkxIDkuNzY4ODMgMy41Mjg5MSA5Ljk2NzIxQzMuNTI4OTEgMTAuMjU2NyAzLjUyMjk3IDEwLjU0NjIgMy41MTEwOCAxMC44MzU3QzMuNDk4OTQgMTEuMTMxNiAzLjUwNzAxIDExLjQyMTUgMy41MzU0IDExLjcwNTVMMy41MzU2IDExLjcwNzJDMy41Njg0NCAxMS45OTAzIDMuNjM0MTIgMTIuMjY1IDMuNzMyNTYgMTIuNTMxTDMuNzMzMDcgMTIuNTMyM0MzLjgzODE3IDEyLjgwNSA0LjAwMzM2IDEzLjA1NTggNC4yMjYwMiAxMy4yODVDNC40NDk2NyAxMy41MTUzIDQuNzA4NDIgMTMuNjkyIDUuMDAxNTIgMTMuODE1M0M1LjI5NDMxIDEzLjkzODQgNS41OTc2NyAxNCA1LjkxMTA3IDE0SDYuMDAwMjRWMTMuMlYxMy4wMTY0SDUuOTExMDdDNS43MTExOSAxMy4wMTY0IDUuNTIzNzEgMTIuOTc3NyA1LjM0Nzg3IDEyLjkwMDhDNS4xNzQyMSAxMi44MTkxIDUuMDIyMTggMTIuNzEyNiA0Ljg5MTExIDEyLjU4MThDNC43NjQxMSAxMi40NDY5IDQuNjYxMjggMTIuMjkxMSA0LjU4MjQ3IDEyLjExMzdDNC41MDg2MiAxMS45MzQ2IDQuNDcxNTggMTEuNzQ0IDQuNDcxNTggMTEuNTQxQzQuNDcxNTggMTEuMzEyNyA0LjQ3NTU0IDExLjA4ODUgNC40ODM0NiAxMC44Njg2QzQuNDkxNDkgMTAuNjQxMSA0LjQ5MTUxIDEwLjQxOTUgNC40ODM0OSAxMC4yMDM5QzQuNDc5MzggOS45ODI0NiA0LjQ2MTA5IDkuNzY4ODMgNC40Mjg0NyA5LjU2MzEyQzQuMzk1MzcgOS4zNTAyNCA0LjMzOTQ2IDkuMTQ3NTcgNC4yNjA2MyA4Ljk1NTM2QzQuMTgxMTUgOC43NjE1NyA0LjA3MjgyIDguNTc3NDYgMy45MzY0IDguNDAyOThDMy44MjM3IDguMjU4ODEgMy42ODU2MyA4LjEyNDYyIDMuNTIzMDcgOEMzLjY4NTYzIDcuODc1MzggMy44MjM3IDcuNzQxMTkgMy45MzY0IDcuNTk3MDJDNC4wNzI4MiA3LjQyMjU0IDQuMTgxMTUgNy4yMzg0MyA0LjI2MDYzIDcuMDQ0NjRDNC4zMzkzOCA2Ljg1MjYzIDQuMzk1MzcgNi42NTE3NSA0LjQyODUgNi40NDI4NUM0LjQ2MTA3IDYuMjMzMyA0LjQ3OTM4IDYuMDE5NzMgNC40ODM0OSA1LjgwMjE5QzQuNDkxNTEgNS41ODI2MiA0LjQ5MTUgNS4zNjEwNSA0LjQ4MzQ1IDUuMTM3NDlDNC40NzU1NCA0LjkxMzQgNC40NzE1OCA0LjY4NzI1IDQuNDcxNTggNC40NTkwMkM0LjQ3MTU4IDQuMjYwMTkgNC41MDg1NyA0LjA3MTUyIDQuNTgyNjMgMy44OTIwNUM0LjY2MTYgMy43MTAzNCA0Ljc2NDQ1IDMuNTU0NzUgNC44OTExIDMuNDI0MzdDNS4wMjIxOCAzLjI4OTQyIDUuMTc0ODUgMy4xODI3NSA1LjM0ODI2IDMuMTA1MTNDNS41MjQwNCAzLjAyNDI3IDUuNzExMzggMi45ODM2MSA1LjkxMTA3IDIuOTgzNjFINi4wMDAyNFpNMTAuMDAwMiAxMy4wMTY0VjEzLjAyODJWMTRIMTAuMDg5NEMxMC40MDI4IDE0IDEwLjcwNjIgMTMuOTM4NCAxMC45OTkgMTMuODE1M0MxMS4yOTIxIDEzLjY5MiAxMS41NTA4IDEzLjUxNTMgMTEuNzc0NSAxMy4yODVDMTEuOTk3MSAxMy4wNTU4IDEyLjE2MjMgMTIuODA1IDEyLjI2NzQgMTIuNTMyM0wxMi4yNjc5IDEyLjUzMUMxMi4zNjY0IDEyLjI2NDggMTIuNDMyMSAxMS45ODggMTIuNDY0OSAxMS43MDA4TDEyLjQ2NTEgMTEuNjk5NUMxMi40OTM1IDExLjQxOTUgMTIuNTAxNSAxMS4xMzE2IDEyLjQ4OTQgMTAuODM1N0MxMi40Nzc1IDEwLjU0NjIgMTIuNDcxNiAxMC4yNTY3IDEyLjQ3MTYgOS45NjcyMUMxMi40NzE2IDkuNzY0NDQgMTIuNTEwNSA5LjU3NDA2IDEyLjU4ODIgOS4zOTQ5M0wxMi41ODg2IDkuMzkzOTlDMTIuNjYzNCA5LjIxNzA0IDEyLjc2NiA5LjA2MTM0IDEyLjg5NzEgOC45MjY0MkMxMy4wMjM4IDguNzk1OTUgMTMuMTc1NiA4LjY4OTQ1IDEzLjM1MzUgOC42MDc1QzEzLjUyOTYgOC41MzA0NiAxMy43MTUzIDguNDkxOCAxMy45MTExIDguNDkxOEgxNC4wMDAyVjguNFY3LjZWNy41MDgySDEzLjkxMTFDMTMuNzE1NiA3LjUwODIgMTMuNTMwMiA3LjQ2NzYyIDEzLjM1NDIgNy4zODY2NkwxMy4zNTI4IDcuMzg2MDRDMTMuMTc1NyA3LjMwODQ0IDEzLjAyNDUgNy4yMDIzOCAxMi44OTggNy4wNjgzOUwxMi44OTYxIDcuMDY2NDhDMTIuNzY2IDYuOTM2MyAxMi42NjM3IDYuNzgxMjkgMTIuNTg5IDYuNjAwNThMMTIuNTg4MiA2LjU5ODkyQzEyLjUxMDQgNi40MTk1MyAxMi40NzE2IDYuMjMxMTcgMTIuNDcxNiA2LjAzMjc5QzEyLjQ3MTYgNS43NDMyOSAxMi40Nzc1IDUuNDUzNzkgMTIuNDg5NCA1LjE2NDI4QzEyLjUwMTUgNC44Njg0MiAxMi40OTM1IDQuNTc4NDggMTIuNDY1MSA0LjI5NDU0TDEyLjQ2NDkgNC4yOTI4NUMxMi40MzIxIDQuMDA5NzEgMTIuMzY2NCAzLjczNTAyIDEyLjI2NzkgMy40Njg5N0wxMi4yNjc0IDMuNDY3NjZDMTIuMTYyMyAzLjE5NDk5IDExLjk5NzEgMi45NDQyMiAxMS43NzQ1IDIuNzE0OThDMTEuNTUwOCAyLjQ4NDc0IDExLjI5MjEgMi4zMDc5OCAxMC45OTkgMi4xODQ3M0MxMC43MDYyIDIuMDYxNjEgMTAuNDAyOCAyIDEwLjA4OTQgMkgxMC4wMDAyVjIuOFYyLjk4MzYxSDEwLjA4OTRDMTAuMjg5MyAyLjk4MzYxIDEwLjQ3NjggMy4wMjIzIDEwLjY1MjYgMy4wOTkxN0MxMC44MjYzIDMuMTgwOTIgMTAuOTc4MyAzLjI4NzM2IDExLjEwOTQgMy40MTgyM0MxMS4yMzY0IDMuNTUzMDUgMTEuMzM5MiAzLjcwODg5IDExLjQxOCAzLjg4NjI4QzExLjQ5MTkgNC4wNjU0IDExLjUyODkgNC4yNTU5NiAxMS41Mjg5IDQuNDU5MDJDMTEuNTI4OSA0LjY4NzI3IDExLjUyNDkgNC45MTE0NSAxMS41MTcgNS4xMzE0MkMxMS41MDkgNS4zNTg5NCAxMS41MDkgNS41ODA0OSAxMS41MTcgNS43OTYwNUMxMS41MjExIDYuMDE3NTQgMTEuNTM5NCA2LjIzMTE3IDExLjU3MiA2LjQzNjg4QzExLjYwNTEgNi42NDk3NiAxMS42NjEgNi44NTI0MyAxMS43Mzk5IDcuMDQ0NjRDMTEuODE5MyA3LjIzODQzIDExLjkyNzcgNy40MjI1NCAxMi4wNjQxIDcuNTk3MDJDMTIuMTc2OCA3Ljc0MTE5IDEyLjMxNDkgNy44NzUzOCAxMi40Nzc0IDhDMTIuMzE0OSA4LjEyNDYyIDEyLjE3NjggOC4yNTg4MSAxMi4wNjQxIDguNDAyOThDMTEuOTI3NyA4LjU3NzQ2IDExLjgxOTMgOC43NjE1NyAxMS43Mzk5IDguOTU1MzZDMTEuNjYxMSA5LjE0NzM3IDExLjYwNTEgOS4zNDgyNSAxMS41NzIgOS41NTcxNUMxMS41Mzk0IDkuNzY2NyAxMS41MjExIDkuOTgwMjcgMTEuNTE3IDEwLjE5NzhDMTEuNTA5IDEwLjQxNzQgMTEuNTA5IDEwLjYzODkgMTEuNTE3IDEwLjg2MjVDMTEuNTI0OSAxMS4wODY2IDExLjUyODkgMTEuMzEyOCAxMS41Mjg5IDExLjU0MUMxMS41Mjg5IDExLjczOTggMTEuNDkxOSAxMS45Mjg1IDExLjQxNzkgMTIuMTA3OUMxMS4zMzg5IDEyLjI4OTcgMTEuMjM2IDEyLjQ0NTIgMTEuMTA5NCAxMi41NzU2QzEwLjk3ODMgMTIuNzEwNiAxMC44MjU2IDEyLjgxNzMgMTAuNjUyMiAxMi44OTQ5QzEwLjQ3NjQgMTIuOTc1NyAxMC4yODkxIDEzLjAxNjQgMTAuMDg5NCAxMy4wMTY0SDEwLjAwMDJaIiBmaWxsPSIjNDI0MjQyIi8+Cjwvc3ZnPg==);
}

.icon-js {
  background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiI+PHRpdGxlPmZpbGVfdHlwZV9qc19vZmZpY2lhbDwvdGl0bGU+PHJlY3QgeD0iMiIgeT0iMiIgd2lkdGg9IjI4IiBoZWlnaHQ9IjI4IiBzdHlsZT0iZmlsbDojZjVkZTE5Ii8+PHBhdGggZD0iTTIwLjgwOSwyMy44NzVhMi44NjYsMi44NjYsMCwwLDAsMi42LDEuNmMxLjA5LDAsMS43ODctLjU0NSwxLjc4Ny0xLjMsMC0uOS0uNzE2LTEuMjIyLTEuOTE2LTEuNzQ3bC0uNjU4LS4yODJjLTEuOS0uODA5LTMuMTYtMS44MjItMy4xNi0zLjk2NCwwLTEuOTczLDEuNS0zLjQ3NiwzLjg1My0zLjQ3NmEzLjg4OSwzLjg4OSwwLDAsMSwzLjc0MiwyLjEwN0wyNSwxOC4xMjhBMS43ODksMS43ODksMCwwLDAsMjMuMzExLDE3YTEuMTQ1LDEuMTQ1LDAsMCwwLTEuMjU5LDEuMTI4YzAsLjc4OS40ODksMS4xMDksMS42MTgsMS42bC42NTguMjgyYzIuMjM2Ljk1OSwzLjUsMS45MzYsMy41LDQuMTMzLDAsMi4zNjktMS44NjEsMy42NjctNC4zNiwzLjY2N2E1LjA1NSw1LjA1NSwwLDAsMS00Ljc5NS0yLjY5MVptLTkuMjk1LjIyOGMuNDEzLjczMy43ODksMS4zNTMsMS42OTMsMS4zNTMuODY0LDAsMS40MS0uMzM4LDEuNDEtMS42NTNWMTQuODU2aDIuNjMxdjguOTgyYzAsMi43MjQtMS42LDMuOTY0LTMuOTI5LDMuOTY0YTQuMDg1LDQuMDg1LDAsMCwxLTMuOTQ3LTIuNFoiLz48L3N2Zz4=);
}
.icon-md {
  background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiI+PHRpdGxlPmZpbGVfdHlwZV9tYXJrZG93bjwvdGl0bGU+PHJlY3QgeD0iMi41IiB5PSI3Ljk1NSIgd2lkdGg9IjI3IiBoZWlnaHQ9IjE2LjA5MSIgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6Izc1NTgzOCIvPjxwb2x5Z29uIHBvaW50cz0iNS45MDkgMjAuNjM2IDUuOTA5IDExLjM2NCA4LjYzNiAxMS4zNjQgMTEuMzY0IDE0Ljc3MyAxNC4wOTEgMTEuMzY0IDE2LjgxOCAxMS4zNjQgMTYuODE4IDIwLjYzNiAxNC4wOTEgMjAuNjM2IDE0LjA5MSAxNS4zMTggMTEuMzY0IDE4LjcyNyA4LjYzNiAxNS4zMTggOC42MzYgMjAuNjM2IDUuOTA5IDIwLjYzNiIgc3R5bGU9ImZpbGw6Izc1NTgzOCIvPjxwb2x5Z29uIHBvaW50cz0iMjIuOTU1IDIwLjYzNiAxOC44NjQgMTYuMTM2IDIxLjU5MSAxNi4xMzYgMjEuNTkxIDExLjM2NCAyNC4zMTggMTEuMzY0IDI0LjMxOCAxNi4xMzYgMjcuMDQ1IDE2LjEzNiAyMi45NTUgMjAuNjM2IiBzdHlsZT0iZmlsbDojNzU1ODM4Ii8+PC9zdmc+);
}
.file-row:hover {
  background-color:#EBEBEB;
  cursor:pointer;
}

.tree-view {
  width: 400px;
  height: 500px;
  overflow: auto;
  background-color: white;
  color: black;
  padding: 10px;
}


.tree-view ul {
  padding: 0;
  margin: 0;
  padding-left: 10px;
}

"final" look

File tree vsCode


This article was originally published in DevToolsDaily blog