|
// retoor <retoor@molodetz.nl>
|
|
|
|
import { Http } from "./Http.js";
|
|
import { Toast } from "./Toast.js";
|
|
import { CodeMirrorModes } from "./codemirrorModes.js";
|
|
|
|
export class ProjectFiles {
|
|
constructor(root) {
|
|
this.root = root;
|
|
this.slug = root.dataset.slug;
|
|
this.isOwner = root.dataset.owner === "1";
|
|
this.base = `/projects/${this.slug}/files`;
|
|
this.files = [];
|
|
this.editor = null;
|
|
this.currentPath = null;
|
|
this.currentDir = "";
|
|
this.selected = new Set();
|
|
this.order = [];
|
|
this.anchor = null;
|
|
this.dragPaths = null;
|
|
this.tree = root.querySelector("#pf-tree");
|
|
this.preview = root.querySelector("#pf-preview");
|
|
this.empty = root.querySelector("#pf-empty");
|
|
this.emptyMsg = root.querySelector(".pf-empty-msg");
|
|
this.currentLabel = root.querySelector("#pf-current");
|
|
this.editorWrap = root.querySelector("#pf-editor-wrap");
|
|
this.uploadWidget = root.querySelector("#pf-upload-widget");
|
|
this.status = root.querySelector("#pf-status");
|
|
}
|
|
|
|
notify(message) {
|
|
if (this.status) Toast.flash(this.status, message, 3000, "");
|
|
}
|
|
|
|
init() {
|
|
const data = this.root.querySelector("#pf-data");
|
|
this.files = data ? JSON.parse(data.textContent || "[]") : [];
|
|
this.initEditor();
|
|
this.bindToolbar();
|
|
this.bindTree();
|
|
this.renderTree();
|
|
this.openDefault();
|
|
}
|
|
|
|
firstFilePath() {
|
|
for (const path of this.order) {
|
|
const node = this.files.find((file) => file.path === path);
|
|
if (node && node.type === "file") return path;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
openDefault() {
|
|
const files = this.files.filter((file) => file.type === "file");
|
|
const readme = files.find((file) => file.name.toLowerCase() === "readme.md")
|
|
|| files.find((file) => file.name.toLowerCase().startsWith("readme."));
|
|
const path = readme ? readme.path : this.firstFilePath();
|
|
if (!path) {
|
|
this.showEmpty(this.isOwner ? "Create a file to get started." : "No files yet.");
|
|
return;
|
|
}
|
|
this.selected = new Set([path]);
|
|
this.anchor = path;
|
|
this.applySelection();
|
|
this.markActive(path);
|
|
this.currentDir = this.dirOf(path);
|
|
this.openFile(path);
|
|
}
|
|
|
|
markActive(path) {
|
|
this.root.querySelectorAll(".pf-node-row.active").forEach((el) => el.classList.remove("active"));
|
|
const row = this.root.querySelector(`.pf-node-row[data-path="${CSS.escape(path)}"]`);
|
|
if (row) row.classList.add("active");
|
|
}
|
|
|
|
initEditor() {
|
|
if (typeof CodeMirror === "undefined") return;
|
|
const textarea = this.root.querySelector("#project-file-editor");
|
|
if (!textarea) return;
|
|
this.editor = CodeMirror.fromTextArea(textarea, {
|
|
lineNumbers: true,
|
|
mode: "plaintext",
|
|
theme: "monokai",
|
|
indentUnit: 4,
|
|
tabSize: 4,
|
|
lineWrapping: true,
|
|
readOnly: !this.isOwner,
|
|
extraKeys: { "Ctrl-S": () => this.save() },
|
|
});
|
|
this.editor.setSize(null, "100%");
|
|
}
|
|
|
|
bindToolbar() {
|
|
const on = (id, handler) => {
|
|
const el = this.root.querySelector(id);
|
|
if (el) el.addEventListener("click", (e) => { e.preventDefault(); handler(); });
|
|
};
|
|
on("#pf-new-file", () => this.newFile());
|
|
on("#pf-new-folder", () => this.newFolder());
|
|
on("#pf-upload", () => this.uploadTo(this.currentDir));
|
|
on("#pf-save", () => this.save());
|
|
on("#pf-rename", () => this.rename());
|
|
on("#pf-delete", () => this.remove());
|
|
if (this.uploadWidget) {
|
|
this.uploadWidget.addEventListener("dp-upload:done", () => this.refresh());
|
|
this.uploadWidget.addEventListener("dp-upload:error", (e) => this.notify(e.detail.message));
|
|
}
|
|
}
|
|
|
|
bindTree() {
|
|
this.tree.addEventListener("click", (e) => {
|
|
if (e.target === this.tree) {
|
|
this.selected.clear();
|
|
this.applySelection();
|
|
}
|
|
});
|
|
if (!this.isOwner) return;
|
|
if (window.app && window.app.contextMenu) {
|
|
window.app.contextMenu.attach(this.tree, (e) => this.buildMenu(e));
|
|
}
|
|
this.tree.addEventListener("dragover", (e) => {
|
|
if (!this.dragPaths) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
this.tree.classList.add("pf-root-drop");
|
|
});
|
|
this.tree.addEventListener("dragleave", (e) => {
|
|
if (e.target === this.tree) this.tree.classList.remove("pf-root-drop");
|
|
});
|
|
this.tree.addEventListener("drop", (e) => {
|
|
if (!this.dragPaths) return;
|
|
e.preventDefault();
|
|
this.tree.classList.remove("pf-root-drop");
|
|
this.onDrop("");
|
|
});
|
|
}
|
|
|
|
dirOf(path) {
|
|
return path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
|
|
}
|
|
|
|
basename(path) {
|
|
return path.split("/").pop();
|
|
}
|
|
|
|
joinDir(dir, name) {
|
|
const clean = name.replace(/^\/+/, "");
|
|
return dir ? `${dir}/${clean}` : clean;
|
|
}
|
|
|
|
buildTree() {
|
|
const nodes = new Map();
|
|
this.files.forEach((file) => nodes.set(file.path, { ...file, children: [] }));
|
|
const roots = [];
|
|
this.files.forEach((file) => {
|
|
const node = nodes.get(file.path);
|
|
const parent = file.path.includes("/") ? nodes.get(file.path.slice(0, file.path.lastIndexOf("/"))) : null;
|
|
(parent ? parent.children : roots).push(node);
|
|
});
|
|
const sort = (list) => {
|
|
list.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
|
|
list.forEach((node) => sort(node.children));
|
|
};
|
|
sort(roots);
|
|
return roots;
|
|
}
|
|
|
|
renderTree() {
|
|
this.order = [];
|
|
const known = new Set(this.files.map((f) => f.path));
|
|
this.selected = new Set([...this.selected].filter((p) => known.has(p)));
|
|
this.tree.textContent = "";
|
|
this.tree.appendChild(this.renderRootRow());
|
|
const roots = this.buildTree();
|
|
if (!roots.length) {
|
|
const hint = document.createElement("div");
|
|
hint.className = "pf-tree-empty";
|
|
hint.textContent = this.isOwner ? "Empty project. Create a file or folder." : "No files yet.";
|
|
this.tree.appendChild(hint);
|
|
return;
|
|
}
|
|
this.tree.appendChild(this.renderNodes(roots));
|
|
this.applySelection();
|
|
}
|
|
|
|
renderRootRow() {
|
|
const row = document.createElement("button");
|
|
row.type = "button";
|
|
row.className = "pf-node-row pf-root-row";
|
|
row.dataset.path = "";
|
|
row.dataset.type = "dir";
|
|
row.draggable = false;
|
|
row.innerHTML = '<span class="pf-icon">📦</span><span class="pf-label">project root</span>';
|
|
row.addEventListener("click", () => {
|
|
this.selected.clear();
|
|
this.applySelection();
|
|
this.root.querySelectorAll(".pf-node-row.active").forEach((el) => el.classList.remove("active"));
|
|
row.classList.add("active");
|
|
this.currentPath = null;
|
|
this.currentDir = "";
|
|
this.showEmpty("Project root");
|
|
});
|
|
if (this.isOwner) {
|
|
row.addEventListener("dragover", (e) => {
|
|
if (!this.dragPaths) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
row.classList.add("pf-dragover");
|
|
});
|
|
row.addEventListener("dragleave", () => row.classList.remove("pf-dragover"));
|
|
row.addEventListener("drop", (e) => {
|
|
if (!this.dragPaths) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
row.classList.remove("pf-dragover");
|
|
this.onDrop("");
|
|
});
|
|
}
|
|
return row;
|
|
}
|
|
|
|
renderNodes(list) {
|
|
const ul = document.createElement("ul");
|
|
ul.className = "pf-tree-list";
|
|
list.forEach((node) => {
|
|
this.order.push(node.path);
|
|
const li = document.createElement("li");
|
|
li.className = `pf-node pf-${node.type}`;
|
|
const row = document.createElement("button");
|
|
row.type = "button";
|
|
row.className = "pf-node-row";
|
|
row.dataset.path = node.path;
|
|
row.dataset.type = node.type;
|
|
row.draggable = this.isOwner;
|
|
row.innerHTML = `<span class="pf-icon">${node.type === "dir" ? "📁" : "📄"}</span><span class="pf-label"></span>`;
|
|
row.querySelector(".pf-label").textContent = node.name;
|
|
row.addEventListener("click", (e) => this.onRowClick(node, li, e));
|
|
if (this.isOwner) this.bindDrag(row, node);
|
|
li.appendChild(row);
|
|
if (node.type === "dir") {
|
|
const child = this.renderNodes(node.children);
|
|
child.classList.add("pf-children");
|
|
li.appendChild(child);
|
|
}
|
|
ul.appendChild(li);
|
|
});
|
|
return ul;
|
|
}
|
|
|
|
bindDrag(row, node) {
|
|
row.addEventListener("dragstart", (e) => {
|
|
const paths = this.selected.has(node.path) && this.selected.size ? Array.from(this.selected) : [node.path];
|
|
this.dragPaths = paths;
|
|
e.dataTransfer.effectAllowed = "move";
|
|
e.dataTransfer.setData("text/plain", paths.join("\n"));
|
|
row.classList.add("pf-dragging");
|
|
});
|
|
row.addEventListener("dragend", () => {
|
|
row.classList.remove("pf-dragging");
|
|
this.dragPaths = null;
|
|
this.root.querySelectorAll(".pf-dragover").forEach((el) => el.classList.remove("pf-dragover"));
|
|
this.tree.classList.remove("pf-root-drop");
|
|
});
|
|
if (node.type !== "dir") return;
|
|
row.addEventListener("dragover", (e) => {
|
|
if (!this.dragPaths) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
row.classList.add("pf-dragover");
|
|
});
|
|
row.addEventListener("dragleave", () => row.classList.remove("pf-dragover"));
|
|
row.addEventListener("drop", (e) => {
|
|
if (!this.dragPaths) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
row.classList.remove("pf-dragover");
|
|
this.onDrop(node.path);
|
|
});
|
|
}
|
|
|
|
onDrop(targetDir) {
|
|
const paths = this.dragPaths || [];
|
|
this.dragPaths = null;
|
|
if (paths.length) this.moveInto(targetDir, paths);
|
|
}
|
|
|
|
onRowClick(node, li, e) {
|
|
if (e.shiftKey && this.anchor) {
|
|
this.selectRange(this.anchor, node.path);
|
|
return;
|
|
}
|
|
if (e.ctrlKey || e.metaKey) {
|
|
if (this.selected.has(node.path)) this.selected.delete(node.path);
|
|
else this.selected.add(node.path);
|
|
this.anchor = node.path;
|
|
this.applySelection();
|
|
return;
|
|
}
|
|
this.selected = new Set([node.path]);
|
|
this.anchor = node.path;
|
|
this.applySelection();
|
|
this.activate(node, li);
|
|
}
|
|
|
|
activate(node, li) {
|
|
this.root.querySelectorAll(".pf-node-row.active").forEach((el) => el.classList.remove("active"));
|
|
const row = li.querySelector(".pf-node-row");
|
|
if (row) row.classList.add("active");
|
|
if (node.type === "dir") {
|
|
li.classList.toggle("collapsed");
|
|
this.currentDir = node.path;
|
|
return;
|
|
}
|
|
this.currentDir = this.dirOf(node.path);
|
|
this.openFile(node.path);
|
|
}
|
|
|
|
selectRange(fromPath, toPath) {
|
|
const i = this.order.indexOf(fromPath);
|
|
const j = this.order.indexOf(toPath);
|
|
if (i < 0 || j < 0) return;
|
|
const [a, b] = i <= j ? [i, j] : [j, i];
|
|
this.selected = new Set(this.order.slice(a, b + 1));
|
|
this.applySelection();
|
|
}
|
|
|
|
applySelection() {
|
|
this.root.querySelectorAll(".pf-node-row").forEach((row) => {
|
|
row.classList.toggle("pf-selected", this.selected.has(row.dataset.path));
|
|
});
|
|
if (this.status && this.selected.size > 1) Toast.flash(this.status, `${this.selected.size} selected`, 1500, "");
|
|
}
|
|
|
|
buildMenu(e) {
|
|
if (!this.isOwner) return [];
|
|
const rowEl = e.target.closest(".pf-node-row");
|
|
if (!rowEl) {
|
|
return [
|
|
{ label: "New file", icon: "📄", onSelect: () => this.newFile("") },
|
|
{ label: "New folder", icon: "📁", onSelect: () => this.newFolder("") },
|
|
{ label: "Upload here", icon: "⬆", onSelect: () => this.uploadTo("") },
|
|
];
|
|
}
|
|
const path = rowEl.dataset.path;
|
|
const type = rowEl.dataset.type;
|
|
if (this.selected.size > 1 && this.selected.has(path)) {
|
|
const count = this.selected.size;
|
|
return [
|
|
{ label: `Move ${count} items to...`, icon: "➡", onSelect: () => this.promptMove(Array.from(this.selected)) },
|
|
{ separator: true },
|
|
{ label: `Delete ${count} items`, icon: "🗑", danger: true, onSelect: () => this.remove(path) },
|
|
];
|
|
}
|
|
const dir = type === "dir" ? path : this.dirOf(path);
|
|
const items = [];
|
|
if (type === "file") {
|
|
items.push({ label: "Open", icon: "📄", onSelect: () => this.openFile(path) });
|
|
} else {
|
|
items.push({ label: "New file here", icon: "📄", onSelect: () => this.newFile(dir) });
|
|
items.push({ label: "New folder here", icon: "📁", onSelect: () => this.newFolder(dir) });
|
|
items.push({ label: "Upload here", icon: "⬆", onSelect: () => this.uploadTo(dir) });
|
|
}
|
|
items.push({ separator: true });
|
|
items.push({ label: "Download as zip", icon: "📦", onSelect: () => window.app.zipDownloader.start(`${this.base}/zip?path=${encodeURIComponent(path)}`) });
|
|
items.push({ label: "Rename / move", icon: "✏", onSelect: () => this.rename(path) });
|
|
items.push({ label: "Delete", icon: "🗑", danger: true, onSelect: () => this.remove(path) });
|
|
return items;
|
|
}
|
|
|
|
async openFile(path) {
|
|
try {
|
|
const data = await Http.getJson(`${this.base}/raw?path=${encodeURIComponent(path)}`);
|
|
this.currentPath = path;
|
|
this.currentLabel.textContent = path;
|
|
this.currentLabel.hidden = false;
|
|
if (data.is_binary) {
|
|
this.showPreview(data);
|
|
} else {
|
|
this.showEditor(path, data.content || "");
|
|
}
|
|
} catch (err) {
|
|
this.notify(err.message);
|
|
}
|
|
}
|
|
|
|
showEditor(path, content) {
|
|
this.empty.hidden = true;
|
|
this.preview.hidden = true;
|
|
this.editorWrap.hidden = false;
|
|
if (this.editor) {
|
|
this.editor.setOption("mode", CodeMirrorModes.modeForFilename(path));
|
|
this.editor.setValue(content);
|
|
setTimeout(() => this.editor.refresh(), 50);
|
|
}
|
|
}
|
|
|
|
showPreview(data) {
|
|
this.empty.hidden = true;
|
|
this.editorWrap.hidden = true;
|
|
this.preview.hidden = false;
|
|
this.preview.textContent = "";
|
|
const mime = data.mime_type || "";
|
|
if (mime.startsWith("image/")) {
|
|
const img = document.createElement("img");
|
|
img.src = data.url;
|
|
img.alt = data.name;
|
|
this.preview.appendChild(img);
|
|
} else if (mime.startsWith("video/")) {
|
|
const video = document.createElement("video");
|
|
video.src = data.url;
|
|
video.controls = true;
|
|
video.preload = "metadata";
|
|
this.preview.appendChild(video);
|
|
} else {
|
|
const link = document.createElement("a");
|
|
link.href = data.url;
|
|
link.textContent = `Download ${data.name}`;
|
|
link.className = "pf-download";
|
|
link.setAttribute("download", data.name);
|
|
this.preview.appendChild(link);
|
|
}
|
|
}
|
|
|
|
showEmpty(message) {
|
|
this.editorWrap.hidden = true;
|
|
this.preview.hidden = true;
|
|
this.empty.hidden = false;
|
|
if (this.emptyMsg) this.emptyMsg.textContent = message || "Select a file to view or edit.";
|
|
this.currentLabel.textContent = "";
|
|
this.currentLabel.hidden = true;
|
|
}
|
|
|
|
post(action, body) {
|
|
return Http.send(`${this.base}/${action}`, body);
|
|
}
|
|
|
|
async refresh(openPath) {
|
|
const data = await Http.getJson(this.base);
|
|
this.files = data.files || [];
|
|
this.renderTree();
|
|
if (openPath) this.openFile(openPath);
|
|
}
|
|
|
|
async promptInput(options) {
|
|
const dialog = window.app && window.app.dialog;
|
|
if (dialog) return dialog.prompt(options);
|
|
return window.prompt(options.message || options.label || options.title || "", options.value || "");
|
|
}
|
|
|
|
async confirmAction(options) {
|
|
const dialog = window.app && window.app.dialog;
|
|
if (dialog) return dialog.confirm(options);
|
|
return window.confirm(options.message);
|
|
}
|
|
|
|
async newFile(baseDir) {
|
|
const dir = baseDir !== undefined ? baseDir : this.currentDir;
|
|
const name = await this.promptInput({
|
|
title: "New file",
|
|
label: dir ? `Inside ${dir}/` : "In project root",
|
|
placeholder: "main.py",
|
|
confirmLabel: "Create",
|
|
});
|
|
if (!name || !name.trim()) return;
|
|
const path = this.joinDir(dir, name.trim());
|
|
try {
|
|
await this.post("write", { path, content: "" });
|
|
await this.refresh(path);
|
|
} catch (err) { this.notify(err.message); }
|
|
}
|
|
|
|
async newFolder(baseDir) {
|
|
const dir = baseDir !== undefined ? baseDir : this.currentDir;
|
|
const name = await this.promptInput({
|
|
title: "New folder",
|
|
label: dir ? `Inside ${dir}/` : "In project root",
|
|
placeholder: "src",
|
|
confirmLabel: "Create",
|
|
});
|
|
if (!name || !name.trim()) return;
|
|
const path = this.joinDir(dir, name.trim());
|
|
try {
|
|
await this.post("mkdir", { path });
|
|
this.currentDir = path;
|
|
await this.refresh();
|
|
} catch (err) { this.notify(err.message); }
|
|
}
|
|
|
|
uploadTo(dir) {
|
|
if (!this.uploadWidget) return;
|
|
this.uploadWidget.extraFields = { path: dir };
|
|
this.uploadWidget.open();
|
|
}
|
|
|
|
async save() {
|
|
if (!this.currentPath || !this.editor) return;
|
|
try {
|
|
await this.post("write", { path: this.currentPath, content: this.editor.getValue() });
|
|
this.notify("Saved");
|
|
} catch (err) { this.notify(err.message); }
|
|
}
|
|
|
|
async rename(target) {
|
|
const from = target || this.currentPath || this.currentDir;
|
|
if (!from) { this.notify("Select a file or folder first"); return; }
|
|
const to = await this.promptInput({
|
|
title: "Rename / move",
|
|
label: "New path",
|
|
value: from,
|
|
confirmLabel: "Save",
|
|
});
|
|
if (!to || !to.trim() || to.trim() === from) return;
|
|
try {
|
|
await this.post("move", { from_path: from, to_path: to.trim() });
|
|
await this.refresh(this.currentPath === from ? to.trim() : null);
|
|
} catch (err) { this.notify(err.message); }
|
|
}
|
|
|
|
async remove(target) {
|
|
const multi = this.selected.size > 1 && (!target || this.selected.has(target));
|
|
const paths = multi ? Array.from(this.selected) : [target || this.currentPath || this.currentDir].filter(Boolean);
|
|
if (!paths.length) { this.notify("Select a file or folder first"); return; }
|
|
const message = paths.length > 1
|
|
? `Delete ${paths.length} items? This cannot be undone.`
|
|
: `Delete "${paths[0]}"? This cannot be undone.`;
|
|
const ok = await this.confirmAction({ title: "Delete", message, danger: true, confirmLabel: "Delete" });
|
|
if (!ok) return;
|
|
try {
|
|
for (const path of paths) await this.post("delete", { path });
|
|
this.selected.clear();
|
|
this.currentPath = null;
|
|
await this.refresh();
|
|
this.openDefault();
|
|
} catch (err) { this.notify(err.message); }
|
|
}
|
|
|
|
async promptMove(paths) {
|
|
const to = await this.promptInput({
|
|
title: `Move ${paths.length} items`,
|
|
label: "Target directory (empty for project root)",
|
|
placeholder: "src",
|
|
confirmLabel: "Move",
|
|
});
|
|
if (to === null || to === undefined) return;
|
|
await this.moveInto(to.trim().replace(/^\/+|\/+$/g, ""), paths);
|
|
}
|
|
|
|
async moveInto(targetDir, paths) {
|
|
const valid = paths.filter((path) => {
|
|
if (targetDir === path || targetDir.startsWith(`${path}/`)) return false;
|
|
if (targetDir === this.dirOf(path)) return false;
|
|
return true;
|
|
});
|
|
if (!valid.length) return;
|
|
try {
|
|
for (const path of valid) {
|
|
const to = targetDir ? `${targetDir}/${this.basename(path)}` : this.basename(path);
|
|
await this.post("move", { from_path: path, to_path: to });
|
|
}
|
|
this.selected.clear();
|
|
await this.refresh();
|
|
} catch (err) { this.notify(err.message); }
|
|
}
|
|
|
|
}
|