diff options
| author | Michael Peter Christen <mc@yacy.net> | 2026-02-10 14:29:12 +0100 |
|---|---|---|
| committer | Michael Peter Christen <mc@yacy.net> | 2026-02-10 14:29:12 +0100 |
| commit | 50cf84fd68f9283241e4882d0ead9c24d6d52fde (patch) | |
| tree | 805ae375a6b8126f83015b2c9b4aa46cc6e85fa5 /htroot | |
| parent | 6a2c27c45b12d1c9945e3a3ae82e8af62bea601d (diff) | |
added virtual file system that stores users data in the users browser
Diffstat (limited to 'htroot')
| -rw-r--r-- | htroot/VFS.html | 951 | ||||
| -rw-r--r-- | htroot/js/vfs.js | 549 |
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, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """); + + 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); +}; |
