summaryrefslogtreecommitdiff
path: root/htroot
diff options
context:
space:
mode:
authorMichael Peter Christen <mc@yacy.net>2026-02-10 14:29:12 +0100
committerMichael Peter Christen <mc@yacy.net>2026-02-10 14:29:12 +0100
commit50cf84fd68f9283241e4882d0ead9c24d6d52fde (patch)
tree805ae375a6b8126f83015b2c9b4aa46cc6e85fa5 /htroot
parent6a2c27c45b12d1c9945e3a3ae82e8af62bea601d (diff)
added virtual file system that stores users data in the users browser
Diffstat (limited to 'htroot')
-rw-r--r--htroot/VFS.html951
-rw-r--r--htroot/js/vfs.js549
2 files changed, 1500 insertions, 0 deletions
diff --git a/htroot/VFS.html b/htroot/VFS.html
new file mode 100644
index 000000000..cc90d1af7
--- /dev/null
+++ b/htroot/VFS.html
@@ -0,0 +1,951 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
+ <head>
+ <title>YaCy '#[clientname]#': Virtual File System</title>
+ #%env/templates/metas.template%#
+ <link rel="stylesheet" type="text/css" href="js/styles/default.min.css" />
+ <style type="text/css">
+ #vfs-intro {
+ margin-bottom: 12px;
+ }
+
+ #finder-window {
+ border: 1px solid #d5d5d5;
+ background: #ffffff;
+ }
+
+ #finder-titlebar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ border-bottom: 1px solid #e5e5e5;
+ background: #f8f8f8;
+ }
+
+ #finder-controls {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ }
+
+ #finder-controls span {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ display: inline-block;
+ border: 1px solid #bfbfbf;
+ background: #e4e4e4;
+ }
+
+ #finder-path {
+ flex: 1 1 220px;
+ font-family: monospace;
+ background: #ffffff;
+ border: 1px solid #d9d9d9;
+ border-radius: 2px;
+ padding: 4px 8px;
+ }
+
+ #finder-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ }
+
+ #finder-upload-input {
+ display: none;
+ }
+
+ #finder-body {
+ display: grid;
+ grid-template-columns: minmax(260px, 1fr) minmax(280px, 1fr);
+ gap: 0;
+ }
+
+ #finder-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ max-height: 420px;
+ overflow: auto;
+ }
+
+ #finder-list li {
+ border-bottom: 1px solid #efefef;
+ }
+
+ #finder-list ul {
+ list-style: none;
+ margin: 0;
+ padding: 0 0 0 16px;
+ }
+
+ #finder-list li > div {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 10px;
+ }
+
+ #finder-list li > div[data-selected="true"] {
+ background: #f2f7ff;
+ }
+
+ #finder-list li > div[data-drop-target="true"] {
+ box-shadow: inset 0 0 0 2px #9fc3ff;
+ }
+
+ #finder-list [data-role="toggle-spacer"] {
+ display: inline-block;
+ width: 22px;
+ }
+
+ #finder-list button[data-action] {
+ border: 1px solid transparent;
+ background: transparent;
+ padding: 2px 6px;
+ line-height: 1;
+ border-radius: 2px;
+ }
+
+ #finder-list button[data-action="toggle"] {
+ width: 22px;
+ text-align: center;
+ padding: 2px 0;
+ font-family: monospace;
+ }
+
+ #finder-list button[data-action]:hover,
+ #finder-list button[data-action]:focus {
+ border-color: #d6d6d6;
+ background: #f7f7f7;
+ outline: none;
+ }
+
+ #finder-list [data-role="name"] {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ #finder-list [data-role="actions"] {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ #finder-list [data-icon] {
+ width: 16px;
+ text-align: center;
+ font-size: 14px;
+ color: #4e6070;
+ }
+
+ #finder-empty {
+ margin: 0;
+ padding: 12px 10px;
+ color: #666666;
+ }
+
+ #finder-preview {
+ border-left: 1px solid #ececec;
+ min-height: 280px;
+ }
+
+ #finder-preview-title {
+ margin: 0;
+ padding: 10px;
+ border-bottom: 1px solid #ececec;
+ font-size: 14px;
+ }
+
+ #finder-preview-body {
+ padding: 10px;
+ overflow: auto;
+ max-height: 420px;
+ }
+
+ .finder-editor {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.45);
+ display: none;
+ align-items: center;
+ justify-content: center;
+ z-index: 3000;
+ padding: 14px;
+ }
+
+ .finder-editor[data-open="true"] {
+ display: flex;
+ }
+
+ .vfs-editor__dialog {
+ width: min(1080px, 100%);
+ max-height: 90vh;
+ background: #ffffff;
+ border: 1px solid #cccccc;
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.25);
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .vfs-editor__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+ }
+
+ .vfs-editor__title {
+ margin: 0;
+ font-size: 16px;
+ }
+
+ .vfs-editor__path {
+ margin: 0;
+ font-family: monospace;
+ font-size: 12px;
+ color: #666666;
+ }
+
+ .vfs-editor__input {
+ min-height: 58vh;
+ resize: vertical;
+ font-family: monospace;
+ font-size: 13px;
+ border: 1px solid #cfcfcf;
+ padding: 8px;
+ }
+
+ .vfs-editor__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ }
+
+ @media (max-width: 900px) {
+ #finder-body {
+ grid-template-columns: 1fr;
+ }
+
+ #finder-preview {
+ border-left: 0;
+ border-top: 1px solid #ececec;
+ }
+ }
+ </style>
+ </head>
+ <body id="vfs">
+ #%env/templates/header.template%#
+ <h2>Virtual File System</h2>
+ <p id="vfs-intro">
+ User storage in the browser cache with file-system-like navigation.
+ </p>
+
+ <div id="finder-window" aria-label="File system browser">
+ <div id="finder-titlebar">
+ <div id="finder-controls" aria-hidden="true">
+ <span></span>
+ <span></span>
+ <span></span>
+ </div>
+ <div id="finder-path">/</div>
+ <div id="finder-toolbar">
+ <button type="button" id="finder-create-folder" class="btn btn-default btn-sm">New Folder</button>
+ <button type="button" id="finder-upload-button" class="btn btn-primary btn-sm">Upload File</button>
+ <input type="file" id="finder-upload-input" />
+ </div>
+ </div>
+ <div id="finder-body">
+ <div>
+ <ul id="finder-list" aria-label="Root contents"></ul>
+ <p id="finder-empty">No files yet. Upload a file or create a folder.</p>
+ </div>
+ <div id="finder-preview">
+ <h3 id="finder-preview-title">Preview</h3>
+ <div id="finder-preview-body">
+ <p>Select a <code>.txt</code> or <code>.md</code> file to preview.</p>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div id="vfs-editor" class="finder-editor" aria-hidden="true">
+ <div id="vfs-editor-dialog" class="vfs-editor__dialog" role="dialog" aria-modal="true" aria-labelledby="vfs-editor-title">
+ <div id="vfs-editor-header" class="vfs-editor__header">
+ <h3 id="vfs-editor-title" class="vfs-editor__title">Edit file</h3>
+ <p id="vfs-editor-path" class="vfs-editor__path"></p>
+ </div>
+ <textarea id="vfs-editor-input" class="vfs-editor__input" spellcheck="false"></textarea>
+ <div id="vfs-editor-actions" class="vfs-editor__actions">
+ <button type="button" id="vfs-editor-discard" class="btn btn-default">Discard</button>
+ <button type="button" id="vfs-editor-save" class="btn btn-primary">Save</button>
+ </div>
+ </div>
+ </div>
+
+ <script type="text/javascript" src="js/vfs.js?v=20260210"></script>
+ <script type="text/javascript" src="js/highlight.min.js"></script>
+ <script type="text/javascript">
+(function () {
+ const finderList = document.getElementById("finder-list");
+ const finderEmpty = document.getElementById("finder-empty");
+ const createFolderButton = document.getElementById("finder-create-folder");
+ const uploadButton = document.getElementById("finder-upload-button");
+ const uploadInput = document.getElementById("finder-upload-input");
+ const finderPath = document.getElementById("finder-path");
+ const previewTitle = document.getElementById("finder-preview-title");
+ const previewBody = document.getElementById("finder-preview-body");
+ const editorOverlay = document.getElementById("vfs-editor");
+ const editorTitle = document.getElementById("vfs-editor-title");
+ const editorPathLabel = document.getElementById("vfs-editor-path");
+ const editorInput = document.getElementById("vfs-editor-input");
+ const editorDiscard = document.getElementById("vfs-editor-discard");
+ const editorSave = document.getElementById("vfs-editor-save");
+ const expandedPaths = new Set(["/"]);
+ let selectedPath = null;
+ let currentDirectory = "/";
+ let activeDropTarget = null;
+ let editorPath = null;
+
+ if (
+ !finderList ||
+ !uploadInput ||
+ !uploadButton ||
+ !createFolderButton ||
+ !finderPath ||
+ !previewTitle ||
+ !previewBody ||
+ !editorOverlay ||
+ !editorTitle ||
+ !editorPathLabel ||
+ !editorInput ||
+ !editorDiscard ||
+ !editorSave
+ ) {
+ return;
+ }
+
+ const seedEntries = [
+ { path: "/Projects/", value: "" },
+ { path: "/Projects/brief.txt", value: "Futuris VFS sample file." },
+ { path: "/Design/", value: "" },
+ { path: "/Design/notes.md", value: "Palette notes and layout ideas." },
+ { path: "/Readme.md", value: "Welcome to the Futuris VFS root." }
+ ];
+
+ const refreshDelay = (fn) => {
+ setTimeout(fn, 120);
+ };
+
+ const escapeHtml = (value) =>
+ String(value)
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;");
+
+ const renderHighlighted = (text, languageHint) => {
+ if (window.hljs && typeof window.hljs.highlight === "function") {
+ try {
+ if (languageHint) {
+ return window.hljs.highlight(text, { language: languageHint }).value;
+ }
+ return window.hljs.highlightAuto(text).value;
+ } catch (error) {
+ return escapeHtml(text);
+ }
+ }
+ return escapeHtml(text);
+ };
+
+ const readFileAsText = (file) =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result || "");
+ reader.onerror = () => reject(reader.error || new Error("Upload failed"));
+ reader.readAsText(file);
+ });
+
+ const createGlyphIcon = (name, role) => {
+ const icon = document.createElement("span");
+ icon.setAttribute("aria-hidden", "true");
+ icon.dataset.icon = role;
+ icon.className = `glyphicon glyphicon-${name}`;
+ return icon;
+ };
+
+ const normalizeDirectoryPath = (path) => {
+ if (!path || path === "/") return "/";
+ if (!String(path).startsWith("/")) throw new Error("Invalid directory path");
+ return path.endsWith("/") ? path : `${path}/`;
+ };
+
+ const parentDirectory = (path) => {
+ if (!path || path === "/") return "/";
+ if (path.endsWith("/")) return normalizeDirectoryPath(path);
+ const index = path.lastIndexOf("/");
+ return index <= 0 ? "/" : path.substring(0, index + 1);
+ };
+
+ const setCurrentDirectory = (path) => {
+ currentDirectory = normalizeDirectoryPath(path);
+ finderPath.textContent = currentDirectory;
+ finderPath.title = currentDirectory;
+ };
+
+ const getEventElement = (event) => {
+ const target = event.target;
+ if (target instanceof Element) return target;
+ if (target && target.nodeType === Node.TEXT_NODE && target.parentElement) {
+ return target.parentElement;
+ }
+ return null;
+ };
+
+ const buildTree = (entries) => {
+ const root = { name: "/", path: "/", type: "folder", children: new Map() };
+
+ entries.forEach((entry) => {
+ if (!entry) return;
+ const isDir = entry.endsWith("/");
+ const parts = entry.split("/").filter(Boolean);
+ let current = root;
+ let currentPath = "/";
+
+ parts.forEach((part, index) => {
+ const isLast = index === parts.length - 1;
+ const partIsDir = isLast ? isDir : true;
+ const nextPath = `${currentPath}${part}${partIsDir ? "/" : ""}`;
+ if (!current.children.has(part)) {
+ current.children.set(part, {
+ name: part,
+ path: nextPath,
+ type: partIsDir ? "folder" : "file",
+ children: new Map()
+ });
+ }
+ const child = current.children.get(part);
+ if (partIsDir) {
+ child.type = "folder";
+ } else if (isLast) {
+ child.type = "file";
+ }
+ current = child;
+ currentPath = nextPath;
+ });
+ });
+
+ return root;
+ };
+
+ const renderTree = (node, container) => {
+ const nodes = Array.from(node.children.values()).sort((a, b) => {
+ if (a.type !== b.type) {
+ return a.type === "folder" ? -1 : 1;
+ }
+ return a.name.localeCompare(b.name);
+ });
+
+ nodes.forEach((child) => {
+ const li = document.createElement("li");
+ li.dataset.path = child.path;
+ li.dataset.kind = child.type;
+
+ const row = document.createElement("div");
+ row.setAttribute("draggable", "true");
+ row.dataset.dragPath = child.path;
+ if (selectedPath === child.path) {
+ row.dataset.selected = "true";
+ }
+
+ if (child.type === "folder") {
+ const toggle = document.createElement("button");
+ toggle.type = "button";
+ toggle.dataset.action = "toggle";
+ toggle.setAttribute("draggable", "false");
+ toggle.textContent = expandedPaths.has(child.path) ? "v" : ">";
+ toggle.setAttribute("aria-label", expandedPaths.has(child.path) ? "Collapse folder" : "Expand folder");
+ row.appendChild(toggle);
+ row.dataset.dropPath = child.path;
+ } else {
+ const spacer = document.createElement("span");
+ spacer.setAttribute("aria-hidden", "true");
+ spacer.dataset.role = "toggle-spacer";
+ row.appendChild(spacer);
+ }
+
+ const icon = createGlyphIcon(child.type === "folder" ? "folder-open" : "file", child.type);
+ row.appendChild(icon);
+
+ const name = document.createElement("span");
+ name.textContent = child.name;
+ name.dataset.role = "name";
+ row.appendChild(name);
+
+ const actions = document.createElement("span");
+ actions.dataset.role = "actions";
+
+ if (child.type === "file") {
+ const edit = document.createElement("button");
+ edit.type = "button";
+ edit.dataset.action = "edit";
+ edit.setAttribute("draggable", "false");
+ edit.title = "Edit file";
+ const editIcon = createGlyphIcon("pencil", "edit");
+ edit.appendChild(editIcon);
+ actions.appendChild(edit);
+
+ const download = document.createElement("button");
+ download.type = "button";
+ download.dataset.action = "download";
+ download.setAttribute("draggable", "false");
+ download.title = "Download file";
+ const downloadIcon = createGlyphIcon("download-alt", "download");
+ download.appendChild(downloadIcon);
+ actions.appendChild(download);
+ }
+
+ const remove = document.createElement("button");
+ remove.type = "button";
+ remove.dataset.action = "delete";
+ remove.setAttribute("draggable", "false");
+ remove.title = "Delete";
+ const deleteIcon = createGlyphIcon("trash", "delete");
+ remove.appendChild(deleteIcon);
+ actions.appendChild(remove);
+
+ row.appendChild(actions);
+ li.appendChild(row);
+
+ if (child.type === "folder" && expandedPaths.has(child.path)) {
+ const nestedList = document.createElement("ul");
+ nestedList.dataset.dropPath = child.path;
+ renderTree(child, nestedList);
+ li.appendChild(nestedList);
+ }
+
+ container.appendChild(li);
+ });
+ };
+
+ const refreshView = async () => {
+ const vfs = await window.vfsReady;
+ const entries = await vfs.ls("/");
+ if (entries.length === 0) {
+ for (const entry of seedEntries) {
+ await vfs.put(entry.path, entry.value);
+ }
+ }
+
+ const updatedEntries = await vfs.ls("/");
+ finderList.innerHTML = "";
+ const tree = buildTree(updatedEntries);
+
+ const rootItem = document.createElement("li");
+ rootItem.dataset.path = "/";
+ rootItem.dataset.kind = "folder";
+ const rootRow = document.createElement("div");
+ rootRow.dataset.dragPath = "/";
+ rootRow.dataset.dropPath = "/";
+ rootRow.dataset.root = "true";
+ if (selectedPath === "/") {
+ rootRow.dataset.selected = "true";
+ }
+
+ const rootToggle = document.createElement("button");
+ rootToggle.type = "button";
+ rootToggle.dataset.action = "toggle";
+ rootToggle.setAttribute("draggable", "false");
+ rootToggle.textContent = expandedPaths.has("/") ? "v" : ">";
+ rootToggle.setAttribute("aria-label", expandedPaths.has("/") ? "Collapse folder" : "Expand folder");
+ rootRow.appendChild(rootToggle);
+
+ const rootIcon = createGlyphIcon("folder-open", "folder");
+ rootRow.appendChild(rootIcon);
+
+ const rootName = document.createElement("span");
+ rootName.textContent = "/";
+ rootName.dataset.role = "name";
+ rootRow.appendChild(rootName);
+
+ rootRow.addEventListener("click", (event) => {
+ const eventElement = getEventElement(event);
+ if (!eventElement) return;
+ if (eventElement.closest("button[data-action], [data-role=\"actions\"]")) return;
+ event.stopPropagation();
+ selectedPath = "/";
+ setCurrentDirectory("/");
+ showPreviewPlaceholder("Select a file to preview.");
+ refreshView();
+ });
+
+ rootItem.appendChild(rootRow);
+
+ if (expandedPaths.has("/")) {
+ const nestedList = document.createElement("ul");
+ nestedList.dataset.dropPath = "/";
+ renderTree(tree, nestedList);
+ rootItem.appendChild(nestedList);
+ }
+
+ finderList.appendChild(rootItem);
+ finderEmpty.style.display = updatedEntries.length ? "none" : "block";
+ };
+
+ const refreshSoon = () => {
+ refreshDelay(refreshView);
+ };
+
+ const showPreviewPlaceholder = (message) => {
+ previewTitle.textContent = "Preview";
+ previewBody.innerHTML = `<p>${escapeHtml(message)}</p>`;
+ };
+
+ const openEditor = async (path) => {
+ if (!path || path.endsWith("/")) return;
+ try {
+ const vfs = await window.vfsReady;
+ const contents = await vfs.getasync(path);
+ editorPath = path;
+ editorTitle.textContent = "Edit file";
+ editorPathLabel.textContent = path;
+ editorInput.value = contents;
+ editorOverlay.dataset.open = "true";
+ editorOverlay.setAttribute("aria-hidden", "false");
+ editorInput.focus();
+ } catch (error) {
+ window.alert("Unable to load file for editing.");
+ }
+ };
+
+ const closeEditor = () => {
+ editorPath = null;
+ editorOverlay.removeAttribute("data-open");
+ editorOverlay.setAttribute("aria-hidden", "true");
+ editorInput.value = "";
+ };
+
+ const extensionToLanguage = (name) => {
+ const match = name.toLowerCase().match(/\.([a-z0-9]+)$/);
+ if (!match) return null;
+ const ext = match[1];
+ const map = {
+ js: "javascript",
+ mjs: "javascript",
+ cjs: "javascript",
+ ts: "typescript",
+ jsx: "javascript",
+ tsx: "typescript",
+ json: "json",
+ md: "markdown",
+ txt: "plaintext",
+ html: "xml",
+ htm: "xml",
+ css: "css",
+ yml: "yaml",
+ yaml: "yaml",
+ sh: "bash",
+ bash: "bash",
+ py: "python",
+ rs: "rust",
+ go: "go",
+ java: "java",
+ c: "c",
+ h: "c",
+ cpp: "cpp",
+ hpp: "cpp"
+ };
+ return map[ext] || null;
+ };
+
+ const updatePreview = async (path) => {
+ if (!path) {
+ showPreviewPlaceholder("Select a file to preview.");
+ return;
+ }
+
+ const fileName = path.split("/").pop() || "";
+ const languageHint = extensionToLanguage(fileName);
+
+ try {
+ const vfs = await window.vfsReady;
+ const contents = await vfs.getasync(path);
+ previewTitle.textContent = fileName;
+ const highlighted = renderHighlighted(contents, languageHint);
+ const languageClass = languageHint ? ` language-${languageHint}` : "";
+ previewBody.innerHTML = `<pre><code class="hljs${languageClass}">${highlighted}</code></pre>`;
+ } catch (error) {
+ showPreviewPlaceholder("Unable to load file preview.");
+ }
+ };
+
+ const moveEntry = async (srcPath, destDir) => {
+ const vfs = await window.vfsReady;
+ const isFolder = srcPath.endsWith("/");
+ const destPath = await vfs.moveTree(srcPath, destDir);
+
+ if (expandedPaths.has(srcPath)) {
+ expandedPaths.delete(srcPath);
+ expandedPaths.add(destPath);
+ }
+ if (selectedPath === srcPath) {
+ selectedPath = destPath;
+ }
+ if (isFolder && currentDirectory.startsWith(srcPath)) {
+ setCurrentDirectory(`${destPath}${currentDirectory.substring(srcPath.length)}`);
+ }
+ };
+
+ createFolderButton.addEventListener("click", async () => {
+ const name = window.prompt("Folder name");
+ if (!name) return;
+ const trimmed = name.trim().replace(/^\/+|\/+$/g, "");
+ if (!trimmed || trimmed.includes("/")) {
+ window.alert("Folder name must be a single segment.");
+ return;
+ }
+ const vfs = await window.vfsReady;
+ const targetDirectory = vfs.normalizeDirPath(currentDirectory);
+ const folderPath = await vfs.mkdir(`${targetDirectory}${trimmed}`);
+ expandedPaths.add(targetDirectory);
+ expandedPaths.add(folderPath);
+ selectedPath = folderPath;
+ setCurrentDirectory(folderPath);
+ refreshSoon();
+ });
+
+ uploadButton.addEventListener("click", () => {
+ uploadInput.click();
+ });
+
+ uploadInput.addEventListener("change", async (event) => {
+ const files = Array.from(event.target.files || []);
+ if (!files.length) return;
+ const vfs = await window.vfsReady;
+ const targetDirectory = vfs.normalizeDirPath(currentDirectory);
+ for (const file of files) {
+ const contents = await readFileAsText(file);
+ await vfs.put(`${targetDirectory}${file.name}`, contents);
+ }
+ uploadInput.value = "";
+ refreshSoon();
+ });
+
+ finderList.addEventListener("click", async (event) => {
+ const eventElement = getEventElement(event);
+ if (!eventElement) return;
+ const button = eventElement.closest("button[data-action]");
+ if (!button) return;
+ const item = button.closest("li");
+ if (!item) return;
+ const path = item.dataset.path;
+ const action = button.dataset.action;
+ const vfs = await window.vfsReady;
+
+ if (action === "toggle") {
+ setCurrentDirectory(path);
+ selectedPath = path;
+ if (expandedPaths.has(path)) {
+ expandedPaths.delete(path);
+ } else {
+ expandedPaths.add(path);
+ }
+ refreshView();
+ return;
+ }
+
+ if (action === "delete") {
+ if (path.endsWith("/")) {
+ await vfs.deleteTree(path);
+ expandedPaths.delete(path);
+ if (selectedPath && selectedPath.startsWith(path)) {
+ selectedPath = null;
+ showPreviewPlaceholder("Select a file to preview.");
+ }
+ if (currentDirectory.startsWith(path)) {
+ setCurrentDirectory("/");
+ }
+ } else {
+ await vfs.deleteTree(path);
+ if (selectedPath === path) {
+ selectedPath = null;
+ showPreviewPlaceholder("Select a file to preview.");
+ }
+ }
+ refreshSoon();
+ return;
+ }
+
+ if (action === "download") {
+ try {
+ const contents = await vfs.getasync(path);
+ const blob = new Blob([contents], { type: "text/plain" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = path.split("/").pop() || "download.txt";
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ } catch (error) {
+ window.alert("Unable to download file.");
+ }
+ return;
+ }
+
+ if (action === "edit") {
+ openEditor(path);
+ }
+ });
+
+ finderList.addEventListener("click", (event) => {
+ const eventElement = getEventElement(event);
+ if (!eventElement) return;
+ const button = eventElement.closest("button[data-action]");
+ if (button) return;
+ const rootRow = eventElement.closest("[data-root=\"true\"]");
+ if (rootRow) {
+ selectedPath = "/";
+ setCurrentDirectory("/");
+ showPreviewPlaceholder("Select a file to preview.");
+ refreshView();
+ return;
+ }
+ const item = eventElement.closest("li[data-path]");
+ if (!item) return;
+ const path = item.dataset.path;
+ const kind = item.dataset.kind;
+ selectedPath = path;
+ if (kind === "folder") {
+ setCurrentDirectory(path);
+ showPreviewPlaceholder("Select a file to preview.");
+ refreshView();
+ return;
+ }
+ setCurrentDirectory(parentDirectory(path));
+ refreshView();
+ updatePreview(selectedPath);
+ });
+
+ finderList.addEventListener("dragstart", (event) => {
+ const eventElement = getEventElement(event);
+ if (!eventElement) return;
+ const row = eventElement.closest("[data-drag-path]");
+ if (!row) return;
+ if (eventElement.closest("[data-role=\"actions\"], button, [data-icon], [data-action=\"toggle\"]")) {
+ event.preventDefault();
+ return;
+ }
+ event.dataTransfer.setData("text/plain", row.dataset.dragPath);
+ event.dataTransfer.effectAllowed = "move";
+ row.dataset.dragging = "true";
+ });
+
+ finderList.addEventListener("dragend", (event) => {
+ const eventElement = getEventElement(event);
+ if (!eventElement) return;
+ const row = eventElement.closest("[data-drag-path]");
+ if (row) {
+ delete row.dataset.dragging;
+ }
+ if (activeDropTarget) {
+ delete activeDropTarget.dataset.dropTarget;
+ activeDropTarget = null;
+ }
+ });
+
+ finderList.addEventListener("dragover", (event) => {
+ const eventElement = getEventElement(event);
+ if (!eventElement) return;
+ const target = eventElement.closest("[data-drop-path]");
+ if (!target) return;
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "move";
+ if (activeDropTarget && activeDropTarget !== target) {
+ delete activeDropTarget.dataset.dropTarget;
+ }
+ activeDropTarget = target;
+ activeDropTarget.dataset.dropTarget = "true";
+ });
+
+ finderList.addEventListener("dragleave", (event) => {
+ const eventElement = getEventElement(event);
+ if (!eventElement) return;
+ const target = eventElement.closest("[data-drop-path]");
+ if (!target || !activeDropTarget) return;
+ if (target === activeDropTarget) {
+ delete activeDropTarget.dataset.dropTarget;
+ activeDropTarget = null;
+ }
+ });
+
+ finderList.addEventListener("drop", async (event) => {
+ const eventElement = getEventElement(event);
+ if (!eventElement) return;
+ const target = eventElement.closest("[data-drop-path]");
+ if (!target) return;
+ event.preventDefault();
+ const srcPath = event.dataTransfer.getData("text/plain");
+ const destDir = target.dataset.dropPath || "/";
+ if (!srcPath || !destDir) return;
+ try {
+ await moveEntry(srcPath, destDir);
+ } catch (error) {
+ window.alert(error && error.message ? error.message : "Unable to move entry.");
+ }
+ if (activeDropTarget) {
+ delete activeDropTarget.dataset.dropTarget;
+ activeDropTarget = null;
+ }
+ refreshSoon();
+ });
+
+ editorOverlay.addEventListener("click", (event) => {
+ if (event.target === editorOverlay) {
+ closeEditor();
+ }
+ });
+
+ editorDiscard.addEventListener("click", () => {
+ closeEditor();
+ });
+
+ editorSave.addEventListener("click", async () => {
+ if (!editorPath) {
+ closeEditor();
+ return;
+ }
+ const vfs = await window.vfsReady;
+ await vfs.put(editorPath, editorInput.value);
+ const savedPath = editorPath;
+ closeEditor();
+ if (selectedPath === savedPath) {
+ updatePreview(savedPath);
+ }
+ });
+
+ const clearActiveDrop = () => {
+ if (activeDropTarget) {
+ delete activeDropTarget.dataset.dropTarget;
+ activeDropTarget = null;
+ }
+ };
+
+ setCurrentDirectory("/");
+ refreshView();
+ updatePreview(selectedPath);
+})();
+ </script>
+ #%env/templates/footer.template%#
+ </body>
+</html>
diff --git a/htroot/js/vfs.js b/htroot/js/vfs.js
new file mode 100644
index 000000000..79c030c45
--- /dev/null
+++ b/htroot/js/vfs.js
@@ -0,0 +1,549 @@
+/**
+ * Virtual File System (VFS) using IndexedDB with Bash-like Commands
+ * (C) 2026 by Michael Peter Christen
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program in the file lgpl21.txt
+ * If not, see <http://www.gnu.org/licenses/>.
+ */
+
+/**
+ * This script implements a simple key-value store using IndexedDB, which is a low-level API
+ * for client-side storage of significant amounts of structured data, including files/blobs.
+ * This API uses indexes to enable high-performance searches of this data.
+ *
+ * The VFS provides a set of methods for storing, retrieving, and deleting key-value pairs in an
+ * IndexedDB object store, where keys represent paths in a file system-like structure.
+ * Paths are delimited by a slash (/) and must start with a leading slash.
+ * Paths ending with a slash are considered directories, while paths not ending with a slash are considered files.
+ *
+ * The VFS includes Bash-like commands for manipulating the virtual file system.
+ *
+ * The VFS is exposed as a global object (window.vfs) for easy access from other parts of the application.
+ */
+
+// Open a connection to the database
+const openRequest = indexedDB.open('vfs', 1);
+
+let vfsReadyResolve;
+let vfsReadyReject;
+window.vfsReady = new Promise((resolve, reject) => {
+ vfsReadyResolve = resolve;
+ vfsReadyReject = reject;
+});
+
+let db;
+
+const applyUnifiedDiff = (originalText, diffText) => {
+ const originalEndsWithNewline = originalText.endsWith('\n');
+ const originalLines = originalText.split('\n');
+ const diffLines = diffText.split('\n');
+ const result = [];
+ let origIndex = 0;
+ let i = 0;
+ const hunkHeader = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
+
+ const collectHunkOps = (startIndex) => {
+ const ops = [];
+ let index = startIndex;
+ while (index < diffLines.length && !diffLines[index].startsWith('@@')) {
+ const hunkLine = diffLines[index];
+ if (hunkLine.startsWith(' ') || hunkLine.startsWith('-') || hunkLine.startsWith('+')) {
+ ops.push(hunkLine);
+ }
+ index += 1;
+ }
+ return { ops, nextIndex: index };
+ };
+
+ const hunkFitsAt = (startAt, ops) => {
+ let checkIndex = startAt;
+ for (const op of ops) {
+ if (op.startsWith(' ') || op.startsWith('-')) {
+ const content = op.slice(1);
+ if (originalLines[checkIndex] !== content) {
+ return false;
+ }
+ checkIndex += 1;
+ }
+ }
+ return true;
+ };
+
+ const findHunkStart = (fromIndex, ops, suggestedStart) => {
+ if (Number.isInteger(suggestedStart) && suggestedStart >= fromIndex) {
+ if (hunkFitsAt(suggestedStart, ops)) {
+ return suggestedStart;
+ }
+ }
+ const firstContext = ops.find((op) => op.startsWith(' '));
+ if (!firstContext) {
+ return fromIndex;
+ }
+ const needle = firstContext.slice(1);
+ for (let idx = fromIndex; idx < originalLines.length; idx += 1) {
+ if (originalLines[idx] === needle && hunkFitsAt(idx, ops)) {
+ return idx;
+ }
+ }
+ return -1;
+ };
+
+ while (i < diffLines.length) {
+ const line = diffLines[i];
+ if (
+ line.startsWith('diff --git') ||
+ line.startsWith('index ') ||
+ line.startsWith('--- ') ||
+ line.startsWith('+++ ')
+ ) {
+ i += 1;
+ continue;
+ }
+ const match = line.match(hunkHeader);
+ if (!match) {
+ i += 1;
+ continue;
+ }
+
+ const oldStart = parseInt(match[1], 10) - 1;
+ const { ops, nextIndex } = collectHunkOps(i + 1);
+ const hunkStart = findHunkStart(origIndex, ops, oldStart);
+ if (hunkStart < 0) {
+ throw new Error('Patch context mismatch.');
+ }
+
+ while (origIndex < hunkStart && origIndex < originalLines.length) {
+ result.push(originalLines[origIndex]);
+ origIndex += 1;
+ }
+
+ for (const op of ops) {
+ if (op.startsWith(' ')) {
+ const content = op.slice(1);
+ if (originalLines[origIndex] !== content) {
+ throw new Error('Patch context mismatch.');
+ }
+ result.push(content);
+ origIndex += 1;
+ } else if (op.startsWith('-')) {
+ const content = op.slice(1);
+ if (originalLines[origIndex] !== content) {
+ throw new Error('Patch removal mismatch.');
+ }
+ origIndex += 1;
+ } else if (op.startsWith('+')) {
+ result.push(op.slice(1));
+ }
+ }
+
+ i = nextIndex;
+ }
+
+ while (origIndex < originalLines.length) {
+ result.push(originalLines[origIndex]);
+ origIndex += 1;
+ }
+
+ let patched = result.join('\n');
+ if (originalEndsWithNewline && !patched.endsWith('\n')) {
+ patched += '\n';
+ }
+ return patched;
+};
+
+// Handle the database upgrade event
+openRequest.onupgradeneeded = function (event) {
+ const db = event.target.result;
+ // Check if the object store exists before creating it
+ if (!db.objectStoreNames.contains('keyValueStore')) {
+ db.createObjectStore('keyValueStore', { keyPath: 'id' });
+ }
+};
+
+// Handle the successful opening of the database
+openRequest.onsuccess = function (event) {
+ db = event.target.result;
+
+ // Define the vfs object with methods to interact with the database
+ const vfs = {
+ put: function (key, value) {
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction(['keyValueStore'], 'readwrite');
+ const store = transaction.objectStore('keyValueStore');
+ const putRequest = store.put({ id: key, value });
+
+ // Handle the successful storage of a key-value pair
+ putRequest.onsuccess = function (event) {
+ console.log('Key-value pair stored successfully.');
+ resolve();
+ };
+ // Handle errors
+ putRequest.onerror = function (event) {
+ console.error('Error storing key-value pair:', event.target.errorCode);
+ reject(event.target.errorCode);
+ };
+ });
+ },
+ getasync: function (key) {
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction(['keyValueStore'], 'readonly');
+ const store = transaction.objectStore('keyValueStore');
+ const getRequest = store.get(key);
+ getRequest.onsuccess = function (event) {
+ // Check if the key exists before resolving the promise
+ if (event.target.result) {
+ resolve(event.target.result.value);
+ } else {
+ reject('Key not found');
+ }
+ };
+ getRequest.onerror = function (event) {
+ reject(event.target.errorCode);
+ };
+ });
+ },
+ get: async function (key) {
+ return await this.getasync(key);
+ },
+ rm: function (key) {
+ const transaction = db.transaction(['keyValueStore'], 'readwrite');
+ const store = transaction.objectStore('keyValueStore');
+ const deleteRequest = store.delete(key);
+ deleteRequest.onsuccess = function (event) {
+ console.log('Entry removed successfully.');
+ };
+ deleteRequest.onerror = function (event) {
+ console.error('Error removing entry:', event.target.errorCode);
+ };
+ },
+
+ // the following methods all consider that keys are paths and have their proper shape:
+ // - a path must have a leading slash
+ // - a path ending with a slash is considered a directory
+ // - a path not ending with a slash is considered a file
+ // - directories cannot be created or removed directly,
+ // - creating a file creates also the parent directory, removing all files removes the directory.
+ touch: function (path) {
+ // Create a file at the specified path.
+ if (!path.startsWith('/') || path === '') {
+ throw new Error('Invalid path');
+ }
+ const dirPath = path.substring(0, path.lastIndexOf('/')) + '/';
+ const fileName = path.substring(path.lastIndexOf('/') + 1);
+ const transaction = db.transaction(['keyValueStore'], 'readwrite');
+ const store = transaction.objectStore('keyValueStore');
+ const putRequest = store.put({ id: path, value: '' });
+ putRequest.onsuccess = function (event) {
+ console.log(`File created at ${path}`);
+ };
+ putRequest.onerror = function (event) {
+ console.error(`Error creating file at ${path}: ${event.target.errorCode}`);
+ };
+ },
+ rm: function (path) {
+ // Remove a file or directory at the specified path.
+ if (!path.startsWith('/') || path === '') {
+ throw new Error('Invalid path');
+ }
+ const transaction = db.transaction(['keyValueStore'], 'readwrite');
+ const store = transaction.objectStore('keyValueStore');
+ const deleteRequest = store.delete(path);
+ deleteRequest.onsuccess = function (event) {
+ console.log(`Entry removed at ${path}`);
+ };
+ deleteRequest.onerror = function (event) {
+ console.error(`Error removing entry at ${path}: ${event.target.errorCode}`);
+ };
+ },
+ cp: function (srcPath, destPath) {
+ // Copy a file or directory from one path to another.
+ if (!srcPath.startsWith('/') || !destPath.startsWith('/') || srcPath === '' || destPath === '') {
+ throw new Error('Invalid path');
+ }
+ const transaction = db.transaction(['keyValueStore'], 'readwrite');
+ const store = transaction.objectStore('keyValueStore');
+ const getRequest = store.get(srcPath);
+ getRequest.onsuccess = function (event) {
+ const value = event.target.result ? event.target.result.value : {};
+ const putRequest = store.put({ id: destPath, value });
+ putRequest.onsuccess = function (event) {
+ console.log(`Entry copied from ${srcPath} to ${destPath}`);
+ };
+ putRequest.onerror = function (event) {
+ console.error(`Error copying entry from ${srcPath} to ${destPath}: ${event.target.errorCode}`);
+ };
+ };
+ getRequest.onerror = function (event) {
+ console.error(`Error getting entry at ${srcPath}: ${event.target.errorCode}`);
+ };
+ },
+ mv: function (srcPath, destPath) {
+ // Move or rename a file or directory from one path to another.
+ if (!srcPath.startsWith('/') || !destPath.startsWith('/') || srcPath === '' || destPath === '') {
+ throw new Error('Invalid path');
+ }
+ const transaction = db.transaction(['keyValueStore'], 'readwrite');
+ const store = transaction.objectStore('keyValueStore');
+ const getRequest = store.get(srcPath);
+ getRequest.onsuccess = function (event) {
+ const value = event.target.result ? event.target.result.value : {};
+ const deleteRequest = store.delete(srcPath);
+ deleteRequest.onsuccess = function (event) {
+ const putRequest = store.put({ id: destPath, value });
+ putRequest.onsuccess = function (event) {
+ console.log(`Entry moved from ${srcPath} to ${destPath}`);
+ };
+ putRequest.onerror = function (event) {
+ console.error(`Error moving entry from ${srcPath} to ${destPath}: ${event.target.errorCode}`);
+ };
+ };
+ deleteRequest.onerror = function (event) {
+ console.error(`Error deleting entry at ${srcPath}: ${event.target.errorCode}`);
+ };
+ };
+ getRequest.onerror = function (event) {
+ console.error(`Error getting entry at ${srcPath}: ${event.target.errorCode}`);
+ };
+ },
+ applyDiff: async function (path, diffText) {
+ if (!path.startsWith('/') || path === '' || path.endsWith('/')) {
+ throw new Error('Invalid file path');
+ }
+ if (typeof diffText !== 'string') {
+ throw new Error('Invalid diff');
+ }
+ const current = await this.getasync(path);
+ const next = applyUnifiedDiff(String(current || ''), diffText);
+ await this.put(path, next);
+ return next;
+ },
+ ls: function (path) {
+ // List the contents of a directory at the specified path.
+ if (!path.endsWith('/')) {
+ throw new Error('Invalid directory path');
+ }
+ const transaction = db.transaction(['keyValueStore'], 'readonly');
+ const store = transaction.objectStore('keyValueStore');
+ const cursorRange = path === '/' ? IDBKeyRange.lowerBound(path) : IDBKeyRange.bound(path, path.substring(0, path.length - 1) + '\uffff', false, true);
+
+ return new Promise((resolve, reject) => {
+ const cursorRequest = store.openCursor(cursorRange);
+ const contents = [];
+ cursorRequest.onsuccess = function (event) {
+ const cursor = event.target.result;
+ if (cursor) {
+ if (cursor.key.startsWith(path)) {
+ contents.push(cursor.key.substring(path.length));
+ }
+ cursor.continue();
+ } else {
+ resolve(contents);
+ }
+ };
+ cursorRequest.onerror = function (event) {
+ reject(`Error listing directory at ${path}: ${event.target.errorCode}`);
+ };
+ });
+ },
+ normalizeDirPath: function (path) {
+ if (!path || path === '/') {
+ return '/';
+ }
+ if (!path.startsWith('/')) {
+ throw new Error('Invalid directory path');
+ }
+ return path.endsWith('/') ? path : `${path}/`;
+ },
+ parentDir: function (path) {
+ if (!path || path === '/') {
+ return '/';
+ }
+ if (!path.startsWith('/')) {
+ throw new Error('Invalid path');
+ }
+ if (path.endsWith('/')) {
+ return this.normalizeDirPath(path);
+ }
+ const index = path.lastIndexOf('/');
+ return index <= 0 ? '/' : path.substring(0, index + 1);
+ },
+ baseName: function (path) {
+ if (!path || path === '/') {
+ return '';
+ }
+ const trimmed = path.endsWith('/') ? path.slice(0, -1) : path;
+ const parts = trimmed.split('/').filter(Boolean);
+ return parts[parts.length - 1] || '';
+ },
+ mkdir: async function (path) {
+ const dirPath = this.normalizeDirPath(path);
+ await this.put(dirPath, '');
+ return dirPath;
+ },
+ deleteTree: async function (path) {
+ if (!path || !path.startsWith('/')) {
+ throw new Error('Invalid path');
+ }
+ if (path.endsWith('/')) {
+ const contents = await this.ls(path);
+ for (const entry of contents) {
+ if (!entry) continue;
+ await this.rm(`${path}${entry}`);
+ }
+ if (path !== '/') {
+ await this.rm(path);
+ }
+ return;
+ }
+ await this.rm(path);
+ },
+ moveTree: async function (srcPath, destDir) {
+ if (!srcPath || !srcPath.startsWith('/')) {
+ throw new Error('Invalid source path');
+ }
+ const targetDir = this.normalizeDirPath(destDir);
+ const isFolder = srcPath.endsWith('/');
+ const name = this.baseName(srcPath);
+ if (!name) {
+ return srcPath;
+ }
+ const destPath = `${targetDir}${name}${isFolder ? '/' : ''}`;
+
+ if (destPath === srcPath) {
+ return destPath;
+ }
+ if (isFolder && targetDir.startsWith(srcPath)) {
+ throw new Error('Cannot move a folder into itself.');
+ }
+
+ if (targetDir !== '/') {
+ await this.mkdir(targetDir);
+ }
+
+ if (isFolder) {
+ const contents = await this.ls(srcPath);
+ for (const entry of contents) {
+ if (!entry) continue;
+ await this.mv(`${srcPath}${entry}`, `${destPath}${entry}`);
+ }
+ }
+
+ await this.mv(srcPath, destPath);
+ return destPath;
+ },
+ cat: function (path) {
+ // Display the contents of a file at the specified path.
+ if (path.endsWith('/')) {
+ throw new Error('Invalid file path');
+ }
+ const transaction = db.transaction(['keyValueStore'], 'readonly');
+ const store = transaction.objectStore('keyValueStore');
+ const getRequest = store.get(path);
+ getRequest.onsuccess = function (event) {
+ console.log(event.target.result ? event.target.result.value : '');
+ };
+ getRequest.onerror = function (event) {
+ console.error(`Error getting file at ${path}: ${event.target.errorCode}`);
+ };
+ },
+ find: function (pattern) {
+ // Search for files or directories matching a specified pattern.
+ const transaction = db.transaction(['keyValueStore'], 'readonly');
+ const store = transaction.objectStore('keyValueStore');
+ const cursorRequest = store.openCursor();
+ const matches = [];
+ cursorRequest.onsuccess = function (event) {
+ const cursor = event.target.result;
+ if (cursor) {
+ if (new RegExp(pattern).test(cursor.key)) {
+ matches.push(cursor.key);
+ }
+ cursor.continue();
+ } else {
+ console.log(matches.join('\n'));
+ }
+ };
+ cursorRequest.onerror = function (event) {
+ console.error(`Error finding pattern ${pattern}: ${event.target.errorCode}`);
+ };
+ },
+
+ du: function () {
+ // Show the disk usage of files and directories.
+ const transaction = db.transaction(['keyValueStore'], 'readonly');
+ const store = transaction.objectStore('keyValueStore');
+ let totalSize = 0;
+
+ return new Promise((resolve, reject) => {
+ const cursorRequest = store.openCursor();
+ cursorRequest.onsuccess = function (event) {
+ const cursor = event.target.result;
+ if (cursor) {
+ const keyLength = cursor.key ? cursor.key.length : 0;
+ const valueLength = cursor.value ? cursor.value.length : 0;
+ if (!isNaN(keyLength) && !isNaN(valueLength)) {
+ totalSize += keyLength + valueLength;
+ }
+ cursor.continue();
+ } else {
+ resolve(totalSize);
+ }
+ };
+ cursorRequest.onerror = function (event) {
+ reject(`Error getting disk usage: ${event.target.errorCode}`);
+ };
+ });
+ },
+ df: function () {
+ // Show the amount of disk space used and available on the file system.
+ if (navigator.storage && navigator.storage.estimate) {
+ return navigator.storage.estimate().then((estimate) => {
+ const quota = Number.isFinite(estimate.quota) ? estimate.quota : 0;
+ const usage = Number.isFinite(estimate.usage) ? estimate.usage : 0;
+ const available = Math.max(0, quota - usage);
+ return { quota, usage, available };
+ });
+ }
+ return Promise.reject('Storage estimate not available');
+ },
+ grep: function (path, pattern) {
+ // Search for a pattern in file content at the specified path.
+ if (!path.startsWith('/') || path === '') {
+ throw new Error('Invalid path');
+ }
+ const transaction = db.transaction(['keyValueStore'], 'readonly');
+ const store = transaction.objectStore('keyValueStore');
+ const getRequest = store.get(path);
+ getRequest.onsuccess = function (event) {
+ const content = event.target.result ? event.target.result.value : '';
+ if (new RegExp(pattern).test(content)) {
+ console.log(`${path}: ${content}`);
+ }
+ };
+ getRequest.onerror = function (event) {
+ console.error(`Error getting file at ${path}: ${event.target.errorCode}`);
+ };
+ }
+ };
+
+ // Attach the vfs object to the window object
+ window.vfs = vfs;
+ if (vfsReadyResolve) vfsReadyResolve(vfs);
+};
+
+// Handle errors when opening the database
+openRequest.onerror = function (event) {
+ console.error('Error opening database:', event.target.errorCode);
+ if (vfsReadyReject) vfsReadyReject(event.target.errorCode);
+};