/* Custom Files — file-manager SPA.
 * Self-contained (no CDN): rich file/folder icons, SweetAlert2 dialogs, a
 * right-click context menu, a syntax-highlight overlay editor with multi-file
 * tabs, auto-close/auto-indent/comment-toggle, and find & replace.
 */
(function () {
    'use strict';
    const boot = window.PpCustomFilesBootstrap || {};
    const API = boot.apiBase;

    const $ = (id) => document.getElementById(id);
    const elTree    = $('cfTree');
    const elLoc     = $('cfLoc');
    const elTabs    = $('cfTabs');
    const elCrumb   = $('cfCrumb');
    const elStatus  = $('cfStatus');
    const elSave    = $('cfSave');
    const elBlank   = $('cfBlank');
    const elCode     = $('cfCode');
    const elCodeArea = $('cfCodeArea');
    const elPreview = $('cfPreview');
    const elToast   = $('cfToast');
    const elUpInput = $('cfUploadInput');
    const elCheck   = $('cfCheck');
    const elCtx     = $('cfCtx');
    const elProblems      = $('cfProblems');
    const elProblemsHead  = $('cfProblemsHead');
    const elProblemsTitle = $('cfProblemsTitle');
    const elProblemsTool  = $('cfProblemsTool');
    const elProblemsBody  = $('cfProblemsBody');
    const elFind = $('cfFind');
    const elWorkspace = document.querySelector('.cf-workspace');
    const elTreeToggle = $('cfTreeToggle');
    const elStatusbar = $('cfStatusbar');
    const elSbLang = $('cfSbLang'), elSbPath = $('cfSbPath'), elSbSel = $('cfSbSel');
    const elSbPos = $('cfSbPos'), elSbPosTxt = $('cfSbPosTxt'), elSbLines = $('cfSbLines');
    const elSbSize = $('cfSbSize'), elSbEnc = $('cfSbEnc');
    const elLive    = $('cfLive');
    const elLiveFrame = $('cfLiveFrame');
    const elLiveMd  = $('cfLiveMd');
    const elLiveNote = $('cfLiveNote');
    const elLiveFlag = $('cfLiveFlag');
    const elViewSeg = $('cfViewSeg');
    const elUndoRedo = $('cfUndoRedo');
    const elUndo = $('cfUndo');
    const elRedo = $('cfRedo');
    const elPvSize  = $('cfPvSize');
    const elPvOpen  = $('cfPreviewOpen');
    const PREVIEW_LIVE = new Set(['html', 'htm', 'svg', 'css', 'md', 'markdown', 'php', 'phtml']);
    // Binary media rendered from the served /preview URL (any size — no base64).
    const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'avif']);
    const VIDEO_EXT = new Set(['mp4', 'm4v', 'webm', 'ogv', 'mov']);
    const AUDIO_EXT = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac']);
    const PDF_EXT   = new Set(['pdf']);
    const isMediaExt = (e) => IMAGE_EXT.has(e) || VIDEO_EXT.has(e) || AUDIO_EXT.has(e) || PDF_EXT.has(e);

    const LINTABLE = ['php', 'phtml', 'js', 'mjs', 'json', 'css', 'html', 'htm'];
    const extOf = (p) => (p && p.includes('.')) ? p.split('.').pop().toLowerCase() : '';
    const baseName = (p) => (p || '').split('/').pop();

    const S = {
        tree: [],
        expanded: new Set(['']),
        currentDir: '',
        open: new Map(),   // path → { ext, view:'code'|'image'|'binary', content, dirty, html, size }
        active: null,      // path of the tab in the editor
        view: localStorage.getItem('cfView') || 'edit',   // edit | split | preview
        pvSize: localStorage.getItem('cfPvSize') || 'lg',  // lg | md | sm (preview device width)
        livable: false,    // is the active file live-previewable?
        filter: '',        // tree search filter
        moveFrom: null,    // node currently being dragged (internal move)
        edit: null,        // in-tree inline editor: { mode:'new-file'|'new-folder'|'rename', dir, node }
    };

    /* ══════════════════════════ icons ══════════════════════════ */
    // Special icons for well-known file names (case-insensitive).
    const NAME_ICONS = {
        'package.json':  ['bi-box-seam', '#cb3837'], 'composer.json': ['bi-box-seam', '#885630'],
        'readme.md':     ['bi-info-square', '#2563eb'], 'license':     ['bi-award', '#64748b'],
        '.gitignore':    ['bi-git', '#f05033'], '.gitattributes': ['bi-git', '#f05033'],
        '.htaccess':     ['bi-shield-lock', '#64748b'], '.env':       ['bi-key', '#eab308'],
        'dockerfile':    ['bi-box', '#2496ed'], 'robots.txt':        ['bi-robot', '#475569'],
        'sitemap.xml':   ['bi-diagram-3', '#f97316'], '.editorconfig': ['bi-sliders', '#64748b'],
        'favicon.ico':   ['bi-star-fill', '#eab308'],
    };
    // Icons by extension.
    const EXT_ICONS = {
        html: ['bi-filetype-html', '#e34c26'], htm: ['bi-filetype-html', '#e34c26'],
        css:  ['bi-filetype-css', '#2965f1'], scss: ['bi-filetype-scss', '#c76494'], less: ['bi-filetype-css', '#1d365d'],
        js:   ['bi-filetype-js', '#f0db4f'], mjs: ['bi-filetype-js', '#f0db4f'], cjs: ['bi-filetype-js', '#f0db4f'],
        ts:   ['bi-filetype-tsx', '#3178c6'], tsx: ['bi-filetype-tsx', '#3178c6'], jsx: ['bi-filetype-jsx', '#61dafb'],
        vue:  ['bi-filetype-html', '#42b883'], svelte: ['bi-filetype-html', '#ff3e00'],
        php:  ['bi-filetype-php', '#8892bf'], phtml: ['bi-filetype-php', '#8892bf'],
        json: ['bi-filetype-json', '#3ba55d'], yml: ['bi-filetype-yml', '#cb171e'], yaml: ['bi-filetype-yml', '#cb171e'],
        xml:  ['bi-filetype-xml', '#f97316'], svg: ['bi-filetype-svg', '#ffb13b'],
        md:   ['bi-filetype-md', '#475569'], markdown: ['bi-filetype-md', '#475569'], txt: ['bi-filetype-txt', '#94a3b8'],
        sql:  ['bi-filetype-sql', '#00758f'], db: ['bi-database', '#00758f'], sqlite: ['bi-database', '#003b57'],
        py:   ['bi-filetype-py', '#3776ab'], rb: ['bi-filetype-rb', '#cc342d'], go: ['bi-filetype-go', '#00add8'],
        sh:   ['bi-filetype-sh', '#4eaa25'], bash: ['bi-filetype-sh', '#4eaa25'], bat: ['bi-terminal', '#475569'],
        ini:  ['bi-gear', '#64748b'], conf: ['bi-gear', '#64748b'], env: ['bi-key', '#eab308'], toml: ['bi-gear', '#64748b'],
        png:  ['bi-file-earmark-image', '#0891b2'], jpg: ['bi-file-earmark-image', '#0891b2'], jpeg: ['bi-file-earmark-image', '#0891b2'],
        gif:  ['bi-file-earmark-image', '#0891b2'], webp: ['bi-file-earmark-image', '#0891b2'], bmp: ['bi-file-earmark-image', '#0891b2'],
        ico:  ['bi-star', '#eab308'], avif: ['bi-file-earmark-image', '#0891b2'],
        pdf:  ['bi-filetype-pdf', '#e11d48'], doc: ['bi-filetype-doc', '#2b579a'], docx: ['bi-filetype-docx', '#2b579a'],
        xls:  ['bi-filetype-xls', '#217346'], xlsx: ['bi-filetype-xlsx', '#217346'], csv: ['bi-filetype-csv', '#217346'],
        ppt:  ['bi-filetype-ppt', '#d24726'], pptx: ['bi-filetype-pptx', '#d24726'],
        zip:  ['bi-file-earmark-zip', '#eab308'], gz: ['bi-file-earmark-zip', '#eab308'], tar: ['bi-file-earmark-zip', '#eab308'], rar: ['bi-file-earmark-zip', '#eab308'],
        mp4:  ['bi-filetype-mp4', '#7c3aed'], webm: ['bi-file-earmark-play', '#7c3aed'], mov: ['bi-file-earmark-play', '#7c3aed'],
        mp3:  ['bi-filetype-mp3', '#db2777'], wav: ['bi-filetype-wav', '#db2777'], ogg: ['bi-file-earmark-music', '#db2777'],
        woff: ['bi-filetype-woff', '#64748b'], woff2: ['bi-filetype-woff', '#64748b'], ttf: ['bi-filetype-ttf', '#64748b'], otf: ['bi-filetype-otf', '#64748b'],
        map:  ['bi-diagram-2', '#94a3b8'], lock: ['bi-lock', '#94a3b8'], log: ['bi-file-text', '#94a3b8'],
    };
    // Contextual folder icons by name.
    const FOLDER_ICONS = {
        css: 'bi-folder-fill', styles: 'bi-folder-fill', js: 'bi-folder-fill', scripts: 'bi-folder-fill',
        img: 'bi-folder-fill', images: 'bi-folder-fill', assets: 'bi-folder-fill', fonts: 'bi-folder-fill',
        '.git': 'bi-git', node_modules: 'bi-folder', dist: 'bi-folder-fill', build: 'bi-folder-fill',
    };

    function iconFor(node) {
        if (node.type === 'dir') {
            const nm = (node.name || '').toLowerCase();
            if (nm === '.git') return { i: 'bi-git', c: '#f05033' };
            return { i: S.expanded.has(node.path) ? 'bi-folder2-open' : (FOLDER_ICONS[nm] || 'bi-folder-fill'), c: '#eab308' };
        }
        const nm = (node.name || '').toLowerCase();
        if (NAME_ICONS[nm]) return { i: NAME_ICONS[nm][0], c: NAME_ICONS[nm][1] };
        if (nm.startsWith('license')) return { i: 'bi-award', c: '#64748b' };
        const e = node.ext || extOf(node.name);
        if (EXT_ICONS[e]) return { i: EXT_ICONS[e][0], c: EXT_ICONS[e][1] };
        return { i: 'bi-file-earmark-text', c: '#94a3b8' };
    }

    /* ══════════════════════════ api / toast ══════════════════════════ */
    async function api(path, opts) {
        const r = await fetch(API + path, Object.assign({ credentials: 'same-origin' }, opts));
        const ct = r.headers.get('content-type') || '';
        const body = ct.includes('json') ? await r.json() : await r.text();
        if (!r.ok || (body && body.success === false)) {
            throw new Error((body && body.error) || ('Request failed (' + r.status + ')'));
        }
        return body;
    }
    const post = (path, data) => api(path, {
        method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
    });
    function toast(msg, kind) {
        elToast.textContent = msg;
        elToast.className = 'cf-toast show ' + (kind || '');
        clearTimeout(toast._t);
        toast._t = setTimeout(() => { elToast.className = 'cf-toast ' + (kind || ''); }, 2600);
    }

    /* ══════════════════════════ SweetAlert2 dialogs ══════════════════════════ */
    function cfConfirm(opts) {
        opts = opts || {};
        if (!window.Swal) return Promise.resolve(window.confirm(opts.text || opts.title || 'Are you sure?'));
        return Swal.fire({
            title: opts.title || 'Are you sure?', html: opts.text || '', icon: opts.icon || 'warning',
            iconColor: opts.danger ? '#dc2626' : '#f9e2af', showCancelButton: true,
            confirmButtonText: opts.confirmText || 'Yes', cancelButtonText: 'Cancel', reverseButtons: true,
            buttonsStyling: false,
            customClass: { popup: 'cf-swal-popup', confirmButton: 'cf-swal-btn cf-swal-confirm' + (opts.danger ? ' is-danger' : ''), cancelButton: 'cf-swal-btn cf-swal-cancel' },
        }).then(r => !!r.isConfirmed);
    }
    function cfPrompt(opts) {
        opts = opts || {};
        if (!window.Swal) return Promise.resolve(window.prompt(opts.title || 'Enter a value', opts.value || ''));
        return Swal.fire({
            title: opts.title || 'Enter a value', input: 'text', inputValue: opts.value || '',
            inputPlaceholder: opts.placeholder || '', inputAttributes: { spellcheck: 'false', autocapitalize: 'off' },
            showCancelButton: true, confirmButtonText: opts.confirmText || 'OK', cancelButtonText: 'Cancel', reverseButtons: true,
            buttonsStyling: false,
            customClass: { popup: 'cf-swal-popup', confirmButton: 'cf-swal-btn cf-swal-confirm', cancelButton: 'cf-swal-btn cf-swal-cancel' },
            inputValidator: opts.allowEmpty ? undefined : (v) => (!v || !v.trim()) ? 'Please enter a name.' : undefined,
        }).then(r => r.isConfirmed ? r.value : null);
    }

    /* ══════════════════════════ tree ══════════════════════════ */
    async function loadTree() {
        try { const r = await api('/tree'); S.tree = r.tree || []; renderTree(); }
        catch (e) { toast(e.message, 'err'); }
    }
    function filterTree(nodes, q) {
        const out = [];
        for (const n of nodes) {
            if (n.type === 'dir') {
                const hit = n.name.toLowerCase().includes(q);
                const kids = n.children ? filterTree(n.children, q) : [];
                if (hit || kids.length) {
                    out.push(Object.assign({}, n, { children: hit ? n.children : kids }));
                    S.expanded.add(n.path);   // reveal matches
                }
            } else if (n.name.toLowerCase().includes(q)) {
                out.push(n);
            }
        }
        return out;
    }
    function renderTree() {
        elTree.innerHTML = '';
        renderLoc();
        const creatingRoot = S.edit && S.edit.mode !== 'rename' && S.edit.dir === '';
        if (!S.tree.length && !creatingRoot) {
            elTree.innerHTML = '<div class="cf-empty-tree"><i class="bi bi-folder-plus" style="font-size:30px;color:#cbd5e1;display:block;margin-bottom:8px"></i>No custom files yet.<br>Use <b>File</b>, <b>Folder</b> or <b>Upload</b> above.</div>';
            return;
        }
        const nodes = S.filter ? filterTree(S.tree, S.filter.toLowerCase()) : S.tree;
        if (!nodes.length && !creatingRoot) {
            elTree.innerHTML = '<div class="cf-empty-tree">No files match “' + esc(S.filter) + '”.</div>';
            return;
        }
        const frag = document.createDocumentFragment();
        if (creatingRoot) frag.appendChild(editRow(0, null));
        walk(nodes, 0, frag);
        elTree.appendChild(frag);
    }
    function walk(nodes, depth, frag) {
        for (const node of nodes) {
            if (S.edit && S.edit.mode === 'rename' && S.edit.node && S.edit.node.path === node.path) {
                frag.appendChild(editRow(depth, node));
            } else {
                frag.appendChild(nodeRow(node, depth));
            }
            if (node.type === 'dir' && S.expanded.has(node.path)) {
                // inline "new file/folder" input nested inside its target folder
                if (S.edit && S.edit.mode !== 'rename' && S.edit.dir === node.path) frag.appendChild(editRow(depth + 1, null));
                if (node.children) walk(node.children, depth + 1, frag);
            }
        }
    }

    /* ── current-folder location bar (address bar) ── */
    function renderLoc() {
        if (!elLoc) return;
        const parts = S.currentDir ? S.currentDir.split('/') : [];
        let html = '<button class="cf-loc-seg' + (parts.length ? '' : ' here') + '" data-dir="" title="Project root">'
            + '<i class="bi bi-house-door-fill"></i></button>';
        let acc = '';
        parts.forEach((p, i) => {
            acc = acc ? acc + '/' + p : p;
            html += '<i class="bi bi-chevron-right cf-loc-sep"></i>'
                + '<button class="cf-loc-seg' + (i === parts.length - 1 ? ' here' : '') + '" data-dir="' + esc(acc) + '" title="' + esc(acc) + '">' + esc(p) + '</button>';
        });
        elLoc.innerHTML = html;
        elLoc.querySelectorAll('.cf-loc-seg').forEach(b => b.addEventListener('click', () => selectDir(b.dataset.dir)));
    }
    function selectDir(dir) {
        S.currentDir = dir || '';
        if (dir) { ensureExpandedFor(dir + '/x'); S.expanded.add(dir); }
        renderTree();
    }

    /* ── inline create / rename (VS Code Explorer-style) ── */
    function startCreate(kind) {
        if (S.filter) { S.filter = ''; const sb = $('cfSearch'); if (sb) sb.value = ''; }
        const dir = S.currentDir || '';
        if (dir) { ensureExpandedFor(dir + '/x'); S.expanded.add(dir); }
        S.edit = { mode: kind === 'folder' ? 'new-folder' : 'new-file', dir };
        renderTree(); focusEditInput();
    }
    function startRename(node) {
        S.edit = { mode: 'rename', node };
        renderTree(); focusEditInput();
    }
    function focusEditInput() {
        const inp = elTree.querySelector('.cf-edit-input');
        if (!inp) return;
        inp.focus();
        if (S.edit && S.edit.mode === 'rename') {
            const v = inp.value, dot = v.lastIndexOf('.');   // keep the extension, select the name
            if (dot > 0) inp.setSelectionRange(0, dot); else inp.select();
        } else { inp.select(); }
        inp.scrollIntoView({ block: 'nearest' });
    }
    function editRow(depth, node) {
        const edit = S.edit;
        const isFolder = edit.mode === 'new-folder' || (node && node.type === 'dir');
        const row = document.createElement('div');
        row.className = 'cf-node cf-editing';
        row.style.paddingLeft = (8 + depth * 14) + 'px';
        const ic = node ? iconFor(node) : { i: isFolder ? 'bi-folder-fill' : 'bi-file-earmark-text', c: isFolder ? '#eab308' : '#94a3b8' };
        row.innerHTML = '<span class="cf-caret"></span>'
            + '<span class="cf-ic"><i class="bi ' + ic.i + '" style="color:' + ic.c + '"></i></span>';
        const input = document.createElement('input');
        input.className = 'cf-edit-input'; input.type = 'text';
        input.spellcheck = false; input.autocapitalize = 'off'; input.autocomplete = 'off';
        input.value = node ? node.name : '';
        input.placeholder = isFolder ? 'folder name' : 'file name (e.g. index.html)';
        row.appendChild(input);

        let done = false;
        const finish = (commit) => {
            if (done) return; done = true;
            if (S.edit !== edit) return;   // a newer edit superseded this one
            const val = input.value.trim();
            S.edit = null;
            if (commit && val && val !== (node ? node.name : '')) commitEdit(edit, val, node);
            else renderTree();
        };
        input.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') { e.preventDefault(); finish(true); }
            else if (e.key === 'Escape') { e.preventDefault(); finish(false); }
        });
        input.addEventListener('blur', () => finish(true));
        input.addEventListener('click', (e) => e.stopPropagation());
        input.addEventListener('mousedown', (e) => e.stopPropagation());
        return row;
    }
    function rekeyTabs(from, to) {
        for (const p of [...S.open.keys()]) {
            if (p === from || p.startsWith(from + '/')) {
                const t = S.open.get(p); S.open.delete(p);
                const np = to + p.slice(from.length); t.ext = extOf(np); S.open.set(np, t);
                if (S.active === p) { S.active = np; setCrumb(np); }
            }
        }
        renderTabs();
    }
    async function commitEdit(edit, val, node) {
        try {
            if (edit.mode === 'rename') {
                const parent = node.path.includes('/') ? node.path.slice(0, node.path.lastIndexOf('/') + 1) : '';
                const to = parent + val;
                await post('/rename', { from: node.path, to });
                rekeyTabs(node.path, to);
                toast('Renamed', 'ok'); await loadTree();
            } else {
                const rel = (edit.dir ? edit.dir + '/' : '') + val;   // val may contain slashes → nested
                if (edit.mode === 'new-folder') {
                    await post('/mkdir', { path: rel });
                    ensureExpandedFor(rel + '/x'); S.expanded.add(rel);
                    S.currentDir = rel;
                    toast('Folder created', 'ok'); await loadTree();
                } else {
                    const content = NEW_TEMPLATES[extOf(rel)] || '';
                    await post('/create', { path: rel, content });
                    ensureExpandedFor(rel);
                    toast('File created', 'ok'); await loadTree();
                    openFile({ path: rel, type: 'file', ext: extOf(rel) });
                }
            }
        } catch (e) { toast(e.message, 'err'); renderTree(); }
    }
    function nodeRow(node, depth) {
        const row = document.createElement('div');
        const isActiveFile = node.type === 'file' && S.active === node.path;
        const isCurDir = node.type === 'dir' && S.currentDir === node.path;
        row.className = 'cf-node' + (isActiveFile ? ' active' : '') + (isCurDir ? ' curdir' : '');
        row.style.paddingLeft = (8 + depth * 14) + 'px';
        row.dataset.path = node.path; row.dataset.type = node.type;
        const ic = iconFor(node);
        const caret = node.type === 'dir'
            ? '<span class="cf-caret"><i class="bi ' + (S.expanded.has(node.path) ? 'bi-chevron-down' : 'bi-chevron-right') + '"></i></span>'
            : '<span class="cf-caret"></span>';
        const badge = node.reserved ? '<span class="cf-badge" title="Reserved by the generated app — skipped at build">reserved</span>' : '';
        const acts = (node.type === 'dir'
                ? '<button class="cf-actbtn rn" title="New file here" data-act="newfile"><i class="bi bi-file-earmark-plus"></i></button>'
                : '')
            + '<button class="cf-actbtn rn" title="Rename" data-act="rename"><i class="bi bi-pencil"></i></button>'
            + '<button class="cf-actbtn" title="Delete" data-act="delete"><i class="bi bi-trash3"></i></button>';
        row.innerHTML = caret
            + '<span class="cf-ic"><i class="bi ' + ic.i + '" style="color:' + ic.c + '"></i></span>'
            + '<span class="cf-nm">' + esc(node.name) + '</span>' + badge
            + '<span class="cf-acts">' + acts + '</span>';
        row.addEventListener('click', (ev) => {
            const actBtn = ev.target.closest('[data-act]');
            if (actBtn) { ev.stopPropagation(); nodeAction(actBtn.dataset.act, node); return; }
            if (node.type === 'dir') {
                if (S.expanded.has(node.path)) S.expanded.delete(node.path); else S.expanded.add(node.path);
                S.currentDir = node.path; renderTree();
            } else { openFile(node); }
        });
        row.addEventListener('contextmenu', (ev) => { ev.preventDefault(); showContextMenu(ev, node); });

        // drag to move (disabled for reserved paths / while filtering)
        if (!node.reserved && !S.filter) {
            row.draggable = true;
            row.addEventListener('dragstart', (ev) => {
                S.moveFrom = node;
                ev.dataTransfer.effectAllowed = 'move';
                ev.dataTransfer.setData('text/plain', node.path);   // marks this as an internal drag
                row.classList.add('dragging');
            });
            row.addEventListener('dragend', () => { S.moveFrom = null; row.classList.remove('dragging'); elTree.querySelectorAll('.cf-drop').forEach(n => n.classList.remove('cf-drop')); });
        }
        if (node.type === 'dir') {
            row.addEventListener('dragover', (ev) => { if (!S.moveFrom) return; ev.preventDefault(); ev.dataTransfer.dropEffect = 'move'; row.classList.add('cf-drop'); });
            row.addEventListener('dragleave', () => row.classList.remove('cf-drop'));
            row.addEventListener('drop', (ev) => { if (!S.moveFrom) return; ev.preventDefault(); ev.stopPropagation(); row.classList.remove('cf-drop'); doMove(S.moveFrom, node.path); S.moveFrom = null; });
        }
        return row;
    }

    async function doMove(node, targetDir) {
        if (!node) return;
        const to = (targetDir ? targetDir + '/' : '') + node.name;
        if (to === node.path) return;
        if (node.type === 'dir' && (targetDir === node.path || targetDir.startsWith(node.path + '/'))) {
            toast("You can't move a folder into itself.", 'err'); return;
        }
        try {
            await post('/rename', { from: node.path, to });
            rekeyTabs(node.path, to);
            if (targetDir) S.expanded.add(targetDir);
            toast('Moved', 'ok'); loadTree();
        } catch (e) { toast(e.message, 'err'); }
    }

    async function nodeAction(act, node) {
        if (act === 'delete') {
            const ok = await cfConfirm({ title: 'Delete “' + baseName(node.path) + '”?', text: node.type === 'dir' ? 'The folder and everything inside it will be removed.' : 'This file will be removed.', confirmText: 'Delete', danger: true });
            if (!ok) return;
            try {
                await post('/delete', { path: node.path });
                // close any open tabs under this path
                for (const p of [...S.open.keys()]) if (p === node.path || p.startsWith(node.path + '/')) closeTab(p, true);
                toast('Deleted', 'ok'); loadTree();
            } catch (e) { toast(e.message, 'err'); }
        } else if (act === 'rename') {
            startRename(node);
        } else if (act === 'duplicate') {
            const base = node.name.includes('.') ? node.name.replace(/(\.[^.]+)$/, '-copy$1') : node.name + '-copy';
            const name = await cfPrompt({ title: 'Duplicate as', value: base, confirmText: 'Duplicate' });
            if (!name) return;
            const parent = node.path.includes('/') ? node.path.slice(0, node.path.lastIndexOf('/') + 1) : '';
            try {
                if (node.type === 'file') {
                    const r = await api('/read?path=' + encodeURIComponent(node.path));
                    await post('/create', { path: parent + name, content: r.content || '' });
                } else { await post('/mkdir', { path: parent + name }); }
                toast('Duplicated', 'ok'); loadTree();
            } catch (e) { toast(e.message, 'err'); }
        } else if (act === 'move') {
            const cur = node.path.includes('/') ? node.path.slice(0, node.path.lastIndexOf('/')) : '';
            const dest = await cfPrompt({ title: 'Move to folder', value: cur, placeholder: 'e.g. site/pages — leave blank for the top level', confirmText: 'Move', allowEmpty: true });
            if (dest === null) return;
            doMove(node, dest.replace(/^\/+|\/+$/g, ''));
        } else if (act === 'download') {
            downloadNode(node);
        } else if (act === 'newfile' || act === 'newfolder') {
            S.currentDir = node.type === 'dir' ? node.path : (node.path.includes('/') ? node.path.slice(0, node.path.lastIndexOf('/')) : '');
            if (node.type === 'dir') S.expanded.add(node.path);
            startCreate(act === 'newfile' ? 'file' : 'folder');
        }
    }

    function downloadNode(node) {
        if (node.type !== 'file') { toast('Select a file to download.', ''); return; }
        api('/read?path=' + encodeURIComponent(node.path)).then(r => {
            let url;
            if (r.image) url = r.dataUri;
            else if (r.binary) { toast('This binary is too large to download from here.', ''); return; }
            else url = 'data:application/octet-stream;charset=utf-8,' + encodeURIComponent(r.content || '');
            const a = document.createElement('a'); a.href = url; a.download = baseName(node.path); a.click();
        }).catch(e => toast(e.message, 'err'));
    }

    /* ══════════════════════════ context menu ══════════════════════════ */
    function showContextMenu(ev, node) {
        const isFile = node.type === 'file';
        const item = (act, icon, label, cls) => '<button data-ctx="' + act + '"' + (cls ? ' class="' + cls + '"' : '') + '><i class="bi ' + icon + '"></i>' + label + '</button>';
        let h = '';
        if (isFile) h += item('open', 'bi-pencil-square', 'Open / Edit');
        if (node.type === 'dir') { h += item('newfile', 'bi-file-earmark-plus', 'New file here') + item('newfolder', 'bi-folder-plus', 'New folder here'); }
        h += '<hr>' + item('rename', 'bi-pencil', 'Rename') + item('move', 'bi-arrows-move', 'Move to…') + item('duplicate', 'bi-files', 'Duplicate');
        if (isFile) h += item('download', 'bi-download', 'Download');
        h += '<hr>' + item('delete', 'bi-trash3', 'Delete', 'danger');
        elCtx.innerHTML = h;
        elCtx.querySelectorAll('[data-ctx]').forEach(b => b.addEventListener('click', () => {
            hideContextMenu();
            const a = b.dataset.ctx;
            if (a === 'open') openFile(node); else nodeAction(a, node);
        }));
        elCtx.style.left = Math.min(ev.clientX, window.innerWidth - 200) + 'px';
        elCtx.style.top = Math.min(ev.clientY, window.innerHeight - 240) + 'px';
        elCtx.classList.add('open');
    }
    function hideContextMenu() { elCtx.classList.remove('open'); }
    document.addEventListener('click', hideContextMenu);
    document.addEventListener('scroll', hideContextMenu, true);
    window.addEventListener('resize', hideContextMenu);

    /* ══════════════════════════ tabs ══════════════════════════ */
    async function openFile(node) {
        if (S.open.has(node.path)) { activate(node.path); return; }
        S.currentDir = node.path.includes('/') ? node.path.slice(0, node.path.lastIndexOf('/')) : '';
        const ext = extOf(node.path);
        // Images/video/audio/PDF stream directly from the /preview URL (any size, no
        // base64) — no need to read the bytes into the SPA at all.
        if (isMediaExt(ext)) {
            const sz = node.size || 0;
            S.open.set(node.path, { ext: ext, dirty: false, size: sz, view: 'media', html: mediaHtml(node.path, ext, sz) });
            activate(node.path);
            return;
        }
        try {
            const r = await api('/read?path=' + encodeURIComponent(node.path));
            const tab = { ext: ext, dirty: false, size: r.size || 0 };
            if (r.image) { tab.view = 'media'; tab.html = '<div class="cf-media"><div class="cf-media-stage"><img class="cf-media-img" src="' + r.dataUri + '" alt=""></div><div class="cf-media-meta" id="cfMediaMeta">' + esc(node.path) + ' · ' + fmtSize(r.size) + '</div></div>'; }
            else if (r.binary) { tab.view = 'binary'; tab.html = '<i class="bi bi-file-earmark-binary" style="font-size:46px;color:#45475a"></i><p>Binary file — not editable here.</p><div class="cf-pmeta">' + esc(node.path) + ' · ' + fmtSize(r.size) + '</div>'; }
            else { tab.view = 'code'; tab.content = r.content || ''; }
            S.open.set(node.path, tab);
            activate(node.path);
        } catch (e) { toast(e.message, 'err'); }
    }
    function syncActiveContent() {
        const t = S.active && S.open.get(S.active);
        if (t && t.view === 'code' && (cm || edFallback)) t.content = edValue();
    }
    function activate(path) {
        if (S.active && S.active !== path) syncActiveContent();
        S.active = path;
        const t = S.open.get(path);
        renderTabs(); renderTree(); setCrumb(path);
        if (!t) { closeEditor(); return; }
        if (t.view === 'code') showCode(t);
        else showPreview(t.html, t.view === 'media');
    }
    function closeTab(path, silent) {
        const t = S.open.get(path);
        if (!t) return;
        const doClose = () => {
            S.open.delete(path);
            if (S.active === path) {
                const next = [...S.open.keys()].pop() || null;
                if (next) activate(next); else { S.active = null; closeEditor(); }
            } else { renderTabs(); }
        };
        if (t.dirty && !silent) {
            cfConfirm({ title: 'Discard changes to “' + baseName(path) + '”?', confirmText: 'Discard', danger: true })
                .then(ok => { if (ok) doClose(); });
        } else { doClose(); }
    }
    function renderTabs() {
        if (!S.open.size) { elTabs.classList.remove('show'); elTabs.innerHTML = ''; return; }
        elTabs.classList.add('show');
        elTabs.innerHTML = '';
        for (const [path, t] of S.open) {
            const ic = iconFor({ type: 'file', name: baseName(path), ext: t.ext });
            const tab = document.createElement('div');
            tab.className = 'cf-tab' + (path === S.active ? ' active' : '') + (t.dirty ? ' dirty' : '');
            tab.innerHTML = '<i class="bi ' + ic.i + ' cf-tab-ic" style="color:' + ic.c + '"></i>'
                + '<span class="cf-tab-nm">' + esc(baseName(path)) + '</span>'
                + '<span class="cf-tab-dot"></span>'
                + '<button class="cf-tab-x" title="Close">&times;</button>';
            tab.addEventListener('click', (ev) => {
                if (ev.target.closest('.cf-tab-x')) { ev.stopPropagation(); closeTab(path); return; }
                activate(path);
            });
            tab.addEventListener('mousedown', (ev) => { if (ev.button === 1) { ev.preventDefault(); closeTab(path); } });
            elTabs.appendChild(tab);
        }
    }

    /* ══════════════════════════ CodeMirror editor engine ══════════════════════════ */
    // Our private, isolated CodeMirror (captured at bundle load) — immune to other
    // page libraries (e.g. GrapesJS) that clobber the global window.CodeMirror.
    const CM = window.__PPCM || window.CodeMirror;
    let cm = null, cmSuppress = false, hintTimer = null, edFallback = null, fbWarned = false;

    function modeFor(ext) {
        switch (ext) {
            case 'js': case 'mjs': case 'cjs': case 'jsx': return 'text/javascript';
            case 'ts': case 'tsx': return 'text/typescript';
            case 'json': return { name: 'javascript', json: true };
            case 'css': case 'scss': case 'less': return 'text/css';
            case 'html': case 'htm': case 'vue': case 'svelte': return 'htmlmixed';
            case 'xml': case 'svg': return 'application/xml';
            case 'php': case 'phtml': return 'application/x-httpd-php';
            case 'md': case 'markdown': return 'text/markdown';
            case 'sql': return 'text/x-sql';
            default: return null;
        }
    }
    function langLabel(ext) {
        return ({
            js: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript', jsx: 'JavaScript', ts: 'TypeScript', tsx: 'TypeScript',
            json: 'JSON', css: 'CSS', scss: 'SCSS', less: 'Less', html: 'HTML', htm: 'HTML', vue: 'Vue', svelte: 'Svelte',
            xml: 'XML', svg: 'SVG', php: 'PHP', phtml: 'PHP', md: 'Markdown', markdown: 'Markdown', sql: 'SQL',
        })[ext] || (ext ? ext.toUpperCase() : 'Plain Text');
    }
    const activeExt = () => { const t = S.active && S.open.get(S.active); return t ? t.ext : ''; };

    // ── IntelliSense: language-aware hints (native CM where available) + doc words ──
    const KEYWORDS = {
        php: ['<?php', '<?=', '?>', 'echo', 'print', 'if', 'else', 'elseif', 'endif', 'foreach', 'endforeach', 'as',
            'for', 'while', 'do', 'switch', 'case', 'default', 'break', 'continue', 'function', 'return', 'class',
            'interface', 'trait', 'extends', 'implements', 'public', 'private', 'protected', 'static', 'const', 'new',
            'use', 'namespace', 'try', 'catch', 'finally', 'throw', 'match', 'fn', 'global', 'isset', 'unset', 'empty',
            'array', 'true', 'false', 'null', 'require', 'require_once', 'include', 'include_once',
            'count', 'implode', 'explode', 'trim', 'str_replace', 'strlen', 'substr', 'preg_match', 'preg_replace',
            'json_encode', 'json_decode', 'htmlspecialchars', 'number_format', 'date', 'time', 'in_array', 'array_map',
            '$_GET', '$_POST', '$_SESSION', '$_SERVER', '$_REQUEST', '$_FILES', '$_COOKIE',
            // platform helpers (see db-helper-facade)
            'dbValue', 'dbRow', 'dbAll', 'dbLookup', 'pp_send_email', 'pp_set_proxy', 'pp_store_upload', 'RequireAuth'],
        html: ['<!DOCTYPE html>', 'html', 'head', 'body', 'title', 'meta', 'link', 'script', 'style', 'div', 'span',
            'section', 'article', 'header', 'footer', 'nav', 'main', 'aside', 'a', 'p', 'ul', 'ol', 'li', 'img',
            'table', 'thead', 'tbody', 'tr', 'td', 'th', 'form', 'input', 'button', 'label', 'select', 'option',
            'textarea', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'class', 'id', 'href', 'src', 'alt', 'type', 'value'],
        css: ['color', 'background', 'background-color', 'display', 'position', 'margin', 'padding', 'border',
            'border-radius', 'width', 'height', 'max-width', 'min-width', 'font-size', 'font-weight', 'font-family',
            'line-height', 'flex', 'flex-direction', 'align-items', 'justify-content', 'gap', 'grid', 'grid-template-columns',
            'transition', 'transform', 'opacity', 'box-shadow', 'text-align', 'overflow', 'z-index', 'cursor',
            'relative', 'absolute', 'fixed', 'sticky', 'flex', 'grid', 'block', 'inline-block', 'none', 'center'],
        js: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case',
            'break', 'continue', 'new', 'class', 'extends', 'super', 'this', 'typeof', 'instanceof', 'await', 'async',
            'try', 'catch', 'finally', 'throw', 'import', 'from', 'export', 'default', 'null', 'undefined', 'true', 'false',
            'document', 'window', 'console', 'querySelector', 'querySelectorAll', 'addEventListener', 'getElementById',
            'createElement', 'appendChild', 'fetch', 'JSON', 'Object', 'Array', 'Promise', 'setTimeout', 'Math'],
        sql: ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'FROM', 'WHERE', 'JOIN', 'LEFT', 'INNER', 'ON', 'AND', 'OR',
            'ORDER BY', 'GROUP BY', 'LIMIT', 'VALUES', 'SET', 'CREATE TABLE', 'ALTER TABLE', 'DROP', 'COUNT', 'DISTINCT'],
    };
    function langKey(ext) {
        if (['php', 'phtml'].includes(ext)) return 'php';
        if (['html', 'htm', 'vue', 'svelte'].includes(ext)) return 'html';
        if (['css', 'scss', 'less'].includes(ext)) return 'css';
        if (['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'json'].includes(ext)) return 'js';
        if (ext === 'sql') return 'sql';
        return '';
    }
    function wordHint(editor, extra) {
        const cur = editor.getCursor(), line = editor.getLine(cur.line);
        let start = cur.ch;
        while (start && /[\w$@#.\-<?]/.test(line.charAt(start - 1))) start--;
        const pre = line.slice(start, cur.ch), preL = pre.toLowerCase();
        const words = new Set(extra || []);
        const re = /[A-Za-z_$<][\w$-]{1,}/g; let m, n = 0; const txt = editor.getValue();
        while ((m = re.exec(txt)) && n < 5000) { words.add(m[0]); n++; }
        let list = [...words];
        list = preL ? list.filter(w => { const wl = w.toLowerCase(); return wl.startsWith(preL) && wl !== preL; })
                    : (extra || []).slice();
        list.sort((a, b) => a.length - b.length || a.localeCompare(b));
        return { list: list.slice(0, 60), from: CM.Pos(cur.line, start), to: CM.Pos(cur.line, cur.ch) };
    }
    // Context-aware "all-in-one" IntelliSense: dispatch to the language AT the cursor,
    // so one .php/.html file gives CSS hints inside <style>, JS hints inside <script>,
    // HTML tag/attr hints in markup, and PHP keyword/helper hints inside <?php ?>.
    function innerModeName(editor) {
        try {
            const cur = editor.getCursor();
            const inner = CM.innerMode(editor.getMode(), editor.getTokenAt(cur).state);
            return (inner && inner.mode && inner.mode.name) || '';
        } catch (e) { return ''; }
    }
    function cfHint(editor) {
        const ext = activeExt();
        const byExt = { css: 'css', scss: 'css', less: 'css', js: 'javascript', mjs: 'javascript', cjs: 'javascript',
            jsx: 'javascript', ts: 'javascript', tsx: 'javascript', json: 'javascript', html: 'html', htm: 'html',
            vue: 'html', svelte: 'html', xml: 'xml', svg: 'xml', sql: 'sql' };
        const inner = innerModeName(editor);
        // Which native CM hint fits the cursor's region?
        let nativeName = null, kwKey = null;
        if (inner === 'css') { nativeName = 'css'; kwKey = 'css'; }
        else if (inner === 'javascript') { nativeName = 'javascript'; kwKey = 'js'; }
        else if (inner === 'xml' || inner === 'htmlmixed') { nativeName = 'html'; kwKey = 'html'; }
        else if (inner === 'php' || inner === 'clike') { nativeName = null; kwKey = 'php'; }
        else { nativeName = byExt[ext] || null; kwKey = langKey(ext); }
        if (nativeName && CM.hint && CM.hint[nativeName]) {
            try { const r = CM.hint[nativeName](editor); if (r && r.list && r.list.length) return r; } catch (e) { /* fall through to word hint */ }
        }
        return wordHint(editor, KEYWORDS[kwKey] || KEYWORDS[langKey(ext)] || []);
    }
    function ensureCm() {
        if (cm) return cm;
        if (!CM || typeof CM !== 'function') { console.error('[CustomFiles] CodeMirror not available — using the basic editor.'); return null; }
        try {
            cm = CM(elCodeArea, {
                value: '', mode: null, lineNumbers: true, autoCloseBrackets: true, autoCloseTags: true,
                matchBrackets: true, matchTags: { bothTags: true }, styleActiveLine: true, indentUnit: 4, tabSize: 4,
                indentWithTabs: false, lineWrapping: false, viewportMargin: 40,
                gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
                lint: false,   // enabled per-file in showCode (only for checkable types)
                hintOptions: { hint: cfHint, completeSingle: false },
                extraKeys: {
                    'Ctrl-Space': (c) => c.showHint(), 'Cmd-Space': (c) => c.showHint(),
                    'Ctrl-S': () => saveFile(), 'Cmd-S': () => saveFile(),
                    'Ctrl-F': () => openFind(false), 'Cmd-F': () => openFind(false),
                    'Ctrl-H': () => openFind(true), 'Cmd-H': () => openFind(true),
                    'Ctrl-/': 'toggleComment', 'Cmd-/': 'toggleComment',
                    'Shift-Tab': 'indentLess', 'Tab': (c) => c.somethingSelected() ? c.indentSelection('add') : c.replaceSelection('    '),
                },
            });
        } catch (e) { console.error('[CustomFiles] CodeMirror failed to initialize — using the basic editor.', e); cm = null; return null; }
        cm.on('change', onCmChange);
        cm.on('cursorActivity', updateStatusBar);
        cm.on('inputRead', (c, chg) => {
            if (c.state.completionActive) return;
            const s = chg.text[0];
            if (!/[\w$.<@]/.test(s || '')) return;
            const tok = c.getTokenAt(c.getCursor());
            if ((tok.string || '').replace(/[^\w$<@]/g, '').length < 2 && s !== '<' && s !== '.') return;
            clearTimeout(hintTimer);
            hintTimer = setTimeout(() => { if (!c.state.completionActive) c.showHint(); }, 130);
        });
        return cm;
    }
    // Fallback plain-textarea editor (only used if CodeMirror can't load) so the module never hard-fails.
    function ensureFallback() {
        if (edFallback) return edFallback;
        const ta = document.createElement('textarea');
        ta.className = 'cf-fallback-editor'; ta.spellcheck = false;
        ta.setAttribute('autocomplete', 'off'); ta.setAttribute('autocapitalize', 'off');
        elCodeArea.appendChild(ta);
        ta.addEventListener('input', () => {
            const t = S.active && S.open.get(S.active);
            if (t && t.view === 'code') { t.content = ta.value; markDirty(true); scheduleLive(); updateStatusBar(); }
        });
        ta.addEventListener('keyup', updateStatusBar);
        ta.addEventListener('click', updateStatusBar);
        ta.addEventListener('keydown', (e) => {
            const mod = navigator.platform.toUpperCase().includes('MAC') ? e.metaKey : e.ctrlKey;
            if (mod && e.key.toLowerCase() === 's') { e.preventDefault(); saveFile(); }
            else if (e.key === 'Tab') { e.preventDefault(); const s = ta.selectionStart, en = ta.selectionEnd; ta.value = ta.value.slice(0, s) + '    ' + ta.value.slice(en); ta.selectionStart = ta.selectionEnd = s + 4; ta.dispatchEvent(new Event('input')); }
        });
        edFallback = ta; return ta;
    }
    // Editor accessors that work with either CodeMirror or the fallback textarea.
    function edValue() { return cm ? cm.getValue() : (edFallback ? edFallback.value : ''); }
    function edFocus() { if (cm) cm.focus(); else if (edFallback) edFallback.focus(); }
    function edSetValue(v) { if (cm) { cmSuppress = true; cm.setValue(v); cm.clearHistory(); cmSuppress = false; } else if (edFallback) { edFallback.value = v; } }
    function edSelectIndex(a, b) {
        if (cm) { const f = cm.posFromIndex(a), t = cm.posFromIndex(b); cm.setSelection(f, t); cm.scrollIntoView({ from: f, to: t }, 80); cm.focus(); }
        else if (edFallback) { edFallback.focus(); edFallback.setSelectionRange(a, b); }
    }
    function edReplaceIndex(a, b, text) {
        if (cm) { cm.replaceRange(text, cm.posFromIndex(a), cm.posFromIndex(b)); }
        else if (edFallback) { const v = edFallback.value; edFallback.value = v.slice(0, a) + text + v.slice(b); edFallback.dispatchEvent(new Event('input')); }
    }
    function edCursor() {
        if (cm) { const c = cm.getCursor(); return { line: c.line + 1, ch: c.ch + 1, sel: cm.getSelection().length, lines: cm.lineCount() }; }
        if (edFallback) {
            const v = edFallback.value, i = edFallback.selectionStart || 0, before = v.slice(0, i);
            const nl = before.lastIndexOf('\n');
            return { line: before.split('\n').length, ch: i - nl, sel: (edFallback.selectionEnd - edFallback.selectionStart) || 0, lines: v.split('\n').length };
        }
        return { line: 1, ch: 1, sel: 0, lines: 1 };
    }
    // Prints a copy-pasteable diagnostic when the rich editor can't load, so we can
    // pinpoint why window.CodeMirror / __PPCM is missing in the real page.
    function dumpEditorDiagnostic() {
        const s = Array.prototype.slice.call(document.scripts).find(x => /codemirror\.bundle\.js/.test(x.src || ''));
        const info = {
            scriptTagFound: !!s,
            scriptSrc: s ? s.src : '(none)',
            __PPCM: typeof window.__PPCM,
            windowCodeMirror: typeof window.CodeMirror,
            define: typeof window.define,
            defineAmd: !!(window.define && window.define.amd),
            grapesjs: typeof window.grapesjs,
        };
        console.error('%c[CustomFiles] EDITOR DIAGNOSTIC — copy everything below:', 'color:#ef4444;font-weight:bold');
        console.error(JSON.stringify(info, null, 2));
        if (s) {
            fetch(s.src, { cache: 'reload' })
                .then(r => console.error('[CustomFiles] bundle fetch status:', r.status, r.headers.get('content-type')))
                .catch(e => console.error('[CustomFiles] bundle fetch FAILED:', e.message));
        }
        return info;
    }
    window.PpCustomFilesDiag = dumpEditorDiagnostic;   // so it can be run manually too

    function onCmChange() {
        if (cmSuppress) return;
        const t = S.active && S.open.get(S.active);
        if (!t || t.view !== 'code') return;
        t.content = cm.getValue();
        markDirty(true); scheduleLive(); updateStatusBar(); updateUndoState();
    }
    // Undo / redo — drives CodeMirror's history (or the textarea fallback), and
    // enables/disables the toolbar buttons from the editor's history size.
    function updateUndoState() {
        if (!elUndo || !elRedo) return;
        if (cm) { const h = cm.historySize ? cm.historySize() : { undo: 1, redo: 1 }; elUndo.disabled = !h.undo; elRedo.disabled = !h.redo; }
        else { elUndo.disabled = false; elRedo.disabled = false; }   // fallback: native undo, state unknown
    }
    function doUndo() {
        if (cm) { cm.undo(); cm.focus(); updateUndoState(); }
        else if (edFallback) { edFallback.focus(); try { document.execCommand('undo'); } catch (e) {} }
    }
    function doRedo() {
        if (cm) { cm.redo(); cm.focus(); updateUndoState(); }
        else if (edFallback) { edFallback.focus(); try { document.execCommand('redo'); } catch (e) {} }
    }
    // Live error linting → red gutter markers + wavy underline + red line tint,
    // backed by the server /lint endpoint (php -l, node --check, JSON/CSS/HTML checks).
    async function cfLintProvider(content, updateLinting, options, editor) {
        const t = S.active && S.open.get(S.active);
        const clearLines = () => { (editor._cfBadLines || []).forEach(x => editor.removeLineClass(x.ln, 'background', x.cls)); editor._cfBadLines = []; };
        if (!t || !LINTABLE.includes(t.ext)) { clearLines(); updateLinting([]); return; }
        let r;
        try { r = await post('/lint', { path: S.active, content }); }
        catch (e) { updateLinting([]); return; }
        if (!r || !r.checked) { clearLines(); updateLinting([]); return; }
        const anns = [];
        clearLines(); editor._cfBadLines = [];
        for (const p of (r.problems || [])) {
            const ln = Math.max(0, (p.line || 1) - 1);
            const lineText = editor.getLine(ln) || '';
            const startCh = Math.max(0, lineText.length - lineText.replace(/^\s+/, '').length);
            const sev = p.severity === 'warning' ? 'warning' : 'error';
            anns.push({ from: CM.Pos(ln, startCh), to: CM.Pos(ln, lineText.length || 1), message: p.message, severity: sev });
            const cls = sev === 'warning' ? 'cf-warn-line' : 'cf-error-line';
            editor.addLineClass(ln, 'background', cls); editor._cfBadLines.push({ ln: ln, cls: cls });
        }
        updateLinting(anns);
    }
    const LINT_CFG = { getAnnotations: cfLintProvider, async: true, delay: 800, lintOnChange: true };

    /* ══════════════════════════ editor (code) ══════════════════════════ */
    function showCode(tab) {
        elBlank.style.display = 'none'; elPreview.style.display = 'none';
        const editor = ensureCm();
        if (editor) {
            if (edFallback) edFallback.style.display = 'none';
            editor.getWrapperElement().style.display = '';
            (editor._cfBadLines || []).forEach(x => editor.removeLineClass(x.ln, 'background', x.cls)); editor._cfBadLines = [];
            editor.setOption('lint', false);   // clear old markers before swapping files
            cmSuppress = true;
            editor.setOption('mode', modeFor(tab.ext));
            editor.setValue(tab.content || '');
            editor.clearHistory();
            cmSuppress = false;
            editor.setOption('lint', LINTABLE.includes(tab.ext) ? LINT_CFG : false);
        } else {
            const ta = ensureFallback();
            ta.style.display = '';
            ta.value = tab.content || '';
            if (!fbWarned) {
                fbWarned = true;
                toast('Rich editor unavailable — using a basic editor. Try a hard refresh (⌘/Ctrl+Shift+R).', 'err');
                dumpEditorDiagnostic();
            }
        }
        elCheck.disabled = !LINTABLE.includes(tab.ext);
        elSave.disabled = !tab.dirty;
        setStatus(tab.dirty);
        hideProblems();
        setupView(tab);
        updateStatusBar();
        if (elUndoRedo) elUndoRedo.style.display = '';
        updateUndoState();
        requestAnimationFrame(() => { if (cm) { cm.refresh(); cm.focus(); } else edFocus(); });
    }
    function showPreview(html, fill) {
        elBlank.style.display = 'none'; elCode.style.display = 'none'; elLive.style.display = 'none';
        elViewSeg.style.display = 'none'; elPvSize.style.display = 'none'; elPvOpen.style.display = 'none';
        if (elUndoRedo) elUndoRedo.style.display = 'none';
        elPreview.style.display = ''; elPreview.classList.toggle('fill', !!fill); elPreview.innerHTML = html;
        elSave.disabled = true; elStatus.textContent = ''; elCheck.disabled = true; hideProblems();
        elStatusbar.style.display = 'none';
        // append the pixel dimensions once an image finishes loading
        const img = elPreview.querySelector('.cf-media-img'), meta = elPreview.querySelector('#cfMediaMeta');
        if (img && meta) {
            const put = () => { if (img.naturalWidth) meta.textContent += ' · ' + img.naturalWidth + ' × ' + img.naturalHeight + ' px'; };
            if (img.complete) put(); else img.addEventListener('load', put, { once: true });
        }
    }
    function mediaHtml(path, ext, size) {
        const url = previewUrl(path);
        const meta = '<div class="cf-media-meta" id="cfMediaMeta">' + esc(path) + ' · ' + fmtSize(size) + '</div>';
        if (IMAGE_EXT.has(ext)) return '<div class="cf-media"><div class="cf-media-stage"><img class="cf-media-img" src="' + url + '" alt=""></div>' + meta + '</div>';
        if (VIDEO_EXT.has(ext)) return '<div class="cf-media"><div class="cf-media-stage"><video class="cf-media-video" src="' + url + '" controls preload="metadata"></video></div>' + meta + '</div>';
        if (AUDIO_EXT.has(ext)) return '<div class="cf-media"><div class="cf-media-stage"><div class="cf-media-audio"><i class="bi bi-file-earmark-music"></i><audio src="' + url + '" controls preload="metadata"></audio></div></div>' + meta + '</div>';
        if (PDF_EXT.has(ext))   return '<div class="cf-media cf-media--pdf"><iframe class="cf-media-pdf" src="' + url + '" title="PDF preview"></iframe>' + meta + '</div>';
        return '';
    }
    function closeEditor() {
        elCode.style.display = 'none'; elPreview.style.display = 'none'; elLive.style.display = 'none';
        elViewSeg.style.display = 'none'; elPvSize.style.display = 'none'; elPvOpen.style.display = 'none';
        if (elUndoRedo) elUndoRedo.style.display = 'none';
        elBlank.style.display = '';
        elCrumb.innerHTML = '<span class="cf-dim">Select a file to edit</span>';
        elSave.disabled = true; elStatus.textContent = ''; elCheck.disabled = true; hideProblems();
        elStatusbar.style.display = 'none';
        renderTabs();
    }

    /* ── live preview (Phase 3) ── */
    function previewUrl(path) {
        const enc = (path || '').split('/').filter(Boolean).map(encodeURIComponent).join('/');
        return API + '/preview' + (enc ? '/' + enc : '');
    }
    function setupView(tab) {
        S.livable = PREVIEW_LIVE.has(tab.ext);
        elViewSeg.style.display = S.livable ? '' : 'none';
        elPvOpen.style.display = ''; // any code file can be opened raw in a new tab
        applyView();
    }
    function applyView() {
        const t = S.active && S.open.get(S.active);
        if (!t || t.view !== 'code') return;
        const showEditor = !S.livable || S.view !== 'preview';
        const showLive = S.livable && (S.view === 'split' || S.view === 'preview');
        elCode.style.display = showEditor ? '' : 'none';
        elLive.style.display = showLive ? '' : 'none';
        elViewSeg.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('active', b.dataset.view === (S.livable ? S.view : 'edit')));
        // responsive preview-size control — only when a live preview is showing
        elPvSize.style.display = showLive ? '' : 'none';
        elPvSize.querySelectorAll('[data-size]').forEach(b => b.classList.toggle('active', b.dataset.size === S.pvSize));
        elLive.classList.remove('size-lg', 'size-md', 'size-sm');
        elLive.classList.add('size-' + S.pvSize);
        if (showEditor && cm) requestAnimationFrame(() => cm.refresh());
        if (showLive) renderLive(t, true);
    }
    function renderLive(tab, immediate) {
        const ext = tab.ext;
        const isPhp = (ext === 'php' || ext === 'phtml');
        elLiveFrame.style.display = 'none'; elLiveMd.style.display = 'none'; elLiveNote.style.display = 'none';
        if (isPhp) { runPhp(!!immediate); return; }   // executes in a sandbox → renders output + badge
        if (elLiveFlag) elLiveFlag.style.display = 'none';
        const content = ((cm || edFallback) ? edValue() : (tab.content || ''));
        if (ext === 'md' || ext === 'markdown') { elLiveMd.style.display = ''; elLiveMd.innerHTML = renderMarkdown(content); return; }
        elLiveFrame.style.display = '';
        if (ext === 'svg') elLiveFrame.srcdoc = content;
        else if (ext === 'css') elLiveFrame.srcdoc = cssScaffold(content);
        else elLiveFrame.srcdoc = injectBase(content, S.active);
    }

    /* ── PHP: run in a server sandbox and render the real output ── */
    let phpRunning = false, phpRunTimer = null;
    function runPhp(immediate) {
        clearTimeout(phpRunTimer);
        const go = async () => {
            if (phpRunning) return;
            phpRunning = true;
            elLiveFrame.style.display = '';
            if (elLiveFlag) { elLiveFlag.style.display = ''; elLiveFlag.className = 'cf-live-flag'; elLiveFlag.innerHTML = '<i class="bi bi-hourglass-split"></i> Running…'; }
            try {
                const r = await post('/run', { path: S.active, content: edValue() });
                renderPhpResult(r);
            } catch (e) { renderPhpResult({ stderr: e.message }); }
            finally { phpRunning = false; }
        };
        if (immediate) go(); else phpRunTimer = setTimeout(go, 900);
    }
    function renderPhpResult(r) {
        r = r || {};
        const out = r.output || '', err = r.stderr || '', timedOut = !!r.timedOut;
        elLiveMd.style.display = 'none'; elLiveNote.style.display = 'none'; elLiveFrame.style.display = '';
        if (elLiveFlag) {
            elLiveFlag.style.display = ''; elLiveFlag.title = 'Click to re-run';
            if (r.unavailable) { elLiveFlag.className = 'cf-live-flag warn'; elLiveFlag.innerHTML = '<i class="bi bi-exclamation-triangle-fill"></i> ' + esc(err || 'PHP cannot run here'); }
            else if (timedOut) { elLiveFlag.className = 'cf-live-flag err'; elLiveFlag.innerHTML = '<i class="bi bi-hourglass-split"></i> Timed out (5s) — stopped · re-run'; }
            else if (err) { elLiveFlag.className = 'cf-live-flag err'; elLiveFlag.innerHTML = '<i class="bi bi-bug-fill"></i> PHP error — see below · re-run'; }
            else { elLiveFlag.className = 'cf-live-flag ok'; elLiveFlag.innerHTML = '<i class="bi bi-lightning-charge-fill"></i> PHP ran in a sandbox' + (r.ms != null ? ' · ' + r.ms + 'ms' : '') + ' · re-run'; }
        }
        let body = injectBase(out, S.active);
        if (err) {
            const block = '<pre style="margin:0;padding:12px 14px;background:#3d1a1a;color:#f8b4b4;'
                + 'font:12px/1.5 ui-monospace,Menlo,Consolas,monospace;white-space:pre-wrap;border-top:2px solid #b91c1c">'
                + esc(err) + '</pre>';
            body = /<\/body>/i.test(body) ? body.replace(/<\/body>/i, block + '</body>') : (body + block);
        }
        if (!out && !err) body = injectBase('<div style="color:#94a3b8;font-family:system-ui;padding:24px">(the script produced no output)</div>', S.active);
        elLiveFrame.srcdoc = body;
    }
    function injectBase(html, path) {
        const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
        const tag = '<base href="' + previewUrl(dir) + '/">';
        if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, m => m + tag);
        if (/<html[^>]*>/i.test(html)) return html.replace(/<html[^>]*>/i, m => m + tag);
        return tag + html;
    }
    function cssScaffold(css) {
        return '<!doctype html><html><head><meta charset="utf-8"><style>'
            + 'body{margin:0;padding:26px;font-family:-apple-system,system-ui,sans-serif;color:#1e293b;line-height:1.6}'
            + '.card{border:1px solid #e2e8f0;border-radius:10px;padding:16px;margin-top:14px}</style>'
            + '<style>' + css + '</style></head><body>'
            + '<h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3>'
            + '<p>A paragraph with <a href="#">a link</a>, <strong>bold</strong> and <em>italic</em> text.</p>'
            + '<p><button>Button</button> &nbsp; <input placeholder="Text input"> &nbsp; '
            + '<label><input type="checkbox"> Checkbox</label></p>'
            + '<ul><li>List item one</li><li>List item two</li></ul>'
            + '<div class="card">A <code>.card</code> element — style it in your CSS.</div>'
            + '</body></html>';
    }
    // Tiny, safe Markdown → HTML (escapes first; blocks javascript:/unsafe URLs).
    function renderMarkdown(src) {
        const safeUrl = (u) => /^(https?:\/\/|\/|\.{0,2}\/|#|mailto:|data:image\/)/i.test(u.trim()) ? u.trim() : '#';
        let s = esc(src);
        s = s.replace(/```([\s\S]*?)```/g, (m, c) => 'PRE' + c.replace(/^\n/, '') + '/PRE');
        s = s.replace(/^###### (.*)$/gm, '<h6>$1</h6>').replace(/^##### (.*)$/gm, '<h5>$1</h5>')
             .replace(/^#### (.*)$/gm, '<h4>$1</h4>').replace(/^### (.*)$/gm, '<h3>$1</h3>')
             .replace(/^## (.*)$/gm, '<h2>$1</h2>').replace(/^# (.*)$/gm, '<h1>$1</h1>');
        s = s.replace(/^\s*(?:---|\*\*\*|___)\s*$/gm, '<hr>');
        s = s.replace(/^&gt; ?(.*)$/gm, '<blockquote>$1</blockquote>');
        s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (m, a, u) => '<img alt="' + a + '" src="' + safeUrl(u) + '">');
        s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (m, t, u) => '<a href="' + safeUrl(u) + '" target="_blank" rel="noopener">' + t + '</a>');
        s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
        s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
        s = s.replace(/(?:^(?:[-*+] |\d+\. ).*(?:\n|$))+/gm, (block) => {
            const ordered = /^\d+\. /.test(block);
            const items = block.trim().split('\n').map(l => '<li>' + l.replace(/^(?:[-*+] |\d+\. )/, '') + '</li>').join('');
            return '<' + (ordered ? 'ol' : 'ul') + '>' + items + '</' + (ordered ? 'ol' : 'ul') + '>';
        });
        s = s.split(/\n{2,}/).map(chunk => {
            const t = chunk.trim();
            if (!t) return '';
            if (/^<(h\d|ul|ol|blockquote|hr|PRE)/.test(t)) return chunk;
            return '<p>' + chunk.replace(/\n/g, '<br>') + '</p>';
        }).join('\n');
        return s.replace(/PRE([\s\S]*?)\/PRE/g, '<pre><code>$1</code></pre>');
    }
    let liveTimer;
    function scheduleLive() {
        if (elLive.style.display === 'none') return;
        clearTimeout(liveTimer);
        liveTimer = setTimeout(() => { const t = S.active && S.open.get(S.active); if (t) renderLive(t, false); }, 250);
    }
    function setCrumb(path) {
        const t = S.open.get(path);
        const parts = path.split('/'); const file = parts.pop();
        const meta = t && t.size ? ' <span class="cf-dim">· ' + fmtSize(t.size) + '</span>' : '';
        elCrumb.innerHTML = (parts.length ? '<span class="cf-dim">' + esc(parts.join('/')) + '/</span>' : '') + esc(file) + meta;
    }
    function setStatus(dirty) {
        elStatus.textContent = dirty ? 'Unsaved' : 'Saved';
        elStatus.className = 'cf-status ' + (dirty ? 'dirty' : 'saved');
    }
    function markDirty(d) {
        const t = S.active && S.open.get(S.active);
        if (!t) return;
        t.dirty = d; elSave.disabled = !d; setStatus(d); renderTabs();
    }
    async function saveFile() {
        const t = S.active && S.open.get(S.active);
        if (!t || t.view !== 'code') return;
        t.content = (cm || edFallback) ? edValue() : t.content;
        try {
            await post('/save', { path: S.active, content: t.content });
            markDirty(false); toast('Saved', 'ok');
            if (LINTABLE.includes(t.ext)) runCheck(true);
        } catch (e) { toast(e.message, 'err'); }
    }

    /* ── compact status bar ── */
    function updateStatusBar() {
        const t = S.active && S.open.get(S.active);
        if ((!cm && !edFallback) || !t || t.view !== 'code') { elStatusbar.style.display = 'none'; return; }
        elStatusbar.style.display = '';
        const cur = edCursor();
        elSbLang.textContent = langLabel(t.ext);
        elSbPath.textContent = '/' + S.active;
        elSbPosTxt.textContent = 'Ln ' + cur.line + ', Col ' + cur.ch;
        if (cur.sel) { elSbSel.textContent = '(' + cur.sel + ' selected)'; elSbSel.style.display = ''; }
        else { elSbSel.style.display = 'none'; }
        elSbLines.textContent = cur.lines + ' lines';
        let bytes; try { bytes = new Blob([edValue()]).size; } catch (e) { bytes = (edValue() || '').length; }
        elSbSize.textContent = fmtSize(bytes);
    }

    /* ══════════════════════════ code checking ══════════════════════════ */
    async function runCheck(silent) {
        const t = S.active && S.open.get(S.active);
        if (!t || t.view !== 'code') return;
        elCheck.classList.add('busy');
        try {
            const r = await post('/lint', { path: S.active, content: edValue() });
            if (!r.checked) { if (!silent) toast('No checker for this file type.', ''); hideProblems(); return; }
            renderProblems(r.problems || [], r.tool || '');
            if (!r.problems || !r.problems.length) { if (!silent) toast('No problems found', 'ok'); }
            else if (!silent) toast(r.problems.length + ' problem(s) found', 'err');
        } catch (e) { if (!silent) toast(e.message, 'err'); }
        finally { elCheck.classList.remove('busy'); }
    }
    function renderProblems(problems, tool) {
        elProblemsTool.textContent = tool;
        if (!problems.length) {
            elProblemsHead.classList.add('clean'); elProblemsTitle.textContent = 'No problems';
            elProblemsBody.innerHTML = '<div class="cf-problem"><span class="cf-pline"></span><span class="cf-psev" style="color:#a6e3a1"><i class="bi bi-check2-circle"></i></span><span class="cf-pmsg">This file parsed without errors.</span></div>';
            elProblems.classList.add('open');
            clearTimeout(renderProblems._t); renderProblems._t = setTimeout(hideProblems, 2200);
            return;
        }
        clearTimeout(renderProblems._t);
        elProblemsHead.classList.remove('clean');
        elProblemsTitle.textContent = problems.length + ' problem' + (problems.length > 1 ? 's' : '');
        elProblemsBody.innerHTML = '';
        for (const p of problems) {
            const row = document.createElement('div');
            row.className = 'cf-problem';
            row.innerHTML = '<span class="cf-pline">' + (p.line != null ? p.line : '—') + '</span>'
                + '<span class="cf-psev ' + esc(p.severity || 'error') + '"><i class="bi bi-x-circle-fill"></i></span>'
                + '<span class="cf-pmsg">' + esc(p.message) + '</span>';
            if (p.line != null) row.addEventListener('click', () => jumpToLine(p.line));
            elProblemsBody.appendChild(row);
        }
        elProblems.classList.add('open');
    }
    function hideProblems() { clearTimeout(renderProblems._t); elProblems.classList.remove('open'); }
    function jumpToLine(line) {
        if (cm) {
            const l = Math.max(0, line - 1);
            cm.setCursor({ line: l, ch: 0 });
            cm.setSelection({ line: l, ch: 0 }, { line: l, ch: (cm.getLine(l) || '').length });
            cm.scrollIntoView({ line: l, ch: 0 }, 120);
            cm.focus();
        } else if (edFallback) {
            const lines = edFallback.value.split('\n'); let idx = 0;
            for (let i = 0; i < Math.min(line - 1, lines.length); i++) idx += lines[i].length + 1;
            edSelectIndex(idx, idx + (lines[line - 1] || '').length);
        }
    }

    /* ══════════════════════════ find & replace (works with either editor) ══════════════════════════ */
    const F = { el: { input: $('cfFindInput'), rep: $('cfReplaceInput'), count: $('cfFindCount') }, matches: [], idx: -1 };
    function openFind(withReplace) {
        if (!cm && !edFallback) return;
        elFind.classList.add('open');
        const sel = cm ? cm.getSelection() : (edFallback ? edFallback.value.slice(edFallback.selectionStart, edFallback.selectionEnd) : '');
        if (sel && !sel.includes('\n')) F.el.input.value = sel;
        runFind();
        (withReplace ? F.el.rep : F.el.input).focus();
        F.el.input.select();
    }
    function closeFind() { elFind.classList.remove('open'); F.matches = []; F.idx = -1; edFocus(); }
    function runFind() {
        const q = F.el.input.value;
        F.matches = []; F.idx = -1;
        if (q) {
            const v = edValue(); let i = 0;
            while ((i = v.indexOf(q, i)) !== -1) { F.matches.push(i); i += q.length || 1; }
        }
        F.el.count.textContent = F.matches.length ? '0/' + F.matches.length : (q ? 'none' : '');
        if (F.matches.length) gotoMatch(0);
    }
    function gotoMatch(i) {
        if (!F.matches.length) return;
        F.idx = (i + F.matches.length) % F.matches.length;
        const start = F.matches[F.idx], len = F.el.input.value.length;
        edSelectIndex(start, start + len);
        F.el.count.textContent = (F.idx + 1) + '/' + F.matches.length;
    }
    function replaceOne() {
        if (F.idx < 0 || !F.matches.length) return;
        const start = F.matches[F.idx], q = F.el.input.value, rep = F.el.rep.value;
        edReplaceIndex(start, start + q.length, rep);
        runFind();
    }
    function replaceAll() {
        const q = F.el.input.value; if (!q) return;
        const n = F.matches.length;
        edSetValue(edValue().split(q).join(F.el.rep.value));
        const t = S.active && S.open.get(S.active); if (t) { t.content = edValue(); markDirty(true); scheduleLive(); }
        runFind(); toast(n + ' replaced', 'ok');
    }
    F.el.input.addEventListener('input', runFind);
    F.el.input.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') { e.preventDefault(); gotoMatch(F.idx + (e.shiftKey ? -1 : 1)); }
        else if (e.key === 'Escape') { e.preventDefault(); closeFind(); }
    });
    F.el.rep.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); closeFind(); } });
    $('cfFindNext').addEventListener('click', () => gotoMatch(F.idx + 1));
    $('cfFindPrev').addEventListener('click', () => gotoMatch(F.idx - 1));
    $('cfFindClose').addEventListener('click', closeFind);
    $('cfReplaceOne').addEventListener('click', replaceOne);
    $('cfReplaceAll').addEventListener('click', replaceAll);

    /* ══════════════════════════ toolbar: new / upload ══════════════════════════ */
    const NEW_TEMPLATES = {
        html: '<!DOCTYPE html>\n<html lang="en">\n<head>\n    <meta charset="UTF-8">\n    <meta name="viewport" content="width=device-width, initial-scale=1.0">\n    <title>Document</title>\n</head>\n<body>\n    \n</body>\n</html>\n',
        css: '/* styles */\n',
        js: '',
        json: '{\n    \n}\n',
        md: '# Title\n\n',
    };
    // New file/folder now use the in-tree inline editor (startCreate); NEW_TEMPLATES
    // above still seeds a new file's initial content by extension.
    function ensureExpandedFor(path) {
        const parts = path.split('/'); parts.pop(); let acc = '';
        for (const p of parts) { acc = acc ? acc + '/' + p : p; S.expanded.add(acc); }
    }
    async function uploadFiles(fileList) {
        if (!fileList || !fileList.length) return;
        const fd = new FormData();
        for (const f of fileList) { fd.append('files[]', f); fd.append('paths[]', f.webkitRelativePath || f.name); }
        fd.append('path', S.currentDir || '');
        try {
            const r = await api('/upload', { method: 'POST', body: fd });
            toast('Uploaded ' + r.stored + ' file(s)' + (r.skipped && r.skipped.length ? ' · skipped ' + r.skipped.length : ''), 'ok');
            loadTree();
        } catch (e) { toast(e.message, 'err'); }
    }

    /* ══════════════════════════ utils ══════════════════════════ */
    function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
    function fmtSize(n) { n = Number(n) || 0; return n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB'; }

    /* ══════════════════════════ wiring ══════════════════════════ */
    $('cfNewFile').addEventListener('click', () => startCreate('file'));
    $('cfNewFolder').addEventListener('click', () => startCreate('folder'));
    $('cfRefresh').addEventListener('click', loadTree);
    $('cfUpload').addEventListener('click', () => elUpInput.click());
    $('cfDownloadZip').addEventListener('click', () => { window.location.href = API + '/download-zip'; });
    $('cfSearch').addEventListener('input', (e) => { S.filter = e.target.value.trim(); renderTree(); });
    elUpInput.addEventListener('change', () => { uploadFiles(elUpInput.files); elUpInput.value = ''; });
    elSave.addEventListener('click', saveFile);
    elCheck.addEventListener('click', () => runCheck(false));
    $('cfProblemsClose').addEventListener('click', hideProblems);

    // undo / redo
    if (elUndo) elUndo.addEventListener('click', doUndo);
    if (elRedo) elRedo.addEventListener('click', doRedo);

    // view toggle (Code / Split / Preview) + open-in-new-tab
    elViewSeg.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => {
        S.view = b.dataset.view; localStorage.setItem('cfView', S.view); applyView();
    }));
    // responsive preview size (Desktop / Tablet / Mobile)
    elPvSize.querySelectorAll('[data-size]').forEach(b => b.addEventListener('click', () => {
        S.pvSize = b.dataset.size; localStorage.setItem('cfPvSize', S.pvSize); applyView();
    }));
    elPvOpen.addEventListener('click', () => { if (S.active) window.open(previewUrl(S.active), '_blank', 'noopener'); });
    elSbPos.addEventListener('click', () => cm && cm.focus());
    if (elLiveFlag) elLiveFlag.addEventListener('click', () => { const t = S.active && S.open.get(S.active); if (t && (t.ext === 'php' || t.ext === 'phtml')) runPhp(true); });

    // full screen — native Fullscreen API with a CSS-maximize fallback
    const elMain = document.querySelector('.cf-main');
    const elFullscreen = $('cfFullscreen');
    function setFs(on) {
        elMain.classList.toggle('cf-fs', on);
        const ic = elFullscreen && elFullscreen.querySelector('i');
        if (ic) ic.className = on ? 'bi bi-fullscreen-exit' : 'bi bi-fullscreen';
        if (elFullscreen) elFullscreen.title = on ? 'Exit full screen (Esc)' : 'Full screen';
        if (cm) requestAnimationFrame(() => cm.refresh());
    }
    if (elFullscreen) elFullscreen.addEventListener('click', () => {
        const on = !elMain.classList.contains('cf-fs');
        if (on) {
            setFs(true);
            const req = elMain.requestFullscreen || elMain.webkitRequestFullscreen;
            if (req) { try { const p = req.call(elMain); if (p && p.catch) p.catch(() => {}); } catch (e) { /* CSS fallback already applied */ } }
        } else {
            if (document.fullscreenElement) { (document.exitFullscreen || document.webkitExitFullscreen).call(document); }
            setFs(false);
        }
    });
    document.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement) setFs(false); });
    document.addEventListener('keydown', (e) => {
        if (e.key === 'Escape' && elMain.classList.contains('cf-fs') && !document.fullscreenElement) setFs(false);
    });

    // sidebar show/hide toggle (fixed 305px width; state persisted)
    function applyTreeHidden(hidden) {
        if (!elWorkspace) return;
        elWorkspace.classList.toggle('tree-hidden', hidden);
        if (elTreeToggle) elTreeToggle.title = hidden ? 'Show the file sidebar' : 'Hide the file sidebar';
        if (cm && !hidden) requestAnimationFrame(() => cm.refresh());
    }
    if (elTreeToggle) elTreeToggle.addEventListener('click', () => {
        const hidden = !elWorkspace.classList.contains('tree-hidden');
        localStorage.setItem('cfTreeHidden', hidden ? '1' : '0');
        applyTreeHidden(hidden);
        if (cm) requestAnimationFrame(() => cm.refresh());
    });
    applyTreeHidden(localStorage.getItem('cfTreeHidden') === '1');

    const dropZone = document.querySelector('.cf-tree');
    const hasFiles = (e) => e.dataTransfer && Array.prototype.includes.call(e.dataTransfer.types || [], 'Files');
    // external file drops → upload (only react to real files, so internal node
    // drags don't flash the whole tree)
    ['dragover', 'dragenter'].forEach(ev => dropZone.addEventListener(ev, e => { if (!hasFiles(e)) return; e.preventDefault(); dropZone.classList.add('is-dragover'); }));
    ['dragleave', 'drop'].forEach(ev => dropZone.addEventListener(ev, () => dropZone.classList.remove('is-dragover')));
    dropZone.addEventListener('drop', e => { if (hasFiles(e)) { e.preventDefault(); uploadFiles(e.dataTransfer.files); } });
    // internal node dragged onto the empty tree area → move to the top level
    elTree.addEventListener('dragover', e => { if (S.moveFrom && e.target === elTree) { e.preventDefault(); elTree.classList.add('is-rootdrop'); } });
    elTree.addEventListener('dragleave', e => { if (e.target === elTree) elTree.classList.remove('is-rootdrop'); });
    elTree.addEventListener('drop', e => { elTree.classList.remove('is-rootdrop'); if (S.moveFrom && !hasFiles(e) && e.target === elTree) { e.preventDefault(); doMove(S.moveFrom, ''); S.moveFrom = null; } });

    window.addEventListener('beforeunload', (e) => {
        for (const t of S.open.values()) if (t.dirty) { e.preventDefault(); e.returnValue = ''; return; }
    });

    /* ── custom tooltips: hijack native `title` inside the workspace, render a themed
          chip. One delegated listener; titles are moved to data-cf-tip on first hover
          so the browser's own (slow, unstyled) tooltip never appears. ── */
    (function initTooltips() {
        const tip = document.createElement('div');
        tip.className = 'cf-tip'; tip.setAttribute('role', 'tooltip');
        document.body.appendChild(tip);
        let showTimer = null, current = null;
        const hide = () => { clearTimeout(showTimer); tip.classList.remove('show'); current = null; };
        function place(el) {
            const r = el.getBoundingClientRect(), tr = tip.getBoundingClientRect();
            let below = true, top = r.bottom + 8;
            if (top + tr.height > window.innerHeight - 6) { top = r.top - tr.height - 8; below = false; }
            let left = r.left + r.width / 2 - tr.width / 2;
            left = Math.max(6, Math.min(left, window.innerWidth - tr.width - 6));
            tip.style.left = left + 'px'; tip.style.top = Math.max(6, top) + 'px';
            tip.style.setProperty('--tip-ax', (r.left + r.width / 2 - left) + 'px');
            tip.classList.toggle('below', below); tip.classList.toggle('above', !below);
        }
        document.addEventListener('mouseover', (e) => {
            const el = e.target.closest('[title], [data-cf-tip]');
            if (!el || !elWorkspace || !elWorkspace.contains(el) || el === current) return;
            let text = el.getAttribute('data-cf-tip');
            if (text == null) { const t = el.getAttribute('title'); if (t != null) { el.setAttribute('data-cf-tip', t); el.removeAttribute('title'); } text = t; }
            if (!text) return;
            current = el;
            clearTimeout(showTimer);
            showTimer = setTimeout(() => { if (current !== el) return; tip.textContent = text; tip.classList.add('show'); place(el); }, 320);
        });
        document.addEventListener('mouseout', (e) => { const el = e.target.closest('[data-cf-tip]'); if (el && el === current) hide(); });
        document.addEventListener('mousedown', hide);
        document.addEventListener('scroll', hide, true);
        window.addEventListener('blur', hide);
    })();

    loadTree();
})();
