mirror of
https://github.com/yacy/yacy_search_server.git
synced 2026-10-25 05:54:15 +00:00
added virtual file system that stores users data in the users browser
This commit is contained in:
1 parent
6a2c27c45b
commit
50cf84fd68
3 files changed
+1515
No files matched your search
+951
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
Reference in new issue
Block a user