/* ObsiGate — UI module */ import { api, AuthManager, initLoginForm } from './auth.js'; import { state } from './state.js'; import { openFile, showWelcome, renderFile, renderImageViewer, el } from './viewer.js'; import { safeCreateIcons, getFileIcon, escapeHtml, detachInlineEditor } from './utils.js'; import { syncActiveFileTreeItem, refreshSidebarTreePreservingState } from './sidebar.js'; import { GraphViewManager } from './graph.js'; import { DashboardConflictsWidget } from './dashboard.js'; import { t, tr } from './i18n.js'; import { toggleThemeMode } from './themes.js'; import { isTauriEnv, removeRoot } from './desktop.js'; // --------------------------------------------------------------------------- // Right Sidebar Manager // --------------------------------------------------------------------------- export const RightSidebarManager = { init() { this.loadState(); this.initToggle(); this.initResize(); }, loadState() { const savedWidth = localStorage.getItem("obsigate-right-sidebar-width"); // Le panneau TOC (« Sur cette page ») part toujours ferme a l'ouverture // de l'app ; seul le bouton TOC/toggle du panneau l'ouvre. La largeur, // elle, est memorisee. state.rightSidebarVisible = false; if (savedWidth) { state.rightSidebarWidth = parseInt(savedWidth) || 280; } this.applyState(); }, applyState() { const sidebar = document.getElementById("right-sidebar"); const handle = document.getElementById("right-sidebar-resize-handle"); const tocBtn = document.getElementById("toc-toggle-btn"); const headerToggleBtn = document.getElementById("right-sidebar-toggle-btn"); if (!sidebar) return; if (state.rightSidebarVisible) { sidebar.classList.remove("hidden"); sidebar.style.width = `${state.rightSidebarWidth}px`; if (handle) handle.classList.remove("hidden"); if (tocBtn) { tocBtn.classList.add("active"); tocBtn.title = "Masquer le sommaire"; } if (headerToggleBtn) { headerToggleBtn.title = "Masquer le panneau"; headerToggleBtn.setAttribute("aria-label", "Masquer le panneau"); } } else { sidebar.classList.add("hidden"); if (handle) handle.classList.add("hidden"); if (tocBtn) { tocBtn.classList.remove("active"); tocBtn.title = "Afficher le sommaire"; } if (headerToggleBtn) { headerToggleBtn.title = "Afficher le panneau"; headerToggleBtn.setAttribute("aria-label", "Afficher le panneau"); } } // Update icons safeCreateIcons(); }, toggle() { state.rightSidebarVisible = !state.rightSidebarVisible; localStorage.setItem("obsigate-right-sidebar-visible", state.rightSidebarVisible); this.applyState(); }, initToggle() { const toggleBtn = document.getElementById("right-sidebar-toggle-btn"); if (toggleBtn) { toggleBtn.addEventListener("click", () => this.toggle()); } }, initResize() { const handle = document.getElementById("right-sidebar-resize-handle"); const sidebar = document.getElementById("right-sidebar"); if (!handle || !sidebar) return; let isResizing = false; let startX = 0; let startWidth = 0; const onMouseDown = (e) => { isResizing = true; startX = e.clientX; startWidth = sidebar.offsetWidth; handle.classList.add("active"); document.body.style.cursor = "ew-resize"; document.body.style.userSelect = "none"; }; const onMouseMove = (e) => { if (!isResizing) return; const delta = startX - e.clientX; let newWidth = startWidth + delta; // Constrain width newWidth = Math.max(200, Math.min(400, newWidth)); sidebar.style.width = `${newWidth}px`; state.rightSidebarWidth = newWidth; }; const onMouseUp = () => { if (!isResizing) return; isResizing = false; handle.classList.remove("active"); document.body.style.cursor = ""; document.body.style.userSelect = ""; localStorage.setItem("obsigate-right-sidebar-width", state.rightSidebarWidth); }; handle.addEventListener("mousedown", onMouseDown); document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }, }; // --------------------------------------------------------------------------- // Theme // --------------------------------------------------------------------------- const _THEME_MODES = ["dark", "light", "high-contrast", "sepia"]; export function initTheme() { // The theme engine (themes.js) owns the CSS variables and the theme *key*; // the attribute must carry the *mode* (dark/light/…). Read // the persisted mode here so the first paint and the highlight.js stylesheet // are right before the async theme init runs (BUG-078). let mode = "dark"; try { mode = localStorage.getItem("obsigate-theme-mode") || "dark"; } catch (e) { /* ignore */ } applyTheme(mode); } export function applyTheme(theme) { let mode = theme; if (_THEME_MODES.indexOf(mode) === -1) { // Callers may pass a theme key (legacy): fall back to the persisted mode. try { mode = localStorage.getItem("obsigate-theme-mode") || "dark"; } catch (e) { mode = "dark"; } } const isDark = mode === "dark"; document.documentElement.setAttribute("data-theme", mode); // Update theme button icon and label const themeBtn = document.getElementById("theme-toggle"); const themeLabel = document.getElementById("theme-label"); if (themeBtn && themeLabel) { const icon = themeBtn.querySelector("i"); if (icon) { icon.setAttribute("data-lucide", isDark ? "moon" : "sun"); } themeLabel.textContent = isDark ? t('theme.dark') : t('theme.light'); safeCreateIcons(); } // Swap highlight.js theme — keyed on the mode, not the theme key (BUG-078). const darkSheet = document.getElementById("hljs-theme-dark"); const lightSheet = document.getElementById("hljs-theme-light"); if (darkSheet && lightSheet) { darkSheet.disabled = !isDark; lightSheet.disabled = isDark; } // Update Mermaid theme for newly rendered diagrams import('./mermaid-viewer.js').then(function(m) { m.updateMermaidTheme(isDark); }).catch(function() {}); } export function toggleTheme() { const newMode = toggleThemeMode(); // Update the theme toggle label const themeLabel = document.getElementById("theme-label"); if (themeLabel) { const modeLabelMap = { dark: 'theme.dark', light: 'theme.light', 'high-contrast': 'theme.high_contrast', sepia: 'theme.sepia' }; themeLabel.textContent = t(modeLabelMap[newMode] || 'theme.dark'); } // Update the theme toggle icon const themeBtn = document.getElementById("theme-toggle"); if (themeBtn) { const icon = themeBtn.querySelector("i"); if (icon) { const isLight = newMode === "light" || newMode === "sepia"; icon.setAttribute("data-lucide", isLight ? "sun" : "moon"); } } safeCreateIcons(); } export function initHeaderMenu() { const menuBtn = document.getElementById("header-menu-btn"); const menuDropdown = document.getElementById("header-menu-dropdown"); if (!menuBtn || !menuDropdown) return; menuBtn.addEventListener("click", (e) => { e.stopPropagation(); menuBtn.classList.toggle("active"); menuDropdown.classList.toggle("active"); }); // Close menu when clicking outside document.addEventListener("click", (e) => { if (!menuDropdown.contains(e.target) && e.target !== menuBtn) { menuBtn.classList.remove("active"); menuDropdown.classList.remove("active"); } }); // Prevent menu from closing when clicking inside menuDropdown.addEventListener("click", (e) => { e.stopPropagation(); }); // API documentation (#72) — opens the OpenAPI/Swagger page in a new tab. const apiRow = document.getElementById("api-docs-menu-row"); if (apiRow) { apiRow.addEventListener("click", () => { closeHeaderMenu(); window.open("/docs", "_blank", "noopener"); }); } } export function closeHeaderMenu() { const menuBtn = document.getElementById("header-menu-btn"); const menuDropdown = document.getElementById("header-menu-dropdown"); if (!menuBtn || !menuDropdown) return; menuBtn.classList.remove("active"); menuDropdown.classList.remove("active"); } // --------------------------------------------------------------------------- // Custom Dropdowns // --------------------------------------------------------------------------- export function initCustomDropdowns() { document.querySelectorAll(".custom-dropdown").forEach((dropdown) => { const trigger = dropdown.querySelector(".custom-dropdown-trigger"); const options = dropdown.querySelectorAll(".custom-dropdown-option"); const hiddenInput = dropdown.querySelector('input[type="hidden"]'); const selectedText = dropdown.querySelector(".custom-dropdown-selected"); const menu = dropdown.querySelector(".custom-dropdown-menu"); if (!trigger) return; // Toggle dropdown trigger.addEventListener("click", (e) => { e.stopPropagation(); const isOpen = dropdown.classList.contains("open"); // Close all other dropdowns document.querySelectorAll(".custom-dropdown.open").forEach((d) => { if (d !== dropdown) d.classList.remove("open"); }); dropdown.classList.toggle("open", !isOpen); trigger.setAttribute("aria-expanded", !isOpen); // Position fixed menu for sidebar dropdowns if (!isOpen && dropdown.classList.contains("sidebar-dropdown") && menu) { const rect = trigger.getBoundingClientRect(); menu.style.top = `${rect.bottom + 4}px`; menu.style.left = `${rect.left}px`; menu.style.width = `${rect.width}px`; } }); // Handle option selection options.forEach((option) => { option.addEventListener("click", (e) => { e.stopPropagation(); const value = option.getAttribute("data-value"); const text = option.textContent; // Update hidden input if (hiddenInput) { hiddenInput.value = value; // Trigger change event hiddenInput.dispatchEvent(new Event("change", { bubbles: true })); } // Update selected text if (selectedText) { selectedText.textContent = text; } // Update visual selection options.forEach((opt) => opt.classList.remove("selected")); option.classList.add("selected"); // Close dropdown dropdown.classList.remove("open"); trigger.setAttribute("aria-expanded", "false"); }); }); }); // Close dropdowns when clicking outside document.addEventListener("click", () => { document.querySelectorAll(".custom-dropdown.open").forEach((dropdown) => { dropdown.classList.remove("open"); const trigger = dropdown.querySelector(".custom-dropdown-trigger"); if (trigger) trigger.setAttribute("aria-expanded", "false"); }); }); } // Helper to populate custom dropdown options export function populateCustomDropdown(dropdownId, optionsList, defaultValue) { const dropdown = document.getElementById(dropdownId); if (!dropdown) return; const optionsContainer = dropdown.querySelector(".custom-dropdown-menu"); const hiddenInput = dropdown.querySelector('input[type="hidden"]'); const selectedText = dropdown.querySelector(".custom-dropdown-selected"); if (!optionsContainer) return; // Clear existing options (keep the first one if it's the default) optionsContainer.innerHTML = ""; // Add new options optionsList.forEach((opt) => { const li = document.createElement("li"); li.className = "custom-dropdown-option"; li.setAttribute("role", "option"); li.setAttribute("data-value", opt.value); li.textContent = opt.text; if (opt.value === defaultValue) { li.classList.add("selected"); if (selectedText) selectedText.textContent = opt.text; if (hiddenInput) hiddenInput.value = opt.value; } optionsContainer.appendChild(li); }); // Re-initialize click handlers optionsContainer.querySelectorAll(".custom-dropdown-option").forEach((option) => { option.addEventListener("click", (e) => { e.stopPropagation(); const value = option.getAttribute("data-value"); const text = option.textContent; if (hiddenInput) { hiddenInput.value = value; hiddenInput.dispatchEvent(new Event("change", { bubbles: true })); } if (selectedText) { selectedText.textContent = text; } optionsContainer.querySelectorAll(".custom-dropdown-option").forEach((opt) => opt.classList.remove("selected")); option.classList.add("selected"); dropdown.classList.remove("open"); const trigger = dropdown.querySelector(".custom-dropdown-trigger"); if (trigger) trigger.setAttribute("aria-expanded", "false"); }); }); } // --------------------------------------------------------------------------- // Toast notifications // --------------------------------------------------------------------------- /** Display a brief toast message at the bottom of the viewport. */ export function showToast(message, type) { // Auto-translate French messages via reverse lookup message = tr(message); type = type || "info"; let container = document.getElementById("toast-container"); if (!container) { container = document.createElement("div"); container.id = "toast-container"; container.className = "toast-container"; container.setAttribute("aria-live", "polite"); document.body.appendChild(container); } var toast = document.createElement("div"); toast.className = "toast toast-" + type; toast.textContent = message; container.appendChild(toast); // Trigger entrance animation requestAnimationFrame(function () { toast.classList.add("show"); }); setTimeout(function () { toast.classList.remove("show"); toast.addEventListener("transitionend", function () { toast.remove(); }); }, 3500); } // --------------------------------------------------------------------------- // Sidebar toggle (desktop) // --------------------------------------------------------------------------- export function initSidebarToggle() { const toggleBtn = document.getElementById("sidebar-toggle-btn"); const sidebar = document.getElementById("sidebar"); const resizeHandle = document.getElementById("sidebar-resize-handle"); if (!toggleBtn || !sidebar || !resizeHandle) return; // Restore saved state const savedState = localStorage.getItem("obsigate-sidebar-hidden"); if (savedState === "true") { sidebar.classList.add("hidden"); resizeHandle.classList.add("hidden"); toggleBtn.classList.add("active"); } toggleBtn.addEventListener("click", () => { const isHidden = sidebar.classList.toggle("hidden"); resizeHandle.classList.toggle("hidden", isHidden); toggleBtn.classList.toggle("active", isHidden); localStorage.setItem("obsigate-sidebar-hidden", isHidden ? "true" : "false"); }); } // --------------------------------------------------------------------------- // Mobile sidebar // --------------------------------------------------------------------------- export function initMobile() { const hamburger = document.getElementById("hamburger-btn"); const overlay = document.getElementById("sidebar-overlay"); const sidebar = document.getElementById("sidebar"); hamburger.addEventListener("click", () => { sidebar.classList.toggle("mobile-open"); overlay.classList.toggle("active"); }); overlay.addEventListener("click", () => { sidebar.classList.remove("mobile-open"); overlay.classList.remove("active"); }); } export function closeMobileSidebar() { const sidebar = document.getElementById("sidebar"); const overlay = document.getElementById("sidebar-overlay"); if (sidebar) sidebar.classList.remove("mobile-open"); if (overlay) overlay.classList.remove("active"); } // --------------------------------------------------------------------------- // Resizable sidebar (horizontal) // --------------------------------------------------------------------------- export function initSidebarResize() { const handle = document.getElementById("sidebar-resize-handle"); const sidebar = document.getElementById("sidebar"); if (!handle || !sidebar) return; // Restore saved width const savedWidth = localStorage.getItem("obsigate-sidebar-width"); if (savedWidth) { sidebar.style.width = savedWidth + "px"; } let startX = 0; let startWidth = 0; function onMouseMove(e) { const newWidth = Math.min(500, Math.max(200, startWidth + (e.clientX - startX))); sidebar.style.width = newWidth + "px"; } function onMouseUp() { document.body.classList.remove("resizing"); handle.classList.remove("active"); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); localStorage.setItem("obsigate-sidebar-width", parseInt(sidebar.style.width)); } handle.addEventListener("mousedown", (e) => { e.preventDefault(); startX = e.clientX; startWidth = sidebar.getBoundingClientRect().width; document.body.classList.add("resizing"); handle.classList.add("active"); document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }); } // --------------------------------------------------------------------------- // Resizable tag section (vertical) // --------------------------------------------------------------------------- export function initTagResize() { const handle = document.getElementById("tag-resize-handle"); const tagSection = document.getElementById("tag-cloud-section"); if (!handle || !tagSection) return; // Restore saved height const savedHeight = localStorage.getItem("obsigate-tag-height"); if (savedHeight) { tagSection.style.height = savedHeight + "px"; } let startY = 0; let startHeight = 0; function onMouseMove(e) { // Dragging up increases height, dragging down decreases const newHeight = Math.min(400, Math.max(60, startHeight - (e.clientY - startY))); tagSection.style.height = newHeight + "px"; } function onMouseUp() { document.body.classList.remove("resizing-v"); handle.classList.remove("active"); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); localStorage.setItem("obsigate-tag-height", parseInt(tagSection.style.height)); } handle.addEventListener("mousedown", (e) => { e.preventDefault(); startY = e.clientY; startHeight = tagSection.getBoundingClientRect().height; document.body.classList.add("resizing-v"); handle.classList.add("active"); document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }); } // --------------------------------------------------------------------------- // Frontmatter Accent Card Builder // --------------------------------------------------------------------------- // Persist toggle state across document openings let _frontmatterOpen = false; export function buildFrontmatterCard(frontmatter) { // Helper: format date function formatDate(iso) { if (!iso) return "—"; const d = new Date(iso); const date = d.toISOString().slice(0, 10); const time = d.toTimeString().slice(0, 5); return `${date} · ${time}`; } // Extract boolean flags const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] })); // Toggle state (persisted across documents) let isOpen = _frontmatterOpen; // Build header with chevron const chevron = el("span", { class: "fm-chevron " + (isOpen ? "open" : "closed") }); chevron.innerHTML = ''; const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]); // ZONE 1: Top strip const topBadges = []; // Title badge const title = frontmatter.titre || frontmatter.title || ""; if (title) { topBadges.push(el("span", { class: "ac-title" }, [document.createTextNode(`"${title}"`)])); } // Status badge if (frontmatter.statut) { const statusBadge = el("span", { class: "ac-badge green" }, [el("span", { class: "ac-dot" }), document.createTextNode(frontmatter.statut)]); topBadges.push(statusBadge); } // Category badge if (frontmatter.catégorie || frontmatter.categorie) { const cat = frontmatter.catégorie || frontmatter.categorie; const catBadge = el("span", { class: "ac-badge blue" }, [document.createTextNode(cat)]); topBadges.push(catBadge); } // Publish badge if (frontmatter.publish) { topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("publié")])); } // Favoris badge if (frontmatter.favoris) { topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("favori")])); } const acTop = el("div", { class: "ac-top" }, topBadges); // ZONE 2: Body 2 columns const leftCol = el("div", { class: "ac-col" }, [ el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("auteur")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.auteur || "—")])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("catégorie")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.catégorie || frontmatter.categorie || "—")])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("statut")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.statut || "—")])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("aliases")]), el("span", { class: "ac-v muted" }, [document.createTextNode(frontmatter.aliases && frontmatter.aliases.length > 0 ? frontmatter.aliases.join(", ") : "[]")])]), ]); const rightCol = el("div", { class: "ac-col" }, [ el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("creation_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.creation_date))])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("modification_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.modification_date))])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("publish")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.publish || false))])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("favoris")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.favoris || false))])]), ]); const acBody = el("div", { class: "ac-body" }, [leftCol, rightCol]); // ZONE 3: Tags row const tagPills = []; if (frontmatter.tags && frontmatter.tags.length > 0) { frontmatter.tags.forEach((tag) => { tagPills.push(el("span", { class: "ac-tag" }, [document.createTextNode(tag)])); }); } const acTagsRow = el("div", { class: "ac-tags-row" }, [el("span", { class: "ac-tags-k" }, [document.createTextNode("tags")]), el("div", { class: "ac-tags-wrap" }, tagPills)]); // ZONE 4: Flags row const flagChips = []; booleanFlags.forEach((flag) => { const chipClass = flag.value ? "flag-chip on" : "flag-chip off"; flagChips.push(el("span", { class: chipClass }, [el("span", { class: "flag-dot" }), document.createTextNode(flag.key)])); }); const acFlagsRow = el("div", { class: "ac-flags-row" }, [el("span", { class: "ac-flags-k" }, [document.createTextNode("flags")]), ...flagChips]); // Assemble the card const acCard = el("div", { class: "ac-card" }, [acTop, acBody, acTagsRow, acFlagsRow]); // Set initial display based on persisted state if (!isOpen) acCard.style.display = "none"; // Toggle functionality fmHeader.addEventListener("click", () => { isOpen = !isOpen; _frontmatterOpen = isOpen; if (isOpen) { acCard.style.display = "block"; chevron.classList.remove("closed"); chevron.classList.add("open"); } else { acCard.style.display = "none"; chevron.classList.remove("open"); chevron.classList.add("closed"); } safeCreateIcons(); }); // Wrap in section const fmSection = el("div", { class: "fm-section" }, [fmHeader, acCard]); return fmSection; } // --------------------------------------------------------------------------- // File Operations Manager // --------------------------------------------------------------------------- const FileOperations = { showCreateDirectoryModal(vault, parentPath) { const overlay = this._createModalOverlay(); const modal = document.createElement('div'); modal.className = 'obsigate-modal'; modal.innerHTML = `

Créer un dossier

`; overlay.appendChild(modal); document.body.appendChild(overlay); setTimeout(() => overlay.classList.add('active'), 10); const input = modal.querySelector('#dir-name-input'); const errorDiv = modal.querySelector('#dir-error'); const createBtn = modal.querySelector('#dir-create-btn'); const cancelBtn = modal.querySelector('#dir-cancel-btn'); input.focus(); const validateName = (name) => { if (!name.trim()) return 'Le nom ne peut pas être vide'; if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; return null; }; input.addEventListener('input', () => { const error = validateName(input.value); if (error) { errorDiv.textContent = error; errorDiv.style.display = 'block'; input.classList.add('error'); } else { errorDiv.style.display = 'none'; input.classList.remove('error'); } }); const create = async () => { const name = input.value.trim(); const error = validateName(name); if (error) { errorDiv.textContent = error; errorDiv.style.display = 'block'; return; } const path = parentPath ? `${parentPath}/${name}` : name; createBtn.disabled = true; createBtn.textContent = t('modal.creating'); try { await api(`/api/directory/${encodeURIComponent(vault)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }), }); showToast(t('toast.dir_created', {name: name}), 'success'); this._closeModal(overlay); await refreshSidebarTreePreservingState(); } catch (err) { showToast(err.message || t('toast.create_error'), 'error'); createBtn.disabled = false; createBtn.textContent = t('modal.create_btn'); } }; createBtn.addEventListener('click', create); cancelBtn.addEventListener('click', () => this._closeModal(overlay)); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') create(); if (e.key === 'Escape') this._closeModal(overlay); }); }, showCreateFileModal(vault, parentPath, preselectExt = null) { const overlay = this._createModalOverlay(); const modal = document.createElement('div'); modal.className = 'obsigate-modal'; modal.innerHTML = `

${t('modal.create_file')}

`; overlay.appendChild(modal); document.body.appendChild(overlay); setTimeout(() => overlay.classList.add('active'), 10); const input = modal.querySelector('#file-name-input'); const extSelect = modal.querySelector('#file-ext-select'); const errorDiv = modal.querySelector('#file-error'); const createBtn = modal.querySelector('#file-create-btn'); const cancelBtn = modal.querySelector('#file-cancel-btn'); input.focus(); if (preselectExt && [...extSelect.options].some(o => o.value === preselectExt)) { extSelect.value = preselectExt; } const validateName = (name) => { if (!name.trim()) return 'Le nom ne peut pas être vide'; if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; return null; }; input.addEventListener('input', () => { const error = validateName(input.value); if (error) { errorDiv.textContent = error; errorDiv.style.display = 'block'; input.classList.add('error'); } else { errorDiv.style.display = 'none'; input.classList.remove('error'); } }); const create = async () => { let name = input.value.trim(); const error = validateName(name); if (error) { errorDiv.textContent = error; errorDiv.style.display = 'block'; return; } const ext = extSelect.value; if (!name.endsWith(ext)) { name += ext; } const path = parentPath ? `${parentPath}/${name}` : name; createBtn.disabled = true; createBtn.textContent = t('modal.creating'); try { await api(`/api/file/${encodeURIComponent(vault)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, content: '' }), }); showToast(t('toast.file_created', {name: name}), 'success'); this._closeModal(overlay); await refreshSidebarTreePreservingState(); openFile(vault, path); } catch (err) { showToast(err.message || t('toast.create_error'), 'error'); createBtn.disabled = false; createBtn.textContent = t('modal.create_btn'); } }; createBtn.addEventListener('click', create); cancelBtn.addEventListener('click', () => this._closeModal(overlay)); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') create(); if (e.key === 'Escape') this._closeModal(overlay); }); }, async startInlineRename(vault, path, type) { const item = document.querySelector(`.tree-item[data-vault="${CSS.escape(vault)}"][data-path="${CSS.escape(path)}"]`); if (!item) { showToast('Élément introuvable dans l’arborescence', 'error'); return; } // Find the text span (.tree-item-text) — tree items use spans, not direct text nodes const textSpan = item.querySelector('.tree-item-text'); if (!textSpan) { showToast(t('ui.cannot_rename'), 'error'); return; } const originalText = textSpan.textContent; const trimmedOriginal = originalText.trim(); const currentName = path.split('/').pop() || trimmedOriginal; const baseName = type === 'file' ? currentName.replace(/(\.[^./\\]+)$/i, '') : currentName; const extension = type === 'file' ? (currentName.match(/(\.[^./\\]+)$/i)?.[1] || '') : ''; const input = document.createElement('input'); input.type = 'text'; input.className = 'sidebar-item-input'; input.value = baseName; // Hide the text span, show input textSpan.style.display = 'none'; textSpan.insertAdjacentElement('afterend', input); const restore = () => { input.remove(); textSpan.style.display = ''; }; const validateName = (name) => { if (!name.trim()) return 'Le nom ne peut pas être vide'; if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; return null; }; const submit = async () => { const name = input.value.trim(); const error = validateName(name); if (error) { showToast(error, 'error'); input.focus(); input.select(); return; } const newName = `${name}${extension}`; if (newName === currentName) { restore(); return; } input.disabled = true; try { const endpoint = type === 'directory' ? `/api/directory/${encodeURIComponent(vault)}` : `/api/file/${encodeURIComponent(vault)}`; const result = await api(endpoint, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, new_name: newName }), }); const nextPath = result.new_path; await refreshSidebarTreePreservingState(); if (type === 'file' && state.currentVault === vault && state.currentPath === path) { await openFile(vault, nextPath); } else if (type === 'directory' && state.currentVault === vault && state.currentPath && (state.currentPath === path || currentPath.startsWith(`${path}/`))) { const suffix = state.currentPath === path ? '' : currentPath.slice(path.length); state.currentPath = `${nextPath}${suffix}`; await focusPathInSidebar(vault, state.currentPath, { alignToTop: false }); } showToast(type === 'directory' ? t('toast.dir_renamed') : t('toast.file_renamed'), 'success'); } catch (err) { input.disabled = false; showToast(err.message || t('toast.rename_error'), 'error'); input.focus(); input.select(); return; } }; input.addEventListener('click', (e) => e.stopPropagation()); input.addEventListener('keydown', async (e) => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); await submit(); } if (e.key === 'Escape') { e.preventDefault(); restore(); } }); input.addEventListener('blur', async () => { if (!input.disabled) { await submit(); } }); input.focus(); input.setSelectionRange(0, input.value.length); }, confirmDeleteDirectory(vault, path) { const overlay = this._createModalOverlay(); const modal = document.createElement('div'); modal.className = 'obsigate-modal'; modal.innerHTML = `

${t('modal.delete_dir')}

`; overlay.appendChild(modal); document.body.appendChild(overlay); setTimeout(() => overlay.classList.add('active'), 10); safeCreateIcons(); const confirmBtn = modal.querySelector('#del-confirm-btn'); const cancelBtn = modal.querySelector('#del-cancel-btn'); const deleteDir = async () => { confirmBtn.disabled = true; confirmBtn.textContent = t('modal.creating'); try { const result = await api(`/api/directory/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { method: 'DELETE', }); showToast(t('toast.dir_deleted', {count: result.deleted_count}), 'success'); this._closeModal(overlay); await refreshSidebarTreePreservingState(); if (state.currentVault === vault && state.currentPath && currentPath.startsWith(path)) { showWelcome(); } } catch (err) { showToast(err.message || t('toast.delete_error'), 'error'); confirmBtn.disabled = false; confirmBtn.textContent = t('modal.delete_permanently'); } }; confirmBtn.addEventListener('click', deleteDir); cancelBtn.addEventListener('click', () => this._closeModal(overlay)); }, confirmDeleteFile(vault, path) { const overlay = this._createModalOverlay(); const modal = document.createElement('div'); modal.className = 'obsigate-modal'; modal.innerHTML = `

${t('modal.delete_file')}

`; overlay.appendChild(modal); document.body.appendChild(overlay); setTimeout(() => overlay.classList.add('active'), 10); safeCreateIcons(); const confirmBtn = modal.querySelector('#del-confirm-btn'); const cancelBtn = modal.querySelector('#del-cancel-btn'); const deleteFile = async () => { confirmBtn.disabled = true; confirmBtn.textContent = t('modal.creating'); try { await api(`/api/file/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { method: 'DELETE', }); showToast(t('toast.file_deleted'), 'success'); this._closeModal(overlay); await refreshSidebarTreePreservingState(); if (state.currentVault === vault && state.currentPath === path) { showWelcome(); } } catch (err) { showToast(err.message || t('toast.delete_error'), 'error'); confirmBtn.disabled = false; confirmBtn.textContent = t('modal.delete_permanently'); } }; confirmBtn.addEventListener('click', deleteFile); cancelBtn.addEventListener('click', () => this._closeModal(overlay)); }, _createModalOverlay() { const overlay = document.createElement('div'); overlay.className = 'obsigate-modal-overlay'; overlay.addEventListener('click', (e) => { if (e.target === overlay) { this._closeModal(overlay); } }); return overlay; }, _closeModal(overlay) { overlay.classList.remove('active'); setTimeout(() => overlay.remove(), 200); } }; // --------------------------------------------------------------------------- // Generic themed dialogs (#154-A2) // --------------------------------------------------------------------------- // Promise-based replacements for window.confirm() / window.prompt() so the // Excel viewer's structure actions and lossy-write confirmations stay inside // the app theme (and are keyboard accessible) instead of native dialogs. function _closeDialog(overlay, resolve, value) { overlay.classList.remove("active"); if (overlay._onKey) document.removeEventListener("keydown", overlay._onKey); setTimeout(() => overlay.remove(), 200); resolve(value); } function _openDialog(innerHtml) { const overlay = document.createElement("div"); overlay.className = "obsigate-modal-overlay"; const modal = document.createElement("div"); modal.className = "obsigate-modal"; modal.setAttribute("role", "dialog"); modal.setAttribute("aria-modal", "true"); modal.innerHTML = innerHtml; overlay.appendChild(modal); document.body.appendChild(overlay); setTimeout(() => overlay.classList.add("active"), 10); return { overlay, modal }; } /** Themed replacement for window.confirm(). Resolves to a boolean. */ export function showConfirm({ title = "", message = "", confirmLabel = "", cancelLabel = "", danger = false } = {}) { return new Promise((resolve) => { const { overlay, modal } = _openDialog(`

${escapeHtml(title)}

`); const done = (v) => _closeDialog(overlay, resolve, v); overlay.addEventListener("click", (e) => { if (e.target === overlay) done(false); }); modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(true)); modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(false)); overlay._onKey = (e) => { if (e.key === "Escape") done(false); else if (e.key === "Enter") done(true); }; document.addEventListener("keydown", overlay._onKey); setTimeout(() => modal.querySelector('[data-dialog="confirm"]')?.focus(), 20); }); } /** Themed replacement for window.prompt(). Resolves to the string (or null). */ export function showPrompt({ title = "", message = "", value = "", placeholder = "", confirmLabel = "", cancelLabel = "" } = {}) { return new Promise((resolve) => { const { overlay, modal } = _openDialog(`

${escapeHtml(title)}

`); const input = modal.querySelector('[data-dialog="input"]'); const done = (v) => _closeDialog(overlay, resolve, v); overlay.addEventListener("click", (e) => { if (e.target === overlay) done(null); }); modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(input.value)); modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(null)); overlay._onKey = (e) => { if (e.key === "Escape") done(null); else if (e.key === "Enter") done(input.value); }; document.addEventListener("keydown", overlay._onKey); setTimeout(() => { input.focus(); input.select(); }, 20); }); } // --------------------------------------------------------------------------- // Find in Page Manager // --------------------------------------------------------------------------- export const FindInPageManager = { isOpen: false, searchTerm: "", matches: [], currentIndex: -1, options: { caseSensitive: false, wholeWord: false, useRegex: false, }, debounceTimer: null, previousFocus: null, init() { const bar = document.getElementById("find-in-page-bar"); const input = document.getElementById("find-input"); const prevBtn = document.getElementById("find-prev"); const nextBtn = document.getElementById("find-next"); const closeBtn = document.getElementById("find-close"); const caseSensitiveBtn = document.getElementById("find-case-sensitive"); const wholeWordBtn = document.getElementById("find-whole-word"); const regexBtn = document.getElementById("find-regex"); if (!bar || !input) return; // Keyboard shortcuts document.addEventListener("keydown", (e) => { // Ctrl+F or Cmd+F to open if ((e.ctrlKey || e.metaKey) && e.key === "f") { e.preventDefault(); this.open(); } // Escape to close if (e.key === "Escape" && this.isOpen) { e.preventDefault(); this.close(); } // Enter to go to next if (e.key === "Enter" && this.isOpen && document.activeElement === input) { e.preventDefault(); if (e.shiftKey) { this.goToPrevious(); } else { this.goToNext(); } } // F3 for next/previous if (e.key === "F3" && this.isOpen) { e.preventDefault(); if (e.shiftKey) { this.goToPrevious(); } else { this.goToNext(); } } }); // Input event with debounce input.addEventListener("input", (e) => { clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => { this.search(e.target.value); }, 250); }); // Navigation buttons prevBtn.addEventListener("click", () => this.goToPrevious()); nextBtn.addEventListener("click", () => this.goToNext()); // Close button closeBtn.addEventListener("click", () => this.close()); // Option toggles caseSensitiveBtn.addEventListener("click", () => { this.options.caseSensitive = !this.options.caseSensitive; caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive); this.saveState(); if (this.searchTerm) this.search(this.searchTerm); }); wholeWordBtn.addEventListener("click", () => { this.options.wholeWord = !this.options.wholeWord; wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord); this.saveState(); if (this.searchTerm) this.search(this.searchTerm); }); regexBtn.addEventListener("click", () => { this.options.useRegex = !this.options.useRegex; regexBtn.setAttribute("aria-pressed", this.options.useRegex); this.saveState(); if (this.searchTerm) this.search(this.searchTerm); }); // Load saved state this.loadState(); }, open() { const bar = document.getElementById("find-in-page-bar"); const input = document.getElementById("find-input"); if (!bar || !input) return; this.previousFocus = document.activeElement; this.isOpen = true; bar.hidden = false; input.focus(); input.select(); safeCreateIcons(); }, close() { const bar = document.getElementById("find-in-page-bar"); if (!bar) return; this.isOpen = false; bar.hidden = true; this.clearHighlights(); this.matches = []; this.currentIndex = -1; this.searchTerm = ""; // Restore previous focus if (this.previousFocus && this.previousFocus.focus) { this.previousFocus.focus(); } }, search(term) { this.searchTerm = term; this.clearHighlights(); this.hideError(); if (!term || term.trim().length === 0) { this.updateCounter(); this.updateNavButtons(); return; } const contentArea = document.querySelector(".md-content"); if (!contentArea) { this.updateCounter(); this.updateNavButtons(); return; } try { const regex = this.createRegex(term); this.matches = []; this.findMatches(contentArea, regex); this.currentIndex = this.matches.length > 0 ? 0 : -1; this.highlightMatches(); this.updateCounter(); this.updateNavButtons(); if (this.matches.length > 0) { this.scrollToMatch(0); } } catch (err) { this.showError(err.message); this.matches = []; this.currentIndex = -1; this.updateCounter(); this.updateNavButtons(); } }, createRegex(term) { let pattern = term; if (!this.options.useRegex) { // Escape special regex characters pattern = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } if (this.options.wholeWord) { pattern = "\\b" + pattern + "\\b"; } const flags = this.options.caseSensitive ? "g" : "gi"; return new RegExp(pattern, flags); }, findMatches(container, regex) { const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { // Skip code blocks, scripts, styles const parent = node.parentElement; if (!parent) return NodeFilter.FILTER_REJECT; const tagName = parent.tagName.toLowerCase(); if (["code", "pre", "script", "style"].includes(tagName)) { return NodeFilter.FILTER_REJECT; } // Skip empty text nodes if (!node.textContent || node.textContent.trim().length === 0) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; }, }); let node; while ((node = walker.nextNode())) { const text = node.textContent; let match; regex.lastIndex = 0; // Reset regex while ((match = regex.exec(text)) !== null) { this.matches.push({ node: node, index: match.index, length: match[0].length, text: match[0], }); // Prevent infinite loop with zero-width matches if (match.index === regex.lastIndex) { regex.lastIndex++; } } } }, highlightMatches() { const matchesByNode = new Map(); this.matches.forEach((match, idx) => { if (!matchesByNode.has(match.node)) { matchesByNode.set(match.node, []); } matchesByNode.get(match.node).push({ match, idx }); }); matchesByNode.forEach((entries, node) => { if (!node || !node.parentNode) return; const text = node.textContent || ""; let cursor = 0; const fragment = document.createDocumentFragment(); entries.sort((a, b) => a.match.index - b.match.index); entries.forEach(({ match, idx }) => { if (match.index > cursor) { fragment.appendChild(document.createTextNode(text.substring(cursor, match.index))); } const matchText = text.substring(match.index, match.index + match.length); const mark = document.createElement("mark"); mark.className = idx === this.currentIndex ? "find-highlight find-highlight-active" : "find-highlight"; mark.textContent = matchText; mark.setAttribute("data-find-index", idx); fragment.appendChild(mark); match.element = mark; cursor = match.index + match.length; }); if (cursor < text.length) { fragment.appendChild(document.createTextNode(text.substring(cursor))); } node.parentNode.replaceChild(fragment, node); }); }, clearHighlights() { const contentArea = document.querySelector(".md-content"); if (!contentArea) return; const marks = contentArea.querySelectorAll("mark.find-highlight"); marks.forEach((mark) => { if (!mark.parentNode) return; const text = mark.textContent; const textNode = document.createTextNode(text); mark.parentNode.replaceChild(textNode, mark); }); // Normalize text nodes to merge adjacent text nodes contentArea.normalize(); }, goToNext() { if (this.matches.length === 0) return; // Remove active class from current if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) { this.matches[this.currentIndex].element.classList.remove("find-highlight-active"); } // Move to next (with wrapping) this.currentIndex = (this.currentIndex + 1) % this.matches.length; // Add active class to new current if (this.matches[this.currentIndex].element) { this.matches[this.currentIndex].element.classList.add("find-highlight-active"); } this.scrollToMatch(this.currentIndex); this.updateCounter(); }, goToPrevious() { if (this.matches.length === 0) return; // Remove active class from current if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) { this.matches[this.currentIndex].element.classList.remove("find-highlight-active"); } // Move to previous (with wrapping) this.currentIndex = this.currentIndex <= 0 ? this.matches.length - 1 : this.currentIndex - 1; // Add active class to new current if (this.matches[this.currentIndex].element) { this.matches[this.currentIndex].element.classList.add("find-highlight-active"); } this.scrollToMatch(this.currentIndex); this.updateCounter(); }, scrollToMatch(index) { if (index < 0 || index >= this.matches.length) return; const match = this.matches[index]; if (!match.element) return; const contentArea = document.getElementById("content-area"); if (!contentArea) { match.element.scrollIntoView({ behavior: "smooth", block: "center" }); return; } // Calculate position with offset for header const elementTop = match.element.offsetTop; const offset = 100; // Offset for header contentArea.scrollTo({ top: elementTop - offset, behavior: "smooth", }); }, updateCounter() { const counter = document.getElementById("find-counter"); if (!counter) return; const count = this.matches.length; if (count === 0) { counter.textContent = "0 occurrence"; } else if (count === 1) { counter.textContent = "1 occurrence"; } else { counter.textContent = `${count} occurrences`; } }, updateNavButtons() { const prevBtn = document.getElementById("find-prev"); const nextBtn = document.getElementById("find-next"); if (!prevBtn || !nextBtn) return; const hasMatches = this.matches.length > 0; prevBtn.disabled = !hasMatches; nextBtn.disabled = !hasMatches; }, showError(message) { const errorEl = document.getElementById("find-error"); if (!errorEl) return; errorEl.textContent = message; errorEl.hidden = false; }, hideError() { const errorEl = document.getElementById("find-error"); if (!errorEl) return; errorEl.hidden = true; }, saveState() { try { const state = { options: this.options, }; localStorage.setItem("obsigate-find-in-page-state", JSON.stringify(state)); } catch (e) { // Ignore localStorage errors } }, loadState() { try { const saved = localStorage.getItem("obsigate-find-in-page-state"); if (saved) { const state = JSON.parse(saved); if (state.options) { this.options = { ...this.options, ...state.options }; // Update button states const caseSensitiveBtn = document.getElementById("find-case-sensitive"); const wholeWordBtn = document.getElementById("find-whole-word"); const regexBtn = document.getElementById("find-regex"); if (caseSensitiveBtn) caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive); if (wholeWordBtn) wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord); if (regexBtn) regexBtn.setAttribute("aria-pressed", this.options.useRegex); } } } catch (e) { // Ignore localStorage errors } }, }; // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- async function init() { initTheme(); initHeaderMenu(); initCustomDropdowns(); document.getElementById("theme-toggle").addEventListener("click", toggleTheme); document.getElementById("header-logo").addEventListener("click", goHome); const refreshBtn = document.getElementById("header-refresh-btn"); if (refreshBtn) refreshBtn.addEventListener("click", goHome); initSearch(); initSidebarToggle(); initMobile(); initVaultContext(); initSidebarTabs(); initHelpModal(); initConfigModal(); initSidebarFilter(); initSidebarResize(); initEditor(); initLoginForm(); initRecentTab(); RightSidebarManager.init(); FindInPageManager.init(); ContextMenuManager.init(); // Check auth status first const authOk = await AuthManager.initAuth(); if (authOk) { // Start SSE sync AFTER auth is established (cookie available) initSyncStatus(); try { await Promise.all([loadVaultSettings(), loadVaults(), loadTags()]); // Initialize dashboard widgets now that vaults are loaded if (typeof DashboardRecentWidget !== "undefined") { DashboardRecentWidget.init(); } // Check for popup mode query parameter const urlParams = new URLSearchParams(window.location.search); if (urlParams.get("popup") === "true") { document.body.classList.add("popup-mode"); } // Handle direct deep-link to file via #file=... if (window.location.hash && window.location.hash.startsWith("#file=")) { const hashVal = window.location.hash.substring(6); const sepIndex = hashVal.indexOf(":"); if (sepIndex > -1) { const vault = decodeURIComponent(hashVal.substring(0, sepIndex)); const path = decodeURIComponent(hashVal.substring(sepIndex + 1)); openFile(vault, path); } } else if (urlParams.get("popup") !== "true") { // Default to dashboard if no deep link and not in popup mode showWelcome(); } } catch (err) { console.error("Failed to initialize ObsiGate:", err); showToast(t("error.initialization"), "error"); } } safeCreateIcons(); } // ---- Keyboard shortcuts for tabs ---- document.addEventListener("keydown", (e) => { if (e.ctrlKey || e.metaKey) { if (e.key === "w" || e.key === "W") { e.preventDefault(); if (TabManager._activeTabId) { TabManager.close(TabManager._activeTabId); } } else if (e.key === "Tab" && !e.shiftKey) { e.preventDefault(); const tabs = TabManager._tabs; const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId); if (currentIdx >= 0 && tabs.length > 1) { const nextIdx = (currentIdx + 1) % tabs.length; TabManager.activate(tabs[nextIdx].id); } } else if (e.key === "Tab" && e.shiftKey) { e.preventDefault(); const tabs = TabManager._tabs; const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId); if (currentIdx >= 0 && tabs.length > 1) { const prevIdx = (currentIdx - 1 + tabs.length) % tabs.length; TabManager.activate(tabs[prevIdx].id); } } } }); // ===== MISSING MANAGERS (extracted separately) ===== export const ContextMenuManager = { _menu: null, _targetElement: null, _targetVault: null, _targetPath: null, _targetType: null, init() { this._menu = document.createElement('div'); this._menu.className = 'context-menu'; this._menu.id = 'context-menu'; document.body.appendChild(this._menu); document.addEventListener('click', () => this.hide()); document.addEventListener('contextmenu', (e) => { if (!e.target.closest('.tree-item')) { this.hide(); } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') this.hide(); }); document.addEventListener('scroll', () => { // Un scroll déclenché juste après l'ouverture vient de la reflow des // icônes (lucide) : on ne referme pas le menu qui vient d'apparaître. if (performance.now() - (this._shownAt || 0) < 250) return; this.hide(); }, true); }, show(x, y, vault, path, type, isReadonly) { this._targetVault = vault; this._targetPath = path; this._targetType = type; this._menu.innerHTML = ''; // Copy path — available for all types const pathToCopy = type === 'vault' ? vault : `${vault}/${path}`; this._addItem('clipboard-copy', 'Copier le chemin', () => this._copyPath(pathToCopy), false); // Graph view — available for all types const graphPath = type === 'vault' ? '' : path; this._addItem('git-graph', 'Vue Graphique', () => GraphViewManager.open(vault, graphPath, type), false); this._addSeparator(); if (type === 'vault') { this._addItem('folder-plus', 'Nouveau dossier', () => this._createDirectory(), isReadonly); this._addItem('file-plus', 'Nouveau fichier', () => this._createFile(), isReadonly); this._addSeparator(); this._addItem('brain', '🧠 BooksLM', () => { import('./bookslm.js').then(m => m.default.open(this._targetVault, '')); }, false); // #159 — retrait d'un vault ou dossier racine : desktop uniquement, // déregistration seule (aucun fichier supprimé), confirmation exigée. if (isTauriEnv()) { this._addSeparator(); this._addItem('folder-minus', t('desktop.remove_root'), () => { const name = this._targetVault; if (name) removeRoot(name, t('desktop.remove_root_confirm', { name: name })); }, false); } } else if (type === 'directory') { this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly); this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly); this._addItem('pen-tool', 'Nouveau diagramme Excalidraw', () => this._createExcalidraw(), isReadonly); this._addSeparator(); this._addItem('bookmark-plus', 'Ajouter aux recherches sauvegardees', () => this._saveDirectorySearch(), false); this._addSeparator(); this._addItem('brain', '🧠 BooksLM', () => { import('./bookslm.js').then(m => m.default.open(this._targetVault, this._targetPath)); }, false); this._addSeparator(); this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly); } else if (type === 'file') { this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly); this._addSeparator(); this._addItem('bookmark-plus', t("viewer.bookmark_add"), () => this._toggleBookmark(), false); this._addSeparator(); this._addItem('history', t("ui.backups_versions"), () => this._openBackupViewer(), false); } this._menu.classList.add('active'); this._shownAt = performance.now(); const rect = this._menu.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let finalX = x; let finalY = y; if (x + rect.width > viewportWidth) { finalX = viewportWidth - rect.width - 10; } if (y + rect.height > viewportHeight) { finalY = viewportHeight - rect.height - 10; } this._menu.style.left = `${finalX}px`; this._menu.style.top = `${finalY}px`; safeCreateIcons(); }, hide() { if (this._menu) { this._menu.classList.remove('active'); } }, _addItem(icon, label, callback, disabled) { const item = document.createElement('div'); item.className = 'context-menu-item' + (disabled ? ' disabled' : ''); item.innerHTML = ` ${label} `; if (!disabled) { item.addEventListener('click', (e) => { e.stopPropagation(); this.hide(); callback(); }); } else { item.title = t("ui.vault_readonly"); } this._menu.appendChild(item); }, _addSeparator() { const sep = document.createElement('div'); sep.className = 'context-menu-separator'; this._menu.appendChild(sep); }, _createDirectory() { FileOperations.showCreateDirectoryModal(this._targetVault, this._targetPath); }, _createFile() { FileOperations.showCreateFileModal(this._targetVault, this._targetPath); }, _createExcalidraw() { FileOperations.showCreateFileModal(this._targetVault, this._targetPath, '.excalidraw'); }, _renameItem() { FileOperations.startInlineRename(this._targetVault, this._targetPath, this._targetType); }, _deleteDirectory() { FileOperations.confirmDeleteDirectory(this._targetVault, this._targetPath); }, _deleteFile() { FileOperations.confirmDeleteFile(this._targetVault, this._targetPath); }, _copyPath(path) { // Try modern clipboard API first, fall back to execCommand for non-secure contexts if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(path).then(() => { showToast(t('toast.path_copied', {path: path}), 'success'); }).catch(() => { this._copyPathFallback(path); }); } else { this._copyPathFallback(path); } }, _copyPathFallback(path) { const textarea = document.createElement('textarea'); textarea.value = path; textarea.style.position = 'fixed'; textarea.style.left = '-9999px'; textarea.style.top = '-9999px'; document.body.appendChild(textarea); textarea.focus(); textarea.select(); try { const success = document.execCommand('copy'); if (success) { showToast(t('toast.path_copied', {path: path}), 'success'); } else { showToast(t('toast.copy_error'), 'error'); } } catch (e) { showToast(t('toast.copy_error'), 'error'); } document.body.removeChild(textarea); }, async _toggleBookmark() { try { const data = await api("/api/bookmarks/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vault: this._targetVault, path: this._targetPath, title: this._targetPath.split("/").pop() }), }); showToast(data.bookmarked ? t("toast.bookmark_added") : t("toast.bookmark_removed"), "success"); if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) { DashboardBookmarkWidget.load(); } } catch (err) { showToast("Erreur: " + err.message, "error"); } }, async _saveDirectorySearch() { try { var vault = this._targetVault || "all"; var dirPath = this._targetPath; var dirName = dirPath.split("/").pop() || dirPath; await api("/api/saved-searches", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: "", vault: vault, case_sensitive: false, whole_word: false, regex: false, include_paths: dirPath + "/**", exclude_paths: "", }), }); showToast(t("toast.dir_saved_search"), "success"); // Switch to saved searches tab var savedTab = document.getElementById("sidebar-tab-saved"); if (savedTab) savedTab.click(); // Reload saved searches import('./viewer.js').then(function(m) { m.loadSavedSearches(); }); } catch (err) { showToast("Erreur: " + err.message, "error"); } }, _openBackupViewer() { // Dynamically import the backup viewer module to avoid circular deps import('./backups.js').then(mod => { mod.openBackupViewer(this._targetVault, this._targetPath); }).catch(err => { showToast(t("toast.viewer_error") + ": " + err.message, "error"); }); this.hide(); } }; export const TabManager = { _tabs: [], _activeTabId: null, _previewTabId: null, // single-click preview tab (temporary, replaced on next preview) _tabCache: {}, // { tabId: { vault, path, title, data, rawSource, sourceView, scrollTop, icon } } _tabBar: null, _tabList: null, _dirtyTabs: new Set(), init() { this._tabBar = document.getElementById("tab-bar"); this._tabList = document.getElementById("tab-list"); // Expose on window so pane-manager.js can reference the singleton window.TabManager = this; window._singletonTabManager = this; }, /** Open a file as a preview tab (single-click). * Replaces any existing preview tab. If the file is already * open as a persistent tab, just activates it. */ async openPreview(vault, path) { // Delegate to active pane's TabManager when split if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) { return window.getActiveTabManager().openPreview(vault, path); } const tabId = `${vault}::${path}`; // If already open as persistent tab, just activate it const existing = this._tabs.find(t => t.id === tabId && !t.preview); if (existing) { this.activate(tabId); return; } // Close existing preview tab if (this._previewTabId && this._previewTabId !== tabId) { this.close(this._previewTabId); } // If already open as preview, just focus it const previewExisting = this._tabs.find(t => t.id === tabId && t.preview); if (previewExisting) { this.activate(tabId); return; } // Create preview tab const name = path.split("/").pop().replace(/\.md$/i, ""); const icon = getFileIcon(name + ".md"); this._tabs.push({ id: tabId, vault, path, name, icon, preview: true }); this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon }; this._previewTabId = tabId; this._renderTabs(); this.activate(tabId); }, /** Convert a preview tab to a persistent tab (double-click). * If already persistent, opens a new duplicate (same file, different tab). */ async openPersistent(vault, path) { // Delegate to active pane's TabManager when split if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) { return window.getActiveTabManager().openPersistent(vault, path); } const tabId = `${vault}::${path}`; // If it's already a preview tab, convert it to persistent const previewTab = this._tabs.find(t => t.id === tabId && t.preview); if (previewTab) { previewTab.preview = false; if (this._previewTabId === tabId) { this._previewTabId = null; } this._renderTabs(); this.activate(tabId); return; } // If already persistent, just focus it const existing = this._tabs.find(t => t.id === tabId && !t.preview); if (existing) { this.activate(tabId); return; } // Create a new persistent tab this.open(vault, path); }, /** Open a file in a tab (or focus existing) */ async open(vault, path, options = {}) { const tabId = `${vault}::${path}`; // If already open, just focus it const existing = this._tabs.find(t => t.id === tabId); if (existing) { // Convert preview to persistent if needed if (existing.preview) { existing.preview = false; if (this._previewTabId === tabId) this._previewTabId = null; this._renderTabs(); } this.activate(tabId); return; } // Create new tab const name = path.split("/").pop().replace(/\.md$/i, ""); const icon = getFileIcon(name + ".md"); this._tabs.push({ id: tabId, vault, path, name, icon }); this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon }; this._renderTabs(); this.activate(tabId); }, /** #110 — ouvre une image jointe au chat dans un onglet, avec le même * visualiseur que pour une image de vault (zoom/pan/plein écran). La pièce * jointe est servie par ``/api/chat/attachment/`` : pas de couple * (vault, path) derrière, donc onglet dédié qui rend le viewer directement * depuis l'URL (aucun fetch de l'API fichiers). * ponytail: l'onglet reste sur le TabManager singleton (les PaneTabManager * n'ont pas cette branche) — suffisant pour un cas limite du mode split. */ async openChatImage(att) { if (!att || !att.url) return; const tabId = `chatimg::${att.url}`; const name = att.name || "image"; const existing = this._tabs.find(t => t.id === tabId); if (existing) { this.activate(tabId); return; } this._tabs.push({ id: tabId, vault: null, path: name, name, icon: "image", chatImage: true }); this._tabCache[tabId] = { chatImage: true, url: att.url, mime: att.mime, name, title: name }; this._renderTabs(); this.activate(tabId); }, /** Open (or retarget) the navigation tab for a vault directory (#158). * One navigation tab per vault: clicking another directory reuses it and * updates its directory, so open file tabs are never displaced. */ async openNav(vault, dir = "") { if (!vault || vault === "all") return; dir = dir || ""; const tabId = `nav::${vault}`; const name = dir ? dir.split("/").pop() : vault; const existing = this._tabs.find(t => t.id === tabId); if (existing) { existing.path = dir; existing.name = name; this._tabCache[tabId].path = dir; this._tabCache[tabId].title = name; // Force a re-render when it is already the active tab if (this._activeTabId === tabId) this._activeTabId = null; this._renderTabs(); this.activate(tabId); return; } this._tabs.push({ id: tabId, vault, path: dir, name, icon: "folder", nav: true }); this._tabCache[tabId] = { vault, path: dir, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: "folder", nav: true }; this._renderTabs(); this.activate(tabId); }, /** Onglet Accueil (#158) : la page d'accueil vit dans un onglet épinglé tout * à gauche de la barre. Recréé (et remonté en position 0) à chaque clic sur * le titre. */ async openHome() { // En split, l'onglet appartient au panneau actif if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) { return window.getActiveTabManager().openHome(t("common.home")); } const tabId = "home"; const idx = this._tabs.findIndex(t => t.id === tabId); if (idx > 0) { const [tab] = this._tabs.splice(idx, 1); this._tabs.unshift(tab); // toujours en tête de barre } else if (idx === -1) { const name = t("common.home"); this._tabs.unshift({ id: tabId, vault: null, path: "", name, icon: "home", home: true }); this._tabCache[tabId] = { home: true, vault: null, path: "", title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: "home" }; } this._renderTabs(); this.activate(tabId); }, /** Drop the active-tab marker — the dashboard/home page is showing. * Keeps the tabs open; clicking one re-activates (and re-renders) it. * L'onglet Accueil, lui, reste l'onglet actif quand la page d'accueil * s'affiche (il EST la page d'accueil). */ deactivate() { const active = this._activeTabId && this._tabCache[this._activeTabId]; if (active && active.home) { this._renderTabs(); return; } this._activeTabId = null; this._renderTabs(); }, /** Activate a specific tab */ async activate(tabId) { if (this._activeTabId === tabId && this._tabs.length > 0) return; // #93 — an inline edition session owns this content area: release it before // the load placeholder / read view wipes it off the DOM. detachInlineEditor(); // Save current tab state if (this._activeTabId && this._tabCache[this._activeTabId]) { this._saveCurrentTabState(); } this._activeTabId = tabId; this._renderTabs(); // Load tab content const cache = this._tabCache[tabId]; if (!cache) return; // Onglet Accueil (#158) — la page d'accueil vit dans l'onglet if (cache.home) { state.currentVault = null; state.currentPath = null; showWelcome(); // conserve l'onglet Accueil actif (deactivate()) if (history.pushState) { history.pushState(null, "", "#"); } return; } // Navigation tab — render the directory view instead of fetching a file if (cache.nav) { state.currentVault = cache.vault; state.currentPath = cache.path || null; const navMod = await import("./vaulthome.js"); await navMod.showVaultHome(cache.vault, cache.path); if (history.pushState) { history.pushState(null, "", `#/nav/${encodeURIComponent(cache.vault)}/${encodeURIComponent(cache.path)}`); } return; } // #110 — onglet image de chat : rendu direct depuis l'URL de la pièce // jointe, sans passer par l'API fichiers (l'image n'est pas dans un vault). if (cache.chatImage) { state.currentVault = null; state.currentPath = null; const chatArea = document.getElementById("content-area"); if (!chatArea) return; renderImageViewer(chatArea, { vault: null, path: cache.name, title: cache.name, url: cache.url, image_mime: cache.mime, }); const dash = document.getElementById("dashboard-home"); if (dash) dash.style.display = "none"; return; } // Update global state state.currentVault = cache.vault; state.currentPath = cache.path; syncActiveFileTreeItem(cache.vault, cache.path); const area = document.getElementById("content-area"); if (cache.data) { // Use cached data this._restoreTabContent(cache, area); } else { // Fetch file content area.innerHTML = '
Chargement...
'; try { const data = await api(`/api/file/${encodeURIComponent(cache.vault)}?path=${encodeURIComponent(cache.path)}`); cache.data = data; cache.title = data.title; renderFile(cache.data); // Restore source view if needed if (cache.sourceView) { await this._toggleSourceView(cache, area); } if (cache.scrollTop) { area.scrollTop = cache.scrollTop; } } catch (err) { area.innerHTML = `
Erreur: ${escapeHtml(err.message)}
`; } } // Update URL hash if (history.pushState) { history.pushState(null, "", `#/file/${encodeURIComponent(cache.vault)}/${encodeURIComponent(cache.path)}`); } // Hide dashboard const dashboard = document.getElementById("dashboard-home"); if (dashboard) dashboard.style.display = "none"; }, /** Close a tab */ close(tabId) { // Delegate to active pane's TabManager when split if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) { return window.getActiveTabManager().close(tabId); } const idx = this._tabs.findIndex(t => t.id === tabId); if (idx === -1) return; const closingTab = this._tabs[idx]; this._tabs.splice(idx, 1); delete this._tabCache[tabId]; this._dirtyTabs.delete(tabId); if (window.NowPlaying && closingTab) { window.NowPlaying.notifyTabClosed(closingTab.vault, closingTab.path); } if (this._tabs.length === 0) { this._activeTabId = null; this._showDashboard(); if (this._tabBar) this._tabBar.hidden = true; } else if (this._activeTabId === tabId) { // Activate adjacent tab const newIdx = Math.min(idx, this._tabs.length - 1); this.activate(this._tabs[newIdx].id); } this._renderTabs(); }, /** Close all tabs */ closeAll() { if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) { return window.getActiveTabManager().closeAll(); } this._tabs = []; this._tabCache = {}; this._dirtyTabs.clear(); this._activeTabId = null; this._showDashboard(); if (this._tabBar) this._tabBar.hidden = true; }, /** Close tabs to the right */ closeRight(tabId) { if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) { return window.getActiveTabManager().closeRight(tabId); } const idx = this._tabs.findIndex(t => t.id === tabId); if (idx === -1) return; const toClose = this._tabs.slice(idx + 1); for (const tab of toClose) { delete this._tabCache[tab.id]; this._dirtyTabs.delete(tab.id); } this._tabs = this._tabs.slice(0, idx + 1); if (!this._tabs.find(t => t.id === this._activeTabId)) { this.activate(tabId); } this._renderTabs(); }, /** Close other tabs */ closeOthers(tabId) { if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) { return window.getActiveTabManager().closeOthers(tabId); } const tab = this._tabs.find(t => t.id === tabId); if (!tab) return; for (const t of this._tabs) { if (t.id !== tabId) { delete this._tabCache[t.id]; this._dirtyTabs.delete(t.id); } } this._tabs = [tab]; this.activate(tabId); this._renderTabs(); }, /** Reorder tabs by drag and drop */ moveTab(fromIdx, toIdx) { if (fromIdx === toIdx || fromIdx < 0 || toIdx < 0) return; const tab = this._tabs.splice(fromIdx, 1)[0]; this._tabs.splice(toIdx, 0, tab); this._renderTabs(); }, /** Save current tab state before switching */ _saveCurrentTabState() { const cache = this._tabCache[this._activeTabId]; if (!cache) return; const area = document.getElementById("content-area"); const rendered = document.getElementById("file-rendered-content"); cache.scrollTop = area.scrollTop; cache.sourceView = rendered ? rendered.style.display === "none" : false; }, /** Restore tab content from cache */ _restoreTabContent(cache, area) { renderFile(cache.data); if (cache.sourceView) { this._restoreSourceView(cache, area); } if (cache.scrollTop) { area.scrollTop = cache.scrollTop; } }, async _toggleSourceView(cache, area) { const rendered = document.getElementById("file-rendered-content"); const raw = document.getElementById("file-raw-content"); if (!rendered || !raw) return; if (!cache.rawSource) { const rawData = await api(`/api/file/${encodeURIComponent(cache.vault)}/raw?path=${encodeURIComponent(cache.path)}`); cache.rawSource = rawData.raw; } raw.textContent = cache.rawSource; rendered.style.display = "none"; raw.style.display = "block"; }, _restoreSourceView(cache, area) { requestAnimationFrame(() => { const rendered = document.getElementById("file-rendered-content"); const raw = document.getElementById("file-raw-content"); if (rendered && raw && cache.rawSource) { raw.textContent = cache.rawSource; rendered.style.display = "none"; raw.style.display = "block"; } }); }, _showDashboard() { const area = document.getElementById("content-area"); // #110 — move any playing media to the persistent dock before wiping. if (window.NowPlaying && area) window.NowPlaying.handleRender(area, null); // Save dashboard DOM before clearing (it may have been removed from DOM by renderFile) let dashboard = document.getElementById("dashboard-home"); if (!dashboard) { // Dashboard was destroyed — rebuild via showWelcome area.innerHTML = ""; showWelcome(); return; } area.innerHTML = ""; dashboard.style.display = ""; area.appendChild(dashboard); // Refresh widgets after restoring if (typeof DashboardStatsWidget !== "undefined") DashboardStatsWidget.load(); if (typeof DashboardConflictsWidget !== "undefined") DashboardConflictsWidget.load(); if (typeof DashboardRecentWidget !== "undefined") DashboardRecentWidget.load(state.selectedContextVault); if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load(state.selectedContextVault); if (history.pushState) { history.pushState(null, "", "#"); } }, /** Render the tab bar */ _renderTabs() { if (!this._tabList) return; this._tabList.innerHTML = ""; if (this._tabs.length === 0) { this._tabBar.hidden = true; return; } this._tabBar.hidden = false; this._tabs.forEach((tab, idx) => { const el = document.createElement("div"); el.className = "tab-item" + (tab.id === this._activeTabId ? " active" : "") + (tab.preview ? " preview" : ""); el.draggable = true; el.dataset.tabId = tab.id; el.dataset.index = idx; // Icon const iconEl = document.createElement("i"); iconEl.setAttribute("data-lucide", tab.icon); iconEl.className = "tab-icon"; iconEl.style.width = "14px"; iconEl.style.height = "14px"; el.appendChild(iconEl); // Name const nameEl = document.createElement("span"); nameEl.className = "tab-name"; nameEl.textContent = tab.name; nameEl.title = `${tab.vault}/${tab.path}`; el.appendChild(nameEl); // Close button const closeEl = document.createElement("span"); closeEl.className = "tab-close"; closeEl.innerHTML = ''; closeEl.addEventListener("click", (e) => { e.stopPropagation(); this.close(tab.id); }); el.appendChild(closeEl); // Click to activate el.addEventListener("click", () => this.activate(tab.id)); // Double-click to close el.addEventListener("dblclick", (e) => { e.preventDefault(); this.close(tab.id); }); // Middle-click to close el.addEventListener("mousedown", (e) => { if (e.button === 1) { e.preventDefault(); this.close(tab.id); } }); // Context menu on tab el.addEventListener("contextmenu", (e) => { e.preventDefault(); this._showTabContextMenu(e.clientX, e.clientY, tab.id); }); // Drag and drop — JSON format for cross-pane compatibility el.addEventListener("dragstart", (e) => { const payload = JSON.stringify({ paneId: 0, tabId: tab.id, index: idx }); e.dataTransfer.setData("text/plain", payload); e.dataTransfer.effectAllowed = "move"; el.classList.add("dragging"); }); el.addEventListener("dragend", () => { el.classList.remove("dragging"); document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove()); }); el.addEventListener("dragover", (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; const rect = el.getBoundingClientRect(); const mid = rect.left + rect.width / 2; document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove()); const indicator = document.createElement("div"); indicator.className = "tab-drop-indicator"; if (e.clientX < mid) { el.before(indicator); } else { el.after(indicator); } }); el.addEventListener("drop", (e) => { e.preventDefault(); document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove()); const raw = e.dataTransfer.getData("text/plain"); if (!raw) return; let dragData; try { dragData = JSON.parse(raw); } catch (e2) { // Legacy format: plain index string const fromIdx = parseInt(raw); if (isNaN(fromIdx)) return; const rect = el.getBoundingClientRect(); const mid = rect.left + rect.width / 2; const toIdx = e.clientX < mid ? idx : idx + 1; if (fromIdx !== toIdx && fromIdx !== toIdx - 1) this.moveTab(fromIdx, toIdx); return; } if (dragData.paneId === 0) { // Same pane — reorder const rect = el.getBoundingClientRect(); const mid = rect.left + rect.width / 2; const toIdx = e.clientX < mid ? idx : idx + 1; if (dragData.index !== toIdx && dragData.index !== toIdx - 1) this.moveTab(dragData.index, toIdx); } // Cross-pane transfers handled by PaneManager's container drop zone }); this._tabList.appendChild(el); }); safeCreateIcons(); }, _showTabContextMenu(x, y, tabId) { const existing = document.getElementById("tab-context-menu"); if (existing) existing.remove(); const menu = document.createElement("div"); menu.id = "tab-context-menu"; menu.className = "context-menu active"; menu.style.left = x + "px"; menu.style.top = y + "px"; menu.innerHTML = `
Diviser à droite
Diviser en bas
Fermer
Fermer les autres
Fermer à droite
Fermer tout
${window.PaneManager && window.PaneManager.isSplit() ? '
Fermer le panneau
' : ''} `; document.body.appendChild(menu); safeCreateIcons(); menu.addEventListener("click", (e) => { const action = e.target.closest(".context-menu-item")?.dataset.action; if (action === "splitRight") { if (window.PaneManager && window.PaneManager._hasAnyTab && window.PaneManager._hasAnyTab()) { window.PaneManager.splitRight(); menu.remove(); return; } } else if (action === "splitDown") { if (window.PaneManager && window.PaneManager._hasAnyTab && window.PaneManager._hasAnyTab()) { window.PaneManager.splitDown(); menu.remove(); return; } } else if (action === "closePane") { if (window.PaneManager) { window.PaneManager.closeActivePane(); menu.remove(); return; } } else if (action === "close") this.close(tabId); else if (action === "closeOthers") this.closeOthers(tabId); else if (action === "closeRight") this.closeRight(tabId); else if (action === "closeAll") this.closeAll(); menu.remove(); }); const closeMenu = () => menu.remove(); document.addEventListener("click", closeMenu, { once: true }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { menu.remove(); } }, { once: true }); }, }; // ---- Modify init to include TabManager ----