mirror of
https://github.com/yacy/yacy_search_server.git
synced 2026-02-25 12:52:25 -05:00
952 lines
26 KiB
HTML
952 lines
26 KiB
HTML
<!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>
|