custom-file-tree
Version:
Add the custom element to your page context using plain old HTML:
1,254 lines (1,245 loc) • 38.5 kB
JavaScript
// src/utils/utils.js
var create = (tag) => document.createElement(tag);
var registry = globalThis.customElements ?? { define: () => {
} };
function getFileContent(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = ({ target }) => resolve(target.result);
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
// src/classes/file-tree-element.js
var HTMLElement = globalThis.HTMLElement ?? class {
};
var FileTreeElement = class extends HTMLElement {
state = {};
eventControllers = [];
constructor() {
super();
this.addUIElements();
}
addUIElements() {
this.icon = this.find(`& > .icon`);
if (!this.icon) {
const icon = this.icon = create(`span`);
icon.classList.add(`icon`);
this.appendChild(icon);
}
this.heading = this.find(`& > entry-heading`);
if (!this.heading) {
const heading = this.heading = create(`entry-heading`);
this.appendChild(heading);
}
if (!this.readonly) {
this.buttons = this.find(`& > span.buttons`);
if (!this.buttons) {
const buttons = this.buttons = create(`span`);
buttons.classList.add(`buttons`);
this.appendChild(buttons);
}
}
}
addExternalListener(target, eventName, handler, options = {}) {
const abortController = new AbortController();
options.signal = abortController.signal;
target.addEventListener(eventName, handler, options);
this.addAbortController(abortController);
}
addListener(eventName, handler, options = {}) {
this.addExternalListener(this, eventName, handler, options);
}
addAbortController(controller) {
this.eventControllers.push(controller);
}
disconnectedCallback() {
const { eventControllers } = this;
while (eventControllers.length) {
eventControllers.shift().abort();
}
}
get removeEmptyDir() {
return this.root.removeEmptyDir;
}
get name() {
return this.getAttribute(`name`);
}
set name(name) {
this.setAttribute(`name`, name);
}
get path() {
return this.getAttribute(`path`);
}
set path(path2) {
if (!path2) return;
const pos = path2.endsWith(`/`) ? -2 : -1;
const terms = path2.split(`/`);
const name = this.name = terms.at(pos).replace(/#.*/, ``);
if (!this.name && path2) {
throw Error(`why? path is ${path2}`);
}
if (this.isFile) {
const dot = name.indexOf(`.`);
if (dot >= 0 && dot < name.length - 1) {
this.extension = name.substring(dot + 1);
this.setAttribute(`extension`, this.extension);
}
}
const heading = this.find(`& > entry-heading`);
heading.textContent = this.name;
this.setAttribute(`path`, path2);
}
updatePath(isFile, oldPath, newPath) {
if (this.path === oldPath) {
this.path = newPath;
return true;
}
if (isFile) return false;
const regex = new RegExp(`^${oldPath}`);
this.path = this.path.replace(regex, newPath);
return true;
}
get dirPath() {
let { path: path2, name } = this;
if (this.isFile) return path2.replace(name, ``);
if (this.isDir) return path2.substring(0, path2.lastIndexOf(name));
throw Error(`entry is file nor dir.`);
}
get root() {
return this.closest(`file-tree`);
}
get parentDir() {
let element = this;
if (element.tagName === `DIR-ENTRY`) {
element = element.parentNode;
}
return element.closest(`dir-entry`);
}
emit(eventType, detail = {}, grant = () => {
}) {
detail.grant = grant;
this.root.dispatchEvent(new CustomEvent(eventType, { detail }));
}
find(qs) {
return this.querySelector(qs);
}
findInTree(qs) {
return this.root.querySelector(qs);
}
findAll(qs) {
return Array.from(this.querySelectorAll(qs));
}
findAllInTree(qs) {
return Array.from(this.root.querySelectorAll(qs));
}
hasButton(className) {
return this.find(`& > .buttons .${className}`);
}
select() {
this.root.unselect();
this.classList.add(`selected`);
this.parentNode?.toggle?.(false);
}
setState(stateUpdate) {
Object.assign(this.state, stateUpdate);
}
};
var EntryHeading = class extends HTMLElement {
};
registry.define(`entry-heading`, EntryHeading);
// src/classes/websocket-interface.js
var FILE_TREE_PREFIX = `file-tree:`;
var WebSocketInterface = class {
// Class extensions can push additional event
// types into this array in order to bypass
// the sync check (e.g. for things that just
// need "an answer" rather than needing to
// be sequentially ordered)
bypassSync = [`load`, `read`];
// A list used to await content responses
// from the server, so that users can just
// "await" entry.load() calls.
waitList = {};
// An "optimistically applied" list of
// pending actions that have been sent
// off to the server, and have hopefully
// been accepted, but may need undoing.
pending = [];
/**
* Set up a websocket connection to a secure
* endpoint for a given file tree element.
*/
constructor(fileTree, url, basePath = `.`, keepAliveInterval = 6e4) {
Object.assign(this, { fileTree, url, basePath, keepAliveInterval });
this.connect();
}
/**
* Connect to a websocket server and let it know which
* base path this file tree wants to be linked to, so
* that it can be joined up with every other file tree
* that's looking at/working with the same base path.
*
* @param {*} url
* @param {*} basePath
*/
async connect(url = this.url, basePath = this.basePath) {
url = url.replace(`https://`, `wss://`);
if (!url.startsWith(`wss://`)) {
throw new Error(`Only secure URLs are supported.`);
}
const socket = this.socket = new WebSocket(url);
socket.addEventListener(`message`, ({ data }) => {
data = JSON.parse(data);
let { type, detail } = data;
if (!type.startsWith(FILE_TREE_PREFIX)) return;
type = type.replace(FILE_TREE_PREFIX, ``);
const handlerName = `on${type}`;
const handler = this[handlerName].bind(this);
if (!handler) {
throw new Error(`Missing implementation for ${handlerName}.`);
}
if (this.checkSync(type, detail.seqnum)) handler(detail);
});
let keepAliveTimer;
const keepAlive = () => {
this.send(`keepalive`, { basePath });
keepAliveTimer = setTimeout(keepAlive, this.keepAliveInterval);
};
socket.addEventListener(`close`, () => {
clearTimeout(keepAliveTimer);
});
socket.addEventListener(`open`, () => {
this.send(`load`, { basePath });
keepAlive();
});
}
/**
* Mark a specific path as awaiting a "read" result.
*/
async markWaiting(path2, resolve) {
this.waitList[path2] = resolve;
}
/**
* Send a message to the server
*/
async send(type, detail = {}) {
const action = { type: `${FILE_TREE_PREFIX}${type}`, detail };
this.pending.push(action);
this.socket.send(JSON.stringify(action));
}
/**
* Verify that we're (a) in sync with respect to the
* sequence numbering for this folder, and (b) in sync
* with respect to which operation we thought we were
* going to see (if we're expecting our own operation(s)
* as next one(s) in the sequence).
* @param {*} type
* @param {*} seqnum
* @returns
*/
checkSync(type, seqnum) {
if (this.bypassSync.includes(type)) return true;
if (seqnum === this.seqnum + 1) {
const { pending } = this;
if (pending.length) {
if (pending[0].type === type) {
pending.shift();
} else {
this.rollback(pending.reverse());
}
}
return this.seqnum = seqnum;
}
this.send(`sync`, { seqnum: this.seqnum });
}
/**
* Do we need to roll back any optimistic changes?
*/
rollback(latestToOldest) {
this.pending = [];
for (const { type, detail } of latestToOldest) {
if (type === `create`) {
this.fileTree.__delete(detail.path);
}
if (type === `delete`) {
this.fileTree.__create(detail.path, detail.isFile);
this.read(path);
}
if (type === `move`) {
this.fileTree.__move(detail.isFile, detail.newPath, detail.oldPath);
}
if (type === `update`) {
this.read(path);
}
}
}
// ==========================================================================
/**
* OT operation from file tree: inform the server of a file or dir creation.
*/
async create(path2, isFile, content) {
this.send(`create`, { path: path2, isFile, content });
}
/**
* OT operation from file tree: inform the server of a deletion.
*/
async delete(path2) {
this.send(`delete`, { path: path2 });
}
/**
* OT operation from file tree: inform the server of a path change.
*/
async move(isFile, oldPath, newPath) {
this.send(`move`, { isFile, oldPath, newPath });
}
/**
* This is a special one time (well, ideally) operation for
* getting file content via websockets rather than via a
* REST API.
*
* The response will either be a string for textual data,
* or an array of ints for binary data, where each array
* element represents a byte value.
*/
async read(path2) {
return new Promise((resolve) => {
this.markWaiting(path2, resolve);
this.send(`read`, { path: path2 });
});
}
/**
* OT operation from file tree: inform the server of a content update.
*/
async update(path2, type, update) {
this.send(`update`, { path: path2, type, update });
}
// ==========================================================================
/**
* Build a tree off of a set of paths. This happens in
* response to a message of the form:
*
* {
* "type": "file-tree:load",
* "detail": {
* "paths": []
* }
* }
*
* where the `paths` payload is an array of strings.
*/
async onload({ id, dirs, files, seqnum }) {
this.id = id;
this.seqnum = seqnum;
this.fileTree.setContent({ dirs, files }, true);
}
/**
* Something has gone horribly wrong, and we need to
* terminate this connection. If `reconnect` is true
* we are allowed to reconnect so that we're back
* in a good state.
*/
async onterminate({ id, reconnect }) {
if (this.id !== id) return;
this.socket.close();
if (reconnect) this.connect();
}
/**
* Handle a create notification, which will tell us which
* path got created, and when that creation happened.
*
* This happens in response to a message of the form:
*
* {
* "type": "file-tree:create",
* "detail": {
* "path": a path string
* "isFile": a bool
* "when": a server-side datetime int
* "by": a uuid string
* }
* }
*/
async oncreate({ path: path2, isFile, from }) {
const { id, fileTree } = this;
if (from === id) return;
const entry = fileTree.__create(path2, isFile);
fileTree.dispatchEvent(
new CustomEvent(`ot:created`, { detail: { entry, path: path2, isFile } })
);
}
/**
* Handle a delete notification, which will tell us
* which path got deleted, and when that delete happened.
*
* This happens in response to a message of the form:
*
* {
* "type": "file-tree:delete",
* "detail": {
* "path": a path string
* "isFile": a bool
* "when": a server-side datetime int
* "by": a uuid string
* }
* }
*/
async ondelete({ path: path2, from }) {
const { id, fileTree } = this;
if (from === id) return;
const entries = fileTree.__delete(path2);
fileTree.dispatchEvent(
new CustomEvent(`ot:deleted`, { detail: { entries, path: path2 } })
);
}
/**
* Handle a move notification, which will tell us
* which path to rename, and when that rename happened.
*
* This happens in response to a message of the form:
*
* {
* "type": "file-tree:move",
* "detail": {
* "oldPath": a path string
* "newPath": a path string
* "when": a server-side datetime int
* "by": a uuid string
* }
* }
*/
async onmove({ isFile, oldPath, newPath, from }) {
const { id, fileTree } = this;
if (from === id) return;
const entry = fileTree.__move(isFile, oldPath, newPath);
fileTree.dispatchEvent(
new CustomEvent(`ot:moved`, {
detail: { entry, isFile, oldPath, newPath }
})
);
}
/**
* This is a special file content handler that
* lets the `read` function resolve with the
* content of the requested file.
*/
async onread({ path: path2, data }) {
const { waitList } = this;
waitList[path2]?.({ data });
delete waitList[path2];
}
/**
* Handle a content update notification, which will tell
* us which file to update, and when that update happened.
*
* This happens in response to a message of the form:
*
* {
* "type": "file-tree:update",
* "detail": {
* "path": a path string
* "update": an update payload
* "when": a server-side datetime int
* "by": a uuid string
* }
* }
*/
async onupdate({ path: path2, type, update, from }) {
const { id, fileTree } = this;
fileTree.__update(path2, type, update, from === id);
}
};
// src/utils/strings.js
var LOCALE_STRINGS = {
"en-GB": {
CREATE_FILE: `Create new file`,
CREATE_FILE_PROMPT: `Please specify a filename.`,
CREATE_FILE_NO_DIRS: `Just add new files directly to the directory where they should live.`,
RENAME_FILE: `Rename file`,
RENAME_FILE_PROMPT: `New file name?`,
RENAME_FILE_MOVE_INSTEAD: `If you want to relocate a file, just move it.`,
DELETE_FILE: `Delete file`,
DELETE_FILE_PROMPT: (path2) => `Are you sure you want to delete ${path2}?`,
CREATE_DIRECTORY: `Add new directory`,
CREATE_DIRECTORY_PROMPT: `Please specify a directory name.`,
CREATE_DIRECTORY_NO_NESTING: `You'll have to create nested directories one at a time.`,
RENAME_DIRECTORY: `Rename directory`,
RENAME_DIRECTORY_PROMPT: `Choose a new directory name`,
RENAME_DIRECTORY_MOVE_INSTEAD: `If you want to relocate a directory, just move it.`,
DELETE_DIRECTORY: `Delete directory`,
DELETE_DIRECTORY_PROMPT: (path2) => `Are you *sure* you want to delete ${path2} and everything in it?`,
UPLOAD_FILES: `Upload files from your device`,
PATH_EXISTS: (path2) => `${path2} already exists.`,
PATH_DOES_NOT_EXIST: (path2) => `${path2} does not exist.`,
PATH_INSIDE_ITSELF: (path2) => `Cannot nest ${path2} inside its own subdirectory.`,
INVALID_UPLOAD_TYPE: (type) => `Unfortunately, a ${type} is not a file or folder.`
}
};
var defaultLocale = `en-GB`;
var userLocale = globalThis.navigator?.language;
var localeStrings = LOCALE_STRINGS[userLocale] || LOCALE_STRINGS[defaultLocale];
// src/utils/upload-file.js
function uploadFilesFromDevice({ root, path: path2 }) {
const upload = create(`input`);
upload.type = `file`;
upload.multiple = true;
const uploadFiles = confirm(
`To upload one or more files, press "OK". To upload an entire folder, press "Cancel".`
);
if (!uploadFiles) upload.webkitdirectory = true;
upload.addEventListener(`change`, () => {
const { files } = upload;
if (!files) return;
processUpload(root, files, path2);
});
upload.click();
}
async function processUpload(root, items, dirPath = ``) {
let bulkUpload = items.length > 1;
async function iterate(item, path2 = ``) {
if (item instanceof File && !item.isDirectory) {
const content = await getFileContent(item);
const filePath = path2 + (item.webkitRelativePath || item.name);
const entryPath = (dirPath === `.` ? `` : dirPath) + filePath;
root.createEntry(entryPath, true, content, bulkUpload);
} else if (item.isFile) {
item.file(async (file) => {
const content = await getFileContent(file);
const filePath = path2 + file.name;
const entryPath = (dirPath === `.` ? `` : dirPath) + filePath;
root.createEntry(entryPath, true, content, bulkUpload);
});
} else if (item.isDirectory) {
bulkUpload = true;
const updatedPath = path2 + item.name + "/";
root.createEntry(updatedPath, false, false, bulkUpload);
item.createReader().readEntries(async (entries) => {
for (let entry of entries) await iterate(entry, updatedPath);
});
}
}
for (let item of items) {
try {
let entry;
if (!entry && item instanceof File) {
entry = item;
}
if (!entry && item.webkitGetAsEntry) {
entry = item.webkitGetAsEntry() ?? entry;
}
if (!entry && item.getAsFile) {
entry = item.getAsFile();
}
await iterate(entry);
} catch (e) {
return alert(localeStrings.INVALID_UPLOAD_TYPE(item.kind));
}
}
}
// src/utils/make-drop-zone.js
function makeDropZone(dirEntry) {
const { readonly } = dirEntry.root;
const abortController = new AbortController();
const unmark = () => {
dirEntry.findAllInTree(`.drop-target`).forEach((d) => d.classList.remove(`drop-target`));
};
dirEntry.draggable = true;
dirEntry.addEventListener(
`dragstart`,
(evt) => {
evt.stopPropagation();
if (dirEntry.root.readonly) return;
dirEntry.classList.add(`dragging`);
dirEntry.dataset.id = `${Date.now()}-${Math.random()}`;
evt.dataTransfer.setData("id", dirEntry.dataset.id);
},
{ signal: abortController.signal }
);
if (readonly) return;
dirEntry.addEventListener(
`dragenter`,
(evt) => {
evt.preventDefault();
unmark();
dirEntry.classList.add(`drop-target`);
},
{ signal: abortController.signal }
);
dirEntry.addEventListener(
`dragover`,
(evt) => {
const el = evt.target;
if (inThisDir(dirEntry, el)) {
evt.preventDefault();
unmark();
dirEntry.classList.add(`drop-target`);
}
},
{ signal: abortController.signal }
);
dirEntry.addEventListener(
`dragleave`,
(evt) => {
evt.preventDefault();
unmark();
},
{ signal: abortController.signal }
);
dirEntry.addEventListener(
`drop`,
async (evt) => {
evt.preventDefault();
evt.stopPropagation();
unmark();
const entryId = evt.dataTransfer.getData(`id`);
if (entryId) return processDragMove(dirEntry, entryId);
await processUpload(dirEntry.root, evt.dataTransfer.items, dirEntry.path);
},
{ signal: abortController.signal }
);
if (dirEntry.path === `.`) {
return dirEntry.draggable = false;
}
return abortController;
}
function inThisDir(dir, entry) {
if (entry === dir) return true;
return entry.closest(`dir-entry`) === dir;
}
function processDragMove(dirEntry, entryId) {
const entry = dirEntry.findInTree(`[data-id="${entryId}"]`);
delete entry.dataset.id;
entry.classList.remove(`dragging`);
if (entry === dirEntry) return;
let dirPath = dirEntry.path;
let newPath = (dirPath !== `.` ? dirPath : ``) + entry.name;
if (entry.isDir) newPath += `/`;
dirEntry.root.moveEntry(entry, newPath);
}
// src/classes/dir-entry.js
var DirEntry = class extends FileTreeElement {
isDir = true;
constructor(root, rootDir = false) {
super();
if (!root.readonly) this.addButtons(rootDir);
}
get path() {
return super.path;
}
set path(v) {
super.path = v;
if (v === `.`) {
this.find(`& > .rename-dir`)?.remove();
this.find(`& > .delete-dir`)?.remove();
}
}
connectedCallback() {
this.addListener(`click`, (evt) => this.selectListener(evt));
this.addExternalListener(
this.icon,
`click`,
(evt) => this.foldListener(evt)
);
const controller = makeDropZone(this);
if (controller) this.addAbortController(controller);
}
selectListener(evt) {
evt.stopPropagation();
evt.preventDefault();
if (this.path === `.`) return;
const tag = evt.target.tagName;
if (tag !== `DIR-ENTRY` && tag !== `ENTRY-HEADING`) return;
this.root.selectEntry(this);
if (this.classList.contains(`closed`)) {
this.foldListener(evt);
}
}
foldListener(evt) {
evt.stopPropagation();
evt.preventDefault();
if (this.path === `.`) return;
const closed = this.classList.contains(`closed`);
this.root.toggleDirectory(this, {
currentState: closed ? `closed` : `open`
});
}
addButtons(rootDir) {
this.createFileButton();
this.createDirButton();
this.addUploadButton();
if (!rootDir) {
this.addRenameButton();
this.addDeleteButton();
}
}
/**
* New file in this directory
*/
createFileButton() {
if (this.hasButton(`create-file`)) return;
const btn = create(`button`);
btn.classList.add(`create-file`);
btn.title = localeStrings.CREATE_FILE;
btn.textContent = `\u{1F4C4}`;
btn.addEventListener(`click`, () => this.#createFile());
this.buttons.appendChild(btn);
}
#createFile() {
let fileName = prompt(localeStrings.CREATE_FILE_PROMPT)?.trim();
if (fileName) {
if (fileName.includes(`/`)) {
return alert(localeStrings.CREATE_FILE_NO_DIRS);
}
if (this.path !== `.`) {
fileName = this.path + fileName;
}
this.root.createEntry(fileName, true);
}
}
/**
* New directory in this directory
*/
createDirButton() {
if (this.hasButton(`create-dir`)) return;
const btn = create(`button`);
btn.classList.add(`create-dir`);
btn.title = localeStrings.CREATE_DIRECTORY;
btn.textContent = `\u{1F4C1}`;
btn.addEventListener(`click`, () => this.#createDir());
this.buttons.appendChild(btn);
}
#createDir() {
let dirName = prompt(String.CREATE_DIRECTORY_PROMPT)?.trim();
if (dirName) {
if (dirName.includes(`/`)) {
return alert(localeStrings.CREATE_DIRECTORY_NO_NESTING);
}
let path2 = (this.path !== `.` ? this.path : ``) + dirName + `/`;
this.root.createEntry(path2, false);
}
}
/**
* Upload files or an entire directory from your device
*/
addUploadButton() {
if (this.hasButton(`upload`)) return;
const btn = create(`button`);
btn.classList.add(`upload`);
btn.title = localeStrings.UPLOAD_FILES;
btn.textContent = `\u{1F4BB}`;
btn.addEventListener(`click`, () => uploadFilesFromDevice(this));
this.buttons.appendChild(btn);
}
/**
* rename this dir.
*/
addRenameButton() {
if (this.path === `.`) return;
if (this.hasButton(`rename-dir`)) return;
const btn = create(`button`);
btn.classList.add(`rename-dir`);
btn.title = localeStrings.RENAME_DIRECTORY;
btn.textContent = `\u270F\uFE0F`;
this.buttons.appendChild(btn);
btn.addEventListener(`click`, () => this.#rename());
}
#rename() {
const newName = prompt(localeStrings.RENAME_DIRECTORY_PROMPT, this.name)?.trim();
if (newName) {
if (newName.includes(`/`)) {
return alert(localeStrings.RENAME_DIRECTORY_MOVE_INSTEAD);
}
this.root.renameEntry(this, newName);
}
}
/**
* Remove this dir and everything in it
*/
addDeleteButton() {
if (this.path === `.`) return;
if (this.hasButton(`delete-dir`)) return;
const btn = create(`button`);
btn.classList.add(`delete-dir`);
btn.title = localeStrings.DELETE_DIRECTORY;
btn.textContent = `\u{1F5D1}\uFE0F`;
this.buttons.appendChild(btn);
btn.addEventListener(`click`, () => this.#deleteDir());
}
#deleteDir() {
const msg = localeStrings.DELETE_DIRECTORY_PROMPT(this.path);
if (confirm(msg)) {
this.root.removeEntry(this);
}
}
/**
* Because the file-tree has a master list of directories, we should
* never need to do any recursion: if there's an addEntry, that entry
* goes here.
*/
addEntry(entry) {
this.appendChild(entry);
this.sort();
}
/**
* If the file tree has the `remove-empty` attribute, deleting the
* last bit of content from a dir should trigger its own deletion.
* @returns
*/
checkEmpty() {
if (!this.removeEmptyDir) return;
if (this.find(`dir-entry, file-entry`)) return;
this.root.removeEntry(this);
}
// File tree sorting, with dirs at the top
sort(recursive = true, separateDirs = true) {
const children = [...this.children];
children.sort((a, b) => {
if (a.tagName === `SPAN` && a.classList.contains(`icon`)) return -1;
if (b.tagName === `SPAN` && b.classList.contains(`icon`)) return 1;
if (a.tagName === `ENTRY-HEADING`) return -1;
if (b.tagName === `ENTRY-HEADING`) return 1;
if (a.tagName === `SPAN` && b.tagName === `SPAN`) return 0;
else if (a.tagName === `SPAN`) return -1;
else if (b.tagName === `SPAN`) return 1;
if (separateDirs) {
if (a.tagName === `DIR-ENTRY` && b.tagName === `DIR-ENTRY`) {
a = a.path;
b = b.path;
return a < b ? -1 : 1;
} else if (a.tagName === `DIR-ENTRY`) {
return -1;
} else if (b.tagName === `DIR-ENTRY`) {
return 1;
}
}
a = a.path;
b = b.path;
return a < b ? -1 : 1;
});
children.forEach((c) => this.appendChild(c));
if (recursive) {
this.findAll(`& > dir-entry`).forEach((d) => d.sort(recursive));
}
}
toggle(state) {
this.classList.toggle(`closed`, state);
this.parentNode?.toggle?.(false);
}
toJSON() {
return JSON.stringify(this.toValue());
}
toString() {
return this.toJSON();
}
toValue() {
return this.root.toValue().filter((v) => v.startsWith(this.path));
}
};
registry.define(`dir-entry`, DirEntry);
// src/classes/file-entry.js
var FileEntry = class extends FileTreeElement {
isFile = true;
constructor(root, fileName, fullPath) {
super(fileName, fullPath);
if (!root.readonly) this.addButtons();
this.addEventHandling(root.readonly);
}
addButtons() {
this.addRenameButton();
this.addDeleteButton();
}
addRenameButton() {
if (this.hasButton(`rename-file`)) return;
const btn = create(`button`);
btn.classList.add(`rename-file`);
btn.title = localeStrings.RENAME_FILE;
btn.textContent = `\u270F\uFE0F`;
this.buttons.appendChild(btn);
btn.addEventListener(`click`, (evt) => {
evt.preventDefault();
evt.stopPropagation();
const newFileName = prompt(
localeStrings.RENAME_FILE_PROMPT,
this.heading.textContent
)?.trim();
if (newFileName) {
if (newFileName.includes(`/`)) {
return alert(localeStrings.RENAME_FILE_MOVE_INSTEAD);
}
this.root.renameEntry(this, newFileName);
}
});
}
addDeleteButton() {
if (this.hasButton(`delete-file`)) return;
const btn = create(`button`);
btn.classList.add(`delete-file`);
btn.title = localeStrings.DELETE_FILE;
btn.textContent = `\u{1F5D1}\uFE0F`;
this.buttons.appendChild(btn);
btn.addEventListener(`click`, (evt) => {
evt.preventDefault();
evt.stopPropagation();
if (confirm(localeStrings.DELETE_FILE_PROMPT(this.path))) {
this.root.removeEntry(this);
}
});
}
addEventHandling(readonly) {
this.addEventListener(`click`, (evt) => {
evt.preventDefault();
evt.stopPropagation();
this.root.selectEntry(this);
});
this.draggable = true;
this.addEventListener(`dragstart`, (evt) => {
evt.stopPropagation();
if (readonly) return;
this.classList.add(`dragging`);
this.dataset.id = `${Date.now()}-${Math.random()}`;
evt.dataTransfer.setData("id", this.dataset.id);
});
}
// This function only works when connected through
// a websocket. Note that we do NOT store the data
// here, that's up to whoever is using this file-tree.
//
// The return type is { data: string|int[], when:datetime }
async load() {
return this.root.loadEntry(this.path);
}
// This function only works when connected through
// a websocket. Note that we do NOT store the data
// here, that's up to whoever is using this file-tree.
async updateContent(type, update) {
this.root.updateEntry(this.path, type, update);
}
toJSON() {
return JSON.stringify(this.toValue());
}
toString() {
return this.path;
}
toValue() {
return [this.toString()];
}
};
registry.define(`file-entry`, FileEntry);
// src/file-tree.js
var FileTree = class extends FileTreeElement {
static observedAttributes = ["src"];
ready = false;
isTree = true;
entries = {};
constructor() {
super();
this.heading.textContent = `File tree`;
}
get root() {
return this;
}
get parentDir() {
return this.rootDir;
}
get readonly() {
return this.hasAttribute(`readonly`);
}
get removeEmptyDir() {
return this.hasAttribute(`remove-empty-dir`);
}
clear() {
this.ready = false;
this.emit(`tree:clear`);
Object.keys(this.entries).forEach((key) => delete this.entries[key]);
if (this.rootDir) this.removeChild(this.rootDir);
const rootDir = this.rootDir = new DirEntry(this, true);
rootDir.path = `.`;
this.appendChild(rootDir);
}
connectedCallback() {
this.addExternalListener(
document,
`dragend`,
() => this.findAll(`.dragging`).forEach((e) => e.classList.remove(`dragging`))
);
}
attributeChangedCallback(name, _, value) {
if (name === `src` && value) {
this.#loadSource(value);
}
}
/**
* Connect to a websocket server. You can provide
* a custom websocket interface class, but then
* you better know what you're doing =)
*
* @param {*} url
* @param {*} basePath
* @param {*} ConnectorClass
*/
async connectViaWebSocket(url, basePath = `.`, keepAliveInterval = 6e4, ConnectorClass = WebSocketInterface) {
this.OT = new ConnectorClass(this, url, basePath, keepAliveInterval);
return this.OT;
}
/**
* Setting files is a destructive operation, clearing whatever is already
* in this tree in favour of new tree content.
*/
setContent({ dirs, files }, bypassOT = false) {
this.clear();
dirs?.forEach(
(path2) => this.#addPath(
`${path2}/`,
false,
// isFile
void 0,
// content
true,
// bulk
`tree:add:dir`,
true,
//immediately create the entry
bypassOT
)
);
files?.forEach(
(path2) => this.#addPath(
path2,
true,
// isFile
void 0,
// content
true,
// bulk
`tree:add:file`,
true,
// immediately create the entry
bypassOT
)
);
this.ready = true;
return this.emit(`tree:ready`);
}
// create or upload
createEntry(path2, isFile, content = void 0, bulk = false) {
let eventType = (isFile ? `file` : `dir`) + `:create`;
this.#addPath(path2, isFile, content, bulk, eventType);
}
// get the file contents for an entry via a websocket connection
async loadEntry(path2) {
return this.OT?.read(path2);
}
// notify the server of a file content change
async updateEntry(path2, type, update) {
return this.OT?.update(path2, type, update);
}
// A rename is a relocation where only the last part of the path changed.
renameEntry(entry, newName) {
const oldPath = entry.path;
const pos = oldPath.lastIndexOf(entry.name);
let newPath = oldPath.substring(0, pos) + newName;
if (entry.isDir) newPath += `/`;
const eventType = (entry.isFile ? `file` : `dir`) + `:rename`;
this.#relocateEntry(entry, oldPath, newPath, eventType);
}
// A move is a relocation where everything *but* the last part of the path may have changed.
moveEntry(entry, newPath) {
const eventType = (entry.isFile ? `file` : `dir`) + `:move`;
this.#relocateEntry(entry, entry.path, newPath, eventType);
}
// Deletes are a DOM removal of the entry itself, and a pruning
// of the path -> entry map for any entry that started with the
// same path, so we don't end up with any orphans.
removeEntry(entry) {
const { path: path2, isFile, parentDir } = entry;
const eventType = (isFile ? `file` : `dir`) + `:delete`;
const detail = { path: path2, emptyDir: this.removeEmptyDir };
this.emit(eventType, detail, () => {
const removed = this.__delete(path2, isFile);
this.OT?.delete(path2);
detail.removed = removed;
setTimeout(() => parentDir.checkEmpty(), 10);
return removed;
});
}
// ================================================================================================
async #loadSource(url) {
const response = await fetch(url);
const data = await response.json();
if (data) {
const { dirs, files } = data;
this.setContent({ dirs, files });
}
}
// private function for initiating <file-entry> or <dir-entry> creation
#addPath(path2, isFile, content = void 0, bulk = false, eventType, immediate = false, bypassOT = false) {
const { entries } = this;
if (entries[path2]) {
return this.emit(`${eventType}:error`, {
error: localeStrings.PATH_EXISTS(path2)
});
}
const detail = { path: path2, content, bulk };
const grant = (processedContent = content) => {
const entry = this.__create(path2, isFile);
if (!bypassOT) this.OT?.create(path2, isFile, processedContent);
detail.entry = entry;
return entry;
};
if (immediate) return grant();
this.emit(eventType, detail, grant);
}
// Ensure that a dir exists (recursively).
#mkdir({ dirPath }) {
const { entries } = this;
if (!dirPath) return this.rootDir;
let dir = this.find(`[path="${dirPath}"`);
if (dir) return dir;
dir = this.rootDir;
dirPath.split(`/`).forEach((fragment) => {
if (!fragment) return;
const subDirPath = (dir.path === `.` ? `` : dir.path) + fragment + `/`;
let subDir = this.find(`[path="${subDirPath}"`);
if (!subDir) {
subDir = new DirEntry(this);
subDir.path = subDirPath;
dir.addEntry(subDir);
entries[subDirPath] = subDir;
}
dir = subDir;
});
return dir;
}
// private function for initiating <file-entry> or <dir-entry> path changes
#relocateEntry(entry, oldPath, newPath, eventType) {
const { entries } = this;
if (oldPath === newPath) return;
if (newPath.startsWith(oldPath)) {
const reduced = newPath.replace(oldPath, ``);
if (reduced.includes(`/`)) {
return this.emit(`${eventType}:error`, {
oldPath,
newPath,
error: localeStrings.PATH_INSIDE_ITSELF(oldPath)
});
}
}
if (entries[newPath]) {
return this.emit(`${eventType}:error`, {
oldPath,
newPath,
error: localeStrings.PATH_EXISTS(newPath)
});
}
const detail = { oldPath, newPath };
this.emit(eventType, detail, () => {
this.__move(entry.isFile, oldPath, newPath);
this.OT?.move(entry.isFile, oldPath, newPath);
detail.entry = entry;
return entry;
});
}
// ================================================================================================
// create notification via websocket or immediate code path:
__create(path2, isFile) {
const { entries } = this;
const EntryType = isFile ? FileEntry : DirEntry;
const entry = entries[path2] = new EntryType(this);
entry.path = path2;
this.#mkdir(entry).addEntry(entry);
return entry;
}
// move notification via websocket or immediate code path:
__move(isFile, oldPath, newPath, when) {
const { entries } = this;
const entry = entries[oldPath];
Object.keys(entries).forEach((key) => {
if (key.startsWith(oldPath)) {
const entry2 = entries[key];
const updated = entry2.updatePath(isFile, oldPath, newPath);
if (updated) {
entries[entry2.path] = entry2;
delete entries[key];
}
}
});
const { dirPath } = entries[newPath] = entry;
let dir = dirPath ? entries[dirPath] : this.rootDir;
dir.addEntry(entry);
return entry;
}
// update notification via websocket or immediate code path:
__update(path2, type, update, ours) {
this.entries[path2]?.dispatchEvent(
new CustomEvent(`content:update`, { detail: { type, update, ours } })
);
}
// delete notification via websocket or immediate code path:
__delete(path2, isFile, when) {
const { entries } = this;
const entry = entries[path2];
const removed = [entry];
if (isFile) {
entry.remove();
delete entries[path2];
} else {
Object.entries(entries).forEach(([key, entry2]) => {
if (key.startsWith(path2)) {
removed.push(entry2);
entry2.remove();
delete entries[key];
}
});
}
return removed;
}
// ================================================================================================
// Select an entry by its path
select(path2) {
const entry = this.entries[path2];
if (!entry) throw new Error(localeStrings.PATH_DOES_NOT_EXIST(path2));
entry.select();
}
// Counterpart to select()
unselect() {
this.find(`.selected`)?.classList.remove(`selected`);
}
// Entry selection depends on the element, so we hand that
// off to the entry itself once granted. (if granted)
selectEntry(entry, detail = {}) {
const eventType = (entry.isFile ? `file` : `dir`) + `:click`;
detail.path = entry.path;
this.emit(eventType, detail, () => {
entry.select();
detail.entry = entry;
return entry;
});
}
toggleDirectory(entry, detail = {}) {
if (entry.isFile) return;
const eventType = `dir:toggle`;
detail.path = entry.path;
this.emit(eventType, detail, () => {
detail.entry = entry;
entry.toggle();
});
}
sort() {
this.rootDir.sort();
}
// ================================================================================================
toJSON() {
return JSON.stringify(Object.keys(this.entries).sort());
}
toString() {
return this.toJSON();
}
toValue() {
return this;
}
};
registry.define(`file-tree`, FileTree);
export {
FILE_TREE_PREFIX,
WebSocketInterface
};