diff --git a/backend/main.py b/backend/main.py index 9550d55..d5526f3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,6 +17,7 @@ from backend.indexer import ( reload_index, index, get_vault_data, + get_vault_names, find_file_in_index, parse_markdown_file, _extract_tags, @@ -120,10 +121,20 @@ class TagsResponse(BaseModel): tags: Dict[str, int] -class ReloadResponse(BaseModel): - """Index reload confirmation with per-vault stats.""" - status: str - vaults: Dict[str, Any] +class TreeSearchResult(BaseModel): + """A single tree search result item.""" + vault: str + path: str + name: str + type: str = Field(description="'file' or 'directory'") + matched_path: str + + +class TreeSearchResponse(BaseModel): + """Tree search response with matching paths.""" + query: str + vault_filter: str + results: List[TreeSearchResult] class HealthResponse(BaseModel): @@ -588,6 +599,74 @@ async def api_tags(vault: Optional[str] = Query(None, description="Vault filter" return {"vault_filter": vault, "tags": tags} +@app.get("/api/tree-search", response_model=TreeSearchResponse) +async def api_tree_search( + q: str = Query("", description="Search query"), + vault: str = Query("all", description="Vault filter"), +): + """Search for files and directories in the tree structure. + + Searches through the file index for matching paths, returning + both files and their parent directories that match the query. + + Args: + q: Search string to match against file/directory paths. + vault: Vault name or "all" to search everywhere. + + Returns: + ``TreeSearchResponse`` with matching paths and their parent directories. + """ + if not q: + return {"query": q, "vault_filter": vault, "results": []} + + query_lower = q.lower() + results = [] + seen_paths = set() # Avoid duplicates + + vaults_to_search = [vault] if vault != "all" else list(index.keys()) + + for vault_name in vaults_to_search: + vault_data = get_vault_data(vault_name) + if not vault_data: + continue + + vault_root = Path(vault_data["path"]) + if not vault_root.exists(): + continue + + for fpath in vault_root.rglob("*"): + if fpath.name.startswith("."): + continue + + try: + rel_path = str(fpath.relative_to(vault_root)).replace("\\", "/") + path_lower = rel_path.lower() + name_lower = fpath.name.lower() + + if query_lower not in name_lower and query_lower not in path_lower: + continue + + entry_type = "directory" if fpath.is_dir() else "file" + entry_key = f"{vault_name}:{entry_type}:{rel_path}" + if entry_key in seen_paths: + continue + + seen_paths.add(entry_key) + results.append({ + "vault": vault_name, + "path": rel_path, + "name": fpath.name, + "type": entry_type, + "matched_path": rel_path, + }) + except PermissionError: + continue + except Exception: + continue + + return {"query": q, "vault_filter": vault, "results": results} + + @app.get("/api/index/reload", response_model=ReloadResponse) async def api_reload(): """Force a full re-index of all configured vaults. diff --git a/frontend/app.js b/frontend/app.js index 2fe70bf..a612617 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -710,24 +710,24 @@ const hasText = input.value.length > 0; clearBtn.style.display = hasText ? "flex" : "none"; + const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); + if (hasText) { - // Expand all vaults and load their contents for filtering - await expandAllVaultsForFiltering(); + await performTreeSearch(q); + } else { + await restoreSidebarTree(); } - const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); - filterSidebarTree(q); filterTagCloud(q); }); caseBtn.addEventListener("click", async () => { sidebarFilterCaseSensitive = !sidebarFilterCaseSensitive; caseBtn.classList.toggle("active"); - if (input.value.trim()) { - await expandAllVaultsForFiltering(); - } const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); - filterSidebarTree(q); + if (input.value.trim()) { + await performTreeSearch(q); + } filterTagCloud(q); }); @@ -736,7 +736,7 @@ clearBtn.style.display = "none"; sidebarFilterCaseSensitive = false; caseBtn.classList.remove("active"); - filterSidebarTree(""); + restoreSidebarTree(); filterTagCloud(""); }); @@ -744,15 +744,101 @@ clearBtn.style.display = "none"; } - async function expandAllVaultsForFiltering() { - const vaultItems = document.querySelectorAll(".vault-item"); - for (const vaultItem of vaultItems) { - const vaultName = vaultItem.getAttribute("data-vault"); - const childContainer = document.getElementById(`vault-children-${vaultName}`); - if (childContainer && childContainer.classList.contains("collapsed")) { - await toggleVault(vaultItem, vaultName, true); - } + async function performTreeSearch(query) { + if (!query) { + await restoreSidebarTree(); + return; } + + try { + const vaultParam = selectedContextVault === "all" ? "all" : 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 (currentVault) { + focusPathInSidebar(currentVault, 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 }, [ + icon("database", 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, sidebarFilterCaseSensitive); + const secondary = el("div", { class: "filter-result-secondary" }); + appendHighlightedText(secondary, entry.path, query, 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 }); + } else { + await openFile(entry.vault, entry.path); + await focusPathInSidebar(entry.vault, entry.path, { alignToTop: false }); + } + closeMobileSidebar(); + }); + + resultsWrapper.appendChild(resultItem); + }); + + container.appendChild(resultsWrapper); + }); + + flushIcons(); } function filterSidebarTree(query) { @@ -1644,6 +1730,40 @@ return s; } + function appendHighlightedText(container, text, query, caseSensitive) { + container.textContent = ""; + if (!query) { + container.appendChild(document.createTextNode(text)); + return; + } + + const source = caseSensitive ? text : text.toLowerCase(); + const needle = caseSensitive ? query : query.toLowerCase(); + let start = 0; + let index = source.indexOf(needle, start); + + if (index === -1) { + container.appendChild(document.createTextNode(text)); + return; + } + + while (index !== -1) { + if (index > start) { + container.appendChild(document.createTextNode(text.slice(start, index))); + } + const mark = el("mark", { class: "filter-highlight" }, [ + document.createTextNode(text.slice(index, index + query.length)), + ]); + container.appendChild(mark); + start = index + query.length; + index = source.indexOf(needle, start); + } + + if (start < text.length) { + container.appendChild(document.createTextNode(text.slice(start))); + } + } + function showWelcome() { const area = document.getElementById("content-area"); area.innerHTML = ` diff --git a/frontend/style.css b/frontend/style.css index 7eec72f..3ef91d9 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -804,6 +804,59 @@ select { display: none; } +.filter-results-header { + margin-top: 8px; +} + +.filter-results-group { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0 10px 12px; +} + +.filter-result-item { + align-items: flex-start; + gap: 8px; + border-radius: 8px; +} + +.filter-result-text { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.filter-result-primary, +.filter-result-secondary { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + +.filter-result-primary { + color: var(--text-primary); +} + +.filter-result-secondary { + color: var(--text-muted); + font-size: 0.78rem; +} + +.sidebar-filter-empty { + padding: 12px 16px; + color: var(--text-muted); + font-size: 0.85rem; +} + +.filter-highlight { + background: color-mix(in srgb, var(--accent) 22%, transparent); + color: var(--accent); + padding: 0 2px; + border-radius: 4px; +} + /* --- Tag resize handle --- */ .tag-resize-handle { height: 5px;