// fileEditor.js import { escapeHTML, showToast } from './domUtils.js'; import { loadFileList } from './fileListView.js'; import { t } from './i18n.js'; // thresholds for editor behavior const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing // Lazy-load CodeMirror modes on demand //const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/"; const CM_LOCAL = "/vendor/codemirror/5.65.5/"; // Which mode file to load for a given name/mime const MODE_URL = { // core/common "xml": "mode/xml/xml.min.js", "css": "mode/css/css.min.js", "javascript": "mode/javascript/javascript.min.js", // meta / combos "htmlmixed": "mode/htmlmixed/htmlmixed.min.js", "application/x-httpd-php": "mode/php/php.min.js", // docs / data "markdown": "mode/markdown/markdown.min.js", "yaml": "mode/yaml/yaml.min.js", "properties": "mode/properties/properties.min.js", "sql": "mode/sql/sql.min.js", // shells "shell": "mode/shell/shell.min.js", // languages "python": "mode/python/python.min.js", "text/x-csrc": "mode/clike/clike.min.js", "text/x-c++src": "mode/clike/clike.min.js", "text/x-java": "mode/clike/clike.min.js", "text/x-csharp": "mode/clike/clike.min.js", "text/x-kotlin": "mode/clike/clike.min.js" }; // Map any mime/alias to the key we use in MODE_URL function normalizeModeName(modeOption) { const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name); if (!name) return null; if (name === "text/html") return "htmlmixed"; // CodeMirror uses htmlmixed for HTML if (name === "php") return "application/x-httpd-php"; // prefer the full mime return name; } const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever function loadScriptOnce(url) { return new Promise((resolve, reject) => { const ver = (window.APP_VERSION ?? 'dev').replace(/^v/, ''); // "v1.6.9" -> "1.6.9" const withQS = url + '?v=' + ver; const key = `cm:${withQS}`; let s = document.querySelector(`script[data-key="${key}"]`); if (s) { if (s.dataset.loaded === "1") return resolve(); s.addEventListener("load", resolve); s.addEventListener("error", () => reject(new Error(`Load failed: ${withQS}`))); return; } s = document.createElement("script"); s.src = withQS; s.async = true; s.dataset.key = key; s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); }); s.addEventListener("error", () => reject(new Error(`Load failed: ${withQS}`))); document.head.appendChild(s); }); } async function ensureModeLoaded(modeOption) { if (!window.CodeMirror) return; const name = normalizeModeName(modeOption); if (!name) return; const isRegistered = () => (window.CodeMirror?.modes && window.CodeMirror.modes[name]) || (window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[name]); if (isRegistered()) return; const url = MODE_URL[name]; if (!url) return; // unknown -> stay in text/plain // Dependencies if (name === "htmlmixed") { await Promise.all([ ensureModeLoaded("xml"), ensureModeLoaded("css"), ensureModeLoaded("javascript") ]); } if (name === "application/x-httpd-php") { await ensureModeLoaded("htmlmixed"); } await loadScriptOnce(CM_LOCAL + url); } function getModeForFile(fileName) { const dot = fileName.lastIndexOf("."); const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : ""; switch (ext) { case "html": case "htm": return "text/html"; case "xml": return "xml"; case "md": case "markdown": return "markdown"; case "yml": case "yaml": return "yaml"; case "css": return "css"; case "js": return "javascript"; case "json": return { name: "javascript", json: true }; case "php": return "application/x-httpd-php"; case "py": return "python"; case "sql": return "sql"; case "sh": case "bash": case "zsh": case "bat": return "shell"; case "ini": case "conf": case "config": case "properties": return "properties"; case "c": case "h": return "text/x-csrc"; case "cpp": case "cxx": case "hpp": case "hh": case "hxx": return "text/x-c++src"; case "java": return "text/x-java"; case "cs": return "text/x-csharp"; case "kt": case "kts": return "text/x-kotlin"; default: return "text/plain"; } } export { getModeForFile }; function adjustEditorSize() { const modal = document.querySelector(".editor-modal"); if (modal && window.currentEditor) { const headerHeight = 60; // adjust as needed const availableHeight = modal.clientHeight - headerHeight; window.currentEditor.setSize("100%", availableHeight + "px"); } } export { adjustEditorSize }; function observeModalResize(modal) { if (!modal) return; const resizeObserver = new ResizeObserver(() => adjustEditorSize()); resizeObserver.observe(modal); } export { observeModalResize }; export function editFile(fileName, folder) { // destroy any previous editor let existingEditor = document.getElementById("editorContainer"); if (existingEditor) existingEditor.remove(); const folderUsed = folder || window.currentFolder || "root"; const folderPath = folderUsed === "root" ? "uploads/" : "uploads/" + folderUsed.split("/").map(encodeURIComponent).join("/") + "/"; const fileUrl = folderPath + encodeURIComponent(fileName) + "?t=" + new Date().getTime(); fetch(fileUrl, { method: "HEAD" }) .then(response => { const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length"); const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null; if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) { showToast("This file is larger than 10 MB and cannot be edited in the browser."); throw new Error("File too large."); } return response; }) .then(() => fetch(fileUrl)) .then(response => { if (!response.ok) throw new Error("HTTP error! Status: " + response.status); const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length"); const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null; return Promise.all([response.text(), sizeBytes]); }) .then(([content, sizeBytes]) => { const forcePlainText = sizeBytes !== null && sizeBytes > EDITOR_PLAIN_THRESHOLD; // --- Build modal immediately and wire close controls BEFORE any async loads --- const modal = document.createElement("div"); modal.id = "editorContainer"; modal.classList.add("modal", "editor-modal"); modal.setAttribute("tabindex", "-1"); // for Escape handling modal.innerHTML = `

${t("editing")}: ${escapeHTML(fileName)} ${forcePlainText ? " (plain text mode)" : ""}

`; document.body.appendChild(modal); modal.style.display = "block"; modal.focus(); let canceled = false; const doClose = () => { canceled = true; window.currentEditor = null; modal.remove(); }; // Wire close actions right away modal.addEventListener("keydown", (e) => { if (e.key === "Escape") doClose(); }); document.getElementById("closeEditorX").addEventListener("click", doClose); document.getElementById("closeBtn").addEventListener("click", doClose); // Keep buttons responsive even before editor exists const decBtn = document.getElementById("decreaseFont"); const incBtn = document.getElementById("increaseFont"); decBtn.addEventListener("click", () => {}); incBtn.addEventListener("click", () => {}); // Theme + mode selection const isDarkMode = document.body.classList.contains("dark-mode"); const theme = isDarkMode ? "material-darker" : "default"; const desiredMode = forcePlainText ? "text/plain" : getModeForFile(fileName); // Helper to check whether a mode is currently registered const modeName = typeof desiredMode === "string" ? desiredMode : (desiredMode && desiredMode.name); const isModeRegistered = () => (window.CodeMirror?.modes && window.CodeMirror.modes[modeName]) || (window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[modeName]); // Start mode loading (don’t block closing) const modePromise = ensureModeLoaded(desiredMode); // Wait up to MODE_LOAD_TIMEOUT_MS; then proceed with whatever is available const timeout = new Promise((res) => setTimeout(res, MODE_LOAD_TIMEOUT_MS)); Promise.race([modePromise, timeout]).then(() => { if (canceled) return; if (!window.CodeMirror) { // Core not present: keep plain