1092 lines
40 KiB
JavaScript
1092 lines
40 KiB
JavaScript
import { state } from './state.js';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Vault context switching
|
||
// ---------------------------------------------------------------------------
|
||
function initVaultContext() {
|
||
const filter = document.getElementById("vault-filter");
|
||
const quickSelect = document.getElementById("vault-quick-select");
|
||
if (!filter || !quickSelect) return;
|
||
|
||
filter.addEventListener("change", async () => {
|
||
await setSelectedVaultContext(filter.value, { focusVault: filter.value !== "all" });
|
||
});
|
||
|
||
quickSelect.addEventListener("change", async () => {
|
||
await setSelectedVaultContext(quickSelect.value, { focusVault: quickSelect.value !== "all" });
|
||
});
|
||
}
|
||
|
||
async function setSelectedVaultContext(vaultName, options) {
|
||
state.selectedContextVault = vaultName;
|
||
state.showingSource = false;
|
||
state.cachedRawSource = null;
|
||
syncVaultSelectors();
|
||
await refreshSidebarForContext();
|
||
await refreshTagsForContext();
|
||
|
||
// Synchroniser le dashboard et les fichiers récents
|
||
if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.load) {
|
||
DashboardRecentWidget.load(vaultName);
|
||
}
|
||
if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) {
|
||
DashboardBookmarkWidget.load(vaultName);
|
||
}
|
||
if (state.activeSidebarTab === "recent") {
|
||
loadRecentFiles(vaultName === "all" ? null : vaultName);
|
||
}
|
||
|
||
showWelcome();
|
||
if (options && options.focusVault && vaultName !== "all") {
|
||
await focusVaultInSidebar(vaultName);
|
||
}
|
||
}
|
||
|
||
function syncVaultSelectors() {
|
||
const filter = document.getElementById("vault-filter");
|
||
const quickSelect = document.getElementById("vault-quick-select");
|
||
const recentFilter = document.getElementById("recent-vault-filter");
|
||
const dashboardFilter = document.getElementById("dashboard-vault-filter");
|
||
const contextText = document.getElementById("vault-context-text");
|
||
|
||
if (filter) filter.value = state.selectedContextVault;
|
||
if (quickSelect) quickSelect.value = state.selectedContextVault;
|
||
if (recentFilter) recentFilter.value = state.selectedContextVault === "all" ? "" : state.selectedContextVault;
|
||
if (dashboardFilter) dashboardFilter.value = state.selectedContextVault;
|
||
|
||
// Mise à jour visuelle des dropdowns personnalisés
|
||
updateCustomDropdownVisual("vault-filter-dropdown", state.selectedContextVault);
|
||
updateCustomDropdownVisual("vault-quick-select-dropdown", state.selectedContextVault);
|
||
|
||
// Update vault context indicator
|
||
if (contextText) {
|
||
contextText.textContent = state.selectedContextVault === "all" ? "All" : state.selectedContextVault;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Updates the visual state of a custom dropdown based on its current value.
|
||
*/
|
||
function updateCustomDropdownVisual(dropdownId, value) {
|
||
const dropdown = document.getElementById(dropdownId);
|
||
if (!dropdown) return;
|
||
|
||
const selectedText = dropdown.querySelector(".custom-dropdown-selected");
|
||
const options = dropdown.querySelectorAll(".custom-dropdown-option");
|
||
|
||
options.forEach((opt) => {
|
||
const optValue = opt.getAttribute("data-value");
|
||
if (optValue === value) {
|
||
opt.classList.add("selected");
|
||
if (selectedText) selectedText.textContent = opt.textContent;
|
||
} else {
|
||
opt.classList.remove("selected");
|
||
}
|
||
});
|
||
}
|
||
|
||
function scrollTreeItemIntoView(element, alignToTop) {
|
||
if (!element) return;
|
||
const scrollContainer = document.getElementById("sidebar-panel-vaults");
|
||
if (!scrollContainer) return;
|
||
|
||
const containerRect = scrollContainer.getBoundingClientRect();
|
||
const elementRect = element.getBoundingClientRect();
|
||
const isAbove = elementRect.top < containerRect.top;
|
||
const isBelow = elementRect.bottom > containerRect.bottom;
|
||
|
||
if (!isAbove && !isBelow && !alignToTop) return;
|
||
|
||
const currentTop = scrollContainer.scrollTop;
|
||
const offsetTop = element.offsetTop;
|
||
const shouldCenter = alignToTop === "center";
|
||
const centeredTop = Math.max(0, currentTop + (elementRect.top - containerRect.top) - (containerRect.height / 2) + (elementRect.height / 2));
|
||
const targetTop = shouldCenter
|
||
? centeredTop
|
||
: alignToTop
|
||
? Math.max(0, offsetTop - 60)
|
||
: Math.max(0, currentTop + (elementRect.top - containerRect.top) - containerRect.height * 0.35);
|
||
|
||
scrollContainer.scrollTo({
|
||
top: targetTop,
|
||
behavior: "smooth",
|
||
});
|
||
}
|
||
|
||
async function refreshSidebarForContext() {
|
||
const container = document.getElementById("vault-tree");
|
||
container.innerHTML = "";
|
||
|
||
const vaultsToShow = state.selectedContextVault === "all" ? state.allVaults : state.allVaults.filter((v) => v.name === state.selectedContextVault);
|
||
|
||
vaultsToShow.forEach((v) => {
|
||
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]);
|
||
vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
|
||
|
||
vaultItem.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
const isReadonly = false;
|
||
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
|
||
});
|
||
attachTreeItemActionButton(vaultItem, v.name, "", "vault", false);
|
||
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false }));
|
||
|
||
container.appendChild(vaultItem);
|
||
|
||
const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` });
|
||
container.appendChild(childContainer);
|
||
});
|
||
|
||
safeCreateIcons();
|
||
}
|
||
|
||
async function focusVaultInSidebar(vaultName) {
|
||
switchSidebarTab("vaults");
|
||
const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`);
|
||
if (!vaultItem) return;
|
||
document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused"));
|
||
vaultItem.classList.add("focused");
|
||
const childContainer = document.getElementById(`vault-children-${vaultName}`);
|
||
if (childContainer && childContainer.classList.contains("collapsed")) {
|
||
await toggleVault(vaultItem, vaultName, true);
|
||
}
|
||
scrollTreeItemIntoView(vaultItem, false);
|
||
}
|
||
|
||
async function refreshTagsForContext() {
|
||
const vaultParam = state.selectedContextVault === "all" ? "" : `?vault=${encodeURIComponent(state.selectedContextVault)}`;
|
||
const data = await api(`/api/tags${vaultParam}`);
|
||
const filteredTags = TagFilterService.filterTags(data.tags);
|
||
renderTagCloud(filteredTags);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helper: Check if path should be displayed based on hideHiddenFiles setting
|
||
// ---------------------------------------------------------------------------
|
||
function shouldDisplayPath(path, vaultName) {
|
||
// Get hideHiddenFiles setting for this vault (default: false = show all)
|
||
const settings = state.vaultSettings[vaultName] || { hideHiddenFiles: false };
|
||
|
||
if (!settings.hideHiddenFiles) {
|
||
// Show all files
|
||
return true;
|
||
}
|
||
|
||
// Check if any segment of the path starts with a dot (hidden)
|
||
const segments = path.split("/").filter(Boolean);
|
||
for (const segment of segments) {
|
||
if (segment.startsWith(".")) {
|
||
return false; // Hide this path
|
||
}
|
||
}
|
||
|
||
return true; // Show this path
|
||
}
|
||
|
||
async function loadVaultSettings() {
|
||
try {
|
||
const settings = await api("/api/vaults/settings/all");
|
||
state.vaultSettings = settings;
|
||
} catch (err) {
|
||
console.error("Failed to load vault settings:", err);
|
||
state.vaultSettings = {};
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sidebar — Vault tree
|
||
// ---------------------------------------------------------------------------
|
||
async function loadVaults() {
|
||
const vaults = await api("/api/vaults");
|
||
state.allVaults = vaults;
|
||
const container = document.getElementById("vault-tree");
|
||
container.innerHTML = "";
|
||
|
||
// Prepare dropdown options
|
||
const dropdownOptions = [{ value: "all", text: "Tous les vaults" }, ...vaults.map((v) => ({ value: v.name, text: v.name }))];
|
||
|
||
// Populate custom dropdowns
|
||
populateCustomDropdown("vault-filter-dropdown", dropdownOptions, "all");
|
||
populateCustomDropdown("vault-quick-select-dropdown", dropdownOptions, "all");
|
||
|
||
// Populate standard selects
|
||
_populateRecentVaultFilter();
|
||
if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.populateVaultFilter) {
|
||
DashboardRecentWidget.populateVaultFilter();
|
||
}
|
||
|
||
vaults.forEach((v) => {
|
||
// Sidebar tree entry
|
||
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]);
|
||
vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
|
||
|
||
vaultItem.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
const isReadonly = false;
|
||
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
|
||
});
|
||
attachTreeItemActionButton(vaultItem, v.name, "", "vault", false);
|
||
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false }));
|
||
|
||
container.appendChild(vaultItem);
|
||
|
||
const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` });
|
||
container.appendChild(childContainer);
|
||
});
|
||
|
||
syncVaultSelectors();
|
||
safeCreateIcons();
|
||
}
|
||
|
||
/**
|
||
* Refreshes the sidebar tree while preserving the expanded state of vaults and folders.
|
||
* Optimized to avoid a full sidebar wipe and minimize visible loading states.
|
||
*/
|
||
/**
|
||
* Incrementally update a directory container without wiping existing DOM.
|
||
* Only adds new items, removes deleted ones, and updates changed ones.
|
||
*/
|
||
async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
||
let data;
|
||
try {
|
||
const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`;
|
||
data = await api(url);
|
||
} catch (err) {
|
||
// Server unavailable — keep existing content
|
||
return;
|
||
}
|
||
|
||
// Build a map of existing DOM elements by path
|
||
const existingItems = {};
|
||
const existingChildren = {}; // path -> child container (for directories)
|
||
for (let i = 0; i < container.children.length; i++) {
|
||
const child = container.children[i];
|
||
if (child.classList.contains("tree-item") && child.dataset.path) {
|
||
existingItems[child.dataset.path] = child;
|
||
// The next sibling should be the tree-children container for this directory
|
||
if (i + 1 < container.children.length) {
|
||
const next = container.children[i + 1];
|
||
if (next.classList.contains("tree-children")) {
|
||
existingChildren[child.dataset.path] = next;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const fragment = document.createDocumentFragment();
|
||
|
||
data.items.forEach((item) => {
|
||
if (!shouldDisplayPath(item.path, vaultName)) return;
|
||
|
||
const existing = existingItems[item.path];
|
||
|
||
if (existing) {
|
||
// Item already exists — reuse it, but update text/badge if needed
|
||
const textEl = existing.querySelector(".tree-item-text");
|
||
const displayName = item.type === "file" && item.name.match(/\.md$/i)
|
||
? item.name.replace(/\.md$/i, "")
|
||
: item.name;
|
||
if (textEl && textEl.textContent !== displayName) {
|
||
textEl.textContent = displayName;
|
||
}
|
||
// Update badge for directories
|
||
if (item.type === "directory") {
|
||
const badge = existing.querySelector(".badge-small");
|
||
const newBadge = `(${item.children_count})`;
|
||
if (badge && badge.textContent !== newBadge) {
|
||
badge.textContent = newBadge;
|
||
} else if (!badge) {
|
||
existing.appendChild(smallBadge(item.children_count));
|
||
}
|
||
}
|
||
fragment.appendChild(existing);
|
||
// Also re-add the child container for directories
|
||
if (item.type === "directory" && existingChildren[item.path]) {
|
||
fragment.appendChild(existingChildren[item.path]);
|
||
} else if (item.type === "directory") {
|
||
// Directory existed but no child container — create one
|
||
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
|
||
fragment.appendChild(subContainer);
|
||
}
|
||
} else {
|
||
// New item — create it
|
||
if (item.type === "directory") {
|
||
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]);
|
||
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
|
||
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
|
||
fragment.appendChild(dirItem);
|
||
|
||
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
|
||
fragment.appendChild(subContainer);
|
||
|
||
dirItem.addEventListener("click", async () => {
|
||
scrollTreeItemIntoView(dirItem, false);
|
||
if (subContainer.classList.contains("collapsed")) {
|
||
if (subContainer.children.length === 0) {
|
||
await loadDirectory(vaultName, item.path, subContainer);
|
||
}
|
||
subContainer.classList.remove("collapsed");
|
||
const chev = dirItem.querySelector("[data-lucide]");
|
||
if (chev) chev.setAttribute("data-lucide", "chevron-down");
|
||
safeCreateIcons();
|
||
} else {
|
||
subContainer.classList.add("collapsed");
|
||
const chev = dirItem.querySelector("[data-lucide]");
|
||
if (chev) chev.setAttribute("data-lucide", "chevron-right");
|
||
safeCreateIcons();
|
||
}
|
||
});
|
||
|
||
dirItem.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false);
|
||
});
|
||
} else {
|
||
const fileIconName = getFileIcon(item.name);
|
||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
||
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
|
||
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||
fileItem.addEventListener("click", () => {
|
||
scrollTreeItemIntoView(fileItem, false);
|
||
TabManager.openPreview(vaultName, item.path);
|
||
closeMobileSidebar();
|
||
});
|
||
|
||
fileItem.addEventListener("dblclick", (e) => {
|
||
e.preventDefault();
|
||
TabManager.openPersistent(vaultName, item.path);
|
||
});
|
||
|
||
fileItem.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", false);
|
||
});
|
||
|
||
fragment.appendChild(fileItem);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Replace container content in a single batch operation to avoid flash
|
||
container.textContent = "";
|
||
container.appendChild(fragment);
|
||
}
|
||
|
||
async function refreshSidebarTreePreservingState() {
|
||
// 1. Capture expanded states
|
||
const expandedVaults = Array.from(document.querySelectorAll(".vault-item"))
|
||
.filter((v) => {
|
||
const children = document.getElementById(`vault-children-${v.dataset.vault}`);
|
||
return children && !children.classList.contains("collapsed");
|
||
})
|
||
.map((v) => v.dataset.vault);
|
||
|
||
const expandedDirs = Array.from(document.querySelectorAll(".tree-item[data-path]"))
|
||
.filter((item) => {
|
||
const vault = item.dataset.vault;
|
||
const path = item.dataset.path;
|
||
const children = document.getElementById(`dir-${vault}-${path}`);
|
||
return children && !children.classList.contains("collapsed");
|
||
})
|
||
.map((item) => ({ vault: item.dataset.vault, path: item.dataset.path }));
|
||
|
||
const selectedItem = document.querySelector(".tree-item.path-selected");
|
||
const selectedState = selectedItem ? { vault: selectedItem.dataset.vault, path: selectedItem.dataset.path } : null;
|
||
|
||
// 2. Soft update: vault names/counts without wiping the tree
|
||
try {
|
||
const vaults = await api("/api/vaults");
|
||
state.allVaults = vaults;
|
||
vaults.forEach((v) => {
|
||
const vItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(v.name)}"]`);
|
||
if (vItem) {
|
||
const badge = vItem.querySelector(".badge-small");
|
||
if (badge) badge.textContent = `(${v.file_count})`;
|
||
}
|
||
});
|
||
} catch (e) {
|
||
console.warn("Soft vault refresh failed, falling back to full reload", e);
|
||
await loadVaults();
|
||
return;
|
||
}
|
||
|
||
// 3. Incrementally update expanded vaults (no DOM wipe)
|
||
for (const vName of expandedVaults) {
|
||
const container = document.getElementById(`vault-children-${vName}`);
|
||
if (container) {
|
||
await incrementalLoadDirectory(vName, "", container);
|
||
}
|
||
}
|
||
|
||
// 4. Incrementally update expanded directories (parents first, no DOM wipe)
|
||
expandedDirs.sort((a, b) => a.path.split("/").length - b.path.split("/").length);
|
||
for (const dir of expandedDirs) {
|
||
const container = document.getElementById(`dir-${dir.vault}-${dir.path}`);
|
||
if (container) {
|
||
try {
|
||
await incrementalLoadDirectory(dir.vault, dir.path, container);
|
||
container.classList.remove("collapsed");
|
||
const dItem = document.querySelector(`.tree-item[data-vault="${CSS.escape(dir.vault)}"][data-path="${CSS.escape(dir.path)}"]`);
|
||
if (dItem) {
|
||
const chev = dItem.querySelector("[data-lucide]");
|
||
if (chev) chev.setAttribute("data-lucide", "chevron-down");
|
||
}
|
||
} catch (e) {
|
||
console.error(`Failed to refresh directory ${dir.vault}/${dir.path}`, e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 5. Restore selection
|
||
if (selectedState) {
|
||
await focusPathInSidebar(selectedState.vault, selectedState.path, { alignToTop: false });
|
||
}
|
||
|
||
safeCreateIcons();
|
||
}
|
||
|
||
async function toggleVault(itemEl, vaultName, forceExpand) {
|
||
const childContainer = document.getElementById(`vault-children-${vaultName}`);
|
||
if (!childContainer) return;
|
||
|
||
scrollTreeItemIntoView(itemEl, false);
|
||
|
||
const shouldExpand = forceExpand || childContainer.classList.contains("collapsed");
|
||
|
||
if (shouldExpand) {
|
||
// Expand — load children if empty
|
||
if (childContainer.children.length === 0) {
|
||
await loadDirectory(vaultName, "", childContainer);
|
||
}
|
||
childContainer.classList.remove("collapsed");
|
||
// Swap chevron
|
||
const chevron = itemEl.querySelector("[data-lucide]");
|
||
if (chevron) chevron.setAttribute("data-lucide", "chevron-down");
|
||
safeCreateIcons();
|
||
} else {
|
||
childContainer.classList.add("collapsed");
|
||
const chevron = itemEl.querySelector("[data-lucide]");
|
||
if (chevron) chevron.setAttribute("data-lucide", "chevron-right");
|
||
safeCreateIcons();
|
||
}
|
||
}
|
||
|
||
async function expandDirectoryInSidebar(vaultName, dirPath, dirItem) {
|
||
const subContainer = document.getElementById(`dir-${vaultName}-${dirPath}`);
|
||
if (!subContainer) return null;
|
||
|
||
if (subContainer.children.length === 0) {
|
||
await loadDirectory(vaultName, dirPath, subContainer);
|
||
}
|
||
|
||
subContainer.classList.remove("collapsed");
|
||
if (dirItem) {
|
||
const chevron = dirItem.querySelector("[data-lucide]");
|
||
if (chevron) chevron.setAttribute("data-lucide", "chevron-down");
|
||
}
|
||
safeCreateIcons();
|
||
return subContainer;
|
||
}
|
||
|
||
async function focusPathInSidebar(vaultName, targetPath, options) {
|
||
switchSidebarTab("vaults");
|
||
|
||
const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`);
|
||
if (!vaultItem) return;
|
||
|
||
document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused"));
|
||
vaultItem.classList.add("focused");
|
||
|
||
const vaultContainer = document.getElementById(`vault-children-${vaultName}`);
|
||
if (!vaultContainer) return;
|
||
|
||
if (vaultContainer.classList.contains("collapsed")) {
|
||
await toggleVault(vaultItem, vaultName, true);
|
||
}
|
||
|
||
if (!targetPath) {
|
||
// Clear any previous path selection
|
||
document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected"));
|
||
scrollTreeItemIntoView(vaultItem, options && options.alignToTop);
|
||
return;
|
||
}
|
||
|
||
const segments = targetPath.split("/").filter(Boolean);
|
||
let currentContainer = vaultContainer;
|
||
let cumulativePath = "";
|
||
let lastTargetItem = null;
|
||
|
||
for (let index = 0; index < segments.length; index++) {
|
||
cumulativePath += (cumulativePath ? "/" : "") + segments[index];
|
||
|
||
let targetItem = null;
|
||
try {
|
||
targetItem = currentContainer.querySelector(`.tree-item[data-vault="${CSS.escape(vaultName)}"][data-path="${CSS.escape(cumulativePath)}"]`);
|
||
} catch (e) {
|
||
targetItem = null;
|
||
}
|
||
|
||
if (!targetItem) {
|
||
return;
|
||
}
|
||
|
||
lastTargetItem = targetItem;
|
||
|
||
const isLastSegment = index === segments.length - 1;
|
||
if (!isLastSegment) {
|
||
const nextContainer = await expandDirectoryInSidebar(vaultName, cumulativePath, targetItem);
|
||
if (nextContainer) {
|
||
currentContainer = nextContainer;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (lastTargetItem && options && options.expandTarget) {
|
||
await expandDirectoryInSidebar(vaultName, targetPath, lastTargetItem);
|
||
}
|
||
|
||
// Clear previous path selections and highlight the final target
|
||
document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected"));
|
||
if (lastTargetItem) {
|
||
lastTargetItem.classList.add("path-selected");
|
||
}
|
||
|
||
scrollTreeItemIntoView(lastTargetItem, options && options.alignToTop);
|
||
}
|
||
|
||
function getParentDirectoryPath(filePath) {
|
||
if (!filePath) return "";
|
||
const segments = filePath.split("/").filter(Boolean);
|
||
if (segments.length <= 1) return "";
|
||
segments.pop();
|
||
return segments.join("/");
|
||
}
|
||
|
||
function syncActiveFileTreeItem(vaultName, filePath) {
|
||
document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active"));
|
||
if (!vaultName || !filePath) return;
|
||
const selector = `.tree-item[data-vault="${CSS.escape(vaultName)}"][data-path="${CSS.escape(filePath)}"]`;
|
||
try {
|
||
const active = document.querySelector(selector);
|
||
if (active) active.classList.add("active");
|
||
} catch (e) {
|
||
/* selector might fail on special chars */
|
||
}
|
||
}
|
||
|
||
async function loadDirectory(vaultName, dirPath, container) {
|
||
// Only show the loading spinner if the container is currently empty
|
||
const isEmpty = container.children.length === 0;
|
||
if (isEmpty) {
|
||
container.innerHTML = '<div class="tree-loading"><div class="loading-spinner" style="width:16px;height:16px;border-width:2px"></div></div>';
|
||
}
|
||
|
||
var data;
|
||
try {
|
||
const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`;
|
||
data = await api(url);
|
||
} catch (err) {
|
||
container.innerHTML = '<div class="tree-loading" style="color:var(--text-muted);font-size:0.75rem;padding:4px 16px">Erreur de chargement</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = "";
|
||
|
||
const fragment = document.createDocumentFragment();
|
||
|
||
data.items.forEach((item) => {
|
||
// Apply client-side filtering for hidden files
|
||
if (!shouldDisplayPath(item.path, vaultName)) {
|
||
return; // Skip this item
|
||
}
|
||
|
||
if (item.type === "directory") {
|
||
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]);
|
||
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
|
||
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
|
||
fragment.appendChild(dirItem);
|
||
|
||
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
|
||
fragment.appendChild(subContainer);
|
||
|
||
dirItem.addEventListener("click", async () => {
|
||
scrollTreeItemIntoView(dirItem, false);
|
||
if (subContainer.classList.contains("collapsed")) {
|
||
if (subContainer.children.length === 0) {
|
||
await loadDirectory(vaultName, item.path, subContainer);
|
||
}
|
||
subContainer.classList.remove("collapsed");
|
||
const chev = dirItem.querySelector("[data-lucide]");
|
||
if (chev) chev.setAttribute("data-lucide", "chevron-down");
|
||
safeCreateIcons();
|
||
} else {
|
||
subContainer.classList.add("collapsed");
|
||
const chev = dirItem.querySelector("[data-lucide]");
|
||
if (chev) chev.setAttribute("data-lucide", "chevron-right");
|
||
safeCreateIcons();
|
||
}
|
||
});
|
||
|
||
dirItem.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
const isReadonly = false;
|
||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly);
|
||
});
|
||
} else {
|
||
const fileIconName = getFileIcon(item.name);
|
||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
||
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
|
||
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||
fileItem.addEventListener("click", () => {
|
||
scrollTreeItemIntoView(fileItem, false);
|
||
TabManager.openPreview(vaultName, item.path);
|
||
closeMobileSidebar();
|
||
});
|
||
|
||
fileItem.addEventListener("dblclick", (e) => {
|
||
e.preventDefault();
|
||
TabManager.openPersistent(vaultName, item.path);
|
||
});
|
||
|
||
fileItem.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
const isReadonly = false;
|
||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly);
|
||
});
|
||
|
||
fragment.appendChild(fileItem);
|
||
}
|
||
});
|
||
|
||
container.appendChild(fragment);
|
||
safeCreateIcons();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sidebar filter
|
||
// ---------------------------------------------------------------------------
|
||
function initSidebarFilter() {
|
||
const input = document.getElementById("sidebar-filter-input");
|
||
const caseBtn = document.getElementById("sidebar-filter-case-btn");
|
||
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
|
||
|
||
input.addEventListener("input", () => {
|
||
const hasText = input.value.length > 0;
|
||
clearBtn.style.display = hasText ? "flex" : "none";
|
||
clearTimeout(state.filterDebounce);
|
||
state.filterDebounce = setTimeout(async () => {
|
||
const q = state.sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
|
||
if (hasText) {
|
||
if (state.activeSidebarTab === "vaults") {
|
||
await performTreeSearch(q);
|
||
} else {
|
||
filterTagCloud(q);
|
||
}
|
||
} else {
|
||
if (state.activeSidebarTab === "vaults") {
|
||
await restoreSidebarTree();
|
||
} else {
|
||
filterTagCloud("");
|
||
}
|
||
}
|
||
}, 220);
|
||
});
|
||
|
||
caseBtn.addEventListener("click", async () => {
|
||
state.sidebarFilterCaseSensitive = !state.sidebarFilterCaseSensitive;
|
||
caseBtn.classList.toggle("active");
|
||
const q = state.sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
|
||
if (input.value.trim()) {
|
||
if (state.activeSidebarTab === "vaults") {
|
||
await performTreeSearch(q);
|
||
} else {
|
||
filterTagCloud(q);
|
||
}
|
||
}
|
||
});
|
||
|
||
clearBtn.addEventListener("click", async () => {
|
||
input.value = "";
|
||
clearBtn.style.display = "none";
|
||
state.sidebarFilterCaseSensitive = false;
|
||
caseBtn.classList.remove("active");
|
||
clearTimeout(state.filterDebounce);
|
||
if (state.activeSidebarTab === "vaults") {
|
||
await restoreSidebarTree();
|
||
} else {
|
||
filterTagCloud("");
|
||
}
|
||
});
|
||
|
||
clearBtn.style.display = "none";
|
||
}
|
||
|
||
async function performTreeSearch(query) {
|
||
if (!query) {
|
||
await restoreSidebarTree();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const vaultParam = state.selectedContextVault === "all" ? "all" : state.selectedContextVault;
|
||
const url = `/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultParam)}`;
|
||
const data = await api(url);
|
||
renderFilteredSidebarResults(query, data.results);
|
||
} catch (err) {
|
||
console.error("Tree search error:", err);
|
||
renderFilteredSidebarResults(query, []);
|
||
}
|
||
}
|
||
|
||
async function restoreSidebarTree() {
|
||
await refreshSidebarForContext();
|
||
if (state.currentVault) {
|
||
focusPathInSidebar(state.currentVault, state.currentPath || "", { alignToTop: false }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
function renderFilteredSidebarResults(query, results) {
|
||
const container = document.getElementById("vault-tree");
|
||
container.innerHTML = "";
|
||
|
||
const grouped = new Map();
|
||
results.forEach((result) => {
|
||
if (!grouped.has(result.vault)) {
|
||
grouped.set(result.vault, []);
|
||
}
|
||
grouped.get(result.vault).push(result);
|
||
});
|
||
|
||
if (grouped.size === 0) {
|
||
container.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode("Aucun répertoire ou fichier correspondant.")]));
|
||
return;
|
||
}
|
||
|
||
grouped.forEach((entries, vaultName) => {
|
||
entries.sort((a, b) => a.path.localeCompare(b.path, undefined, { sensitivity: "base" }));
|
||
|
||
const vaultHeader = el("div", { class: "tree-item vault-item filter-results-header", "data-vault": vaultName }, [getVaultIcon(vaultName, 16), document.createTextNode(` ${vaultName} `), smallBadge(entries.length)]);
|
||
container.appendChild(vaultHeader);
|
||
|
||
const resultsWrapper = el("div", { class: "filter-results-group" });
|
||
entries.forEach((entry) => {
|
||
const resultItem = el(
|
||
"div",
|
||
{
|
||
class: `tree-item filter-result-item filter-result-${entry.type}`,
|
||
"data-vault": entry.vault,
|
||
"data-path": entry.path,
|
||
"data-type": entry.type,
|
||
},
|
||
[icon(entry.type === "directory" ? "folder" : getFileIcon(entry.name), 16)],
|
||
);
|
||
|
||
const textWrap = el("div", { class: "filter-result-text" });
|
||
const primary = el("div", { class: "filter-result-primary" });
|
||
appendHighlightedText(primary, entry.name, query, state.sidebarFilterCaseSensitive);
|
||
const secondary = el("div", { class: "filter-result-secondary" });
|
||
appendHighlightedText(secondary, entry.path, query, state.sidebarFilterCaseSensitive);
|
||
textWrap.appendChild(primary);
|
||
textWrap.appendChild(secondary);
|
||
resultItem.appendChild(textWrap);
|
||
|
||
resultItem.addEventListener("click", async () => {
|
||
const input = document.getElementById("sidebar-filter-input");
|
||
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
|
||
if (input) input.value = "";
|
||
if (clearBtn) clearBtn.style.display = "none";
|
||
await restoreSidebarTree();
|
||
if (entry.type === "directory") {
|
||
await focusPathInSidebar(entry.vault, entry.path, { alignToTop: true, expandTarget: true });
|
||
} else {
|
||
await TabManager.openPreview(entry.vault, entry.path);
|
||
await focusPathInSidebar(entry.vault, getParentDirectoryPath(entry.path), { alignToTop: true, expandTarget: true });
|
||
syncActiveFileTreeItem(entry.vault, entry.path);
|
||
}
|
||
closeMobileSidebar();
|
||
});
|
||
|
||
resultsWrapper.appendChild(resultItem);
|
||
});
|
||
|
||
container.appendChild(resultsWrapper);
|
||
});
|
||
|
||
flushIcons();
|
||
}
|
||
|
||
function filterSidebarTree(query) {
|
||
const tree = document.getElementById("vault-tree");
|
||
const items = tree.querySelectorAll(".tree-item");
|
||
const containers = tree.querySelectorAll(".tree-children");
|
||
|
||
if (!query) {
|
||
items.forEach((item) => item.classList.remove("filtered-out"));
|
||
containers.forEach((c) => {
|
||
c.classList.remove("filtered-out");
|
||
// Keep current collapsed state when clearing filter
|
||
});
|
||
return;
|
||
}
|
||
|
||
// First pass: mark all as filtered out
|
||
items.forEach((item) => item.classList.add("filtered-out"));
|
||
containers.forEach((c) => c.classList.add("filtered-out"));
|
||
|
||
// Second pass: find matching items and mark them + ancestors + descendants
|
||
const matchingItems = new Set();
|
||
|
||
items.forEach((item) => {
|
||
const text = state.sidebarFilterCaseSensitive ? item.textContent : item.textContent.toLowerCase();
|
||
const searchQuery = state.sidebarFilterCaseSensitive ? query : query.toLowerCase();
|
||
if (text.includes(searchQuery)) {
|
||
matchingItems.add(item);
|
||
item.classList.remove("filtered-out");
|
||
|
||
// Show all ancestor containers
|
||
let parent = item.parentElement;
|
||
while (parent && parent !== tree) {
|
||
parent.classList.remove("filtered-out");
|
||
if (parent.classList.contains("tree-children")) {
|
||
parent.classList.remove("collapsed");
|
||
}
|
||
parent = parent.parentElement;
|
||
}
|
||
|
||
// If this is a directory (has a children container after it), show all descendants
|
||
const nextEl = item.nextElementSibling;
|
||
if (nextEl && nextEl.classList.contains("tree-children")) {
|
||
nextEl.classList.remove("filtered-out");
|
||
nextEl.classList.remove("collapsed");
|
||
// Recursively show all children in this container
|
||
showAllDescendants(nextEl);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Third pass: show items that are descendants of matching directories
|
||
// and ensure their containers are visible
|
||
matchingItems.forEach((item) => {
|
||
const nextEl = item.nextElementSibling;
|
||
if (nextEl && nextEl.classList.contains("tree-children")) {
|
||
const children = nextEl.querySelectorAll(".tree-item");
|
||
children.forEach((child) => child.classList.remove("filtered-out"));
|
||
}
|
||
});
|
||
}
|
||
|
||
function showAllDescendants(container) {
|
||
const items = container.querySelectorAll(".tree-item");
|
||
items.forEach((item) => {
|
||
item.classList.remove("filtered-out");
|
||
// If this item has children, also show them
|
||
const nextEl = item.nextElementSibling;
|
||
if (nextEl && nextEl.classList.contains("tree-children")) {
|
||
nextEl.classList.remove("filtered-out");
|
||
nextEl.classList.remove("collapsed");
|
||
}
|
||
});
|
||
// Also ensure all nested containers are visible
|
||
const nestedContainers = container.querySelectorAll(".tree-children");
|
||
nestedContainers.forEach((c) => {
|
||
c.classList.remove("filtered-out");
|
||
c.classList.remove("collapsed");
|
||
});
|
||
}
|
||
|
||
function filterTagCloud(query) {
|
||
const tags = document.querySelectorAll("#tag-cloud .tag-item");
|
||
tags.forEach((tag) => {
|
||
const text = state.sidebarFilterCaseSensitive ? tag.textContent : tag.textContent.toLowerCase();
|
||
const searchQuery = state.sidebarFilterCaseSensitive ? query : query.toLowerCase();
|
||
if (!query || text.includes(searchQuery)) {
|
||
tag.classList.remove("filtered-out");
|
||
} else {
|
||
tag.classList.add("filtered-out");
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tag Filter Service
|
||
// ---------------------------------------------------------------------------
|
||
const TagFilterService = {
|
||
defaultFilters: [
|
||
{ pattern: "#<% ... %>", regex: "#<%.*%>", enabled: true },
|
||
{ pattern: "#{{ ... }}", regex: "#\\{\\{.*\\}\\}", enabled: true },
|
||
{ pattern: "#{ ... }", regex: "#\\{.*\\}", enabled: true },
|
||
],
|
||
|
||
getConfig() {
|
||
const stored = localStorage.getItem("obsigate-tag-filters");
|
||
if (stored) {
|
||
try {
|
||
return JSON.parse(stored);
|
||
} catch (e) {
|
||
return { tagFilters: this.defaultFilters };
|
||
}
|
||
}
|
||
return { tagFilters: this.defaultFilters };
|
||
},
|
||
|
||
saveConfig(config) {
|
||
localStorage.setItem("obsigate-tag-filters", JSON.stringify(config));
|
||
},
|
||
|
||
patternToRegex(pattern) {
|
||
// 1. Escape ALL special regex characters
|
||
// We use a broader set including * and .
|
||
let regex = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
|
||
// 2. Convert escaped '*' to '.*' (wildcard)
|
||
regex = regex.replace(/\\\*/g, ".*");
|
||
|
||
// 3. Convert escaped '...' (or any sequence of 2+ dots like ..) to '.*'
|
||
// We also handle optional whitespace around it to make it more user-friendly
|
||
regex = regex.replace(/\s*\\\.{2,}\s*/g, ".*");
|
||
|
||
return regex;
|
||
},
|
||
|
||
isTagFiltered(tag) {
|
||
const config = this.getConfig();
|
||
const filters = config.tagFilters || this.defaultFilters;
|
||
const tagWithHash = `#${tag}`;
|
||
|
||
for (const filter of filters) {
|
||
if (!filter.enabled) continue;
|
||
try {
|
||
// Robustly handle regex with or without ^/$
|
||
let patternStr = filter.regex;
|
||
if (!patternStr.startsWith("^")) patternStr = "^" + patternStr;
|
||
if (!patternStr.endsWith("$")) patternStr = patternStr + "$";
|
||
|
||
const regex = new RegExp(patternStr);
|
||
if (regex.test(tagWithHash)) {
|
||
return true;
|
||
}
|
||
} catch (e) {
|
||
console.warn("Invalid regex:", filter.regex, e);
|
||
}
|
||
}
|
||
return false;
|
||
},
|
||
|
||
filterTags(tags) {
|
||
const filtered = {};
|
||
Object.entries(tags).forEach(([tag, count]) => {
|
||
if (!this.isTagFiltered(tag)) {
|
||
filtered[tag] = count;
|
||
}
|
||
});
|
||
return filtered;
|
||
},
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tags
|
||
// ---------------------------------------------------------------------------
|
||
async function loadTags() {
|
||
const data = await api("/api/tags");
|
||
const filteredTags = TagFilterService.filterTags(data.tags);
|
||
renderTagCloud(filteredTags);
|
||
}
|
||
|
||
function renderTagCloud(tags) {
|
||
const cloud = document.getElementById("tag-cloud");
|
||
cloud.innerHTML = "";
|
||
|
||
const counts = Object.values(tags);
|
||
if (counts.length === 0) return;
|
||
|
||
const maxCount = Math.max(...counts);
|
||
const minSize = 0.7;
|
||
const maxSize = 1.25;
|
||
|
||
Object.entries(tags).forEach(([tag, count]) => {
|
||
const ratio = maxCount > 1 ? (count - 1) / (maxCount - 1) : 0;
|
||
const size = minSize + ratio * (maxSize - minSize);
|
||
const tagEl = el("span", { class: "tag-item", style: `font-size:${size}rem` }, [document.createTextNode(`#${tag}`)]);
|
||
tagEl.addEventListener("click", () => searchByTag(tag));
|
||
cloud.appendChild(tagEl);
|
||
});
|
||
}
|
||
|
||
function addTagFilter(tag) {
|
||
if (!state.selectedTags.includes(tag)) {
|
||
state.selectedTags.push(tag);
|
||
performTagSearch();
|
||
}
|
||
}
|
||
|
||
function removeTagFilter(tag) {
|
||
state.selectedTags = state.selectedTags.filter((t) => t !== tag);
|
||
if (state.selectedTags.length > 0) {
|
||
performTagSearch();
|
||
} else {
|
||
const input = document.getElementById("search-input");
|
||
if (input.value.trim()) {
|
||
performAdvancedSearch(input.value.trim(), document.getElementById("vault-filter").value, null);
|
||
} else {
|
||
showWelcome();
|
||
}
|
||
}
|
||
}
|
||
|
||
function performTagSearch() {
|
||
const input = document.getElementById("search-input");
|
||
const query = input.value.trim();
|
||
const vault = document.getElementById("vault-filter").value;
|
||
performAdvancedSearch(query, vault, state.selectedTags.length > 0 ? state.selectedTags.join(",") : null);
|
||
}
|
||
|
||
function buildSearchResultsHeader(data, query, tagFilter) {
|
||
const header = el("div", { class: "search-results-header" });
|
||
const summaryText = el("span", { class: "search-results-summary-text" });
|
||
|
||
if (query && tagFilter) {
|
||
summaryText.textContent = `${data.count} résultat(s) pour "${query}" avec les tags`;
|
||
} else if (query) {
|
||
summaryText.textContent = `${data.count} résultat(s) pour "${query}"`;
|
||
} else if (tagFilter) {
|
||
summaryText.textContent = `${data.count} fichier(s) avec les tags`;
|
||
} else {
|
||
summaryText.textContent = `${data.count} résultat(s)`;
|
||
}
|
||
|
||
header.appendChild(summaryText);
|
||
|
||
if (state.selectedTags.length > 0) {
|
||
const activeTags = el("div", { class: "search-results-active-tags" });
|
||
state.selectedTags.forEach((tag) => {
|
||
const removeBtn = el(
|
||
"button",
|
||
{
|
||
class: "search-results-active-tag-remove",
|
||
title: `Retirer ${tag} du filtre`,
|
||
"aria-label": `Retirer ${tag} du filtre`,
|
||
},
|
||
[document.createTextNode("×")],
|
||
);
|
||
removeBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
removeTagFilter(tag);
|
||
});
|
||
|
||
const chip = el("span", { class: "search-results-active-tag" }, [document.createTextNode(`#${tag}`), removeBtn]);
|
||
activeTags.appendChild(chip);
|
||
});
|
||
header.appendChild(activeTags);
|
||
}
|
||
|
||
return header;
|
||
}
|
||
|
||
function searchByTag(tag) {
|
||
addTagFilter(tag);
|
||
}
|
||
|
||
|
||
export { initVaultContext, setSelectedVaultContext, syncVaultSelectors, shouldDisplayPath, loadVaults, initSidebarFilter, TagFilterService, loadTags, scrollTreeItemIntoView, refreshSidebarForContext, focusVaultInSidebar, refreshTagsForContext };
|