// SPDX-License-Identifier: GPL-3.0-or-later
/* === Severity filters + search === */
let activeFilter = "all";
const PILL_ICONS = { all: ICON_ALL, high: SEV_ICONS.high, medium: SEV_ICONS.medium, info: SEV_ICONS.info };
function renderFilters(counts) {
const total = counts.high + counts.medium + counts.info;
const defs = [
{ sev: "all", label: "All" + (total ? " (" + total + ")" : ""), icon: PILL_ICONS.all },
{ sev: "high", label: "High" + (counts.high ? " (" + counts.high + ")" : ""), icon: PILL_ICONS.high },
{ sev: "medium", label: "Medium" + (counts.medium ? " (" + counts.medium + ")" : ""), icon: PILL_ICONS.medium },
{ sev: "info", label: "Info" + (counts.info ? " (" + counts.info + ")" : ""), icon: PILL_ICONS.info },
];
const empty = (sev) => sev !== "all" && (counts[sev] || 0) === 0;
if (empty(activeFilter)) activeFilter = "all";
$("#filters").innerHTML = defs.map((d) =>
''
).join("");
syncClear();
}
let filterTimer = null;
function applyFilters() {
// Debounce: typing "network" shouldn't re-layout 40 cards 7 times
if (filterTimer) clearTimeout(filterTimer);
filterTimer = setTimeout(() => applyFiltersNow(), 60);
}
function applyFiltersNow() {
const report = $("#report");
const hint = $("#drillhint");
report.hidden = !activeFilter;
hint.hidden = !!activeFilter;
if (activeFilter) {
hint.textContent = "";
} else {
hint.textContent = groupBy === "category"
? "Select a category to see findings."
: "Select a severity to see findings.";
}
const qRaw = $("#search").value.trim();
const q = qRaw.toLowerCase();
// code: prefix → filter by stable code only (e.g. "code:network/" or "code:disk/full")
const codeFilter = q.startsWith("code:") ? q.slice(5).trim() : null;
let visible = 0;
document.querySelectorAll("#report .group").forEach((g) => {
let any = false;
g.querySelectorAll(".card, .crow").forEach((c) => {
const sevOk = activeFilter === "all" || c.classList.contains(activeFilter);
let match = sevOk;
if (match && q) {
if (codeFilter !== null) {
const code = (c.dataset.code || "").toLowerCase();
match = code.includes(codeFilter);
} else {
match = c.textContent.toLowerCase().includes(q);
}
}
c.style.display = match ? "" : "none";
if (match) {
any = true;
highlightMatches(c, codeFilter !== null ? "" : qRaw);
} else {
highlightMatches(c, "");
}
});
if (!qRaw) g.querySelectorAll(".card, .crow").forEach(c => highlightMatches(c, ""));
g.hidden = !any;
if (any) visible++;
});
const nomatch = $("#nomatch");
nomatch.hidden = !(activeFilter && qRaw && visible === 0);
if (!nomatch.hidden) {
nomatch.innerHTML = '
No findings match \u201c' + esc(qRaw) + '\u201d
' +
'';
}
// Wide: if the pane's selection was filtered out, pick the next visible
// one; while it stays visible the pane is left alone (no scroll churn).
if (typeof wideModeOn === "function" && wideModeOn()) {
const sel = document.querySelector("#report details.card.selected");
if (!sel || sel.style.display === "none") autoSelectFinding();
}
syncClear();
syncAutoPausedUI();
syncUrlState();
}
function syncClear() {
const clear = $("#clearbtn");
const hasFilter = activeFilter && activeFilter !== "all";
const hasSearch = $("#search").value.trim().length > 0;
clear.disabled = !(hasFilter || hasSearch);
}
function highlightMatches(card, q) {
card.querySelectorAll("mark.hl").forEach(m => {
const t = document.createTextNode(m.textContent);
m.replaceWith(t);
});
card.normalize();
if (!q) return;
const terms = q.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (!terms.length) return;
const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT, {
acceptNode(n) {
if (!n.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
if (n.parentElement && n.parentElement.closest("button, a")) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp("(" + terms.map(escRe).join("|") + ")", "gi");
for (const n of nodes) {
if (!re.test(n.nodeValue)) continue;
re.lastIndex = 0;
const frag = document.createDocumentFragment();
let last = 0, m;
const txt = n.nodeValue;
while ((m = re.exec(txt)) !== null) {
if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
const mark = document.createElement("mark");
mark.className = "hl";
mark.textContent = m[0];
frag.appendChild(mark);
last = m.index + m[0].length;
}
if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last, txt.length)));
n.replaceWith(frag);
}
}
function syncGroupsOpen() {
// Wide mode is a workbench: every group stays open, filtering never
// collapses data the user can see at a glance. (wideModeOn is hoisted
// from ui-wide.js; guarded for the headless sandbox.)
const wideEl = typeof document !== "undefined" ? document.documentElement : null;
if (wideEl && wideEl.classList && wideEl.classList.contains("wide")) {
document.querySelectorAll("#report .group").forEach((g) => g.setAttribute("open", ""));
syncAutoPausedUI();
return;
}
// Severity mode: filtering reveals the group — open it so findings are
// visible right away. On "All", only High opens (progressive disclosure:
// Informational can hold dozens of items); a specific severity opens its
// own group. Category mode: leave expansion to the user.
document.querySelectorAll("#report .group").forEach((g) => {
if (g.dataset.type === "cat") return;
g.open = activeFilter === g.dataset.key || (activeFilter === "all" && g.dataset.key === "high");
});
syncAutoPausedUI();
}