/* ObsiGate — UI module */
import { api, AuthManager, initLoginForm } from './auth.js';
import { state } from './state.js';
import { openFile, showWelcome, renderFile, el } from './viewer.js';
import { safeCreateIcons, getFileIcon, escapeHtml } from './utils.js';
import { syncActiveFileTreeItem, refreshSidebarTreePreservingState } from './sidebar.js';
import { GraphViewManager } from './graph.js';
import { DashboardConflictsWidget } from './dashboard.js';
// ---------------------------------------------------------------------------
// Right Sidebar Manager
// ---------------------------------------------------------------------------
export const RightSidebarManager = {
init() {
this.loadState();
this.initToggle();
this.initResize();
},
loadState() {
const savedVisible = localStorage.getItem("obsigate-right-sidebar-visible");
const savedWidth = localStorage.getItem("obsigate-right-sidebar-width");
if (savedVisible !== null) {
state.rightSidebarVisible = savedVisible === "true";
}
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
// ---------------------------------------------------------------------------
export function initTheme() {
const saved = localStorage.getItem("obsigate-theme") || "dark";
applyTheme(saved);
}
export function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("obsigate-theme", theme);
// 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", theme === "dark" ? "moon" : "sun");
}
themeLabel.textContent = theme === "dark" ? "Sombre" : "Clair";
safeCreateIcons();
}
// Swap highlight.js theme
const darkSheet = document.getElementById("hljs-theme-dark");
const lightSheet = document.getElementById("hljs-theme-light");
if (darkSheet && lightSheet) {
darkSheet.disabled = theme !== "dark";
lightSheet.disabled = theme !== "light";
}
}
export function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme");
applyTheme(current === "dark" ? "light" : "dark");
}
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();
});
}
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) {
console.log("showToast called with:", message, type);
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
// ---------------------------------------------------------------------------
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
let isOpen = true;
// Build header with chevron
const chevron = el("span", { class: "fm-chevron open" });
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]);
// Toggle functionality
fmHeader.addEventListener("click", () => {
isOpen = !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 = `
`;
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 = 'Création...';
try {
await api(`/api/directory/${encodeURIComponent(vault)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
showToast(`Dossier "${name}" créé`, 'success');
this._closeModal(overlay);
await refreshSidebarTreePreservingState();
} catch (err) {
showToast(err.message || 'Erreur lors de la création', 'error');
createBtn.disabled = false;
createBtn.textContent = 'Créer';
}
};
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) {
const overlay = this._createModalOverlay();
const modal = document.createElement('div');
modal.className = 'obsigate-modal';
modal.innerHTML = `
`;
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();
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 = 'Création...';
try {
await api(`/api/file/${encodeURIComponent(vault)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, content: '' }),
});
showToast(`Fichier "${name}" créé`, 'success');
this._closeModal(overlay);
await refreshSidebarTreePreservingState();
openFile(vault, path);
} catch (err) {
showToast(err.message || 'Erreur lors de la création', 'error');
createBtn.disabled = false;
createBtn.textContent = 'Créer';
}
};
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;
}
const textNode = Array.from(item.childNodes).find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());
if (!textNode) {
showToast('Impossible de renommer cet élément', 'error');
return;
}
const originalText = textNode.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;
textNode.textContent = ' ';
const badge = item.querySelector('.badge-small');
if (badge) {
item.insertBefore(input, badge);
} else {
item.appendChild(input);
}
const restore = () => {
input.remove();
textNode.textContent = originalText;
};
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' ? 'Dossier renommé' : 'Fichier renommé', 'success');
} catch (err) {
input.disabled = false;
showToast(err.message || 'Erreur lors du renommage', '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 = `
Attention ! Cette action est irréversible.
Tous les fichiers et sous-dossiers seront supprimés définitivement.
`;
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 = 'Suppression...';
try {
const result = await api(`/api/directory/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, {
method: 'DELETE',
});
showToast(`Dossier supprimé (${result.deleted_count} fichiers)`, 'success');
this._closeModal(overlay);
await refreshSidebarTreePreservingState();
if (state.currentVault === vault && state.currentPath && currentPath.startsWith(path)) {
showWelcome();
}
} catch (err) {
showToast(err.message || 'Erreur lors de la suppression', 'error');
confirmBtn.disabled = false;
confirmBtn.textContent = 'Supprimer définitivement';
}
};
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 = `
Attention ! Cette action est irréversible.
`;
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 = 'Suppression...';
try {
await api(`/api/file/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, {
method: 'DELETE',
});
showToast('Fichier supprimé', 'success');
this._closeModal(overlay);
await refreshSidebarTreePreservingState();
if (state.currentVault === vault && state.currentPath === path) {
showWelcome();
}
} catch (err) {
showToast(err.message || 'Erreur lors de la suppression', 'error');
confirmBtn.disabled = false;
confirmBtn.textContent = 'Supprimer définitivement';
}
};
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);
}
};
// ---------------------------------------------------------------------------
// 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("Erreur lors de l'initialisation", "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', () => 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);
} 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._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', 'Ajouter aux bookmarks', () => this._toggleBookmark(), false);
}
this._menu.classList.add('active');
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 = 'Vault en lecture seule';
}
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);
},
_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(`Chemin copié : ${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(`Chemin copié : ${path}`, 'success');
} else {
showToast('Erreur lors de la copie', 'error');
}
} catch (e) {
showToast('Erreur lors de la copie', '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 ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success");
if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) {
DashboardBookmarkWidget.load();
}
} catch (err) { showToast("Erreur: " + err.message, "error"); }
}
};
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");
},
/** 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) {
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) {
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);
},
/** Activate a specific tab */
async activate(tabId) {
if (this._activeTabId === tabId && this._tabs.length > 0) return;
// 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;
// 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) {
const idx = this._tabs.findIndex(t => t.id === tabId);
if (idx === -1) return;
this._tabs.splice(idx, 1);
delete this._tabCache[tabId];
this._dirtyTabs.delete(tabId);
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() {
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) {
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) {
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");
// 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
el.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("text/plain", String(idx));
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 fromIdx = parseInt(e.dataTransfer.getData("text/plain"));
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);
}
});
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 = `
`;
document.body.appendChild(menu);
safeCreateIcons();
menu.addEventListener("click", (e) => {
const action = e.target.closest(".context-menu-item")?.dataset.action;
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 });
},
};
// ---- 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);
}
}
}
});
// ---- Modify init to include TabManager ----