Implement backend tree search API with frontend integration for real-time sidebar filtering with highlighted matches and vault-scoped results

This commit is contained in:
Bruno Charest 2026-03-22 22:06:18 -04:00
parent 29e6e1c052
commit 9cd0090117
3 changed files with 273 additions and 21 deletions

View File

@ -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.

View File

@ -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 = `

View File

@ -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;