This is an HTML custom element for adding file tree visualisation and interaction to your page.
Simply add the element .js and .css files to your page using plain HTML:
<script src="somewhere/file-tree.esm.js" type="module" async></script>
<link rel="stylesheet" href="somewhere/file-tree.css" async />And then you can work with any <file-tree> like you would any other HTML element. For example:
// query select, or really any normal way to get an element handle:
const fileTree = document.querySelector(`file-tree`);
// Tell the file tree which files exist
fileTree.setContent({
files: [
`README.md`,
`dist/client.bundle.js`,
`src/server/index.js`,
`LICENSE.md`,
`src/client/index.js`,
`src/server/middleware.js`,
`package.json`,
`dist/client.bundle.min.js`,
],
});After which users can play with the file tree as much as they like: all operations generate "permission-seeking" events, which need to be explicitly granted before the filetree will let them happen, meaning that you have code like:
filetree.addEventListener(`file:rename`, async ({ detail }) => {
const { oldPath, newPath, grant } = detail;
// we'll have the API determine whether this operation is allowed or not:
const result = await api.renameFile(oldPath, newPath);
if (result.error) {
warnUser(`An error occurred trying to rename ${oldPath} to ${newPath}.`);
} else if (result.denied) {
warnUser(`You do not have permission to rename files.`);
} else {
grant();
}
});Thus ensuring that the file tree stays in sync with your real filesystem (whether that's through an api as in the example, or a client-side )
There is a live demo that shows off the above, with event handling set up to blanket-allow every action a user can take.
Part of the functionality for this element is based on the HTML5 drag-and-drop API (for parts of the file tree itself, as well as dragging files and folders into it from your device), which is notoriously based on "mouse events" rather than "pointer events", meaning there is no touch support out of the box.
However, touch support can be trivially achieved using the following shim, which has its own repository over on https://github.com/pomax/dragdroptouch (which is a fork of https://github.com/Bernardo-Castilho/dragdroptouch, rewritten as a modern ESM with support for autoloading)
<script
src="https://pomax.github.io/dragdroptouch/dist/drag-drop-touch.esm.min.js?autoload"
type="module"
></script>Load this as first thing on your page, and done: drag-and-drop using touch will now work.
If you wish to associate data with <file-entry> and <dir-entry> elements, you can do so by adding data to their .state property either directly, or by using the .setState(update) function, which takes an update object and applies all key:value pairs in the update to the element's state.
While in HTML context this should be obvious: this is done synchronously, unlike the similarly named function that you might be familiar with from frameworks like React or Preact. The <file-tree> is a normal HTML element and updates take effect immediately.
As mentioned above, events are "permission seeking", meaning that they are dispatched before an action is allowed to take place. Your event listener code is responsible for deciding whether or not that action is allowed to take place given the full context of who's performing it on which file/directory.
If an event is not allowed to happen, your code can simply exit the event handler. The file-tree will remain as it was before the user tried to manipulate it.
If an event is allowed to happen, your code must call event.detail.grant(), which lets the file tree perform the associated action.
If you wish to receive a signal for when the tree has "in principle" finished building itself (because file/dir add operations may still be pending grants), you can listen for the tree:ready event.
Events are listed here as name → detail object content, with the grant function omitted from the detail object, as by definition all events come with a grant function.
file:click→{element, path},
Dispatched when a file entry is clicked, withpathrepresenting the full path of the file in question.
Granting this action will assign theselectedclass to the associated file entry.file:create→{path, content?, bulk?},
Dispatched when a new file is created by name, withelementbeing the file tree element, andpathbeing the file's full path. If this file was created through a file "upload", it will also have acontentvalue of type ArrayBuffer representing the file's byte code. If thebulkflag is set totruethen this was part of a bulk insertion (e.g. a folder upload).
Granting this action will create a new file entry, nested according to thepathvalue.file:rename→{oldPath, newPath},
Dispatched when an existing file is renamed by the user, witholdPathbeing the current file path, andnewPaththe desired new path.
Granting this action will change the file entry's label and path values.
Note: file renames are (currently) restricted to file names only, as renames that include directory prefixes (including../) should be effected by just moving the file to the correct directory.file:move→{oldPath, newPath},
Dispatched when a file gets moved to a different directory, witholdPathbeing the current file path, andnewPaththe desired new path.
Granting this action will move the file entry from its current location to the location indicated bynewPath.file:delete→{path},
Dispatched when a file gets deleted, withpathrepresenting the full path of the file in question.
Granting this action will remove the file entry from the tree.
Note: if this is the only file in a directory, and the<file-tree>specifies theremove-emptyattribute, the now empty directory will also be deleted, gated by adir:deletepermission event, but not gated by aconfirm()dialog to the user.
dir:click→{path},
Dispatched when a directory entry is clicked, withpathrepresenting the full path of the directory in question.
Granting this action will assign theselectedclass to the associated directory entry.dir:toggle→{path, currentState},
Dispatched when a directory icon is clicked, withpathrepresenting the full path of the directory in question, andcurrentStatereflecting whether this directory is currently visualized as"open"or"closed", determined by whether or not its class list includes theclosedclass.
Granting this action will toggle theclosedclass on the associated directory entry.dir:create→{path},
Dispatched when a directory gets created, withpathbeing the directory's full path.
Granting this action will create a new directory entry, nested according to thepathvalue.dir:rename→{oldPath, newPath},
Dispatched when an existing directory is renamed by the user, witholdPathbeing the current directory path, andnewPaththe desired new path.
Granting this action will change the directory entry's label and path values.
Note: directory nesting cannot (currently) be effected by renaming, and should instead be effected by just moving the directory into or out of another directory.dir:move→{oldPath, newPath},
Dispatched when a directory gets moved to a different parent directory, witholdPathbeing the current directory path, andnewPaththe desired new path.
Granting this action will move the directory entry from its current location to the location indicated bynewPath.dir:delete→{path},
Dispatched when a directory gets deleted, withpathrepresenting the full path of the directory in question.
Granting this action will remove the directory entry (including its associated content) from the tree.
Note: this action is gated behind aconfirm()dialog for the user.
File tree tags may specify a "remove-empty" attribute, i.e.
<file-tree remove-empty="true"></file-tree>Setting this attribute tells the file tree that it may delete directories that become empty due to file move/delete operations.
By default, file trees content "normally", even though under the hood all content is wrapped by a directory entry with path "." to act as a root. File tree tags may specify a "show-top-level" attribute to show this root directory, i.e.
<file-tree show-top-level="true"></file-tree>The <file-tree> element can be told to connect via a secure websocket, rather than using REST operations, in which case things may change "on their own".
Any "create", "move" ("rename"), and "delete" operations that were initiated remotely will be automatically applied to your <file-tree> (bypassing the grant mechanism) in order to keep you in sync with the remote.
The "update" operation is somewhat special, as <file-tree> is agnostic about how you're dealing with file content, instead relying on you to hook into the file:click event to do whatever you want to do. However, file content changes can be initiated by the server, in which case the relevant <file-entry> will generate a content:update event that you can listen for in your code:
const content = {};
fileTree.addEventListener(`file:click`, async ({ detail }) => {
// Get this file's content from the server
const entry = detail.entry ?? detail.grant();
const data = (content[entry.path] ??= (await entry.load()).data);
currentEntry = entry;
// And then let's assume we do something with that
// content, like showing it in a code editor
updateEditor(currentEntry, data);
// We then make sure to listen to content updates
// from the server, so we can update our local
// copy to reflect the remote copy:
entry.addEventListener(`content:update`, async (evt) => {
const { type, update } = evt.detail;
if (type === `some agreed upon mechanism name`) {
// Do we have a local copy of this file?
const { path } = entry;
if (!content[path]) return;
// We do: update our local copy to be in sync
// with the remote copy at the server:
const oldContent = content[path];
const newContent = updateLocalCopy(oldContent, update);
content[path] = newContent;
// And then if we were viewing this entry in our
// code editor, update that:
if (entry === currentEntry) {
updateEditor(currentEntry, newContent);
}
}
});
});See the websocket demo for a much more detailed, and fully functional, example of how you might want to use this.
To use "out of the box" websocket functionality, create your <file-tree> element with a websocket attribute. With that set up, you can connect your tree to websocket endpoint using:
if (fileTree.hasAttribute(`websocket`)) {
// What URL do we want to connect to?
const url = `https://server.example.com`;
// Which basepath should this file tree be looking at?
// For example, if the server has a `content` dir that
// is filled with project dirs, then a file tree connection
// "for a specific project" makes far more sense than a
// conection that shows every single project dir.
//
// Note that this can be omitted if that path is `.`
const basePath = `.`;
// Let's connect!
fileTree.connectViaWebSocket(url, basePath);
}The url can be either https: or wss:, but it must be a secure URL. For what are hopefully incredibly obvious security reasons, websocket traffic for file tree operations will not work using insecure plain text transmission.
When a connection is established, the file tree will automatically populate by sending a JSON-encoded { type: "file-tree:load" } object to the server, and then waiting for the server to respond with a JSON-encoded { type: "file-tree:load", detail: { dirs: [...], files: [...] }} where the { dirs, files } content is the same as is used by the setContent function.
While the regular file tree events are for local user initiated actions, there are additional events for when changes are made due to remote changes. These events are generated after the file tree gets updated and do no have a "grant" mechanism (you don't get a choice in terms of whether to stay in sync with the remote server of course)
ot:createdwith event details{ entry, path, isFile }whereentryis the new entry in the file treeot:movedwith event details{ entry, isFile, oldPath, newPath }whereentryis the moved entry in the file tree. Note that this even is generated in response to a rename as well as a move, as those are the same operation under the hood.ot:deletedwith event details{ entries, path }wherepathis the deleted path, andentriesis an array of one or more entries that were removed from the file tree as a result.
In order for a <file-tree> to talk to your server over websockets, you will need to implement the following contract, where each event is sent as a JSON encoded message:
On connect, the server should generate a unique id that it can use to track call origins, so that it can track what to send to whom. When file trees connect, they will send a JSON-encoded { type: "file-tree:load" } object, which should trigger a server response that is a a JSON-encoded { type: "file-tree:load", detail: { id, paths: [...] }} where the paths content is an array of path strings, and the id is the unique id that was generated when the connection was established, so that clients know their server-side identity.
Create calls are sent by the client as:
{
type: "file-tree:create",
detail: {
id: the client's id,
path: "the entry's path string",
isFile: true if file creation, false if dir creation
}
}
and should be transmitted to clients as:
{
type: "file-tree:create",
detail: {
from: id of the origin
path: "the entry's path string",
isFile: true if file, false if dir
when: the datetime int for when the server applied the create
}
}
The id can be used in your code to identify other clients (e.g. to show "X did Y" notifications), and the when argument is effectively the server-side sequence number. Actions will always be applied in chronological order by the server, and clients can use the when value as a way to tell whether they're out of sync or not.
Move/rename calls are sent by the client as:
{
type: "file-tree:move",
detail: {
id: the client's id,
oldPath: "the entry's path string",
newPath: "the entry's path string"
}
}
and should be transmitted to clients as:
{
type: "file-tree:move",
detail: { from, oldPath, newPath, when }
}
Update calls are sent by the client as:
{
type: "file-tree:update",
detail: {
id: the client's id,
path: "the entry's path string",
update: the update payload
}
}
Note that the update payload is up to whoever implements this client/server contract, because there are a million and one ways to "sync" content changes, from full-fat content updates to sending diff patches to sending operation transforms to even more creative solutions.
Updates should be transmitted to clients as:
{
type: "file-tree:update",
detail: { from, path, update, when }
}
Delete calls are sent by the client as:
{
type: "file-tree:delete",
detail: {
id: the client's id,
path: "the entry's path string",
}
}
Deletes should be transmitted to clients as:
{
type: "file-tree:update",
detail: { from, path, when }
}
Note that deletes from the server to the client don't need to say whether to remove an empty dir: if the dir got removed, then the delete that clients will receive is for that dir, not the file whose removal triggered the empty dir deletion
There is a special read event that gets sent by the client as
{
type: "file-tree:read",
detail {
path: "the file's path"
}
}
This is a request for the server to send the entire file's content back using the format:
{
type: "file-tree:read",
details { path, data, when }
}
In this response data is either a string or an array of ints. If the latter, this is binary data, where each array element represents a byte value.
This call is (obviously) not forwarded to any other clients, and exists purely as a way to bootstrap a file's content synchronization, pending future file-tree:update messages.
Websocket clients send a keep-alive signal in the form of a message that takes the form:
{
type: `file-tree:keepalive`,
detail: {
basePath: `the base path that this client was registered for`
}
}
This can be ignored, or you can use it as a way to make sure that "whatever needs to be running" stays running while there's an active websocket connection, rather than going to sleep, getting shut down, or otherwise being made unavailable. Note that this is a one way message, no response is expected.
By default the send interval for this message is 60 seconds, but this can be changed by passing an interval in milliseconds as third argument to the connectViaWebSocket function:
if (fileTree.hasAttribute(`websocket`)) {
// Our websocket server
const url = `wss://server.example.com`;
// The remote dir we want to watch:
const basePath = `experimental`;
// With a keepalive signal sent every 4 minutes and 33 seconds:
const keepAliveInterval = 273_000;
fileTree.connectViaWebSocket(url, basePath, keelAliveInterval);
}If you don't like the default styling, just override it! This custom element uses normal CSS, so you're under no obligation to load the file-tree.css file, either load it and then override the parts you want to customize, or don't even load file-tree.css at all and come up with your own styling.
That said, there are a number of CSS variables that you override on the file-tree selector if you just want to tweak things a little, with their current definitions being:
file-tree {
--fallback-icon: "🌲";
--open-dir-icon: "📒";
--closed-dir-icon: "📕";
--file-icon: "📄";
--icon-size: 1.25em;
--line-height: 1.5em;
--indent: 1em;
--entry-padding: 0.25em;
--highlight-background: lightcyan;
--highlight-background-bw: #ddd;
--highlight-border-color: blue;
--drop-target-color: rgb(205, 255, 242);
}
For example, if you just want to customize the icons and colors, load the file-tree.css and then load your own overrides that set new values for those CSS variables. Nice and simple!
- If you think you've found a bug, feel free to file it over on the the issue tracker: https://github.com/Pomax/custom-file-tree/issues
- If you have ideas about how
<file-tree>should work, start a discussion over on: https://github.com/Pomax/custom-file-tree/discussions - If you just want to leave a transient/drive-by comment, feel free to contact me on mastodon: https://mastodon.social/@TheRealPomax
— Pomax