// 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">&#x1F4E6;</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" ? "&#x1F4C1;" : "&#x1F4C4;"}</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: "&#x1F4C4;", onSelect: () => this.newFile("") },
{ label: "New folder", icon: "&#x1F4C1;", onSelect: () => this.newFolder("") },
{ label: "Upload here", icon: "&#x2B06;", 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: "&#x27A1;", onSelect: () => this.promptMove(Array.from(this.selected)) },
{ separator: true },
{ label: `Delete ${count} items`, icon: "&#x1F5D1;", danger: true, onSelect: () => this.remove(path) },
];
}
const dir = type === "dir" ? path : this.dirOf(path);
const items = [];
if (type === "file") {
items.push({ label: "Open", icon: "&#x1F4C4;", onSelect: () => this.openFile(path) });
} else {
items.push({ label: "New file here", icon: "&#x1F4C4;", onSelect: () => this.newFile(dir) });
items.push({ label: "New folder here", icon: "&#x1F4C1;", onSelect: () => this.newFolder(dir) });
items.push({ label: "Upload here", icon: "&#x2B06;", onSelect: () => this.uploadTo(dir) });
}
items.push({ separator: true });
items.push({ label: "Download as zip", icon: "&#x1F4E6;", onSelect: () => window.app.zipDownloader.start(`${this.base}/zip?path=${encodeURIComponent(path)}`) });
items.push({ label: "Rename / move", icon: "&#x270F;", onSelect: () => this.rename(path) });
items.push({ label: "Delete", icon: "&#x1F5D1;", 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); }
}
}