Create a Filesystem Browser with React.js and Chonky

ยท

8 min read

Create a Filesystem Browser with React.js and Chonky

Introducton

Wouldn't it be nice to browse your files from a page within your webapp? YES! And with Chonky it can be easily achieved. This blog post will show you how to utilise Express.js and React.js to create a reusable component to show a file browser for virtually any path on your filesystem. We will even create the functionality to download the files too!

Chonky is a library that contains everything you need to graphically represent a filesystem within a React application but we have to do some work ourselves to link it to said filesystem. We will do this using a couple of Express API endpoints.

I will not be going over how to create an Express API, nor a starter React application as there are plenty of other tutorials out there for that. This blog post assumes you have an express server working and some form of React application even if it is just created by Create React App

Creating the Filesystem Walker

Chonky requires our files to be provided in a particular JSON structure using parent IDs and children to form a structure of the data. To achieve this, we need to create a function that will "walk" through a directory recursively and create this structure that Chonky can then use...

const fs = require('fs');
const path = require('path');

let fileMap = {
    fileMap: {}
};

async function* walk(dir, rootDirId, rootFolderName) {
    for await (const d of await fs.promises.opendir(path.resolve(dir))) {

        // grab the stats we need and set unique ids
        const parentStats = fs.statSync(path.resolve(dir));
        const parentId = `${parentStats.dev}-${parentStats.ino}`;
        const stats = fs.statSync(path.join(path.resolve(dir), d.name));
        const fileId = `${stats.dev}-${stats.ino}`;
        const entry = path.join(path.resolve(dir), d.name);

        if (d.isDirectory()) {

            // create the directory entry if it doesn't exist
            if (!fileMap.fileMap[fileId]) {
                fileMap.fileMap[fileId] = {
                    id: fileId,
                    name: d.name,
                    isDir: true,
                    childrenIds: [],
                    fullPath: entry
                }

                if (fileId !== parentId)
                    fileMap.fileMap[fileId].parentId = parentId;
            }

            // populate the parent dir entry if it doesn't exist
            if (!fileMap.fileMap[parentId]) {
                fileMap.fileMap[parentId] = {
                    id: parentId,
                    name: parentId === rootDirId ? rootFolderName : d.name,
                    isDir: true,
                    childrenIds: [fileId],
                    fullPath: path.resolve(dir)
                }
            } else {
                // otherwise, just append this id to the existing parent
                fileMap.fileMap[parentId].childrenIds.push(fileId);
            }

            // recursively go to next dir
            yield* await walk(entry, rootDirId);

        } else if (d.isFile()) {

            // populate the parent dir entry if it doesn't exist
            if (!fileMap.fileMap[parentId]) {
                fileMap.fileMap[parentId] = {
                    id: parentId,
                    name: parentId === rootDirId ? rootFolderName : d.name,
                    isDir: true,
                    childrenIds: [fileId],
                    fullPath: path.resolve(dir)
                }
            } else {
                // otherwise, just append this id to the existing parent
                fileMap.fileMap[parentId].childrenIds.push(fileId);
            }

            // add the entry for the file
            fileMap.fileMap[fileId] = {
                id: fileId,
                name: d.name,
                parentId: `${parentStats.dev}-${parentStats.ino}`,
                size: stats.size,
                modDate: stats.mtime,
                fullPath: entry
            }

            yield entry;
        }
    }
}

async function walkDirectory(dir, rootFolderName) {

    // check the dir exists
    if (!fs.existsSync(dir))
        return undefined;

    // set root stuff
    const rootDir = path.resolve(dir);
    const getRootDir = () => path.resolve(rootDir);
    const rootDirStats = fs.statSync(getRootDir());
    const rootDirId = `${rootDirStats.dev}-${rootDirStats.ino}`;

    // create initial return object
    fileMap = {
        rootFolderId: rootDirId,
        fileMap: {}
    };

    // do the walk
    for await (const p of walk(getRootDir(), rootDirId, rootFolderName)) { }

    return fileMap;
}

module.exports = {
    walkDirectory
}

Add the Express API Endpoints

Now we need to expose the filesystem walker function we just created to our React application by creating an API endpoint for it to use. This endpoint will return the structure that Chonky can parse to provide us with the file structure.

As a bonus, we also have an endpoint to be able to download files which we will also hook up to the Chonky component we create later.

const express = require('express');
const router = express.Router();
const filesystemWalker = require('./fileSystemWalker');

// retrieve the filesystem structure
router.post('/filesystem', async (req, res) => {
    const pathToWalk = req.body.path;
    const rootFolderName = req.body.path;
    const files = await filesystemWalker.walkDirectory(pathToWalk, rootFolderName);
    res.status(200).send(files);
});

// download a selected file
router.post('/filesystem/download', async (req, res) => {

    // check for the requested files
    const requestedFiles = req.body;

    // if we have more than 1 file then zip them up, otherwise, download the file selected
    if (requestedFiles.length > 1) {

        const filesToDownload = requestedFiles.map(f => ({ path: f.fullPath, name: f.name }));

        res.zip({
            files: filesToDownload,
            filename: 'zip-file-name.zip'
        });

    } else {
        res.sendFile(requestedFiles[0].fullPath);
    }
})

module.exports = router;

Creating the Reusable File Browser React Component

One of the great things about React components is that they can be reused. We want to follow that principle and create a generic file browser component that can be provided with a path. This component will make all the necessary API calls to the Express endpoints we created earlier.

To support the download capability, we will be using the Downloadjs library to help with the file handling.

import React, { useState, useEffect, useMemo, useCallback } from 'react';
import {
    ChonkyActions,
    FileBrowser,
    FileContextMenu,
    FileHelper,
    FileList,
    FileNavbar,
    FileToolbar,
    defineFileAction,
} from 'chonky';
import downloader from 'downloadjs';
import { ChonkyIconFA } from 'chonky-icon-fontawesome';

// used to create timestamp string for zip filename
export const zeroPad = (num) => ('0' + (num)).slice(-2);
export const getTimestamp = (timestamp = undefined) => {

    if (!timestamp) timestamp = new Date();

    const month = zeroPad(timestamp.getUTCMonth() + 1),
        day = zeroPad(timestamp.getUTCDate()),
        year = timestamp.getUTCFullYear(),
        hour = zeroPad(timestamp.getUTCHours()),
        minute = zeroPad(timestamp.getUTCMinutes()),
        seconds = zeroPad(timestamp.getUTCSeconds());

    return `${year}${month}${day}_${hour}${minute}${seconds}`
}

// create custom icons here
const iconMap = {
    refresh: '๐Ÿ”„',
};

// use this to append our custom icon set to the default one
export const CustomIconSet = React.memo((props) => {
    const requestedIcon = iconMap[props.icon];
    if (requestedIcon) {
        return <span>{requestedIcon}</span>;
    }
    return <ChonkyIconFA {...props} />;
});

// function to download the selected files from chonky
const downloadFiles = (selectedFiles = [], fileName = undefined) => {
    return new Promise((resolve, reject) => {

        let downloadFileName = fileName;

        // if we don't have a name, we need to either use the name of the file or a timestamp for multiple files
        if (!downloadFileName) {
            if (selectedFiles.length === 1)
                downloadFileName = selectedFiles[0].name;
            else
                downloadFileName = `${getTimestamp()}.zip`;
        }

        // do the request
        fetch('/filesystem/download', {
            method: 'POST',
            headers: {
                'content-type': 'application/json'
            },
            body: JSON.stringify(selectedFiles)
        })
            .then(response => response.blob())
            .then(blob => {
                downloader(blob, downloadFileName);
                resolve();
            }).catch(() => {
                reject();
            });
    })
}

const FileBrowserDownloader = ({ path, multiFileName, singleFileName }) => {

    // check what file action has been called. Could be navigating into a dir or selecting download
    const useFileActionHandler = (
        setCurrentFolderId
    ) => {
        return useCallback(
            (data) => {
                switch (data.id) {
                    case ChonkyActions.OpenFiles.id: {
                        const { targetFile, files } = data.payload;
                        const fileToOpen = targetFile ?? files[0];
                        if (fileToOpen && FileHelper.isDirectory(fileToOpen)) {
                            setCurrentFolderId(fileToOpen.id);
                            return;
                        }
                        break;
                    }
                    case ChonkyActions.DownloadFiles.id: {
                        downloadFiles(data.state.selectedFiles, data.state.selectedFiles.length > 1 ? multiFileName : singleFileName);
                        break;
                    }
                    case 'refreshFilesystem': {
                        fetchFiles(path);
                        break;
                    }
                    default:
                        break;
                }
            },
            [setCurrentFolderId]
        );
    };

    const useFiles = (currentFolderId, fileMap) => {
        return useMemo(() => {
            if (!fileMap || !currentFolderId || !fileMap[currentFolderId]) return [];

            const currentFolder = fileMap[currentFolderId];
            const files = currentFolder.childrenIds
                ? currentFolder.childrenIds.map((fileId) => fileMap[fileId] ?? null)
                : [];
            return files;
        }, [currentFolderId, fileMap]);
    };

    const useFolderChain = (currentFolderId, fileMap) => {
        return useMemo(() => {

            if (!fileMap || !currentFolderId || !fileMap[currentFolderId]) return [];

            const currentFolder = fileMap[currentFolderId];
            const folderChain = [currentFolder];

            let parentId = currentFolder.parentId;
            while (parentId) {
                const parentFile = fileMap[parentId];
                if (parentFile) {
                    folderChain.unshift(parentFile);
                    parentId = parentFile.parentId;
                } else {
                    parentId = null;
                }
            }

            return folderChain;
        }, [currentFolderId, fileMap]);
    };

    // specify the actions we want including any custom ones that we define
    const rootActions = [
        ChonkyActions.EnableListView,
        ChonkyActions.EnableGridView,
        ChonkyActions.SortFilesByName,
        ChonkyActions.SortFilesByDate,
        defineFileAction({
            id: 'refreshFilesystem',
            requiresSelection: false,
            button: {
                name: 'Refresh',
                toolbar: true,
                contextMenu: false,
                icon: 'refresh'
            }
        })
    ];

    const [fileMap, setFileMap] = useState(undefined);
    const [currentFolderId, setCurrentFolderId] = useState(undefined);
    const [fileActions, setFileActions] = useState(rootActions);
    const files = useFiles(currentFolderId, fileMap);
    const folderChain = useFolderChain(currentFolderId, fileMap);

    const handleFileAction = useFileActionHandler(setCurrentFolderId);

    // function to handle grabbing the file list
    const fetchFiles = (path) => {
        return new Promise((resolve, reject) => {

            fetch('/filesystem', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ path })
            })
                .then(res => {

                    if (res.ok) {
                        res.text()
                            .then(text => {
                                const data = JSON.parse(text);

                                setFileMap(data.fileMap);

                                // check that the folder we are currently in still exists
                                if (!currentFolderId || currentFolderId === '')
                                    setCurrentFolderId(data.rootFolderId);
                                else if (!data.fileMap[currentFolderId])
                                    setCurrentFolderId(data.rootFolderId);

                            });
                    }
                })
                .catch(err => {
                    console.error(err);
                    reject();
                })
                .finally(() => {
                    resolve();
                })
        });
    }

    // check if there is a permission check for the download function
    useEffect(() => {
        setFileActions([...fileActions, ChonkyActions.DownloadFiles]);
        fetchFiles(path);
    }, []);

    return (
        <div style={{ height: 600 }}>
            <FileBrowser
                files={files}
                iconComponent={CustomIconSet}
                folderChain={folderChain}
                fileActions={fileActions}
                onFileAction={handleFileAction}
                darkMode={true}
                defaultSortActionId={ChonkyActions.SortFilesByName.id}
                defaultFileViewActionId={ChonkyActions.EnableListView.id}
                disableDragAndDrop
                disableDefaultFileActions
                disableDragAndDropProvider
                clearSelectionOnOutsideClick
            >
                <FileNavbar />
                <FileToolbar />
                <FileList />
                <FileContextMenu />
            </FileBrowser>
        </div>
    );
};

export default FileBrowserDownloader;

Using the Component

Now, whenever we want to add a file browser to our application we can simply add our component and provide it with the path that we wish to use as our root directory...

import React from 'react';
import FileBrowserDownloader from './FileBrowserDownloader';

const MyFileBrowser = () => {
    return (
        <FileBrowserDownloader
            path='/home/lewis/chonky'
        />
    )
}

export default MyFileBrowser

Example

Say we create some sample files in the /home/lewis/chonky directory like the following:

Files.png

Our MyFileBrowser component that we created earlier would show the following:

Chonky.png

Then, we can right click on a file and pick the "Download Files" option to download the selected file. Multiple files can also be selected and downloaded which will be downloaded in the form of a .zip file containing all the selected files.

Download.png

Thanks

Thanks for reading! You can now create a React based file browser and enable the downloading of files from your web app!

Happy coding!

ย