main.js

import { generateId } from "./utilities/tools.js"
import { initTheme, setTheme, getTheme, getCustomThemes } from "./utilities/theme.js"
import { LOCAL_MODE } from "./utilities/constants.js"
import { Server } from "./server.js"
import { ProjectContextMenu } from "./components/project_context_menu.js"
import { ShareDialog } from "./components/share_dialog.js"
import { ConfirmDialog } from "./components/confirm_dialog.js"
import { Workspace } from "./workspace.js"
import { Project } from "./project.js"
import { LoginDialog } from "./components/login_dialog.js"
import { firebaseAuth, onAuthStateChanged, signOut } from "./firebase.js"
import "./components/info_widget.js"

// ── Account settings drawer ────────────────────────────────────────────────
let _drawerLoaded = false;

/**
 * Opens the account settings drawer, lazy-loading the iframe on first open.
 * @param {string|null} [tab=null] - Optional tab name to switch to (e.g. 'subscription').
 */
function openAccountDrawer(tab = null) {
    const overlay = document.getElementById('accountDrawerOverlay');
    const frame   = document.getElementById('accountDrawerFrame');
    if (!_drawerLoaded) {
        frame.src   = tab ? `/account?tab=${tab}` : '/account';
        _drawerLoaded = true;
    } else if (tab) {
        frame.contentWindow?.postMessage({ type: 'switch-tab', tab }, '*');
    }
    overlay.classList.add('open');
}

window.openAccountDrawer = openAccountDrawer;

window.closeAccountDrawer = function() {
    document.getElementById('accountDrawerOverlay').classList.remove('open');
    window.dispatchEvent(new CustomEvent('account-drawer-closed'));
};

document.addEventListener('DOMContentLoaded', () => {
    document.getElementById('accountDrawerScrim').addEventListener('click', window.closeAccountDrawer);
    document.addEventListener('keydown', (e) => {
        if (e.key === 'Escape') window.closeAccountDrawer();
    });
});

// Re-apply theme when the iframe changes any theme-related localStorage key
window.addEventListener('storage', (e) => {
    if (e.key === 'theme' || e.key === 'custom-themes' ||
        e.key?.startsWith('custom-colors-')) {
        setTheme(getTheme());
    }
});

/**
* @class Main app; handles project management
*
* @prop {object} serverProjects - A list of projects that are saved on the server
* @prop {object} localProjects - A list of projects that are available locally and not saved to the server
*
* @prop {Workspace} workspace - The app workspace which handles project editing
* @prop {LoginDialog} loginDialog - The login dialog
* @prop {ProjectContextMenu} activeCtxMenu - The currently open Context Menu, if there is one
*
* @prop {bool} sidebarCollapsed - True, if the sidebar is in a collapsed state
*/
class App {
    server = null;
    serverProjects = {};
    sharedProjects = {};
    localProjects = {};
    currentFolderPath = '';      // path within server projects the user is browsing
    currentFolderFolders = [];   // subfolders at currentFolderPath (fetched from API)
    sharedFolderPath = '';       // path within shared section the user is browsing
    sharedFolderFolderItems = []; // subfolders at sharedFolderPath
    sharedFolderProjects = {};   // projects at sharedFolderPath (when non-root)
    myFilesCollapsed = false;
    sharedCollapsed = false;
    #autoSaveTimers = new Map();
    _hasAutoOpened = false;

    /** Initializes the App, wiring up the server and workspace. */
    constructor() {
        this.sidebarCollapsed = false;
        this.activeCtxMenu = null;
        this.uploadingProjects = new Set();
        this.uploadProgress = new Map(); // project → upload fraction 0–1
        this.uploadControllers = new Map(); // project → AbortController
        this.serverProjectsLoading = false;
        this.sharedProjectsLoading = false;

        this.server = new Server({
            onConnect: async () => {
                this.loginDialog?.close();
                // Restore startup_behavior from server preferences into localStorage
                const prefs = this.server.backendUser?.preferences || {};
                if (prefs.startup_behavior) {
                    localStorage.setItem('startup-behavior', prefs.startup_behavior);
                }
                if (prefs.autosave !== undefined) {
                    localStorage.setItem('autosave', prefs.autosave ? 'true' : 'false');
                }
                if (prefs.undo_queue_size !== undefined) {
                    localStorage.setItem('undo-queue-size', prefs.undo_queue_size);
                }
                await this.refreshServerProjects();
                if (!LOCAL_MODE) {
                    this.refreshSharedProjects();
                    this.refreshSharedFolderItems();
                }
                this._updateUserDisplay();
                // Auto-reopen the last open project on first connect
                if (!this._hasAutoOpened) {
                    this._hasAutoOpened = true;
                    this._maybeReopenLastProject();
                }
            },
            onDisconnect: () => {
                this.serverProjects = {};
                this.sharedProjects = {};
                this.sharedFolderPath = '';
                this.sharedFolderFolderItems = [];
                this.sharedFolderProjects = {};
                this.currentFolderPath = '';
                this.currentFolderFolders = [];
                this.workspace.clearWorkspace();
                this.renderSidebar();
                this._updateUserDisplay();
            },
            onStatusChanged: () => {},
        });

        this.workspace = new Workspace({
            onNewProject: async () => {
                const id = await this.createProject("Untitled Project");
                this.openProject(this.getProject(id));
            },
            onOpenProject: () => {
                this.openPackagedProject();
            },
            onUpload: (project) => {
                this.pushProjectToServer(project);
            },
            onProjectModified: (project) => {
                this.#scheduleAutoSave(project);
            },
            isServerConnected: () => this.server.isConnected,
            getToken: () => this.server.getToken(),
            onPresentation: (project) => {
                if (!project?.projectId) return;
                new ShareDialog(project.projectId, this.server);
            },
            onRenderSidebar: () => this.renderSidebar(),
        });
        this.loginDialog = LOCAL_MODE ? null : new LoginDialog();

        this.#getElements();
        this.#setupListeners();
        this.renderSidebar();
    }

    /**
    * Initializes member variables for all relevant DOM elements
    * @ignore
    */
    #getElements() {
        this.root = document;
        this.sidebar = this.root.querySelector("#sidebar");
        this.sidebarToggle = this.root.querySelector("#sidebarToggle");
        this.sidebarResizeHandle = this.root.querySelector("#sidebarResizeHandle");
        this.themeBtns = this.root.querySelectorAll(".theme-btn");
        this.sidebarList = this.root.querySelector("#sidebarList");
        this.sidebarEmpty = this.root.querySelector("#sidebarEmpty");
        this.sidebarMyFilesSection = this.root.querySelector("#sidebarMyFilesSection");
        this.sidebarBreadcrumb = this.root.querySelector("#sidebarBreadcrumb");
        this.sidebarFolderItems = this.root.querySelector("#sidebarFolderItems");
        this.sidebarServerItems = this.root.querySelector("#sidebarServerItems");
        this.sidebarLocalSection = this.root.querySelector("#sidebarLocalSection");

        this.newProjectBtn = this.root.querySelector("#newProjectBtn");
        this.newFolderBtn = this.root.querySelector("#newFolderBtn");
        this.openProjectBtn = this.root.querySelector("#openProjectBtn");

        this.sidebarHomeBtn = this.root.querySelector("#sidebarHomeBtn");
        this.openDocsButton = this.root.querySelector("#openDocsButton");
        this.sidebarShortcutsBtn = this.root.querySelector("#sidebarShortcutsBtn");
        this.sidebarThemeBtn = this.root.querySelector("#sidebarThemeBtn");
        this.sidebarThemePopup = this.root.querySelector("#sidebarThemePopup");
        this.kbdLegend = this.root.querySelector("#kbdLegend");

        this.userAvatarBtn = this.root.querySelector("#userAvatarBtn");
        this.userAvatarImg = this.root.querySelector("#userAvatarImg");
        this.userNameDisplay = this.root.querySelector("#userNameDisplay");
        this.userEmailDisplay = this.root.querySelector("#userEmailDisplay");
        this.userDropdown = this.root.querySelector("#userDropdown");
        this.userDropdownName = this.root.querySelector("#userDropdownName");
        this.userDropdownDivider = this.root.querySelector("#userDropdownDivider");
        this.userDropdownLogin = this.root.querySelector("#userDropdownLogin");
        this.userDropdownAccountSettings = this.root.querySelector("#userDropdownAccountSettings");
        this.userDropdownLogout = this.root.querySelector("#userDropdownLogout");

        this.myFilesContent          = this.root.querySelector("#myFilesContent");
        this.sidebarSharedSection    = this.root.querySelector("#sidebarSharedSection");
        this.sidebarSharedBreadcrumb = this.root.querySelector("#sidebarSharedBreadcrumb");
        this.sharedContent           = this.root.querySelector("#sharedContent");
        this.sidebarSharedItems      = this.root.querySelector("#sidebarSharedItems");
    }

    /**
    * Sets up any listeners for pre-rendered DOM elements
    * @ignore
    */
    #setupListeners() {
        window.addEventListener('beforeunload', (e) => {
            if (this.workspace.activeProject.isDirty()) {
                e.preventDefault();
            }
        });

        this.root.querySelector('#sidebarFooterSettingsBtn').addEventListener('click', () => {
            openAccountDrawer();
        });

        this.sidebarHomeBtn.addEventListener('click', () => {
            this.closeActiveProject({ onClosed: () => {} });
        });

        this.openDocsButton.addEventListener("click", () => {
            window.open("/docs", "_blank");
        });

        // Shortcuts toggle
        this.sidebarShortcutsBtn.classList.add('active');
        this.sidebarShortcutsBtn.addEventListener('click', () => {
            this.kbdLegend.classList.toggle('hidden');
            this.sidebarShortcutsBtn.classList.toggle('active', !this.kbdLegend.classList.contains('hidden'));
        });

        // Theme popup
        this.sidebarThemeBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            const visible = this.sidebarThemePopup.style.display !== 'none';
            this.sidebarThemePopup.style.display = visible ? 'none' : 'flex';
        });
        document.addEventListener('click', () => {
            this.sidebarThemePopup.style.display = 'none';
        });
        this.sidebarThemePopup.addEventListener('click', (e) => e.stopPropagation());

        // Theme toggle buttons
        this.themeBtns.forEach(btn => {
            btn.addEventListener('click', () => {
                setTheme(btn.dataset.themeValue);
                this.sidebarThemePopup.style.display = 'none';
                this.#updateThemeBtns();
                this._syncThemeToServer();
            });
        });
        this.#updateThemeBtns();

        if (LOCAL_MODE) {
            this.root.querySelector('#sidebarUser').style.display = 'none';
        }

        // Add a click trigger for collapsing and expanding the sidebar
        this.sidebarToggle.addEventListener('click', () => {
            this.sidebarCollapsed = !this.sidebarCollapsed;
            const sidebar = this.sidebar;
            const toggle  = this.sidebarToggle;
            sidebar.classList.toggle('collapsed', this.sidebarCollapsed);
            toggle.classList.toggle('collapsed', this.sidebarCollapsed);
            toggle.textContent = this.sidebarCollapsed ? '▶' : '◀';
            if (this.sidebarCollapsed) {
                this.sidebarWidth = sidebar.offsetWidth;
                sidebar.style.width = '';
                toggle.style.left = '';
            } else {
                if (this.sidebarWidth) {
                    sidebar.style.width = `${this.sidebarWidth}px`;
                    toggle.style.left = `${this.sidebarWidth}px`;
                }
            }
        });

        // Drag-to-resize sidebar
        const SIDEBAR_MIN_WIDTH = 240;
        this.sidebarResizeHandle.addEventListener('mousedown', (e) => {
            e.preventDefault();
            this.sidebarResizeHandle.classList.add('dragging');
            this.sidebar.style.transition = 'none';
            this.sidebarToggle.style.transition = 'none';

            const onMouseMove = (e) => {
                const newWidth = Math.max(SIDEBAR_MIN_WIDTH, e.clientX);
                this.sidebar.style.width = `${newWidth}px`;
                this.sidebarToggle.style.left = `${newWidth}px`;
            };

            const onMouseUp = () => {
                this.sidebarResizeHandle.classList.remove('dragging');
                this.sidebar.style.transition = '';
                this.sidebarToggle.style.transition = '';
                document.removeEventListener('mousemove', onMouseMove);
                document.removeEventListener('mouseup', onMouseUp);
            };

            document.addEventListener('mousemove', onMouseMove);
            document.addEventListener('mouseup', onMouseUp);
        });

        // New folder button
        this.newFolderBtn.addEventListener('click', () => {
            this.createServerFolder();
        });

        // Add a click trigger for creating a new project
        this.newProjectBtn.addEventListener('click', async () => {
            const id = await this.createProject('Untitled Project');
            if (!this.workspace.activeProject) {
                this.openProject(this.getProject(id));
            }
        });

        // Add a click trigger for opening a packaged project
        this.openProjectBtn.addEventListener('click', () => {
            this.openPackagedProject();
        });

        // open custom context menu on right click
        this.sidebarList.addEventListener('contextmenu', (e) => {
            e.stopPropagation();
            e.preventDefault();
            this.openProjectCtxMenu(e.clientX, e.clientY);
        });

        // Shared section breadcrumb is built dynamically in renderSidebar

        // User avatar dropdown
        this.userAvatarBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            const isOpen = this.userDropdown.style.display !== 'none';
            this.userDropdown.style.display = isOpen ? 'none' : 'block';
        });

        document.addEventListener('click', () => {
            this.userDropdown.style.display = 'none';
        });

        this.userDropdown.addEventListener('click', (e) => {
            e.stopPropagation();
        });

        this.userDropdownLogin.addEventListener('click', () => {
            this.userDropdown.style.display = 'none';
            this.loginDialog.open();
        });

        this.userDropdownAccountSettings.addEventListener('click', () => {
            this.userDropdown.style.display = 'none';
            openAccountDrawer();
        });

        this.userDropdownLogout.addEventListener('click', async () => {
            this.userDropdown.style.display = 'none';
            await this.server.disconnectFromServer();
        });

        this._updateUserDisplay();
    }

    /** Syncs the current theme selection to the server, merging into existing preferences. */
    async _syncThemeToServer() {
        if (LOCAL_MODE) return;
        const token = await this.server.getToken();
        if (!token) return;
        const base = this.server.backendUser?.preferences || {};
        const patch = { theme: localStorage.getItem('theme') || 'auto', custom_themes: getCustomThemes() };
        try { patch.custom_colors_light = JSON.parse(localStorage.getItem('custom-colors-light') || '{}'); } catch { patch.custom_colors_light = {}; }
        try { patch.custom_colors_dark  = JSON.parse(localStorage.getItem('custom-colors-dark')  || '{}'); } catch { patch.custom_colors_dark  = {}; }
        try {
            await fetch('/api/users/me/preferences', {
                method: 'PUT',
                headers: { 'Content-Type': 'application/json', 'X-Auth-Token': token },
                body: JSON.stringify({ preferences: { ...base, ...patch } }),
            });
        } catch {}
    }

    /**
     * Updates the sidebar user section to reflect the current login state.
     * Shows name, avatar, and appropriate dropdown items.
     */
    _updateUserDisplay() {
        if (LOCAL_MODE) return;
        const user = this.server.backendUser;
        const fbUser = this.server.firebaseUser;

        if (user || fbUser) {
            const name  = user?.display_name || fbUser?.displayName || '';
            const email = user?.email || fbUser?.email || '';
            this.userNameDisplay.textContent = name || email || 'User';
            if (name && email) {
                this.userEmailDisplay.textContent = email;
                this.userEmailDisplay.style.display = '';
            } else {
                this.userEmailDisplay.style.display = 'none';
            }

            const photoURL = fbUser?.photoURL;
            if (photoURL) {
                this.userAvatarImg.src = photoURL;
            } else {
                this.userAvatarImg.src = '/static/media/user_icon.svg';
            }

            const fullName = user?.display_name || fbUser?.displayName || '';
            const label = fullName || email;
            if (label) {
                this.userDropdownName.textContent = label;
                this.userDropdownName.style.display = 'block';
                this.userDropdownDivider.style.display = 'block';
            } else {
                this.userDropdownName.style.display = 'none';
                this.userDropdownDivider.style.display = 'none';
            }

            this.userDropdownLogin.style.display = 'none';
            this.userDropdownAccountSettings.style.display = 'block';
            this.userDropdownLogout.style.display = 'block';
        } else {
            this.userNameDisplay.textContent = 'Guest';
            this.userEmailDisplay.style.display = 'none';
            this.userAvatarImg.src = '/static/media/user_icon.svg';
            this.userDropdownName.style.display = 'none';
            this.userDropdownDivider.style.display = 'none';
            this.userDropdownLogin.style.display = 'block';
            this.userDropdownAccountSettings.style.display = 'none';
            this.userDropdownLogout.style.display = 'none';
        }
    }

    // ----- PROJECT SERVER ACCESS ----- //

    /**
    * Fetches the full project list from the server and rebuilds the server
    * project registry. Updates the sidebar when complete.
    */
    async refreshServerProjects() {
        this.serverProjectsLoading = true;
        this.renderSidebar();
        try {
            // Get list of available projects from the server
            const projectList = await this.server.getProjectList();
            // Re-initialize the projects dictionary
            this.serverProjects = {};

            const buildProject = (projectData) => {
                let project = new Project(projectData.id, projectData.name, {
                    onStateChange: () => {
                        this.renderSidebar(projectData.id);
                    },
                    server: this.server,
                });
                project.createdDate = projectData.created;
                project.modifiedDate = projectData.modified;
                project.hasTranscript = projectData.has_transcript;
                project.folderPath = projectData.folder_path || '';
                project.ownerName = projectData.owner_name || null;
                project.isPublic = projectData.public || false;
                project.accessLevel = 'owner';
                project.localOnly = false;
                project.synced = true;
                return project;
            }

            // convert to a list of Project objects
            let projects = projectList.map(buildProject);
            // put the project objects into the appstate server projects dictionary
            projects.forEach(project => this.serverProjects[project.projectId] = project);

            // Fetch subfolders for the current folder view
            await this.refreshCurrentFolderFolders();

        } catch(e) {
            console.error('Failed to load projects', e);
        } finally {
            this.serverProjectsLoading = false;
            this.renderSidebar();
        }
    }

    /**
    * Fetches the list of projects shared with the current user and updates the sidebar.
    */
    async refreshSharedProjects() {
        this.sharedProjectsLoading = true;
        this.renderSidebar();
        try {
            const projectList = await this.server.getSharedProjects();
            this.sharedProjects = {};
            projectList.forEach(projectData => {
                const project = new Project(projectData.id, projectData.name, {
                    onStateChange: () => { this.renderSidebar(); },
                    server: this.server,
                });
                project.createdDate = projectData.created;
                project.modifiedDate = projectData.modified;
                project.hasTranscript = projectData.has_transcript;
                project.folderPath = '';
                project.ownerName = projectData.owner_name || null;
                project.isPublic = projectData.public || false;
                project.accessLevel = projectData.access_level || 'viewer';
                project.readOnly = project.accessLevel === 'viewer';
                project.localOnly = false;
                project.synced = true;
                this.sharedProjects[project.projectId] = project;
            });
        } catch(e) {
            console.error('Failed to load shared projects', e);
        } finally {
            this.sharedProjectsLoading = false;
            this.renderSidebar();
        }
    }

    /**
     * Fetches root-level folders accessible in the Shared section.
     * When sharedFolderPath is non-empty, fetches that folder's contents instead.
     */
    async refreshSharedFolderItems() {
        if (!this.server.isConnected) return;
        try {
            if (this.sharedFolderPath) {
                const contents = await this.server.listDirectory(this.sharedFolderPath);
                this.sharedFolderFolderItems = contents.folders || [];
                this.sharedFolderProjects = {};
                (contents.projects || []).forEach(projectData => {
                    const project = new Project(projectData.id, projectData.name, {
                        onStateChange: () => { this.renderSidebar(); },
                        server: this.server,
                    });
                    project.createdDate = projectData.created;
                    project.modifiedDate = projectData.modified;
                    project.hasTranscript = projectData.has_transcript;
                    project.folderPath = projectData.folder_path || '';
                    project.ownerName = projectData.owner_name || null;
                    project.isPublic = projectData.public || false;
                    project.accessLevel = projectData.access_level || 'viewer';
                    project.readOnly = project.accessLevel !== 'editor';
                    project.localOnly = false;
                    project.synced = true;
                    this.sharedFolderProjects[project.projectId] = project;
                });
            } else {
                const result = await this.server.getSharedFolders();
                this.sharedFolderFolderItems = result.folders || [];
                this.sharedFolderProjects = {};
            }
        } catch(e) {
            console.error('Failed to load shared folder items:', e);
        } finally {
            this.renderSidebar();
        }
    }

    /**
     * Navigates the shared section to the given folder path.
     * @param {string} folderPath - relative folder path ('' for root)
     */
    async navigateToSharedFolder(folderPath) {
        this.sharedFolderPath = folderPath;
        await this.refreshSharedFolderItems();
    }

    /**
    * Uploads a project to the server.
    * - First upload (localOnly): creates a new server project with all data, then migrates
    *   the project from localProjects to serverProjects using the server-assigned id.
    * - Subsequent uploads: only dirty sections are sent; project metadata is always
    *   included when any section is dirty (speakers excluded from metadata payload).
    * After a successful upload, the project is marked clean and localOnly is set to false.
    * @param {Project} project - the project to upload
    */
    // ----- FOLDER NAVIGATION ----- //

    /**
     * Fetches and stores the list of immediate subfolders at currentFolderPath.
     */
    async refreshCurrentFolderFolders() {
        if (!this.server.isConnected) {
            this.currentFolderFolders = [];
            return;
        }
        try {
            const contents = await this.server.listDirectory(this.currentFolderPath);
            this.currentFolderFolders = contents.folders || [];
        } catch(e) {
            this.currentFolderFolders = [];
            console.error('Failed to list folder:', e);
        }
    }

    /**
     * Navigates the server section to the given folder path.
     * @param {string} folderPath - relative folder path ('' for root)
     */
    async navigateToFolder(folderPath) {
        this.currentFolderPath = folderPath;
        await this.refreshCurrentFolderFolders();
        this.renderSidebar();
    }

    /**
     * Prompts the user for a name and creates a new server folder at the current path.
     */
    createServerFolder() {
        if (!this.server.isConnected) {
            alert('Connect to a server to create folders.');
            return;
        }
        const input = document.createElement('input');
        input.type = 'text';
        input.placeholder = 'Folder name';
        input.style.cssText = 'width:100%;box-sizing:border-box;background:var(--bg2);border:1px solid var(--border);color:var(--text);padding:0.35rem 0.5rem;font-family:var(--font-mono);font-size:var(--fs-caption);';
        new ConfirmDialog('New folder name', {
            onConfirm: async () => {
                const name = input.value;
                if (!name || !name.trim()) return;
                try {
                    await this.server.createFolder(this.currentFolderPath, name.trim());
                    await this.refreshCurrentFolderFolders();
                    this.renderSidebar();
                } catch(e) {
                    alert(`Could not create folder: ${e.message}`);
                }
            }
        }, '', 'Create', 'Cancel', input);
        setTimeout(() => input.focus(), 0);
    }

    /**
     * Activates inline rename editing for a folder item in the sidebar.
     * @param {object} folder - folder descriptor {name, path}
     * @param {HTMLElement} item - the folder's DOM element
     */
    startFolderRename(folder, item) {
        const nameEl = item.querySelector('.sidebar-item-name');
        if (!nameEl) return;

        const input = document.createElement('input');
        input.type = 'text';
        input.className = 'sidebar-item-name-input';
        input.value = folder.name;

        const commit = async () => {
            const newName = input.value.trim();
            if (!newName || newName === folder.name) {
                this.renderSidebar();
                return;
            }
            try {
                await this.server.renameFolder(folder.path, newName);
                // If we were inside this folder, navigate to the new path
                if (this.currentFolderPath === folder.path) {
                    const parent = folder.path.split('/').slice(0, -1).join('/');
                    const newPath = parent ? `${parent}/${newName}` : newName;
                    this.currentFolderPath = newPath;
                }
                await this.refreshCurrentFolderFolders();
                this.renderSidebar();
            } catch(e) {
                alert(`Could not rename folder: ${e.message}`);
                this.renderSidebar();
            }
        };

        input.addEventListener('blur', commit);
        input.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
            if (e.key === 'Escape') {
                input.removeEventListener('blur', commit);
                this.renderSidebar();
            }
            e.stopPropagation();
        });
        input.addEventListener('click', (e) => e.stopPropagation());

        nameEl.replaceWith(input);
        input.focus();
        input.select();
    }

    /**
     * Deletes a server folder after confirmation. Shows a warning with the count
     * of contents and offers the option to merge contents into the parent instead.
     * @param {object} folder - folder descriptor {name, path}
     */
    async deleteFolder(folder) {
        // Fetch count for the warning message
        let count = { projects: 0, folders: 0 };
        try {
            count = await this.server.getFolderCount(folder.path);
        } catch(e) { /* best-effort */ }

        // Build the warning overlay manually so we can have 3 action buttons
        const overlay = document.createElement('div');
        overlay.className = 'confirm-dialog-overlay';

        const modal = document.createElement('div');
        modal.className = 'confirm-dialog-modal';
        modal.style.cssText = 'max-height:none;width:420px;padding:0;';

        const header = document.createElement('div');
        header.className = 'confirm-dialog-header';
        header.innerHTML = `<span>Delete Folder</span>`;
        modal.appendChild(header);

        const body = document.createElement('div');
        body.style.cssText = 'padding:1rem 1.25rem;display:flex;flex-direction:column;gap:0.85rem;';

        const msg = document.createElement('div');
        msg.style.cssText = 'font-family:var(--font-mono);font-size:var(--fs-caption);color:var(--text);line-height:1.6;';
        const contentDesc = [];
        if (count.projects > 0) contentDesc.push(`${count.projects} project${count.projects !== 1 ? 's' : ''}`);
        if (count.folders > 0) contentDesc.push(`${count.folders} subfolder${count.folders !== 1 ? 's' : ''}`);
        msg.textContent = contentDesc.length
            ? `"${folder.name}" contains ${contentDesc.join(' and ')}. Choose how to proceed:`
            : `Delete the empty folder "${folder.name}"?`;
        body.appendChild(msg);

        const actions = document.createElement('div');
        actions.style.cssText = 'display:flex;gap:0.5rem;justify-content:flex-end;flex-wrap:wrap;margin-top:0.25rem;';

        const cancel = document.createElement('button');
        cancel.className = 'sample-btn';
        cancel.textContent = 'Cancel';
        cancel.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);';
        cancel.addEventListener('click', () => overlay.remove());
        actions.appendChild(cancel);

        if (contentDesc.length) {
            const merge = document.createElement('button');
            merge.className = 'sample-btn';
            merge.textContent = 'Move contents up';
            merge.title = 'Move all projects and subfolders to the parent, then delete this folder';
            merge.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);';
            merge.addEventListener('click', async () => {
                overlay.remove();
                await this.#doDeleteFolder(folder, true);
            });
            actions.appendChild(merge);
        }

        const deleteAll = document.createElement('button');
        deleteAll.className = 'sample-btn';
        deleteAll.textContent = contentDesc.length ? 'Delete all' : 'Delete';
        deleteAll.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);border-color:var(--danger-border);color:var(--danger-muted);';
        deleteAll.onmouseenter = () => { deleteAll.style.background = 'var(--danger-faint)'; deleteAll.style.borderColor = 'var(--danger)'; };
        deleteAll.onmouseleave = () => { deleteAll.style.background = ''; deleteAll.style.borderColor = 'var(--danger-border)'; };
        deleteAll.addEventListener('click', async () => {
            overlay.remove();
            await this.#doDeleteFolder(folder, false);
        });
        actions.appendChild(deleteAll);

        body.appendChild(actions);
        modal.appendChild(body);
        overlay.appendChild(modal);
        let _md1 = false;
        overlay.addEventListener('mousedown', (e) => { _md1 = e.target === overlay; });
        overlay.addEventListener('click', (e) => { if (e.target === overlay && _md1) overlay.remove(); });
        document.body.appendChild(overlay);
    }

    /**
     * Performs the actual folder deletion after user confirmation.
     * @param {object} folder - folder descriptor {name, path}
     * @param {boolean} merge - if true, merge contents into parent first
     * @ignore
     */
    async #doDeleteFolder(folder, merge) {
        try {
            await this.server.deleteFolder(folder.path, merge);
            // If we were inside this folder (or deeper), navigate to parent
            if (this.currentFolderPath === folder.path ||
                this.currentFolderPath.startsWith(folder.path + '/')) {
                const parent = folder.path.split('/').slice(0, -1).join('/');
                this.currentFolderPath = parent;
            }
            await this.refreshCurrentFolderFolders();
            // Remove deleted projects from registry if not merging
            if (!merge) {
                for (const [id, project] of Object.entries(this.serverProjects)) {
                    if (project.folderPath === folder.path ||
                        project.folderPath.startsWith(folder.path + '/')) {
                        delete this.serverProjects[id];
                    }
                }
            } else {
                // Update folderPath for projects that were in this folder
                const parentPath = folder.path.split('/').slice(0, -1).join('/');
                for (const project of Object.values(this.serverProjects)) {
                    if (project.folderPath === folder.path) {
                        project.folderPath = parentPath;
                    }
                }
            }
            this.renderSidebar();
        } catch(e) {
            alert(`Could not delete folder: ${e.message}`);
        }
    }

    /**
     * Shows a folder picker dialog and moves the given project to the chosen folder.
     * @param {object} project - the project to move
     */
    async moveProjectToFolder(project) {
        if (!this.server.isConnected) return;

        // Collect all unique folder paths from the registry + currentFolderFolders
        const folderSet = new Set(['']);  // always include root
        for (const p of Object.values(this.serverProjects)) {
            if (p.folderPath) folderSet.add(p.folderPath);
        }
        // Also include current location folders
        for (const f of this.currentFolderFolders) {
            folderSet.add(f.path);
        }
        // Remove the project's current folder (can't move to where it already is)
        folderSet.delete(project.folderPath);

        const folders = Array.from(folderSet).sort();

        // Build picker overlay
        const overlay = document.createElement('div');
        overlay.className = 'confirm-dialog-overlay';

        const modal = document.createElement('div');
        modal.className = 'confirm-dialog-modal';
        modal.style.cssText = 'max-height:none;width:380px;padding:0;';

        const header = document.createElement('div');
        header.className = 'confirm-dialog-header';
        header.innerHTML = '<span>Move to Folder</span>';
        modal.appendChild(header);

        const body = document.createElement('div');
        body.style.cssText = 'padding:1rem 1.25rem;display:flex;flex-direction:column;gap:0.85rem;';

        const list = document.createElement('div');
        list.style.cssText = 'display:flex;flex-direction:column;gap:2px;max-height:240px;overflow-y:auto;';

        for (const path of folders) {
            const opt = document.createElement('div');
            opt.style.cssText = 'display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.5rem;border-radius:3px;cursor:pointer;font-family:var(--font-mono);font-size:var(--fs-label);color:var(--text);transition:background 0.1s;';
            opt.onmouseenter = () => { opt.style.background = 'var(--surface2)'; };
            opt.onmouseleave = () => { opt.style.background = ''; };

            const icon = document.createElement('span');
            icon.textContent = path ? '📁' : '🏠';
            icon.style.fontSize = '0.8rem';

            const label = document.createElement('span');
            label.textContent = path ? path.replace(/\//g, ' › ') : 'Root';

            opt.appendChild(icon);
            opt.appendChild(label);
            opt.addEventListener('click', async () => {
                overlay.remove();
                try {
                    const oldPath = project.folderPath;
                    await this.server.moveProjectToFolder(project.projectId, path);
                    project.folderPath = path;
                    await this.refreshCurrentFolderFolders();
                    this.renderSidebar();
                    this.workspace.history.push({
                        label: 'Move project', dirtyFlags: ['projectFolder'],
                        undo: async () => {
                            await this.server.moveProjectToFolder(project.projectId, oldPath);
                            project.folderPath = oldPath;
                        },
                        redo: async () => {
                            await this.server.moveProjectToFolder(project.projectId, path);
                            project.folderPath = path;
                        },
                    });
                } catch(e) {
                    alert(`Could not move project: ${e.message}`);
                }
            });
            list.appendChild(opt);
        }

        body.appendChild(list);

        const cancel = document.createElement('button');
        cancel.className = 'sample-btn';
        cancel.textContent = 'Cancel';
        cancel.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);align-self:flex-end;';
        cancel.addEventListener('click', () => overlay.remove());
        body.appendChild(cancel);

        modal.appendChild(body);
        overlay.appendChild(modal);
        let _md2 = false;
        overlay.addEventListener('mousedown', (e) => { _md2 = e.target === overlay; });
        overlay.addEventListener('click', (e) => { if (e.target === overlay && _md2) overlay.remove(); });
        document.body.appendChild(overlay);
    }

    /**
     * Uploads a local project to the connected server, streaming progress updates.
     * @param {object} project - The local project to push.
     * @returns {Promise<void>}
     */
    async pushProjectToServer(project) {
        if (!this.server.isConnected) {
            alert('Not connected to a server.');
            return;
        }

        if (this.uploadingProjects.has(project)) {
            return;
        }

        const uploadController = new AbortController();
        this.uploadingProjects.add(project);
        this.uploadProgress.set(project, 0);
        this.uploadControllers.set(project, uploadController);
        this.renderSidebar();
        const wasActiveProject = this.workspace.activeProject === project;
        if (wasActiveProject) {
            this.workspace.setUploadBusy(true);
        }

        const onProgress = (fraction) => {
            this.uploadProgress.set(project, fraction);
            const item = this.sidebarList.querySelector(`[data-id="${project.projectId}"]`);
            if (item) {
                if (fraction >= 1) {
                    // Bytes fully sent — remove the bar so the spinner alone signals
                    // that we're waiting for the server to acknowledge.
                    item.querySelector('.sidebar-item-progress')?.remove();
                } else {
                    const bar = item.querySelector('.sidebar-item-progress-bar');
                    if (bar) bar.style.width = `${fraction * 100}%`;
                }
            }
        };

        try {
            // Build transcript File if the section is dirty (or this is the first upload)
            let transcriptFile = null;
            if (project.hasTranscript && (project.transcriptDirty || project.localOnly)) {
                const json = JSON.stringify(project.transcript().compileJSON());
                transcriptFile = new File([json], 'transcript.json', { type: 'application/json' });
            }

            // Collect speaker sample Files if the section is dirty (or this is the first upload)
            const sampleFiles = {};
            if (project.speakersDirty || project.localOnly) {
                for (const [sid, spk] of Object.entries(project.speakers())) {
                    if (spk.sample?.blobUrl) {
                        try {
                            const resp = await fetch(spk.sample.blobUrl);
                            const blob = await resp.blob();
                            sampleFiles[sid] = new File([blob], `${sid}.wav`, { type: 'audio/wav' });
                        } catch(e) {
                            console.warn('Could not collect sample for speaker', sid, e);
                        }
                    }
                }
            }

            if (project.localOnly) {
                const waveform = project.waveform();

                // Use the stored File reference if available (large files) so the browser
                // can stream it directly into the XHR without a full in-memory pre-read.
                // Fall back to fetching the blob URL for small files (already in-memory).
                const audioFile = waveform?.file
                    ?? (waveform?.url ? new File([await fetch(waveform.url).then(r => r.blob())], waveform.filename || 'audio.wav', { type: 'audio/wav' }) : null);

                const uploadMetadata = {
                    ...project.metadata(),
                    folder_path: this.currentFolderPath,
                };
                const result = await this.server.createProject(
                    uploadMetadata, project.waveformMetadata(), audioFile, transcriptFile, sampleFiles, onProgress, uploadController.signal
                );

                // Refresh cached user so storage checks use up-to-date usage
                this.server.refreshUser().catch(() => {});

                // Migrate from localProjects to serverProjects with the server-assigned id
                const oldId = project.projectId;
                project.projectId = result.id;
                project.folderPath = result.folder_path || this.currentFolderPath;
                project.localOnly = false;
                project.markClean();
                project._onStateChange = () => this.renderSidebar(result.id);

                delete this.localProjects[oldId];
                this.serverProjects[result.id] = project;

                if (wasActiveProject) {
                    this.workspace.transcriptPanel.startPollingAudioReady();
                }

            } else {
                // Existing server project — upload only dirty sections.
                // Metadata (name, dates) is always included when any section is dirty.
                // Speakers are sent as a separate payload when the speakers section is dirty.
                const metadata = project.isDirty() ? {
                    name:     project.projectName,
                    created:  project.createdDate,
                    modified: project.modifiedDate,
                } : null;

                const speakersData = project.speakersDirty ? project.metadata().speakers : null;
                const uploadTranscript = project.transcriptDirty ? transcriptFile : null;
                const uploadSamples    = project.speakersDirty   ? sampleFiles    : {};

                let uploadAudio = null;
                if (project.waveformDirty) {
                    const waveform = project.waveform();
                    uploadAudio = waveform?.file
                        ?? (waveform?.url ? new File([await fetch(waveform.url).then(r => r.blob())], waveform.filename || 'audio.wav', { type: 'audio/wav' }) : null);
                }

                await this.server.updateProject(
                    project.projectId, metadata, speakersData, uploadAudio, uploadTranscript, uploadSamples, onProgress, uploadController.signal
                );
                project.markClean();

                // If new audio was uploaded, sync the locally-computed peaks to the server.
                // updateProject doesn't send peaks, so without this the server would keep
                // any stale peaks from the previous audio.
                if (uploadAudio) {
                    // Refresh cached user so storage checks use up-to-date usage
                    this.server.refreshUser().catch(() => {});

                    const wm = project.waveformMetadata();
                    if (wm.peaks.length > 0) {
                        this.server.saveWaveform(project.projectId, wm)
                            .catch(e => console.warn('Could not save peaks after audio update:', e));
                    }
                }

                if (wasActiveProject && uploadAudio) {
                    this.workspace.transcriptPanel.startPollingAudioReady();
                }
            }

            this.renderSidebar();
        } catch(e) {
            console.error('Upload failed:', e);
            alert(`Upload failed: ${e.message}`);
        } finally {
            this.uploadingProjects.delete(project);
            this.uploadProgress.delete(project);
            this.uploadControllers.delete(project);
            this.renderSidebar();
            if (wasActiveProject) {
                this.workspace.setUploadBusy(false);
            }
        }
    }


    /**
     * Schedules a debounced auto-save for a server project.
     * Cancels any pending timer for the same project first.
     * @param {object} project - the project to auto-save
     */
    #scheduleAutoSave(project) {
        if (localStorage.getItem('autosave') === 'false') return;
        if (this.#autoSaveTimers.has(project)) {
            clearTimeout(this.#autoSaveTimers.get(project));
        }
        const timer = setTimeout(() => {
            this.#autoSaveTimers.delete(project);
            if (project.isDirty() && this.server.isConnected && !project.localOnly) {
                this.pushProjectToServer(project);
            }
        }, 2000);
        this.#autoSaveTimers.set(project, timer);
    }

    // ----- PROJECT LIFECYCLE ----- //

    /**
    * Looks up a project by id in both registries (server takes priority).
    * @param {string} projectId - the project's unique identifier
    * @returns {object|null} project object, or null if not found
    */
    getProject(projectId) {
        return this.serverProjects[projectId] || this.localProjects[projectId] || null;
    }

    /**
    * Returns true if the project with the given id is stored on the server.
    * @param {string} projectId - the project's unique identifier
    * @returns {boolean}
    */
    isServerProject(projectId) {
        return !this.getProject(projectId).localOnly;
    }

    /**
    * Creates a new project. When connected, creates on the server first;
    * falls back to local on failure. When offline, creates locally.
    * @param {string} [name='Untitled Project'] - display name for the new project
    * @returns {Promise<string>} the new project's id
    */
    async createProject(name = 'Untitled Project') {
        if (this.server.isConnected) {
            try {
                const result = await this.server.createProject(
                    { name, folder_path: this.currentFolderPath },
                    null, null, null, {}, null, null
                );
                const id = result.id;
                const newProject = new Project(id, name, {
                    onStateChange: () => { this.renderSidebar(id); },
                    server: this.server,
                });
                newProject.folderPath = result.folder_path || this.currentFolderPath;
                newProject.accessLevel = 'owner';
                newProject.localOnly = false;
                newProject.synced = true;
                this.serverProjects[id] = newProject;
                this.renderSidebar();
                return id;
            } catch(e) {
                console.error('Server project creation failed, falling back to local:', e);
                alert(`Could not create project on server: ${e.message}. Creating locally instead.`);
            }
        }

        // Offline or server fallback: create locally
        const id = generateId();
        const newProject = new Project(id, name, {
            onStateChange: () => { this.renderSidebar(id); },
            server: this.server,
        });
        this.localProjects[id] = newProject;
        this.renderSidebar();
        return id;
    }

    /**
    * Opens a file picker for .wfs project archives, unpacks the selected file,
    * registers it as a local project, and opens it in the workspace.
    * @param {File|null} [filepath=null] - pre-selected file to open; if null, shows a file picker
    */
    async openPackagedProject(filepath = null) {
        let file = filepath;

        if (!file) {
            try {
                const [fileHandle] = await window.showOpenFilePicker({
                    types: [{ description: 'Waveform Studio Project', accept: { 'application/octet-stream': ['.wfs'] } }],
                    multiple: false,
                });
                file = await fileHandle.getFile();
            } catch (err) {
                if (err.name !== 'AbortError') console.error('Open failed:', err);
                return;
            }
        }


        try {
            const project = await Project.unpackageProject(file, { server: this.server });

            const register = () => {
                project._onStateChange = () => this.renderSidebar(project.projectId);
                this.localProjects[project.projectId] = project;
                this.renderSidebar();
                this.openProject(project);
            };

            const existing = this.getProject(project.projectId);
            if (existing) {
                new ConfirmDialog(
                    'Project ID Conflict',
                    {
                        onConfirm: () => register(),
                        onDismiss: () => {
                            project.projectId = generateId();
                            register();
                        },
                    },
                    `A project named "${existing.projectName}" already exists with the same ID as "${project.projectName}". Overwrite it, or open as a new project with a different ID?`,
                    'Overwrite',
                    'New Project'
                );
            } else {
                register();
            }
        } catch (err) {
            console.error('Failed to load project:', err);
        }
    }

    /**
    * Deletes a project after confirmation. For server projects, also removes it
    * from the server via the API. Resets the workspace if the deleted project
    * was the currently active one.
    * @param {object} project - The project to be deleted
     *@param {bool} bypassWarning - If true, the delete confirm dialog will be auto-accepted
    */
    async deleteProject(project, bypassWarning = false) {
        const name = project.projectName || 'this project';
        const isServer = !project.localOnly;
        const warningText = isServer
            ? `Delete "${name}" from the server? This cannot be undone.`
            : `Delete "${name}"? This cannot be undone.`;

        const doConfirm = async () => {
            // Abort any in-progress upload for this project
            this.uploadControllers.get(project)?.abort();

            if (isServer) {
                try {
                    await this.server.deleteProject(project.projectId);
                    delete this.serverProjects[project.projectId];
                } catch (e) {
                    console.error('Could not delete from server:', e);
                    return;
                }
            } else {
                delete this.localProjects[project.projectId];
            }

            if (this.workspace.activeProject?.projectId === project.projectId) {
                this.workspace.clearWorkspace();
                this.workspace.activeProject = null;
            }
            this.renderSidebar();
        };

        // if warning is bypassed, autoconfirm
        if (bypassWarning) {
            doConfirm();
        } else {
            new ConfirmDialog(
                'Delete Project', {
                    onConfirm: async () => { doConfirm(); },
                },
                warningText,
                'Delete'
            );
        }
    }

    /**
    * Removes a server project from the server and keeps it as a local-only project.
    * Prompts the user to confirm before proceeding.
    * @param {object} project - The server project to make local
    */
    /**
     * Shows a dialog explaining project compression, then compresses the project on confirm.
     * Compression deletes the original audio file and keeps only the generated MP3.
     * @param {object} project - The server project to compress
     */
    async compressProject(project) {
        let info;
        try {
            info = await this.server.getCompressInfo(project.projectId);
        } catch (e) {
            console.error('Could not fetch compress info:', e);
            return;
        }

        if (!info.can_compress) {
            alert(info.reason || 'This project cannot be compressed.');
            return;
        }

        const fmt = (bytes) => {
            if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
            return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
        };

        const storageBlock = document.createElement('div');
        storageBlock.style.cssText = 'font-family:var(--font-mono);font-size:var(--fs-caption);line-height:1.8;background:var(--bg-2);border:1px solid var(--border);border-radius:4px;padding:0.6rem 0.75rem;';
        storageBlock.innerHTML = `
            <div><strong>Before:</strong> ${fmt(info.before_bytes)}</div>
            <div><strong>After:&nbsp;</strong> ${fmt(info.after_bytes)} <span style="color:var(--text-muted)">(saves ${fmt(info.before_bytes - info.after_bytes)})</span></div>
        `.trim();

        new ConfirmDialog(
            'Compress Project',
            {
                onConfirm: async () => {
                    try {
                        await this.server.compressProject(project.projectId);
                    } catch (e) {
                        console.error('Compression failed:', e);
                        alert('Compression failed: ' + (e.message || e));
                        return;
                    }
                    if (this.workspace.activeProject?.projectId === project.projectId) {
                        this.openProject(project);
                    } else {
                        this.renderSidebar();
                    }
                },
            },
            'This will permanently remove the original audio file from the server, keeping only the generated MP3. Download a copy of the original first if you want to keep it.\n\nNote: the MP3 may have lower audio quality than the original.',
            'Compress',
            'Cancel',
            storageBlock,
        );
    }

    /**
    * Opens a project and resets the workspace.
    * @param {object} project - The project to be opened
    */
    async openProject(project) {
        localStorage.setItem('last-open-project', project.projectId);
        this.closeActiveProject({
            onClosed: () => {
                this.workspace.loadProject(project);
                this.renderSidebar();
            }
        });
    }

    /**
     * Opens the last-viewed project on startup, if the preference is set and the
     * project is available in the current session's server project list.
     */
    _maybeReopenLastProject() {
        const behavior = localStorage.getItem('startup-behavior') || 'last_project';
        if (behavior !== 'last_project') return;
        const lastId = localStorage.getItem('last-open-project');
        if (!lastId) return;
        const project = this.getProject(lastId);
        if (project) this.openProject(project);
    }

    /**
    * Closes the currently active project, prompting the user to confirm if
    * there are unsaved changes. Calls onClosed on success or onCancel if the
    * user dismisses the confirmation dialog.
    * @param {object} options - options for the close operation
    * @param {function} [options.onClosed] - called after the project is closed
    * @param {function} [options.onCancel] - called if the user cancels
    */
    closeActiveProject({ onClosed, onCancel }) {
        const _onClosed = onClosed ?? (() => {})
        const _onCancel = onCancel ?? (() => {})

        const activeProject = this.workspace.activeProject;
        if (!activeProject) {
            _onClosed();
            return;
        }

        const _doClose = () => {
            if (activeProject && this.#autoSaveTimers.has(activeProject)) {
                clearTimeout(this.#autoSaveTimers.get(activeProject));
                this.#autoSaveTimers.delete(activeProject);
            }
            localStorage.removeItem('last-open-project');
            this.workspace.clearWorkspace();
            this.renderSidebar();
        }

        if(this.workspace.activeProject && this.workspace.activeProject.isDirty()) {
            const confirmDialog = new ConfirmDialog(
                "Unsaved Changes", {
                    onConfirm: () => {
                        _doClose();
                        _onClosed();
                        activeProject.markClean();
                    },
                    onDismiss: () => {
                        _onCancel();
                    }
                },
                'The currently open project has unsaved changes.  If you close it without saving, they will be lost.',
                'Confirm', 'Cancel')
        } else {
            _doClose();
            _onClosed();
        }
    }

    /**
    * Duplicates the given project and handles it based on whether it is a local or server project.
    * @param {object} originalProject - The project that should be duplicated
    */
    async duplicateProject(originalProject) {
        if (originalProject.localOnly) {
            this.#createLocalCopy(originalProject);
            this.renderSidebar();
        } else {
            try {
                const result = await this.server.duplicateProject(originalProject.projectId, this.currentFolderPath);
                const copy = new Project(result.id, result.name, {
                    onStateChange: () => { this.renderSidebar(result.id); },
                    server: this.server,
                });
                copy.createdDate = result.created;
                copy.modifiedDate = result.modified;
                copy.hasTranscript = result.has_transcript;
                copy.folderPath = this.currentFolderPath;
                copy.accessLevel = 'owner';
                copy.isPublic = false;
                copy.readOnly = false;
                copy.localOnly = false;
                copy.synced = true;
                this.serverProjects[result.id] = copy;
                this.renderSidebar();
            } catch (e) {
                console.error('Failed to duplicate project on server:', e);
                alert(`Duplicate failed: ${e.message}`);
            }
        }
    }

    /**
    * Creates a duplicate of a project, sets it to be local only and puts it in the local projects dict
    * @param {object} project - The project to be duplicated
    * @ignore
    */
    #createLocalCopy(project) {
        const newId = generateId();
        const newName = `${project.projectName} Copy`;
        const copy = project.createCopy(newId, newName, {
            onStateChange: () => {
                this.renderSidebar(newId);
            }
        });
        copy.isLocal = true;
        copy.synced = false;

        this.localProjects[newId] = copy;
    }

    // ----- SIDEBAR ----- //

    /**
     * Builds a single sidebar list item DOM element.
     * @param {object} project - project object
     * @param {'server'|'local'} type - whether this is a server or local project
     * @returns {HTMLElement}
     * @ignore
     */
    #buildProjectItem(project, type) {
        const item = document.createElement('div');

        // determine whether this is the active project
        let active = false;
        if (this.workspace.activeProject && this.workspace.activeProject.projectId === project.projectId) {
            active = true;
        }

        // set the className to active if this project is active in the workspace
        item.className = 'sidebar-item' + (active ? ' active' : '');
        // save the project ID to the div dataset
        item.dataset.id = project.projectId;

        // build name element
        const nameEl = document.createElement('span');
        nameEl.className = 'sidebar-item-name';
        const nameText = document.createElement('span');
        nameText.className = 'sidebar-item-name-text';
        nameText.textContent = project.projectName;
        nameEl.appendChild(nameText);
        if (project.isPublic || project.ownerName) {
            const ownerEl = document.createElement('span');
            ownerEl.className = 'sidebar-item-owner';
            ownerEl.textContent = project.isPublic ? 'Public' : `Owner: ${project.ownerName}`;
            nameEl.appendChild(ownerEl);
        }
        item.appendChild(nameEl);

        // Show "uploading" badge while uploading, or "modified" badge when there are unsynced local edits
        if (this.uploadingProjects.has(project)) {
            const badge = document.createElement('span');
            badge.className = 'sidebar-item-badge uploading';
            badge.textContent = 'uploading';
            badge.title = 'Uploading to server…';
            item.appendChild(badge);
        } else if (project.isDirty()) {
            const badge = document.createElement('span');
            badge.className = 'sidebar-item-badge modified';
            badge.textContent = 'modified';
            badge.title = 'Unpushed local changes';
            item.appendChild(badge);
        }

        if (type !== 'shared') {
            // Push button (↑) is shown when connected and the project has unsynced or dirty data.
            // While uploading, the button is replaced with a spinner.
            if (this.server.isConnected && (this.uploadingProjects.has(project) || !project.synced || project.isDirty())) {
                const pushBtn = document.createElement('button');
                if (this.uploadingProjects.has(project)) {
                    pushBtn.className = 'sidebar-item-push uploading';
                    pushBtn.disabled = true;
                    pushBtn.title = 'Uploading…';
                    const spinner = document.createElement('span');
                    spinner.className = 'sidebar-spinner';
                    pushBtn.appendChild(spinner);
                } else {
                    pushBtn.className = 'sidebar-item-push';
                    pushBtn.innerHTML = '<span class="icon icon-arrow-up" style="width:11px;height:11px;"></span>';
                    pushBtn.title = type === 'server' ? 'Push changes to server' : 'Upload project to server';
                    pushBtn.addEventListener('click', (e) => {
                        e.stopPropagation();
                        this.pushProjectToServer(project);
                    });
                }
                item.appendChild(pushBtn);
            }

            // Progress bar shown at the bottom of the item while uploading
            if (this.uploadingProjects.has(project)) {
                const progressTrack = document.createElement('div');
                progressTrack.className = 'sidebar-item-progress';
                const progressBar = document.createElement('div');
                progressBar.className = 'sidebar-item-progress-bar';
                progressBar.style.width = `${(this.uploadProgress.get(project) ?? 0) * 100}%`;
                progressTrack.appendChild(progressBar);
                item.appendChild(progressTrack);
            }

            // Delete button
            const delBtn = document.createElement('button');
            delBtn.className = 'sidebar-item-del';
            delBtn.innerHTML = '<span class="icon icon-close" style="width:11px;height:11px;"></span>';
            delBtn.title = type === 'server' ? 'Delete from server' : 'Delete project';
            delBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                this.deleteProject(project);
            });
            item.appendChild(delBtn);
        }

        // open project on click
        item.addEventListener('click', (e) => {
            e.stopPropagation();
            this.openProject(project);
        });

        // open custom context menu on right click
        item.addEventListener('contextmenu', (e) => {
            e.stopPropagation();
            e.preventDefault();
            this.openProjectCtxMenu(e.clientX, e.clientY, project);
        });

        return item;
    }

    /**
    * Fully re-renders the project sidebar list from the current registry state.
    * Shows server projects first (sorted by modified desc), then local projects.
    * Shows the "No projects yet" empty state when both registries are empty.
    */
    renderSidebar() {
        const localProjects = Object.values(this.localProjects);

        // Projects visible in the current server folder
        const folderProjects = Object.values(this.serverProjects)
            .filter(p => p.folderPath === this.currentFolderPath);

        const totalVisible = folderProjects.length + localProjects.length +
            this.currentFolderFolders.length;

        // Show empty state only when disconnected, not loading, and no local projects
        const showEmpty = !this.server.isConnected && !localProjects.length && !this.serverProjectsLoading;
        this.sidebarEmpty.style.display = showEmpty ? 'block' : 'none';

        /**
         * Sorts projects by modified date descending.
         * @param {object} a - first project
         * @param {object} b - second project
         * @returns {number} Negative, zero, or positive for sort ordering.
         */
        function projectSort(a, b) {
            return (b.modifiedDate || '').localeCompare(a.modifiedDate || '');
        }

        // Update Server section
        if (this.serverProjectsLoading) {
            this.sidebarBreadcrumb.style.display = 'none';
            this.sidebarFolderItems.replaceChildren();
            this.sidebarServerItems.replaceChildren();

            const loadingRow = document.createElement('div');
            loadingRow.className = 'sidebar-loading-row';
            const spinner = document.createElement('span');
            spinner.className = 'sidebar-spinner';
            const label = document.createElement('span');
            label.textContent = 'Loading projects…';
            loadingRow.appendChild(spinner);
            loadingRow.appendChild(label);
            this.sidebarServerItems.appendChild(loadingRow);

        } else if (this.server.isConnected) {
            // Breadcrumb always shown when connected
            this.sidebarBreadcrumb.style.display = 'flex';
            this.sidebarBreadcrumb.replaceChildren(...this.#buildBreadcrumbSegments());

            this.sidebarFolderItems.replaceChildren();
            if (this.currentFolderPath) {
                // "Previous Folder" back button
                this.sidebarFolderItems.appendChild(this.#buildPreviousFolderItem());
            }

            // Folder items (subfolders in current directory)
            this.currentFolderFolders.forEach(folder => {
                this.sidebarFolderItems.appendChild(this.#buildFolderItem(folder));
            });

            // Server project items in current folder
            this.sidebarServerItems.replaceChildren();
            folderProjects.sort(projectSort).forEach(project => {
                this.sidebarServerItems.appendChild(this.#buildProjectItem(project, 'server'));
            });

        } else {
            this.sidebarBreadcrumb.style.display = 'none';
            this.sidebarFolderItems.replaceChildren();
            this.sidebarServerItems.replaceChildren();
        }

        // Update Shared With Me section
        const sharedProjects = Object.values(this.sharedProjects);
        const sharedFolderProjects = Object.values(this.sharedFolderProjects);
        const hasSharedContent = this.sharedFolderFolderItems.length > 0
            || sharedProjects.length > 0 || sharedFolderProjects.length > 0;
        this.sidebarSharedSection.style.display =
            (this.server.isConnected && (this.sharedProjectsLoading || hasSharedContent)) ? '' : 'none';

        this.sidebarSharedBreadcrumb.replaceChildren(...this.#buildSharedBreadcrumbSegments());

        if (this.sharedProjectsLoading) {
            this.sidebarSharedItems.replaceChildren();
            const loadingRow = document.createElement('div');
            loadingRow.className = 'sidebar-loading-row';
            const spinner = document.createElement('span');
            spinner.className = 'sidebar-spinner';
            const label = document.createElement('span');
            label.textContent = 'Loading…';
            loadingRow.appendChild(spinner);
            loadingRow.appendChild(label);
            this.sidebarSharedItems.appendChild(loadingRow);
        } else {
            this.sidebarSharedItems.replaceChildren();
            if (this.sharedFolderPath) {
                this.sidebarSharedItems.appendChild(this.#buildSharedPreviousFolderItem());
            }
            // Folder items (shared root folders or subfolders when navigated in)
            this.sharedFolderFolderItems.forEach(folder => {
                this.sidebarSharedItems.appendChild(this.#buildSharedFolderItem(folder));
            });
            // Projects: inside a folder show folder contents; at root show flat shared list
            const projectsToShow = this.sharedFolderPath ? sharedFolderProjects : sharedProjects;
            projectsToShow.sort(projectSort).forEach(project => {
                this.sidebarSharedItems.appendChild(this.#buildProjectItem(project, 'shared'));
            });
        }

        // Update Local section
        this.sidebarLocalSection.replaceChildren();
        if (localProjects.length > 0) {
            const header = document.createElement('div');
            header.className = 'sidebar-section';
            header.textContent = this.server.isConnected ? 'Offline' : 'Local';
            this.sidebarLocalSection.appendChild(header);

            localProjects.sort(projectSort).forEach(project => {
                this.sidebarLocalSection.appendChild(this.#buildProjectItem(project, 'local'));
            });
        }
    }

    /**
     * Builds the breadcrumb segment elements for the current folder path.
     * @returns {HTMLElement[]}
     * @ignore
     */
    #buildBreadcrumbSegments() {
        const segments = [];

        // Root segment — always shown
        const rootSeg = document.createElement('span');
        rootSeg.className = 'sidebar-breadcrumb-seg' +
            (this.currentFolderPath === '' || this.myFilesCollapsed ? ' current' : '');
        rootSeg.textContent = 'MY FILES';
        rootSeg.title = 'Go to root';
        if (this.currentFolderPath !== '' && !this.myFilesCollapsed) {
            rootSeg.addEventListener('click', () => this.navigateToFolder(''));
        }
        segments.push(rootSeg);

        // Path segments — only when expanded
        if (this.currentFolderPath && !this.myFilesCollapsed) {
            const parts = this.currentFolderPath.split('/');
            parts.forEach((part, i) => {
                const sep = document.createElement('span');
                sep.className = 'sidebar-breadcrumb-sep';
                sep.textContent = '›';
                segments.push(sep);

                const seg = document.createElement('span');
                seg.className = 'sidebar-breadcrumb-seg' + (i === parts.length - 1 ? ' current' : '');
                seg.textContent = part;
                if (i < parts.length - 1) {
                    const targetPath = parts.slice(0, i + 1).join('/');
                    seg.addEventListener('click', () => this.navigateToFolder(targetPath));
                }
                segments.push(seg);
            });
        }

        // Collapse button — always at right edge
        const collapseBtn = document.createElement('button');
        collapseBtn.className = 'sidebar-section-collapse' + (this.myFilesCollapsed ? ' collapsed' : '');
        collapseBtn.title = this.myFilesCollapsed ? 'Expand' : 'Collapse';
        collapseBtn.textContent = '▾';
        collapseBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            this.myFilesCollapsed = !this.myFilesCollapsed;
            this.myFilesContent.style.display = this.myFilesCollapsed ? 'none' : '';
            this.renderSidebar();
        });
        segments.push(collapseBtn);

        return segments;
    }

    /** @returns {HTMLElement} a "← Previous Folder" back-navigation item */
    #buildPreviousFolderItem() {
        const parts = this.currentFolderPath.split('/');
        const parentPath = parts.slice(0, -1).join('/');

        const item = document.createElement('div');
        item.className = 'sidebar-folder-item sidebar-folder-item--back';
        item.title = parentPath ? parentPath : 'Server root';

        const iconWrap = document.createElement('span');
        iconWrap.className = 'sidebar-folder-icon';
        const icon = document.createElement('span');
        icon.className = 'icon icon-folder';
        icon.style.cssText = 'width:13px;height:13px;';
        iconWrap.appendChild(icon);
        item.appendChild(iconWrap);

        const label = document.createElement('span');
        label.className = 'sidebar-item-name';
        label.innerHTML = '<span class="icon icon-arrow-left" style="width:11px;height:11px;"></span> Previous Folder';
        item.appendChild(label);

        item.addEventListener('click', () => this.navigateToFolder(parentPath));
        return item;
    }

    /** @returns {HTMLElement} a "← Previous Folder" back-navigation item for the Shared section */
    #buildSharedPreviousFolderItem() {
        const parts = this.sharedFolderPath.split('/');
        const parentPath = parts.slice(0, -1).join('/');

        const item = document.createElement('div');
        item.className = 'sidebar-folder-item sidebar-folder-item--back';
        item.title = parentPath ? parentPath : 'Shared root';

        const iconWrap = document.createElement('span');
        iconWrap.className = 'sidebar-folder-icon';
        const icon = document.createElement('span');
        icon.className = 'icon icon-folder';
        icon.style.cssText = 'width:13px;height:13px;';
        iconWrap.appendChild(icon);
        item.appendChild(iconWrap);

        const label = document.createElement('span');
        label.className = 'sidebar-item-name';
        label.innerHTML = '<span class="icon icon-arrow-left" style="width:11px;height:11px;"></span> Previous Folder';
        item.appendChild(label);

        item.addEventListener('click', () => this.navigateToSharedFolder(parentPath));
        return item;
    }

    /**
     * Builds a sidebar item for a folder.
     * @param {object} folder - folder descriptor {name, path}
     * @returns {HTMLElement}
     * @ignore
     */
    #buildSharedBreadcrumbSegments() {
        const segments = [];

        const rootSeg = document.createElement('span');
        rootSeg.className = 'sidebar-breadcrumb-seg' +
            (this.sharedFolderPath === '' || this.sharedCollapsed ? ' current' : '');
        rootSeg.textContent = 'SHARED';
        if (this.sharedFolderPath && !this.sharedCollapsed) {
            rootSeg.addEventListener('click', () => this.navigateToSharedFolder(''));
        }
        segments.push(rootSeg);

        if (this.sharedFolderPath && !this.sharedCollapsed) {
            const parts = this.sharedFolderPath.split('/');
            parts.forEach((part, i) => {
                const sep = document.createElement('span');
                sep.className = 'sidebar-breadcrumb-sep';
                sep.textContent = '›';
                segments.push(sep);

                const seg = document.createElement('span');
                seg.className = 'sidebar-breadcrumb-seg' + (i === parts.length - 1 ? ' current' : '');
                seg.textContent = part;
                if (i < parts.length - 1) {
                    const targetPath = parts.slice(0, i + 1).join('/');
                    seg.addEventListener('click', () => this.navigateToSharedFolder(targetPath));
                }
                segments.push(seg);
            });
        }

        const collapseBtn = document.createElement('button');
        collapseBtn.className = 'sidebar-section-collapse' + (this.sharedCollapsed ? ' collapsed' : '');
        collapseBtn.title = this.sharedCollapsed ? 'Expand' : 'Collapse';
        collapseBtn.textContent = '▾';
        collapseBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            this.sharedCollapsed = !this.sharedCollapsed;
            this.sharedContent.style.display = this.sharedCollapsed ? 'none' : '';
            this.renderSidebar();
        });
        segments.push(collapseBtn);

        return segments;
    }

    /**
     * Builds a sidebar folder item for use in the Shared section (no delete/rename).
     * @param {object} folder - folder descriptor {name, path, owner_name, public}
     * @returns {HTMLElement}
     * @ignore
     */
    #buildSharedFolderItem(folder) {
        const item = document.createElement('div');
        item.className = 'sidebar-folder-item';
        item.dataset.path = folder.path;

        const iconWrap = document.createElement('span');
        iconWrap.className = 'sidebar-folder-icon';
        const icon = document.createElement('span');
        icon.className = 'icon icon-folder';
        icon.style.cssText = 'width:13px;height:13px;';
        iconWrap.appendChild(icon);
        item.appendChild(iconWrap);

        const nameEl = document.createElement('span');
        nameEl.className = 'sidebar-item-name';
        const nameText = document.createElement('span');
        nameText.className = 'sidebar-item-name-text';
        nameText.textContent = folder.name;
        nameEl.appendChild(nameText);
        if (folder.public || folder.owner_name) {
            const ownerEl = document.createElement('span');
            ownerEl.className = 'sidebar-item-owner';
            ownerEl.textContent = folder.public ? 'Public' : `Owner: ${folder.owner_name}`;
            nameEl.appendChild(ownerEl);
        }
        item.appendChild(nameEl);

        const arrow = document.createElement('span');
        arrow.className = 'sidebar-folder-arrow';
        arrow.textContent = '›';
        item.appendChild(arrow);

        item.addEventListener('click', () => this.navigateToSharedFolder(folder.path));

        return item;
    }

    /**
     * Build a sidebar folder item element for an owned folder.
     * @param {object} folder - Folder metadata including `path`, `name`, and `public`.
     * @returns {HTMLElement} The folder item element.
     */
    #buildFolderItem(folder) {
        const item = document.createElement('div');
        item.className = 'sidebar-folder-item';
        item.dataset.path = folder.path;

        const iconWrap = document.createElement('span');
        iconWrap.className = 'sidebar-folder-icon';
        const icon = document.createElement('span');
        icon.className = 'icon icon-folder';
        icon.style.cssText = 'width:13px;height:13px;';
        iconWrap.appendChild(icon);
        item.appendChild(iconWrap);

        const nameEl = document.createElement('span');
        nameEl.className = 'sidebar-item-name';
        const nameText = document.createElement('span');
        nameText.className = 'sidebar-item-name-text';
        nameText.textContent = folder.name;
        nameEl.appendChild(nameText);
        if (folder.public || folder.owner_name) {
            const ownerEl = document.createElement('span');
            ownerEl.className = 'sidebar-item-owner';
            ownerEl.textContent = folder.public ? 'Public' : `Owner: ${folder.owner_name}`;
            nameEl.appendChild(ownerEl);
        }
        item.appendChild(nameEl);

        const arrow = document.createElement('span');
        arrow.className = 'sidebar-folder-arrow';
        arrow.textContent = '›';
        item.appendChild(arrow);

        const delBtn = document.createElement('button');
        delBtn.className = 'sidebar-item-del';
        delBtn.innerHTML = '<span class="icon icon-close" style="width:11px;height:11px;"></span>';
        delBtn.title = 'Delete folder';
        delBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            this.deleteFolder(folder);
        });
        item.appendChild(delBtn);

        item.addEventListener('click', () => this.navigateToFolder(folder.path));

        item.addEventListener('contextmenu', (e) => {
            e.stopPropagation();
            e.preventDefault();
            this.openFolderCtxMenu(e.clientX, e.clientY, folder, item);
        });

        return item;
    }

    /**
     * Opens a small context menu for a folder with Rename and Delete actions.
     * @param {number} x - viewport x position
     * @param {number} y - viewport y position
     * @param {object} folder - folder descriptor {name, path}
     * @param {HTMLElement} item - the folder's DOM element (for inline rename)
     */
    openFolderCtxMenu(x, y, folder, item) {
        this.closeCtxMenu();

        const menu = document.createElement('div');
        menu.className = 'ctx-menu';

        const controls = document.createElement('div');
        controls.className = 'ctx-controls';

        const rename = document.createElement('div');
        rename.className = 'ctx-item';
        rename.innerHTML = '<span class="ctx-icon">✎</span><span class="ctx-item-inner">Rename</span>';
        rename.addEventListener('click', () => {
            menu.remove();
            this.activeCtxMenu = null;
            this.startFolderRename(folder, item);
        });

        const del = document.createElement('div');
        del.className = 'ctx-item';
        del.innerHTML = '<span class="ctx-icon">🗑</span><span class="ctx-item-inner">Delete</span>';
        del.addEventListener('click', () => {
            menu.remove();
            this.activeCtxMenu = null;
            this.deleteFolder(folder);
        });

        controls.appendChild(rename);
        controls.appendChild(del);

        if (folder.id) {
            const idLine = document.createElement('div');
            idLine.className = 'ctx-id-line';
            idLine.textContent = folder.id;
            controls.appendChild(idLine);
        }

        menu.appendChild(controls);
        document.body.appendChild(menu);

        // Position
        menu.style.left = x + 'px';
        menu.style.top = y + 'px';
        requestAnimationFrame(() => {
            const r = menu.getBoundingClientRect();
            if (x + r.width > window.innerWidth) menu.style.left = (window.innerWidth - r.width - 8) + 'px';
            if (y + r.height > window.innerHeight) menu.style.top = (window.innerHeight - r.height - 8) + 'px';
        });

        // Dismiss on outside click
        this.activeCtxMenu = { close: () => menu.remove() };
        setTimeout(() => {
            const outside = (e) => {
                if (!menu.contains(e.target)) {
                    menu.remove();
                    this.activeCtxMenu = null;
                    document.removeEventListener('mousedown', outside);
                }
            };
            document.addEventListener('mousedown', outside);
        }, 0);
    }

    /**
    * Closes the active context menu
    */
    closeCtxMenu() {
        if (this.activeCtxMenu) {
            this.activeCtxMenu.close();
            this.activeCtxMenu = null;
        }
    }

    /**
    * Opens a new project context menu.  If the provided project is null, it will open a truncated general menu
    * with only options for non-project-dependant tasks like creating a new project
    * @param {float} x - The x coordinate at which to place the context menu
    * @param {float} y - The y coordinate at which to place the context menu
    * @param {object} project - The project context for the context menu
    */
    openProjectCtxMenu(x, y, project = null) {
        // If there is already a context menu open, close it
        this.closeCtxMenu();

        // If the project is passed, make a full context menu.  Otherwise, make a general one
        if (project) {
            // Create a new context menu with its callbacks
            const readOnly = project.readOnly ?? false;
            this.activeCtxMenu = new ProjectContextMenu(x, y, project, {
                onDelete: !readOnly ? () => {
                    this.closeCtxMenu();
                    this.deleteProject(project);
                } : null,
                onDuplicate: () => {
                    this.closeCtxMenu();
                    this.duplicateProject(project);
                },
                onNew: () => {
                    this.closeCtxMenu();
                    this.createProject();
                },
                onOpen: () => {
                    this.closeCtxMenu();
                    this.openPackagedProject();
                },
                onRename: !readOnly ? () => {
                    this.closeCtxMenu();
                    this.startSidebarRename(project);
                } : null,
                onDismiss: () => { this.closeCtxMenu(); },

                // show upload when connected and project has local-only or dirty data
                onUpload: (this.server.isConnected && (project.localOnly || project.isDirty())) ? () => {
                    this.closeCtxMenu();
                    this.pushProjectToServer(project);
                } : null,
                // show download
                onDownload: true ? () => {
                    this.closeCtxMenu();
                    this.workspace.saveLocalCopy(project);
                } : null,
                // show revert when project has unsaved changes
                onRevert: project.isDirty() ? () => {
                    this.closeCtxMenu();
                    new ConfirmDialog('Discard all changes since the last save?', {
                        onConfirm: () => { project.revertToLastSave(); }
                    });
                } : null,
                // show move to folder when connected, server project, and not read-only
                onMoveToFolder: (!readOnly && this.server.isConnected && !project.localOnly) ? () => {
                    this.closeCtxMenu();
                    this.moveProjectToFolder(project);
                } : null,
                onNewFolder: this.server.isConnected ? () => {
                    this.closeCtxMenu();
                    this.createServerFolder();
                } : null,
                onPresentation: (this.server.isConnected && !project.localOnly) ? () => {
                    this.closeCtxMenu();
                    window.open(`/presentation/${project.projectId}`, '_blank');
                } : null,
                onShare: (this.server.isConnected && !project.localOnly && !readOnly) ? () => {
                    this.closeCtxMenu();
                    new ShareDialog(project.projectId, this.server);
                } : null,
                onCompress: (!readOnly && this.server.isConnected && !project.localOnly) ? () => {
                    this.closeCtxMenu();
                    this.compressProject(project);
                } : null,
                onDismiss: () => { this.closeCtxMenu(); },
            });
        } else {
            this.activeCtxMenu = new ProjectContextMenu(x, y, project, {
                onNew: () => {
                    this.closeCtxMenu();
                    this.createProject();
                },
                onOpen: () => {
                    this.closeCtxMenu();
                    this.openPackagedProject();
                },
                onNewFolder: this.server.isConnected ? () => {
                    this.closeCtxMenu();
                    this.createServerFolder();
                } : null,
                onDismiss: () => { this.closeCtxMenu(); },
            });
        }
    }

    /**
    * Activates inline rename editing for a project's sidebar item.
    * Replaces the name span with a text input; commits on blur/Enter, cancels on Escape.
    * @param {object} project - The project to rename
    */
    startSidebarRename(project) {
        const item = this.sidebarList.querySelector(`.sidebar-item[data-id="${project.projectId}"]`);
        if (!item) return;

        const nameEl = item.querySelector('.sidebar-item-name');
        if (!nameEl) return;

        const input = document.createElement('input');
        input.type = 'text';
        input.className = 'sidebar-item-name-input';
        input.value = project.projectName;

        const commit = () => {
            const newName = input.value.trim() || project.projectName;
            project.setName(newName);
            // If this is the active project in the workspace, update its title display too
            if (this.workspace.activeProject?.projectId === project.projectId) {
                this.workspace.projectTitle.textContent = newName;
                this.workspace.projectNameInput.value = newName;
            }
        };

        input.addEventListener('blur', commit);
        input.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
            if (e.key === 'Escape') {
                input.removeEventListener('blur', commit);
                this.renderSidebar();
            }
            e.stopPropagation();
        });

        // Prevent item click from firing while editing
        input.addEventListener('click', (e) => e.stopPropagation());

        nameEl.replaceWith(input);
        input.focus();
        input.select();
    }

    /** Marks the active theme button to match the current theme preference. */
    #updateThemeBtns() {
        const current = getTheme();
        this.themeBtns.forEach(btn => {
            btn.classList.toggle('active', btn.dataset.themeValue === current);
        });
        const icons = { auto: '◑', light: '☀', dark: '☾' };
        this.sidebarThemeBtn.textContent = icons[current] ?? '◑';
    }

}

// ENTRYPOINT
initTheme();
const app = new App();

window.addEventListener('load', () => {
  document.fonts.ready.then(() => {
    const overlay = document.getElementById('loading-overlay');
    overlay.classList.add('hidden');
    overlay.addEventListener('transitionend', () => overlay.remove(), { once: true });
  });

  if (LOCAL_MODE) {
    app.server.connectLocal();
  } else {
    let _connecting = false;
    onAuthStateChanged(firebaseAuth, (firebaseUser) => {
      if (firebaseUser && !app.server.isConnected && !_connecting) {
        _connecting = true;
        app.server.connectWithFirebaseUser(window.DEFAULT_SERVER, firebaseUser)
          .catch(async (e) => {
              app.loginDialog?.showConnectError(e.message || 'Server rejected the sign-in.');
              try { await signOut(firebaseAuth); } catch (_) {}
          })
          .finally(() => { _connecting = false; });
      } else if (!firebaseUser) {
        if (app.server.isConnected) app.server.disconnectFromServer();
        app.loginDialog?.openRequired();
      }
    });
  }
});