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:
Our MyFileBrowser
component that we created earlier would show the following:
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.
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!