blob: 2493fb1da7f350e70131cc787fac088d0211a59b [file]
// Copyright (c) 2026, the R8 project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/* ==========================================================================
APPLICATION LOGIC
==========================================================================
This script handles the interactive features of the Report.
It is divided into:
1. Constants & Configuration
2. UI Utilities (DOM helpers)
3. Main App Controller (Router)
4. ReportApp (The main table/grid view)
========================================================================== */
/**
* Global Constants used throughout the application.
*/
const CONSTANTS = {
VIEWS: {
MODULES: "modules",
PACKAGES: "packages",
DETAILS: "details",
FILE_DETAILS: "file-details"
},
DEFAULTS: {
AGGREGATED: "Aggregated"
},
};
/**
* UI Utilities
* Collection of helper functions for DOM manipulation and common UI patterns.
*/
const UIUtils = {
/**
* Returns the CSS class for score percentage coloring.
* @param {number|string} percentage - The score percentage (0-100 or "--").
* @returns {string} CSS class name.
*/
getScoreClass(percentage) {
if (percentage === "--") return "text-gray-500";
if (percentage >= 80) return "text-green-600";
if (percentage >= 60) return "text-yellow-600";
return "text-red-600";
},
getMatchClass(percentage) {
if (percentage === "--") return "text-gray-500";
if (percentage < 10) return "text-green-600";
if (percentage < 20) return "text-orange-600";
return "text-red-600";
},
/**
* Toggles the visibility of a DOM element.
* @param {HTMLElement} element - The element to toggle.
* @param {boolean} show - Whether to show or hide.
*/
toggleVisibility(element, show) {
if (!element) return;
if (show) {
element.classList.remove("hidden");
// Restore appropriate display type
if (element.id === "ts-selected-state") {
element.style.display = "flex";
} else {
element.style.display = "";
}
} else {
element.classList.add("hidden");
element.style.display = "none";
}
},
/**
* Builds a multi-select dropdown with "Select All" / "Clear" actions (Zone A)
* and a scrollable list of options (Zone B).
*
* @param {string} containerId - ID of the dropdown container.
* @param {Array} options - List of options {name, value}.
* @param {Array} selectedStateArr - Array storing currently selected values.
* @param {Function} onSelectionChange - Callback when selection changes.
* @param {boolean} searchable - Whether to include a search box.
* @param {boolean} multiSelect - Whether to allow multiple selections.
*/
buildActionDropdown(containerId, options, selectedStateArr,
onSelectionChange, searchable = true, multiSelect = true, totalCount =
null, itemName = "items", searchCallback = null) {
const container = document.getElementById(containerId);
if (!container) return;
// Container config
container.innerHTML = "";
container.style.padding = "0";
container.style.overflow = "hidden"; // Clip corners
// --- ZONE 0: Search (Top Level) ---
let searchInput = null;
if (searchable) {
const searchContainer = document.createElement("div");
searchContainer.className = "dropdown-search-zone";
searchContainer.style.padding = "0.5rem";
searchContainer.style.borderBottom = "1px solid var(--border-color)";
searchContainer.style.background = "var(--bg-surface)";
searchInput = document.createElement("input");
searchInput.type = "text";
searchInput.className = "popover-search";
searchInput.style.width = "100%";
searchInput.placeholder = "Search...";
searchContainer.appendChild(searchInput);
container.appendChild(searchContainer);
}
// --- ZONE B: Option List ---
const listZone = document.createElement("div");
listZone.className = "dropdown-scroll-zone";
container.appendChild(listZone);
// Render List Logic
const renderList = (optionsToRender) => {
listZone.innerHTML = "";
if (optionsToRender.length === 0) {
listZone.innerHTML =
`<div class="p-4 text-xs text-gray-400 text-center">No options available</div>`;
return;
}
optionsToRender.forEach(opt => {
// SKIP "All" options if they exist in the passed options list.
if (opt.value === "all") return;
const isChecked = selectedStateArr.includes(opt.value);
const item = document.createElement(multiSelect ? "label" :
"div");
item.className = "popover-item" + (isChecked && !multiSelect ?
" active-item" : "");
let checkbox = null;
if (multiSelect) {
checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "popover-checkbox";
checkbox.checked = isChecked;
item.appendChild(checkbox);
}
const label = document.createElement("span");
label.innerHTML = opt.name;
item.appendChild(label);
// Interaction: Toggle Individual
const handleSelect = (e) => {
if (multiSelect) {
if (checkbox.checked) {
if (!selectedStateArr.includes(opt.value))
selectedStateArr.push(opt.value);
} else {
const idx = selectedStateArr.indexOf(opt.value);
if (idx > -1) selectedStateArr.splice(idx, 1);
}
} else {
selectedStateArr.length = 0;
selectedStateArr.push(opt.value);
}
if (!multiSelect) {
renderList(optionsToRender);
}
onSelectionChange();
};
if (multiSelect) {
item.addEventListener("change", (e) => {
e.stopPropagation();
handleSelect(e);
});
} else {
item.addEventListener("click", (e) => {
e.stopPropagation();
handleSelect(e);
});
}
listZone.appendChild(item);
});
};
renderList(options);
// --- ZONE C: Footer ---
let footer = null;
if (totalCount !== null) {
footer = document.createElement("div");
footer.className = "dropdown-footer";
footer.style.padding = "0.5rem 1rem";
footer.style.fontSize = "0.75rem";
footer.style.color = "var(--text-gray-400)";
footer.style.borderTop = "1px solid var(--border-color)";
footer.style.background = "var(--bg-subtle)";
footer.textContent =
`Showing ${options.length} out of ${totalCount} ${itemName}`;
container.appendChild(footer);
}
// --- Search Logic ---
if (searchInput) {
searchInput.addEventListener("input", (e) => {
const term = e.target.value.toLowerCase();
if (searchCallback) {
const {
options: filteredOptions,
total: mCount
} = searchCallback(term);
renderList(filteredOptions);
if (footer) {
footer.textContent =
`Showing ${filteredOptions.length} out of ${mCount} ${itemName}`;
}
} else {
const items = listZone.querySelectorAll(".popover-item");
let visibleCount = 0;
items.forEach(item => {
const label = item.querySelector("span").textContent
.toLowerCase();
const isVisible = label.includes(term);
item.style.display = isVisible ? "flex" : "none";
if (isVisible) visibleCount++;
});
if (footer && totalCount !== null) {
footer.textContent =
`Showing ${visibleCount} out of ${totalCount} ${itemName}`;
}
}
});
}
},
/**
* Renders the text on the filter chip (e.g., "Module: All" or "Module: :core:network (+2)").
*/
renderChipText(element, label, type, isMulti = false) {
if (!element) return;
// Logic for displaying close button: ALWAYS show if 'type' is present (meaning removable)
// The user specifically requested option to remove filter when all items are selected.
const showClose = !!type;
if (!showClose) {
element.innerHTML = `<span class="filter-text">${label}</span>`;
} else {
element.innerHTML = `
<span class="filter-text">${label}</span>
<span class="chip-close" data-clear="${type}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="14" height="14">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
</span>`;
}
},
getFilterLabel(prefix, selectedArr, totalCount, options = []) {
if (selectedArr.length === 0 || (totalCount > 0 && selectedArr.length ===
totalCount)) return `${prefix}: All`;
if (selectedArr.length === 1) {
const val = selectedArr[0];
const opt = options.find(o => String(o.value) === String(val));
const name = opt ? opt.name : val;
// Strip HTML tags if any (like in keepRuleOptions)
const cleanName = name.replace(/<[^>]*>/g, "");
return `${prefix}: ${cleanName}`;
}
return `${prefix}: ${selectedArr.length} Selected`;
},
/**
* Finds the common prefix among an array of strings.
* @param {string[]} strings - Array of strings to analyze.
* @returns {string} The common prefix.
*/
findCommonPrefix(strings) {
if (!strings || strings.length === 0) return "";
let prefix = strings[0];
for (let i = 1; i < strings.length; i++) {
if (strings[i].startsWith("Android Gradle plugin")) {
continue;
}
while (strings[i].indexOf(prefix) !== 0) {
prefix = prefix.substring(0, prefix.length - 1);
if (prefix === "") return "";
}
}
// Ensure we only strip up to the last slash to keep the filename.
const lastSlash = prefix.lastIndexOf('/');
if (lastSlash !== -1) {
return prefix.substring(0, lastSlash + 1);
}
return "";
}
};
/* ==========================================================================
MAIN APP CONTROLLER
========================================================================== */
const App = {
keepRadiusData: null,
lookups: {},
detailsState: {
ruleId: null,
classQuery: "",
fieldQuery: "",
methodQuery: ""
},
init() {
// Initialize Report (Main Grid)
ReportApp.init();
// Load Protobuf Data in the Background
this.loadProtoData();
const headerLink = document.getElementById("header-link");
if (headerLink) {
headerLink.addEventListener("click", (e) => {
e.preventDefault();
this.showReportView();
});
}
// Setup Kept Lists Search listeners
const setupListSearch = (type, stateKey) => {
const toggleBtn = document.querySelector(`.search-toggle-btn[data-target="${type}"]`);
const container = document.getElementById(`${type}-search-container`);
const input = document.getElementById(`${type}-search-input`);
if (toggleBtn && container && input) {
toggleBtn.addEventListener("click", () => {
const isHidden = container.style.display === "none";
container.style.display = isHidden ? "block" : "none";
toggleBtn.classList.toggle("active", isHidden);
if (isHidden) {
input.focus();
} else {
// Clear query when closing search
input.value = "";
App.detailsState[stateKey] = "";
const rule = App.keepRadiusData?.keepRuleKeepRadiusTable.find(r => r.id === parseInt(App.detailsState.ruleId));
if (rule) App.renderKeptLists(rule, type);
}
});
input.addEventListener("input", (e) => {
App.detailsState[stateKey] = e.target.value;
const rule = App.keepRadiusData?.keepRuleKeepRadiusTable.find(r => r.id === parseInt(App.detailsState.ruleId));
if (rule) App.renderKeptLists(rule, type);
});
}
};
setupListSearch("classes", "classQuery");
setupListSearch("fields", "fieldQuery");
setupListSearch("methods", "methodQuery");
},
showDetailsView(ruleId) {
this.detailsState.ruleId = ruleId;
this.detailsState.classQuery = "";
this.detailsState.fieldQuery = "";
this.detailsState.methodQuery = "";
ReportApp.state.currentView = CONSTANTS.VIEWS.DETAILS;
document.getElementById("report-view").style.display = "none";
document.getElementById("file-details-view").style.display = "none";
document.getElementById("report-view-controls").style.display = "none";
document.getElementById("details-view").style.display = "flex";
const ruleContainer = document.getElementById("details-rule-container");
const impactContainer = document.getElementById("details-impact-container");
const identicalRulesBody = document.getElementById(
"details-identical-rules-body");
const identicalRulesHeader = document.getElementById(
"details-identical-rules-header");
const identicalRulesTitle = document.getElementById(
"details-identical-rules-title");
const subsumedByBody = document.getElementById(
"details-subsumed-by-body");
const subsumedByHeader = document.getElementById(
"details-subsumed-by-header");
const subsumedByTitle = document.getElementById(
"details-subsumed-by-title");
const impactHeader = document.getElementById(
"details-rule-impact-header");
const classesContent = document.getElementById("details-classes-content");
const methodsContent = document.getElementById("details-methods-content");
const fieldsContent = document.getElementById("details-fields-content");
let rule = this.keepRadiusData?.keepRuleKeepRadiusTable.find(r => r.id === parseInt(ruleId));
let isGlobal = false;
if (!rule) {
rule = this.keepRadiusData?.globalKeepRuleKeepRadiusTable.find(r => r.id === parseInt(ruleId));
isGlobal = true;
}
if (rule) {
const fileOriginId = rule?.origin?.fileOriginId;
const fileOrigin = this.keepRadiusData?.fileOriginTable.find(f => f
.id === fileOriginId);
let originStr = "";
if (fileOrigin) {
const mavenName = formatMavenCoordinate(fileOrigin.mavenCoordinate);
originStr =
`${mavenName || fileOrigin.filename}:${rule.origin?.lineNumber || 1}`;
if (mavenName) {
originStr += ` (${fileOrigin.filename})`;
}
}
if (ruleContainer) {
const highlightedSource = highlightRule(rule.source);
ruleContainer.innerHTML = `
${originStr ? `<div style="font-size: 0.75rem; color: var(--text-gray-500); margin-bottom: 0.5rem; font-family: var(--font-family-mono);">${escapeHTML(originStr)}</div>` : ""}
<div style="background-color: var(--bg-body); border: 1px solid var(--border-color); border-radius: 4px; padding: 0.75rem;">
<pre style="white-space: pre-wrap; font-family: var(--font-family-mono); font-size: 0.8125rem; margin: 0; color: var(--text-main);">${highlightedSource}</pre>
</div>
`;
}
const impactContainer = document.getElementById("details-impact-container");
const relatedRulesContainer = document.getElementById("related-rules-container");
const gridContainer = document.getElementById("details-classes-content")?.parentElement?.parentElement;
if (isGlobal) {
if (impactContainer) impactContainer.parentElement.style.display = "none";
if (relatedRulesContainer) relatedRulesContainer.style.display = "none";
if (gridContainer) gridContainer.style.display = "none";
} else {
if (impactContainer) impactContainer.parentElement.style.display = "";
if (relatedRulesContainer) relatedRulesContainer.style.display = "";
if (gridContainer) gridContainer.style.display = "grid";
const constraintsMap = getConstraintsMap(this.keepRadiusData);
const constraints = constraintsMap.get(rule.constraintsId) || [];
const getTag = (c, label) => {
const isRestricted = constraints.includes(c);
if (!isRestricted) return "";
const color = "oklch(0.446 0.043 257.281)";
const bgColor = "oklch(0.984 0.003 247.858)";
const borderColor = "oklch(0.929 0.013 255.508)";
return `<span class="impact-tag" style="display: inline-block; color: ${color}; background-color: ${bgColor}; border: 1px solid ${borderColor}; border-radius: 4px; padding: 2px 8px; font-size: 10px; font-weight: 400; height: 21px; line-height: 15px; text-transform: uppercase; letter-spacing: 0.25px; box-sizing: border-box; text-align: center;">${label}</span>`;
};
const impactTagsHtml = `
<div class="impact-container">
${getTag('DONT_OBFUSCATE', 'OBFUSCATE')}
${getTag('DONT_OPTIMIZE', 'OPTIMIZE')}
${getTag('DONT_SHRINK', 'SHRINK')}
</div>
`;
const br = rule.keepRadius || {};
const classIds = br.classKeepRadius || [];
const fieldIds = br.fieldKeepRadius || [];
const methodIds = br.methodKeepRadius || [];
const matchedTotal = classIds.length + fieldIds.length + methodIds
.length;
const totalLive = getLiveItemCount(this.keepRadiusData);
const liveClasses = this.keepRadiusData?.buildInfo?.liveClassCount ||
0;
const liveFields = this.keepRadiusData?.buildInfo?.liveFieldCount || 0;
const liveMethods = this.keepRadiusData?.buildInfo?.liveMethodCount ||
0;
const renderMatchCell = (count, total, borderLeft = true) => {
const perc = total > 0 ? (count / total * 100) : 0;
const colorClass = UIUtils.getMatchClass(perc);
const bl = borderLeft ? "border-l border-gray-200" : "";
return `
<td class="text-center ${bl}" style="padding: 1rem; width: 100px; min-width: 100px;">
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span class="font-medium ${colorClass}">${perc.toFixed(1)}%</span>
<span class="text-xs text-gray-500 mt-1">${count}</span>
</div>
</td>
`;
};
if (impactContainer) {
const getPerc = (count, total) => total > 0 ? (count / total * 100) : 0;
const classPerc = getPerc(classIds.length, liveClasses);
const fieldPerc = getPerc(fieldIds.length, liveFields);
const methodPerc = getPerc(methodIds.length, liveMethods);
impactContainer.innerHTML = `
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 2rem; padding: 0; align-items: start;">
<!-- Kept Classes -->
<div>
<div style="color: var(--text-gray-500); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem;">Kept Classes</div>
<div style="display: flex; align-items: baseline; gap: 0.5rem; margin-bottom: 0.5rem;">
<span style="color: oklch(0.505 0.213 27.518); font-size: 1.25rem; font-weight: 500;">${classPerc.toFixed(1)}%</span>
<span style="color: var(--text-gray-500); font-size: 0.75rem;">${classIds.length} / ${liveClasses}</span>
</div>
<div style="height: 4px; background-color: var(--bg-hover); border-radius: 2px; width: 100%;">
<div style="height: 100%; background-color: oklch(0.505 0.213 27.518); border-radius: 2px; width: ${classPerc}%;"></div>
</div>
</div>
<!-- Kept Fields -->
<div>
<div style="color: var(--text-gray-500); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem;">Kept Fields</div>
<div style="display: flex; align-items: baseline; gap: 0.5rem; margin-bottom: 0.5rem;">
<span style="color: oklch(0.505 0.213 27.518); font-size: 1.25rem; font-weight: 500;">${fieldPerc.toFixed(1)}%</span>
<span style="color: var(--text-gray-500); font-size: 0.75rem;">${fieldIds.length} / ${liveFields}</span>
</div>
<div style="height: 4px; background-color: var(--bg-hover); border-radius: 2px; width: 100%;">
<div style="height: 100%; background-color: oklch(0.505 0.213 27.518); border-radius: 2px; width: ${fieldPerc}%;"></div>
</div>
</div>
<!-- Kept Methods -->
<div>
<div style="color: var(--text-gray-500); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem;">Kept Methods</div>
<div style="display: flex; align-items: baseline; gap: 0.5rem; margin-bottom: 0.5rem;">
<span style="color: oklch(0.505 0.213 27.518); font-size: 1.25rem; font-weight: 500;">${methodPerc.toFixed(1)}%</span>
<span style="color: var(--text-gray-500); font-size: 0.75rem;">${methodIds.length} / ${liveMethods}</span>
</div>
<div style="height: 4px; background-color: var(--bg-hover); border-radius: 2px; width: 100%;">
<div style="height: 100%; background-color: oklch(0.505 0.213 27.518); border-radius: 2px; width: ${methodPerc}%;"></div>
</div>
</div>
<!-- Blocked by Rule -->
<div>
<div style="color: var(--text-gray-500); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: .80rem;">Blocked by Rule</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
${impactTagsHtml}
</div>
</div>
</div>
`;
}
const allRules = this.keepRadiusData?.keepRuleKeepRadiusTable || [];
const subsumingIds = br.subsumedBy || [];
const identicalRules = [];
const subsumedByRules = [];
subsumingIds.forEach(id => {
const otherRule = allRules.find(r => r.id === id);
if (otherRule) {
const otherSubsumedBy = otherRule.keepRadius?.subsumedBy || [];
if (otherSubsumedBy.includes(rule.id)) {
identicalRules.push(otherRule);
} else {
subsumedByRules.push(otherRule);
}
}
});
if (relatedRulesContainer) {
const hasIdentical = identicalRules.length > 0;
const hasSubsumed = subsumedByRules.length > 0;
const renderDetailRuleRow = (r) => {
const rBr = r.keepRadius || {};
const rClassIds = rBr.classKeepRadius || [];
const rFieldIds = rBr.fieldKeepRadius || [];
const rMethodIds = rBr.methodKeepRadius || [];
const totalLive = getLiveItemCount(this.keepRadiusData);
const liveClasses = this.keepRadiusData?.buildInfo?.liveClassCount || 0;
const liveFields = this.keepRadiusData?.buildInfo?.liveFieldCount || 0;
const liveMethods = this.keepRadiusData?.buildInfo?.liveMethodCount || 0;
const renderMatchCell = (count, total) => {
const perc = total > 0 ? (count / total * 100) : 0;
return `
<td class="text-center" style="padding: 0.5rem; border-left: 1px solid var(--border-color);">
<div style="display: flex; flex-direction: column; align-items: center;">
<span style="color: var(--text-red-600); font-weight: 500;">${perc.toFixed(1)}%</span>
<span class="text-xs text-gray-500">${count}</span>
</div>
</td>
`;
};
const constraintsMap = getConstraintsMap(this.keepRadiusData);
const rConstraints = constraintsMap.get(r.constraintsId) || [];
const getTag = (c, label) => {
const isRestricted = rConstraints.includes(c);
const color = isRestricted ? "oklch(0.446 0.043 257.281)" : "#cbd5e1";
const bgColor = isRestricted ? "oklch(0.984 0.003 247.858)" : "#f8fafc";
const borderColor = isRestricted ? "oklch(0.929 0.013 255.508)" : "#e2e8f0";
return `<span class="impact-tag" style="display: inline-block; color: ${color}; background-color: ${bgColor}; border: 1px solid ${borderColor}; border-radius: 4px; padding: 2px 8px; font-size: 10px; font-weight: 400; height: 21px; line-height: 15px; text-transform: uppercase; letter-spacing: 0.25px; box-sizing: border-box; text-align: center;">${label}</span>`;
};
const impactCell = `
<td style="padding: 1rem; border-left: 1px solid var(--border-color);">
<div class="flex justify-start" style="gap: 0.5rem;">
${getTag('DONT_OBFUSCATE', 'OBFUSCATE')}
${getTag('DONT_OPTIMIZE', 'OPTIMIZE')}
${getTag('DONT_SHRINK', 'SHRINK')}
</div>
</td>
`;
return `
<tr class="table-row border-t border-gray-200 hover:bg-gray-50 cursor-pointer" onclick="App.showDetailsView('${r.id}')">
<td style="padding: 0.5rem; width: 40%;">
<pre style="white-space: pre-wrap; font-family: var(--font-family-mono); font-size: 0.8125rem; margin: 0;">${highlightRule(r.source)}</pre>
</td>
${renderMatchCell(rClassIds.length + rFieldIds.length + rMethodIds.length, totalLive)}
${renderMatchCell(rClassIds.length, liveClasses)}
${renderMatchCell(rFieldIds.length, liveFields)}
${renderMatchCell(rMethodIds.length, liveMethods)}
${impactCell}
</tr>
`;
};
const renderSection = (title, rules, explainer) => {
const hasRules = rules.length > 0;
let sectionHtml = `
<div class="table-container" style="display: flex; flex-direction: column;">
<div style="padding: 0.75rem 1rem; border-bottom: 1px solid var(--border-color); background-color: var(--bg-subtle); display: flex; justify-content: space-between; align-items: center;">
<div>
<span style="font-size: 0.875rem; font-weight: 600;">${title}</span>
<span style="color: var(--text-gray-500); font-size: 0.75rem; margin-left: 0.25rem;">· ${hasRules ? rules.length : 'None'}</span>
</div>
${hasRules ? `<span style="color: var(--text-gray-500); font-size: 0.75rem;">${explainer}</span>` : ''}
</div>
`;
if (hasRules) {
sectionHtml += `
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th rowspan="2" class="text-left bg-gray-50" style="padding: 0 1rem; width: 40%;">RULE</th>
<th colspan="4" class="text-center bg-gray-50" style="padding: .25rem 1rem; width: 40%; border-left: 1px solid var(--border-color);">KEPT ITEMS <span style="color: var(--text-muted);text-transform: none;font-weight: 500;">Higher is worse</span> <span class="tooltip-icon" data-tooltip="Items retained in the app due to this rule">?</span></th>
<th rowspan="2" class="text-left bg-gray-50" style="padding: 0 1rem; width: 20%; border-left: 1px solid var(--border-color);">BLOCKED BY RULE <span class="tooltip-icon" data-tooltip="Specific actions blocked by this rule">?</span></th>
</tr>
<tr>
<th class="text-center text-xs font-medium text-gray-500" style="padding: .15rem 1rem; width: 10%; border-left: 1px solid var(--border-color);">Total</th>
<th class="text-center text-xs font-medium text-gray-500" style="padding: .15rem 1rem; width: 10%; border-left: 1px solid var(--border-color);">Classes</th>
<th class="text-center text-xs font-medium text-gray-500" style="padding: .15rem 1rem; width: 10%; border-left: 1px solid var(--border-color);">Fields</th>
<th class="text-center text-xs font-medium text-gray-500" style="padding: .15rem 1rem; width: 10%; border-left: 1px solid var(--border-color);">Methods</th>
</tr>
</thead>
<tbody>
${rules.map(r => renderDetailRuleRow(r)).join('')}
</tbody>
</table>
`;
}
sectionHtml += `</div>`;
return sectionHtml;
};
if (!hasIdentical && !hasSubsumed) {
relatedRulesContainer.innerHTML = `
<div class="table-container" style="padding: 0.75rem 1rem; font-size: 0.875rem; display: flex; gap: 2rem; align-items: center;">
<span style="font-weight: 600; color: var(--text-gray-900);">Related rules:</span>
<span style="color: var(--text-gray-500);">Identical · None</span>
<span style="color: var(--text-gray-500);">Subsumed by · None</span>
</div>
`;
} else {
let html = '<div style="display: flex; flex-direction: column; gap: 1.5rem;">';
html += renderSection("Identical Rules", identicalRules, "Same matchers, can be deduplicated");
html += renderSection("Subsumed By", subsumedByRules, "Already covered by a broader rule");
html += '</div>';
relatedRulesContainer.innerHTML = html;
}
}
// Reset search inputs UI
const resetSearchUI = (type) => {
const container = document.getElementById(`${type}-search-container`);
const input = document.getElementById(`${type}-search-input`);
const toggleBtn = document.querySelector(`.search-toggle-btn[data-target="${type}"]`);
if (container && input && toggleBtn) {
container.style.display = "none";
input.value = "";
toggleBtn.classList.remove("active");
}
};
resetSearchUI("classes");
resetSearchUI("fields");
resetSearchUI("methods");
// Initial render of kept lists
this.renderKeptLists(rule);
}
} else {
ruleBody.innerHTML =
'<tr><td colspan="2" style="padding: 1rem;">Rule not found.</td></tr>';
classesContent.innerHTML = "";
methodsContent.innerHTML = "";
fieldsContent.innerHTML = "";
}
const fileOrigin = this.keepRadiusData?.fileOriginTable.find(f => f
.id === rule?.origin?.fileOriginId);
const fileOriginName = formatMavenCoordinate(fileOrigin
?.mavenCoordinate) || fileOrigin?.filename;
this.updateDetailsBreadcrumbs(fileOriginName, fileOrigin?.id);
},
renderKeptLists(rule, targetType = null) {
const br = rule.keepRadius || {};
const typeRefMap = App.lookups.typeReference;
const renderList = (ids, getLabel) => {
if (ids.length === 0)
return '<div style="padding: 0.5rem; color: var(--text-gray-500); font-size: 0.8125rem;">None</div>';
const formatSleekItem = (fullName) => {
const firstParen = fullName.indexOf('(');
const searchString = firstParen === -1 ? fullName : fullName.substring(0, firstParen);
const lastDot = searchString.lastIndexOf('.');
if (lastDot === -1) {
return `<span style="color: var(--text-gray-900); font-weight: 500;">${escapeHTML(fullName)}</span>`;
}
const pkg = fullName.substring(0, lastDot + 1);
const name = fullName.substring(lastDot + 1);
return `<span style="color: var(--text-gray-500);">${escapeHTML(pkg)}</span><span style="color: var(--text-gray-900); font-weight: 500;">${escapeHTML(name)}</span>`;
};
const limit = 1000;
const toRender = ids.slice(0, limit);
const listHtml = toRender.map(id => `
<div style="padding: 0.375rem 0.5rem; border-bottom: 1px solid #f1f5f9; font-family: var(--font-family-mono); font-size: 0.8125rem;" class="hover-bg-gray-100">
${formatSleekItem(getLabel(id))}
</div>
`).join("");
if (ids.length > limit) {
return listHtml + `
<div style="padding: 0.5rem; color: var(--text-gray-500); font-size: 0.8125rem; font-style: italic;">
... and ${ids.length - limit} more items
</div>`;
}
return listHtml;
};
// 1. CLASSES COLUMN
if (!targetType || targetType === "classes") {
const classIds = br.classKeepRadius || [];
const classesContent = document.getElementById("details-classes-content");
const filteredClassIds = classIds.filter(id => {
const info = App.lookups.keptClassInfo.get(id);
const name = formatDescriptor(typeRefMap.get(info?.classReferenceId));
return name.toLowerCase().includes(this.detailsState.classQuery.toLowerCase());
});
document.getElementById("details-classes-count").textContent = `· ${filteredClassIds.length}`;
classesContent.innerHTML = renderList(filteredClassIds, (id) => {
const info = App.lookups.keptClassInfo.get(id);
return formatDescriptor(typeRefMap.get(info?.classReferenceId));
});
}
// 2. FIELDS COLUMN
if (!targetType || targetType === "fields") {
const fieldIds = br.fieldKeepRadius || [];
const fieldsContent = document.getElementById("details-fields-content");
const filteredFieldIds = fieldIds.filter(id => {
const info = App.lookups.keptFieldInfo.get(id);
const ref = App.lookups.fieldReference.get(info?.fieldReferenceId);
const name = formatFieldName(ref, App.lookups);
return name.toLowerCase().includes(this.detailsState.fieldQuery.toLowerCase());
});
document.getElementById("details-fields-count").textContent = `· ${filteredFieldIds.length}`;
fieldsContent.innerHTML = renderList(filteredFieldIds, (id) => {
const info = App.lookups.keptFieldInfo.get(id);
const ref = App.lookups.fieldReference.get(info?.fieldReferenceId);
return formatFieldName(ref, App.lookups);
});
}
// 3. METHODS COLUMN
if (!targetType || targetType === "methods") {
const methodIds = br.methodKeepRadius || [];
const methodsContent = document.getElementById("details-methods-content");
const filteredMethodIds = methodIds.filter(id => {
const info = App.lookups.keptMethodInfo.get(id);
const ref = App.lookups.methodReference.get(info?.methodReferenceId);
const name = formatMethodName(ref, App.lookups);
return name.toLowerCase().includes(this.detailsState.methodQuery.toLowerCase());
});
document.getElementById("details-methods-count").textContent = `· ${filteredMethodIds.length}`;
methodsContent.innerHTML = renderList(filteredMethodIds, (id) => {
const info = App.lookups.keptMethodInfo.get(id);
const ref = App.lookups.methodReference.get(info?.methodReferenceId);
return formatMethodName(ref, App.lookups);
});
}
},
showReportView() {
ReportApp.state.currentView = CONSTANTS.VIEWS.MODULES;
document.getElementById("details-view").style.display = "none";
document.getElementById("file-details-view").style.display = "none";
document.getElementById("report-view-controls").style.display = "flex";
document.getElementById("report-view").style.display = "flex";
ReportApp.render();
},
showFileDetailsView(fileOriginId) {
ReportApp.state.currentView = CONSTANTS.VIEWS.FILE_DETAILS;
ReportApp.state.drillContext.fileOriginId = fileOriginId;
this.renderFileDetailsView(fileOriginId);
},
renderFileDetailsView(fileOriginId) {
document.getElementById("report-view").style.display = "none";
document.getElementById("details-view").style.display = "none";
document.getElementById("report-view-controls").style.display = "none";
document.getElementById("file-details-view").style.display = "flex";
const impactBody = document.getElementById("file-details-impact-body");
const rulesBody = document.getElementById("file-details-rules-body");
const rulesHeader = document.getElementById("file-details-rules-header");
const fileOrigin = this.keepRadiusData?.fileOriginTable.find(f => f
.id === parseInt(fileOriginId));
if (!fileOrigin) return;
const fileRules = this.keepRadiusData.keepRuleKeepRadiusTable
.filter(r => r.origin?.fileOriginId === fileOrigin.id);
const globalRules = [];
if (this.keepRadiusData.globalKeepRuleKeepRadiusTable) {
const totalClasses = this.keepRadiusData?.buildInfo?.liveClassCount || 0;
const totalFields = this.keepRadiusData?.buildInfo?.liveFieldCount || 0;
const totalMethods = this.keepRadiusData?.buildInfo?.liveMethodCount || 0;
this.keepRadiusData.globalKeepRuleKeepRadiusTable.forEach(rule => {
if (rule.source === '-dontoptimize' || rule.source === '-dontshrink' || rule.source === '-dontobfuscate') {
if (rule.origin?.fileOriginId === fileOrigin.id) {
globalRules.push({
id: rule.id,
source: rule.source,
constraintsId: rule.constraintsId,
isGlobal: true,
keepRadius: {
classKeepRadius: new Array(totalClasses),
fieldKeepRadius: new Array(totalFields),
methodKeepRadius: new Array(totalMethods),
}
});
}
}
});
}
const allRulesForFile = [...globalRules, ...fileRules];
let rules = allRulesForFile;
const lens = ReportApp.state.filters.keepRules[0];
if (lens) {
rules = ReportApp.applyKeepRuleLens(rules, lens);
}
const isIdenticalLens = lens === "Identical";
const isSubsumedLens = lens === "Subsumed";
if (isIdenticalLens || isSubsumedLens) {
rulesHeader.innerHTML = `
<tr>
<th class="text-left bg-gray-50 z-30" style="padding: 1rem; width: 600px; min-width: 600px; border-bottom: 1px solid var(--border-color);">Rule</th>
<th class="text-left bg-gray-50 border-l border-gray-200" style="padding: 1rem; width: 100%; border-bottom: 1px solid var(--border-color);">${isIdenticalLens ? 'Identical Rules' : 'Subsumed By'}</th>
</tr>
`;
} else {
rulesHeader.innerHTML = `
<tr>
<th rowspan="2" class="text-left bg-gray-50" style="padding: 1rem; width: 40%; border-bottom: 1px solid var(--border-color);">Rule</th>
<th colspan="4" class="text-center bg-gray-50" style="padding: 0.5rem; width: 40%; border-left: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);">Kept Items <span style="color: var(--text-muted);text-transform: none;font-weight: 500;">Higher is worse</span></th>
<th rowspan="2" class="text-left bg-gray-50" style="padding: 0.5rem; width: 20%; border-left: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);">Blocked by Rule</th>
</tr>
<tr>
<th class="text-center text-xs font-medium text-gray-500" style="padding: 0.5rem; width: 10%; border-left: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);">Total</th>
<th class="text-center text-xs font-medium text-gray-500" style="padding: 0.5rem; width: 10%; border-left: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);">Classes</th>
<th class="text-center text-xs font-medium text-gray-500" style="padding: 0.5rem; width: 10%; border-left: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);">Fields</th>
<th class="text-center text-xs font-medium text-gray-500" style="padding: 0.5rem; width: 10%; border-left: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);">Methods</th>
</tr>
`;
}
const liveClasses = this.keepRadiusData?.buildInfo?.liveClassCount || 0;
const liveFields = this.keepRadiusData?.buildInfo?.liveFieldCount || 0;
const liveMethods = this.keepRadiusData?.buildInfo?.liveMethodCount || 0;
const matchedClasses = new Set();
const matchedFields = new Set();
const matchedMethods = new Set();
allRulesForFile.forEach(rule => {
const br = rule.keepRadius || {};
(br.classKeepRadius || []).forEach(id => matchedClasses.add(id));
(br.fieldKeepRadius || []).forEach(id => matchedFields.add(id));
(br.methodKeepRadius || []).forEach(id => matchedMethods.add(id));
});
const renderMatchCell = (count, total, borderLeft = true) => {
const perc = total > 0 ? (count / total * 100) : 0;
const colorClass = UIUtils.getMatchClass(perc);
const bl = borderLeft ? "border-l border-gray-200" : "";
return `
<td class="text-center ${bl}" style="padding: 1rem;">
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span class="font-medium ${colorClass}">${perc.toFixed(1)}%</span>
<span class="text-xs text-gray-500 mt-1">${count}</span>
</div>
</td>
`;
};
impactBody.innerHTML = `
<tr class="border-t border-gray-200">
${renderMatchCell(matchedClasses.size, liveClasses, false)}
${renderMatchCell(matchedFields.size, liveFields)}
${renderMatchCell(matchedMethods.size, liveMethods)}
</tr>
`;
rulesBody.innerHTML = rules.map(rule => {
if (isIdenticalLens || isSubsumedLens) {
const list = isIdenticalLens ? rule.identicalRules : rule
.subsumedByRules;
const otherRulesHtml = (list || []).map(other => `
<div class="text-xs text-blue-600 hover:underline cursor-pointer mb-1" onclick="event.stopPropagation(); App.showDetailsView('${other.id}')">
<pre style="white-space: pre-wrap; margin: 0; font-family: var(--font-family-mono);">${escapeHTML(other.source)}</pre>
</div>
`).join("");
return `
<tr class="border-t border-gray-200 hover:bg-gray-50 cursor-pointer" onclick="App.showDetailsView('${rule.id}')">
<td class="sticky-name font-medium text-blue-600 hover:underline" title="${escapeHTML(rule.source)}" style="padding: 1rem; width: 600px; min-width: 600px;">
<pre style="white-space: pre-wrap; font-family: var(--font-family-mono); font-size: 0.8125rem; margin: 0; pointer-events: none;">${escapeHTML(rule.source)}</pre>
</td>
<td class="border-l border-gray-200" style="padding: 1rem; width: 100%;">
${otherRulesHtml || '<span class="text-gray-400 italic">None</span>'}
</td>
</tr>
`;
}
const br = rule.keepRadius || {};
const c = (br.classKeepRadius || []).length;
const f = (br.fieldKeepRadius || []).length;
const m = (br.methodKeepRadius || []).length;
const constraintsMap = getConstraintsMap(this.keepRadiusData);
let constraints = [...(constraintsMap.get(rule.constraintsId) || [])];
if (rule.isGlobal) {
if (rule.source === '-dontobfuscate' && !constraints.includes('DONT_OBFUSCATE')) {
constraints.push('DONT_OBFUSCATE');
}
if (rule.source === '-dontoptimize' && !constraints.includes('DONT_OPTIMIZE')) {
constraints.push('DONT_OPTIMIZE');
}
if (rule.source === '-dontshrink' && !constraints.includes('DONT_SHRINK')) {
constraints.push('DONT_SHRINK');
}
}
const getTag = (c, label) => {
const isRestricted = constraints.includes(c);
const color = isRestricted ? "oklch(0.446 0.043 257.281)" : "#cbd5e1";
const bgColor = isRestricted ? "oklch(0.984 0.003 247.858)" : "#f8fafc";
const borderColor = isRestricted ? "oklch(0.929 0.013 255.508)" : "#e2e8f0";
return `<span class="impact-tag" style="display: inline-block; color: ${color}; background-color: ${bgColor}; border: 1px solid ${borderColor}; border-radius: 4px; padding: 2px 8px; font-size: 10px; font-weight: 400; height: 21px; line-height: 15px; text-transform: uppercase; letter-spacing: 0.25px; box-sizing: border-box; text-align: center;">${label}</span>`;
};
const impactCell = `
<td style="padding: 0.5rem; border-left: 1px solid var(--border-color);">
<div class="flex justify-start" style="gap: 0.5rem;">
${getTag('DONT_OBFUSCATE', 'OBFUSCATE')}
${getTag('DONT_OPTIMIZE', 'OPTIMIZE')}
${getTag('DONT_SHRINK', 'SHRINK')}
</div>
</td>
`;
const renderMatchCell = (count, total, borderLeft = true) => {
const perc = total > 0 ? (count / total * 100) : 0;
const colorClass = UIUtils.getMatchClass(perc);
const bl = borderLeft ? "border-left: 1px solid var(--border-color);" : "";
return `
<td class="text-center" style="padding: 1rem; ${bl}">
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span class="font-medium ${colorClass}">${perc.toFixed(1)}%</span>
<span class="text-xs text-gray-500 mt-1">${count}</span>
</div>
</td>
`;
};
const totalLive = getLiveItemCount(this.keepRadiusData);
const liveClasses = this.keepRadiusData?.buildInfo?.liveClassCount || 0;
const liveFields = this.keepRadiusData?.buildInfo?.liveFieldCount || 0;
const liveMethods = this.keepRadiusData?.buildInfo?.liveMethodCount || 0;
const highlightedSource = highlightRule(rule.source);
return `
<tr class="border-t border-gray-200 hover:bg-gray-50 cursor-pointer" onclick="App.showDetailsView('${rule.id}')">
<td style="padding: 0.5rem; width: 40%;">
<pre style="white-space: pre-wrap; font-family: var(--font-family-mono); font-size: 0.8125rem; margin: 0;">${highlightedSource}</pre>
</td>
${renderMatchCell(c + f + m, totalLive, true)}
${renderMatchCell(c, liveClasses)}
${renderMatchCell(f, liveFields)}
${renderMatchCell(m, liveMethods)}
${impactCell}
</tr>
`;
}).join("");
const fileOriginName = formatMavenCoordinate(fileOrigin
.mavenCoordinate) || fileOrigin.filename;
this.updateFileDetailsBreadcrumbs(fileOriginName);
},
updateFileDetailsBreadcrumbs(filename) {
const bc = document.getElementById("file-details-breadcrumbs");
if (!bc) return;
const linkClass = "breadcrumb-pill";
const textClass = "breadcrumb-text";
const sep = '<span class="text-gray-300 mx-1">/</span>';
bc.innerHTML = `
<span class="${linkClass}" id="file-details-back-to-summary">Project</span>
${sep}
<span class="${textClass}">${escapeHTML(filename)}</span>
`;
document.getElementById("file-details-back-to-summary").addEventListener(
"click", () => {
this.showReportView();
});
},
updateDetailsBreadcrumbs(filename, fileOriginId) {
const bc = document.getElementById("details-breadcrumbs");
if (!bc) return;
const linkClass = "breadcrumb-pill";
const textClass = "breadcrumb-text";
const sep = '<span class="text-gray-300 mx-1">/</span>';
let html = `
<span class="${linkClass}" id="details-back-to-summary">Project</span>
${sep}`;
if (filename && fileOriginId !== undefined) {
html += `
<span class="${linkClass}" id="details-back-to-file">${escapeHTML(filename)}</span>
${sep}`;
}
html += `<span class="${textClass}">Keep Rule Details</span>`;
bc.innerHTML = html;
document.getElementById("details-back-to-summary").addEventListener(
"click", () => {
this.showReportView();
});
if (filename && fileOriginId !== undefined) {
document.getElementById("details-back-to-file").addEventListener(
"click", () => {
this.showFileDetailsView(fileOriginId);
});
}
},
async loadProtoData() {
const embeddedProtoSchemaSource = document.getElementById('keepradius-proto');
const embeddedProtoDataSource = document.getElementById('keepradius-data');
try {
const root = protobuf.parse(embeddedProtoSchemaSource.textContent).root;
const data = embeddedProtoDataSource.textContent.trim();
const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0));
const KeepRadiusContainer = root.lookupType(
"com.android.tools.r8.keepradius.proto.KeepRadiusContainer");
const message = KeepRadiusContainer.decode(bytes);
this.keepRadiusData = KeepRadiusContainer.toObject(message, {
longs: String,
enums: String,
bytes: String,
defaults: true,
arrays: true,
objects: true,
oneofs: true
});
// Extract and strip common prefix from file names
if (this.keepRadiusData.fileOriginTable) {
const filenames = this.keepRadiusData.fileOriginTable.map(f => f
.filename).filter(Boolean);
const commonPrefix = UIUtils.findCommonPrefix(filenames);
if (commonPrefix) {
this.keepRadiusData.fileOriginTable.forEach(f => {
if (f.filename && f.filename.startsWith(commonPrefix)) {
f.filename = f.filename.substring(commonPrefix.length);
}
});
}
}
// Build highly optimized O(1) lookup Maps for details and filtering views
App.lookups = {
keptClassInfo: new Map((this.keepRadiusData.keptClassInfoTable || []).map(c => [c.id, c])),
keptFieldInfo: new Map((this.keepRadiusData.keptFieldInfoTable || []).map(f => [f.id, f])),
keptMethodInfo: new Map((this.keepRadiusData.keptMethodInfoTable || []).map(m => [m.id, m])),
fieldReference: new Map((this.keepRadiusData.fieldReferenceTable || []).map(r => [r.id, r])),
methodReference: new Map((this.keepRadiusData.methodReferenceTable || []).map(r => [r.id, r])),
protoReference: new Map((this.keepRadiusData.protoReferenceTable || []).map(p => [p.id, p])),
typeReferenceList: new Map((this.keepRadiusData.typeReferenceListTable || []).map(l => [l.id, l])),
typeReference: new Map((this.keepRadiusData.typeReferenceTable || []).map(t => [t.id, t.javaDescriptor]))
};
console.log("Protobuf data loaded successfully:", this
.keepRadiusData);
// Trigger re-render now that data is available
ReportApp.render();
} catch (err) {
console.error("Failed to load protobuf data:", err);
}
},
};
/* ==========================================================================
REPORT APP (Grid/List View)
========================================================================== */
const ReportApp = {
state: {
currentView: CONSTANTS.VIEWS.MODULES,
filters: {
keepRules: [],
classes: [],
fields: [],
methods: []
},
activeFilterChips: [],
sort: {
by: "matches.total",
order: "desc"
},
drillContext: {
module: null,
pkg: null,
fileOriginId: null
}, // Navigation path
statsVisible: true,
searchTerm: "",
showBlockedByRule: true
},
elements: {},
init() {
this.cacheDOMElements();
this.populateHeaderInfo();
this.populateFilters();
this.bindEvents();
this.render();
},
cacheDOMElements() {
const getById = (id) => document.getElementById(id);
this.elements = {
clsChip: getById("cls-chip-container"),
clsBtn: getById("cls-filter-btn"),
clsDropdown: getById("cls-dropdown"),
clsText: getById("cls-filter-text"),
fieldChip: getById("field-chip-container"),
fieldBtn: getById("field-filter-btn"),
fieldDropdown: getById("field-dropdown"),
fieldText: getById("field-filter-text"),
methodChip: getById("method-chip-container"),
methodBtn: getById("method-filter-btn"),
methodDropdown: getById("method-dropdown"),
methodText: getById("method-filter-text"),
addFilterContainer: getById("add-filter-container"),
addFilterBtn: getById("add-filter-btn"),
addFilterDropdown: getById("add-filter-dropdown"),
addFilterList: getById("add-filter-list"),
grpBtn: getById("group-by-btn"),
grpDropdown: getById("group-by-dropdown"),
grpText: getById("group-by-text"),
grpContainer: getById("group-by-container"),
grpList: getById("group-by-list"),
tableHeaders: getById("table-headers"),
tableData: getById("table-data"),
statsTableHeaders: getById("stats-table-headers"),
statsTableData: getById("stats-table-data"),
statsContainer: getById("stats-container"),
mainTable: getById("main-table"),
totalObfuscation: getById("total-obfuscation"),
totalOptimization: getById("total-optimization"),
totalShrinking: getById("total-shrinking"),
searchInput: getById("search-input"),
clearSearchBtn: getById("clear-search-btn"),
searchContainer: getById("search-container"),
searchIconBtn: getById("search-icon-btn"),
toggleColumnsBtn: getById("toggle-columns-btn"),
columnsDropdown: getById("columns-dropdown"),
toggleBlockedByRuleCb: getById("toggle-blocked-by-rule-cb"),
};
},
populateHeaderInfo() { },
/**
* Initializes all filters (Variants, Modules, etc.)
*/
populateFilters() {
// --- 1. Dynamic Filters (Modules/Packages/Classes) ---
this.updateDynamicFilters();
},
/**
* Updates the available options in Module/Package/Class dropdowns based on dependencies.
* e.g., Selecting "Module A" filters the Package options to only those in Module A.
*/
updateDynamicFilters() {
const {
keepRules,
classes,
fields,
methods
} = this.state.filters;
const chips = this.state.activeFilterChips;
// --- 1. Keep Rules Lens ---
const keepRuleOptions = [{
name: "<b>Identical:</b> Show rules that match the same items as other rules",
value: "Identical"
},
{
name: "<b>Subsumed:</b> Show rules that match a subset of the items matched by another rule",
value: "Subsumed"
},
{
name: "<b>Unused:</b> Show rules that don't match anything",
value: "Unused"
},
];
const lens = this.state.filters.keepRules[0] || "All";
const tabsContainer = document.getElementById("lens-tabs");
if (tabsContainer) {
tabsContainer.querySelectorAll(".segment-btn").forEach(btn => {
if (btn.dataset.lens === lens) {
btn.classList.add("active");
} else {
btn.classList.remove("active");
}
});
}
// --- 2. Classes Filter ---
const {
options: classOptions,
total: totalClasses
} = this.getKeptClasses();
UIUtils.buildActionDropdown("cls-dropdown", classOptions, classes, () => {
this.updateDynamicFilters();
this.render();
}, true, true, totalClasses, "classes", (term) => {
const brData = App.keepRadiusData;
if (!brData || !brData.keptClassInfoTable) return {
options: [],
total: 0
};
const typeRefMap = new Map();
brData.typeReferenceTable.forEach(t => typeRefMap.set(t.id, t
.javaDescriptor));
const filtered = brData.keptClassInfoTable.filter(c => {
const name = formatDescriptor(typeRefMap.get(c
.classReferenceId));
return name.toLowerCase().includes(term);
});
const results = filtered.slice(0, 1000).map(c => {
const name = escapeHTML(formatDescriptor(typeRefMap.get(c
.classReferenceId)));
return {
name,
value: c.id
};
});
return {
options: results,
total: filtered.length
};
});
UIUtils.renderChipText(this.elements.clsText, UIUtils.getFilterLabel(
"Classes", classes, totalClasses, classOptions), "class", true);
UIUtils.toggleVisibility(this.elements.clsChip, chips.includes("class"));
// --- 3. Fields Filter ---
const {
options: fieldOptions,
total: totalFields
} = this.getKeptFields();
UIUtils.buildActionDropdown("field-dropdown", fieldOptions, fields,
() => {
this.updateDynamicFilters();
this.render();
}, true, true, totalFields, "fields", (term) => {
const brData = App.keepRadiusData;
if (!brData || !brData.keptFieldInfoTable) return {
options: [],
total: 0
};
const typeRefMap = new Map();
brData.typeReferenceTable.forEach(t => typeRefMap.set(t.id, t
.javaDescriptor));
const fieldRefMap = new Map();
brData.fieldReferenceTable.forEach(f => fieldRefMap.set(f.id, f));
const filtered = brData.keptFieldInfoTable.filter(f => {
const fieldRef = fieldRefMap.get(f.fieldReferenceId);
const name = formatFieldName(fieldRef, brData, typeRefMap);
return name.toLowerCase().includes(term);
});
const results = filtered.slice(0, 1000).map(f => {
const fieldRef = fieldRefMap.get(f.fieldReferenceId);
const name = escapeHTML(formatFieldName(fieldRef, brData,
typeRefMap));
return {
name,
value: f.id
};
});
return {
options: results,
total: filtered.length
};
});
UIUtils.renderChipText(this.elements.fieldText, UIUtils.getFilterLabel(
"Fields", fields, totalFields, fieldOptions), "field", true);
UIUtils.toggleVisibility(this.elements.fieldChip, chips.includes(
"field"));
// --- 4. Methods Filter ---
const {
options: methodOptions,
total: totalMethods
} = this.getKeptMethods();
UIUtils.buildActionDropdown("method-dropdown", methodOptions, methods,
() => {
this.updateDynamicFilters();
this.render();
}, true, true, totalMethods, "methods", (term) => {
const brData = App.keepRadiusData;
if (!brData || !brData.keptMethodInfoTable) return {
options: [],
total: 0
};
const typeRefMap = new Map();
brData.typeReferenceTable.forEach(t => typeRefMap.set(t.id, t
.javaDescriptor));
const methodRefMap = new Map();
brData.methodReferenceTable.forEach(m => methodRefMap.set(m.id, m));
const filtered = brData.keptMethodInfoTable.filter(m => {
const methodRef = methodRefMap.get(m.methodReferenceId);
const name = formatMethodName(methodRef, brData, typeRefMap);
return name.toLowerCase().includes(term);
});
const results = filtered.slice(0, 1000).map(m => {
const methodRef = methodRefMap.get(m.methodReferenceId);
const name = escapeHTML(formatMethodName(methodRef, brData,
typeRefMap));
return {
name,
value: m.id
};
});
return {
options: results,
total: filtered.length
};
});
UIUtils.renderChipText(this.elements.methodText, UIUtils.getFilterLabel(
"Methods", methods, totalMethods, methodOptions), "method", true);
UIUtils.toggleVisibility(this.elements.methodChip, chips.includes(
"method"));
// --- Add Filter Button Logic ---
const availableFilters = [];
if (!chips.includes("class")) availableFilters.push({
name: "Classes",
value: "class",
options: classOptions,
total: totalClasses,
state: classes
});
if (!chips.includes("field")) availableFilters.push({
name: "Fields",
value: "field",
options: fieldOptions,
total: totalFields,
state: fields
});
if (!chips.includes("method")) availableFilters.push({
name: "Methods",
value: "method",
options: methodOptions,
total: totalMethods,
state: methods
});
if (availableFilters.length > 0) {
this.elements.addFilterContainer.classList.remove("hidden");
this.elements.addFilterList.innerHTML = availableFilters
.map(f =>
`<a href="#" class="dropdown-item add-filter-option" data-value="${f.value}">${f.name}</a>`
)
.join("");
this.elements.addFilterList.querySelectorAll(".add-filter-option")
.forEach(item => {
item.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const val = item.dataset.value;
if (!this.state.activeFilterChips.includes(val)) {
this.state.activeFilterChips.push(val);
}
// Close the "Add filter" dropdown
this.elements.addFilterDropdown.classList.add("hidden");
this.elements.addFilterBtn.classList.remove("bg-gray-200");
this.updateDynamicFilters();
this.render();
// Auto-open newly added dropdown
setTimeout(() => {
if (val === "class") this.toggleDropdown(this.elements
.clsDropdown, this.elements.clsBtn);
if (val === "field") this.toggleDropdown(this.elements
.fieldDropdown, this.elements.fieldBtn);
if (val === "method") this.toggleDropdown(this
.elements.methodDropdown, this.elements.methodBtn);
}, 0);
});
});
} else {
this.elements.addFilterContainer.classList.add("hidden");
}
},
getKeptClasses() {
const brData = App.keepRadiusData;
if (!brData || !brData.keptClassInfoTable) return {
options: [],
total: 0
};
const typeRefMap = new Map();
brData.typeReferenceTable.forEach(t => typeRefMap.set(t.id, t
.javaDescriptor));
const options = brData.keptClassInfoTable.slice(0, 1000).map(c => {
const name = escapeHTML(formatDescriptor(typeRefMap.get(c
.classReferenceId)));
return {
name,
value: c.id
};
});
return {
options,
total: brData.keptClassInfoTable.length
};
},
getKeptFields() {
const brData = App.keepRadiusData;
if (!brData || !brData.keptFieldInfoTable) return {
options: [],
total: 0
};
const typeRefMap = new Map();
brData.typeReferenceTable.forEach(t => typeRefMap.set(t.id, t
.javaDescriptor));
const fieldRefMap = new Map();
brData.fieldReferenceTable.forEach(f => fieldRefMap.set(f.id, f));
const options = brData.keptFieldInfoTable.slice(0, 1000).map(f => {
const fieldRef = fieldRefMap.get(f.fieldReferenceId);
const name = escapeHTML(formatFieldName(fieldRef, brData,
typeRefMap));
return {
name,
value: f.id
};
});
return {
options,
total: brData.keptFieldInfoTable.length
};
},
getKeptMethods() {
const brData = App.keepRadiusData;
if (!brData || !brData.keptMethodInfoTable) return {
options: [],
total: 0
};
const typeRefMap = new Map();
brData.typeReferenceTable.forEach(t => typeRefMap.set(t.id, t
.javaDescriptor));
const methodRefMap = new Map();
brData.methodReferenceTable.forEach(m => methodRefMap.set(m.id, m));
const options = brData.keptMethodInfoTable.slice(0, 1000).map(m => {
const methodRef = methodRefMap.get(m.methodReferenceId);
const name = escapeHTML(formatMethodName(methodRef, brData,
typeRefMap));
return {
name,
value: m.id
};
});
return {
options,
total: brData.keptMethodInfoTable.length
};
},
toggleDropdown(dropdown, triggerBtn = null) {
const allHelpers = [{
dd: this.elements.grpDropdown,
btn: this.elements.grpBtn
},
{
dd: this.elements.clsDropdown,
btn: this.elements.clsBtn
},
{
dd: this.elements.fieldDropdown,
btn: this.elements.fieldBtn
},
{
dd: this.elements.methodDropdown,
btn: this.elements.methodBtn
},
{
dd: this.elements.addFilterDropdown,
btn: this.elements.addFilterBtn
},
{
dd: this.elements.columnsDropdown,
btn: this.elements.toggleColumnsBtn
},
];
const isOpening = dropdown.classList.contains("hidden");
// Close all others first
allHelpers.forEach(({
dd,
btn
}) => {
if (dd !== dropdown) {
if (dd) dd.classList.add("hidden");
if (btn) btn.classList.remove("bg-gray-200");
}
});
if (isOpening) {
dropdown.classList.remove("hidden");
if (triggerBtn) triggerBtn.classList.add("bg-gray-200");
} else {
dropdown.classList.add("hidden");
if (triggerBtn) triggerBtn.classList.remove("bg-gray-200");
}
},
bindEvents() {
// --- Dropdown Management ---
const map = [{
btn: this.elements.grpBtn,
dd: this.elements.grpDropdown
},
{
btn: this.elements.clsBtn,
dd: this.elements.clsDropdown
},
{
btn: this.elements.fieldBtn,
dd: this.elements.fieldDropdown
},
{
btn: this.elements.methodBtn,
dd: this.elements.methodDropdown
},
{
btn: this.elements.addFilterBtn,
dd: this.elements.addFilterDropdown
},
{
btn: this.elements.toggleColumnsBtn,
dd: this.elements.columnsDropdown
},
];
map.forEach(({
btn,
dd
}) => {
if (btn && dd) {
btn.addEventListener("click", (e) => {
if (e.target.closest(".chip-close")) return;
e.stopPropagation();
this.toggleDropdown(dd, btn);
this.updateDynamicFilters();
});
}
});
// --- Column Toggle ---
if (this.elements.toggleBlockedByRuleCb) {
this.elements.toggleBlockedByRuleCb.addEventListener("change", (e) => {
this.state.showBlockedByRule = e.target.checked;
this.render();
});
}
const tabsContainer = document.getElementById("lens-tabs");
if (tabsContainer) {
tabsContainer.addEventListener("click", (e) => {
const btn = e.target.closest(".segment-btn");
if (!btn) return;
const lens = btn.dataset.lens;
this.state.filters.keepRules = lens === "All" ? [] : [lens];
this.updateDynamicFilters();
this.render();
});
}
// Global click listener to close dropdowns
document.addEventListener("click", (e) => {
let anyClosed = false;
map.forEach(({
dd,
btn
}) => {
if (dd && !dd.contains(e.target) && (!btn || !btn.contains(e
.target))) {
if (!dd.classList.contains("hidden")) {
dd.classList.add("hidden");
if (btn) btn.classList.remove("bg-gray-200");
anyClosed = true;
}
}
});
if (anyClosed) {
this.updateDynamicFilters();
}
// Close search bar if clicked outside
if (this.elements.searchContainer && !this.elements.searchContainer.contains(e.target)) {
this.elements.searchContainer.classList.remove("active");
}
});
// Chip removals
document.getElementById("filter-chips-container").addEventListener(
"click", (e) => {
const closeBtn = e.target.closest(".chip-close");
if (closeBtn) {
e.stopPropagation();
const type = closeBtn.dataset.clear;
if (type === "module") {
this.state.filters.keepRules = [];
this.state.activeFilterChips = this.state.activeFilterChips
.filter(c => c !== "module");
} else if (type === "class") {
this.state.filters.classes = [];
this.state.activeFilterChips = this.state.activeFilterChips
.filter(c => c !== "class");
} else if (type === "field") {
this.state.filters.fields = [];
this.state.activeFilterChips = this.state.activeFilterChips
.filter(c => c !== "field");
} else if (type === "method") {
this.state.filters.methods = [];
this.state.activeFilterChips = this.state.activeFilterChips
.filter(c => c !== "method");
}
this.updateDynamicFilters();
this.render();
}
});
// --- Group By Dropdown ---
this.elements.grpDropdown.addEventListener("click", (e) => {
const item = e.target.closest(".dropdown-item");
if (!item) return;
e.preventDefault();
this.state.currentView = item.dataset.value;
this.state.drillContext = {
module: null,
pkg: null
}; // Reset Drill-Down
// Reset default sort based on view
if (this.state.currentView === CONSTANTS.VIEWS.CLASSES ||
this.state.currentView === CONSTANTS.VIEWS.FIELDS ||
this.state.currentView === CONSTANTS.VIEWS.METHODS) {
this.state.sort = {
by: "name",
order: "asc"
};
} else {
this.state.sort = {
by: "matches.total",
order: "desc"
};
}
this.toggleDropdown(this.elements.grpDropdown, this.elements
.grpBtn);
this.render();
});
// --- Table Sorting ---
this.elements.tableHeaders.addEventListener("click", (e) => {
const th = e.target.closest("[data-sort-by]");
if (!th) return;
const newSortBy = th.dataset.sortBy;
if (this.state.sort.by === newSortBy)
this.state.sort.order = this.state.sort.order === "asc" ? "desc" :
"asc";
else {
this.state.sort.by = newSortBy;
this.state.sort.order = "desc";
}
this.render();
});
// --- Table Row Interaction (Manual Drill-Down) ---
this.elements.tableData.addEventListener("click", (e) => {
const ruleTd = e.target.closest("td[data-rule-id]");
if (ruleTd) {
e.preventDefault();
App.showDetailsView(ruleTd.dataset.ruleId);
return;
}
const fileTd = e.target.closest("td[data-file-origin-id]");
if (fileTd) {
e.preventDefault();
App.showFileDetailsView(fileTd.dataset.fileOriginId);
return;
}
const td = e.target.closest("td[data-name]");
if (!td) return;
const {
name,
type,
moduleName
} = td.dataset;
// Update Drill-Context (Silent)
if (type === "module") {
this.state.drillContext.module = name;
this.state.drillContext.pkg = null;
} else if (type === "package") {
this.state.drillContext.module = moduleName || this.state
.drillContext.module;
this.state.drillContext.pkg = name;
}
this.updateBreadcrumbs();
this.render();
});
// --- Search Icon Toggle ---
if (this.elements.searchIconBtn) {
this.elements.searchIconBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.elements.searchContainer.classList.toggle("active");
if (this.elements.searchContainer.classList.contains("active")) {
this.elements.searchInput.focus();
}
});
}
// --- Search Input ---
if (this.elements.searchInput) {
this.elements.searchInput.addEventListener("input", (e) => {
this.state.searchTerm = e.target.value;
if (this.state.searchTerm) {
this.elements.clearSearchBtn.classList.remove("hidden");
} else {
this.elements.clearSearchBtn.classList.add("hidden");
}
this.render();
});
}
if (this.elements.clearSearchBtn) {
this.elements.clearSearchBtn.addEventListener("click", () => {
this.elements.searchInput.value = "";
this.state.searchTerm = "";
this.elements.clearSearchBtn.classList.add("hidden");
this.render();
});
}
// --- Help Hub Events ---
const helpHubFab = document.getElementById("help-hub-fab");
const helpHubPanel = document.getElementById("help-hub-panel");
const closeHelpHubBtn = document.getElementById("close-help-hub");
const helpHubSearchInput = document.querySelector("#help-hub-panel .search-input");
if (helpHubFab && helpHubPanel) {
helpHubFab.addEventListener("click", (e) => {
e.stopPropagation();
helpHubPanel.classList.toggle("open");
});
}
if (closeHelpHubBtn && helpHubPanel) {
closeHelpHubBtn.addEventListener("click", (e) => {
e.stopPropagation();
helpHubPanel.classList.remove("open");
});
}
// Close help hub when clicking outside
document.addEventListener("click", (e) => {
if (helpHubPanel && !helpHubPanel.contains(e.target) && !helpHubFab.contains(e.target)) {
helpHubPanel.classList.remove("open");
}
});
// Help Hub Accordion
if (helpHubPanel) {
const legendItems = helpHubPanel.querySelectorAll(".legend-item");
legendItems.forEach(item => {
const header = item.querySelector(".legend-item-header");
if (header) {
header.addEventListener("click", () => {
// Close all other items
legendItems.forEach(other => {
if (other !== item) {
other.classList.remove("open");
}
});
// Toggle current item
item.classList.toggle("open");
});
}
});
}
},
/**
* Core Data Pipeline: Filters and Flattens data based on current View Mode and Filters.
*/
applyKeepRuleLens(rules, lens) {
const brData = App.keepRadiusData;
if (!lens || !brData) return rules;
const ruleMap = new Map();
brData.keepRuleKeepRadiusTable.forEach(r => {
ruleMap.set(r.id, r);
});
return rules.filter(r => {
const getSubsumedBy = (rule) => rule.subsumedBy || (rule
.keepRadius && rule.keepRadius.subsumedBy) || [];
const getMatchesTotal = (rule) => {
if (rule.matches && rule.matches.total !== undefined)
return rule.matches.total;
const b = rule.keepRadius || {};
return (b.classKeepRadius || []).length + (b
.fieldKeepRadius || []).length + (b.methodKeepRadius ||
[]).length;
};
const subsumedBy = getSubsumedBy(r);
if (lens === "Identical") {
const identical = subsumedBy.filter(otherId => {
const other = ruleMap.get(otherId);
return other && (other.keepRadius?.subsumedBy || [])
.includes(r.id);
}).map(otherId => ruleMap.get(otherId));
if (identical.length > 0) {
r.identicalRules = identical;
return true;
}
return false;
} else if (lens === "Subsumed") {
if (subsumedBy.length > 0) {
r.subsumedByRules = subsumedBy.map(id => ruleMap.get(id));
return true;
}
return false;
} else if (lens === "Unused") {
return getMatchesTotal(r) === 0;
}
return true;
});
},
getFilteredData() {
const {
currentView,
filters
} = this.state;
const brData = App.keepRadiusData;
if (!brData) return [];
let data = [];
if (currentView === CONSTANTS.VIEWS.PACKAGES) {
data = getRuleFiles(brData).filter(f => f.matches.total > 0);
} else {
data = getRules(brData);
// If lens is 'Residual', 'All', or default, show only rules with matches > 0.
// If lens is 'Unused', show only rules with matches === 0.
const lens = filters.keepRules[0];
if (lens === "Residual" || lens === "All" || !lens) {
data = data.filter(r => r.matches.total > 0);
} else if (lens === "Unused") {
data = data.filter(r => r.matches.total === 0);
}
}
// Apply Keep Rules Lens (Filtering)
if (currentView === CONSTANTS.VIEWS.MODULES && filters.keepRules.length >
0) {
data = this.applyKeepRuleLens(data, filters.keepRules[0]);
}
// Apply Class/Field/Method filters
if (filters.classes.length > 0 || filters.fields.length > 0 || filters
.methods.length > 0) {
const matchedRuleIds = new Set();
if (filters.classes.length > 0) {
brData.keptClassInfoTable.forEach(c => {
if (filters.classes.includes(c.id)) {
(c.keptBy || []).forEach(rid => matchedRuleIds.add(rid));
}
});
}
if (filters.fields.length > 0) {
brData.keptFieldInfoTable.forEach(f => {
if (filters.fields.includes(f.id)) {
(f.keptBy || []).forEach(rid => matchedRuleIds.add(rid));
}
});
}
if (filters.methods.length > 0) {
brData.keptMethodInfoTable.forEach(m => {
if (filters.methods.includes(m.id)) {
(m.keptBy || []).forEach(rid => matchedRuleIds.add(rid));
}
});
}
data = data.filter(r => matchedRuleIds.has(r.id));
}
if (this.state.searchTerm) {
const term = this.state.searchTerm.toLowerCase();
data = data.filter(item => item.name.toLowerCase().includes(term));
}
return data;
},
getSortedData(data) {
const {
by,
order
} = this.state.sort;
if (!by) return data;
const getVal = (obj, path) => {
return path.split('.').reduce((o, key) => (o && o[key] !==
undefined) ? o[key] : 0, obj);
};
return [...data].sort((a, b) => {
const vA = getVal(a, by);
const vB = getVal(b, by);
if (typeof vA === "string")
return order === "asc" ? vA.localeCompare(vB) : vB.localeCompare(
vA);
return order === "asc" ? (vA || 0) - (vB || 0) : (vB || 0) - (vA ||
0);
});
},
updateBreadcrumbs() {
const bc = document.getElementById("flat-breadcrumbs");
if (!bc) return;
const linkClass = "breadcrumb-pill";
const textClass = "breadcrumb-text";
const sep = '<span class="text-gray-300 mx-1">/</span>';
let html = "";
const {
module,
pkg: pkg
} = this.state.drillContext;
const toggleText = this.state.statsVisible ? "Hide" :
"Show";
const toggleHtml = `
<button class="dropdown-btn" data-action="toggle-stats" style="border: none; background: transparent; padding: 0.25rem 0.5rem; display: flex; align-items: center; border-radius: 4px; margin-left: auto; cursor: pointer;">
<span style="font-weight: 500; color: var(--text-gray-400);">${toggleText}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24"
stroke="currentColor" style="margin-left: 0.25rem; color: var(--text-gray-500); transform: ${this.state.statsVisible ? 'rotate(180deg)' : 'rotate(0deg)'}; transition: transform 0.2s;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
`;
if (module) {
// Drilled Down Path
html += `<span class="${linkClass}" data-action="reset">R8 Optimization Levels</span>`;
html += sep;
if (pkg) {
html +=
`<span class="${linkClass}" data-action="module" data-val="${escapeHTML(module)}">${escapeHTML(module)}</span>`;
html += sep;
html += `<span class="${textClass}">${escapeHTML(pkg)}</span>`;
} else {
html += `<span class="${textClass}">${escapeHTML(module)}</span>`;
}
} else {
// Global View
html = `<span class="${textClass}">R8 Optimization Levels</span>`;
}
const subtextHtml = `
<div style="font-size: 0.75rem; color: var(--text-gray-500); font-weight: 400; margin-top: 0.25rem; text-transform: none; letter-spacing: normal; padding: 0rem 0.5rem;">
The percentage of your app’s codebase that R8 successfully shrinks, optimizes, and obfuscates. Achieving a higher percentage indicates a leaner, more performant application.
</div>
`;
bc.style.display = "flex";
bc.style.alignItems = "center";
bc.style.width = "100%";
bc.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: start;">
<div style="display: flex; align-items: center;">
${html}
</div>
${this.state.statsVisible ? subtextHtml : ''}
</div>
${toggleHtml}
`;
// Breadcrumb Click Handlers
bc.querySelectorAll("[data-action]").forEach((el) => {
el.addEventListener("click", (e) => {
const action = el.dataset.action;
if (action === "reset") {
this.state.drillContext = {
module: null,
pkg: null
};
this.render();
} else if (action === "module") {
this.state.drillContext.pkg = null;
this.render();
} else if (action === "toggle-stats") {
this.state.statsVisible = !this.state.statsVisible;
this.render();
}
});
});
},
render() {
this.updateBreadcrumbs();
if (this.state.currentView === CONSTANTS.VIEWS.FILE_DETAILS) {
App.renderFileDetailsView(this.state.drillContext.fileOriginId);
return;
}
if (this.state.currentView === CONSTANTS.VIEWS.DETAILS) {
return;
}
// Update Label of Group Dropdown
const currentView = this.state.currentView;
const viewLabels = {
modules: "Keep Rules",
packages: "Keep Rule Files"
};
this.elements.grpText.textContent = viewLabels[currentView] || (
currentView.charAt(0).toUpperCase() + currentView.slice(1));
if (this.elements.grpList) {
this.elements.grpList.querySelectorAll(".dropdown-item").forEach((
el) => {
if (el.dataset.value === currentView)
el.classList.add("bg-gray-100", "text-gray-900",
"font-semibold");
else el.classList.remove("bg-gray-100", "text-gray-900",
"font-semibold");
});
}
// Process Data
let data = this.getFilteredData();
data = this.getSortedData(data);
// Update Summary Counts
let relevantClasses = [],
modCount = 0;
if (currentView === CONSTANTS.VIEWS.PACKAGES) {
relevantClasses = data.flatMap((p) => p.classes || []);
modCount = new Set(data.map((p) => p.moduleName)).size;
} else {
relevantClasses = data.flatMap((m) => (m.packages || []).flatMap((p) =>
p.classes || []));
modCount = data.length;
}
// Stats table visibility
if (this.elements.statsContainer) {
this.elements.statsContainer.style.display = this.state.statsVisible ?
"flex" : "none";
this.elements.statsContainer.style.marginBottom = this.state
.statsVisible ? "2rem" : "0";
}
const brData = App.keepRadiusData;
const totalLive = getLiveItemCount(brData);
const formatPerc = (disallowCount) => totalLive > 0 ? (100 - (
disallowCount / totalLive * 100)).toFixed(1) + '%' : '--';
const getPerc = (disallowCount) => totalLive > 0 ? (100 - (disallowCount /
totalLive * 100)) : '--';
const setStatValue = (element, disallowCount) => {
if (!brData) {
element.textContent = '--';
element.className = 'stat-value ' + UIUtils.getScoreClass('--');
return;
}
const perc = getPerc(disallowCount);
element.textContent = formatPerc(disallowCount);
element.className = 'stat-value ' + UIUtils.getScoreClass(perc);
};
setStatValue(this.elements.totalObfuscation, getDisallowObfuscationCount(
brData));
setStatValue(this.elements.totalOptimization,
getDisallowOptimizationCount(brData));
setStatValue(this.elements.totalShrinking, getDisallowShrinkingCount(
brData));
// Render Tables
this.renderHeaders();
this.renderFlatRows(data);
this.renderStatsTable();
},
renderStatsTable() {
const brData = App.keepRadiusData;
if (!brData) return;
const stats = getDetailedStats(brData);
if (!stats) return;
const updateCard = (key) => {
const setStat = (id, disallowCount, total) => {
const el = document.getElementById(id);
const perc = total > 0 ? (100 - (disallowCount / total * 100)) : 100;
if (el) {
el.textContent = perc.toFixed(1) + '%';
}
return perc;
};
const totalPerc = setStat(`card-total-${key}`, stats.overall[key], stats.overall.total);
const updateItem = (type) => {
const bar = document.getElementById(`card-${key}-${type}-bar`);
const val = document.getElementById(`card-${key}-${type}-val`);
if (bar && val) {
const disallow = stats[type][key];
const total = stats[type].total;
const perc = total > 0 ? (100 - (disallow / total * 100)) : 100;
bar.style.width = `${perc}%`;
val.textContent = `${perc.toFixed(1)}%`;
const colorClass = UIUtils.getScoreClass(perc);
if (colorClass.includes('green')) {
bar.style.background = 'var(--text-green-600)';
} else if (colorClass.includes('yellow')) {
bar.style.background = 'var(--text-yellow-600)';
} else if (colorClass.includes('red')) {
bar.style.background = 'var(--text-red-600)';
}
}
};
updateItem("classes");
updateItem("fields");
updateItem("methods");
};
updateCard("obfuscation");
updateCard("optimization");
updateCard("shrinking");
},
renderHeaders() {
const {
currentView,
sort
} = this.state;
const topHeader = document.createElement("tr");
const subHeader = document.createElement("tr");
const viewLabels = {
modules: "RULE",
packages: "KEEP RULE FILES"
};
const title = viewLabels[currentView];
const ind = (key) => {
if (sort.by !== key) return "";
return sort.order === "asc" ?
`<span class="sort-icon text-blue-600 ml-1">â–²</span>` :
`<span class="sort-icon text-blue-600 ml-1">â–¼</span>`;
};
if (currentView === CONSTANTS.VIEWS.PACKAGES) {
topHeader.innerHTML = `
<th rowspan="2" class="text-left sticky-name bg-gray-50 z-30" data-sort-by="name" style="width: 40%; min-width: 300px;">
<div class="flex items-center cursor-pointer hover:text-blue-600" style="padding: 1rem;">
${title}${ind("name")}
</div>
</th>
<th rowspan="2" class="text-center bg-gray-50 border-l border-gray-200 cursor-pointer hover:bg-gray-100" data-sort-by="keepRules" style="padding: 1rem; width: 12%;">Keep Rules${ind("keepRules")}</th>
<th rowspan="2" class="text-center border-l border-gray-200 bg-gray-50 cursor-pointer hover:bg-gray-100" data-sort-by="matches.total" style="padding: 0.5rem; width: 12%;">Kept Items${ind("matches.total")}</th>
<th rowspan="2" class="text-center text-xs font-medium text-gray-500 border-l border-gray-200 cursor-pointer hover:bg-gray-100" data-sort-by="impact.obfuscation" style="padding: 0.5rem; width: 12%;">Obfuscation Score${ind("impact.obfuscation")}</th>
<th rowspan="2" class="text-center text-xs font-medium text-gray-500 border-l border-gray-200 cursor-pointer hover:bg-gray-100" data-sort-by="impact.optimization" style="padding: 0.5rem; width: 12%;">Optimization Score${ind("impact.optimization")}</th>
<th rowspan="2" class="text-center text-xs font-medium text-gray-500 border-l border-gray-200 cursor-pointer hover:bg-gray-100" data-sort-by="impact.shrinking" style="padding: 0.5rem; width: 12%;">Shrinking Score${ind("impact.shrinking")}</th>
`;
subHeader.innerHTML = ``;
} else {
const lens = this.state.filters.keepRules[0];
const isIdenticalLens = currentView === CONSTANTS.VIEWS.MODULES &&
lens === "Identical";
const isSubsumedLens = currentView === CONSTANTS.VIEWS.MODULES &&
lens === "Subsumed";
const isUnusedLens = currentView === CONSTANTS.VIEWS.MODULES &&
lens === "Unused";
if (this.elements.mainTable) {
if (isIdenticalLens || isSubsumedLens || isUnusedLens) {
this.elements.mainTable.style.tableLayout = "fixed";
} else {
this.elements.mainTable.style.tableLayout = "";
}
}
if (isIdenticalLens || isSubsumedLens) {
topHeader.innerHTML = `
<th rowspan="2" class="text-left sticky-name bg-gray-50 z-30" data-sort-by="name" style="width: 50%; min-width: 300px;">
<div class="flex items-center cursor-pointer hover:text-blue-600" style="padding: 1rem;">
${title}${ind("name")}
</div>
</th>
<th rowspan="2" class="text-left bg-gray-50 border-l border-gray-200" style="padding: 1rem; width: 50%;">${isIdenticalLens ? 'Identical Rules' : 'Subsumed By'}</th>
`;
subHeader.innerHTML = '';
} else if (isUnusedLens) {
topHeader.innerHTML = `
<th rowspan="2" class="text-left sticky-name bg-gray-50 z-30" data-sort-by="name" style="width: 50%; min-width: 300px;">
<div class="flex items-center cursor-pointer hover:text-blue-600" style="padding: 1rem;">
${title}${ind("name")}
</div>
</th>
<th rowspan="2" class="text-left bg-gray-50 border-l border-gray-200" style="padding: 1rem; width: 50%;">Origin</th>
`;
subHeader.innerHTML = '';
} else {
topHeader.innerHTML = `
<th rowspan="2" class="text-left sticky-name bg-gray-50 z-30" data-sort-by="name" style="width: 40%; min-width: 300px;">
<div class="flex items-center cursor-pointer hover:text-blue-600" style="padding: 1rem;">
${title}${ind("name")}
</div>
</th>
<th colspan="4" class="text-center border-l border-gray-200 bg-gray-50" style="padding: 0.5rem; width: 40%;">KEPT ITEMS <span style="color: var(--text-muted);text-transform: none;font-weight: 500;">Higher is worse</span> <span class="tooltip-icon" data-tooltip="Items retained in the app due to this rule">?</span></th>
${this.state.showBlockedByRule ? `
<th rowspan="2" class="text-left bg-gray-50" style="padding: 1rem; width: 20%; border-left: 1px solid var(--border-color);">BLOCKED BY RULE <span class="tooltip-icon" data-tooltip="Specific actions blocked by this rule">?</span></th>
` : ''}
`;
subHeader.innerHTML = `
<th class="text-center text-xs font-medium text-gray-500 border-l border-gray-200 cursor-pointer hover:bg-gray-100" data-sort-by="matches.total" style="padding: 0.5rem; width: 100px; min-width: 100px;">Total${ind("matches.total")}</th>
<th class="text-center text-xs font-medium text-gray-500 border-l border-gray-200 cursor-pointer hover:bg-gray-100" data-sort-by="matches.classes" style="padding: 0.5rem; width: 100px; min-width: 100px;">Classes${ind("matches.classes")}</th>
<th class="text-center text-xs font-medium text-gray-500 border-l border-gray-200 cursor-pointer hover:bg-gray-100" data-sort-by="matches.fields" style="padding: 0.5rem; width: 100px; min-width: 100px;">Fields${ind("matches.fields")}</th>
<th class="text-center text-xs font-medium text-gray-500 border-l border-gray-200 cursor-pointer hover:bg-gray-100" data-sort-by="matches.methods" style="padding: 0.5rem; width: 100px; min-width: 100px;">Methods${ind("matches.methods")}</th>
`;
}
}
this.elements.tableHeaders.innerHTML = "";
this.elements.tableHeaders.append(topHeader, subHeader);
},
renderFlatRows(data) {
const {
currentView
} = this.state;
if (!data.length) {
this.elements.tableData.innerHTML =
`<tr><td colspan="10" class="text-center py-8 text-gray-500">No results found.</td></tr>`;
return;
}
const brData = App.keepRadiusData;
const build = brData?.buildInfo || {};
const totalLive = getLiveItemCount(brData);
const renderMatchCell = (count, total) => {
const perc = total > 0 ? (count / total * 100) : 0;
const colorClass = UIUtils.getMatchClass(perc);
return `
<td class="text-center border-l border-gray-200" style="padding: 1rem; width: 12%;">
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span class="font-medium ${colorClass}">${perc.toFixed(1)}%</span>
<span class="text-xs text-gray-500 mt-1">${count}</span>
</div>
</td>
`;
};
const renderScoreCell = (disallowCount, total) => {
const perc = total > 0 ? (100 - (disallowCount / total * 100)) : 100;
const colorClass = UIUtils.getScoreClass(perc);
return `
<td class="text-center border-l border-gray-200" style="padding: 1rem; width: 12%;">
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span class="font-medium ${colorClass}">${perc.toFixed(1)}%</span>
<span class="text-xs text-gray-500 mt-1">${total - disallowCount}/${total}</span>
</div>
</td>
`;
};
this.elements.tableData.innerHTML = data.map((item) => {
const cleanedName = item.name.trim();
const escapedName = escapeHTML(cleanedName);
let nameCell = "";
if (currentView === CONSTANTS.VIEWS.MODULES) {
const highlightedName = highlightRule(cleanedName);
nameCell = `<td class="sticky-name font-medium hover:underline cursor-pointer" title="${escapedName}" style="padding: 1rem; width: 40%; min-width: 300px; font-family: var(--font-family-mono);" data-rule-id="${item.id}">
<pre style="white-space: pre-wrap; font-family: var(--font-family-mono); font-size: 0.8125rem; margin: 0; pointer-events: none;">${highlightedName}</pre>
</td>`;
} else if (currentView === CONSTANTS.VIEWS.PACKAGES) {
nameCell = `<td class="sticky-name font-medium text-blue-600 hover:underline cursor-pointer" title="${escapedName}" style="padding: 1rem; width: 40%; min-width: 300px;" data-file-origin-id="${item.id}">
<pre style="white-space: pre-wrap; font-family: var(--font-family-mono); font-size: 0.8125rem; margin: 0; pointer-events: none;">${escapedName}</pre>
</td>`;
} else {
nameCell =
`<td class="sticky-name font-medium text-gray-900" title="${escapedName}" style="padding: 1rem; width: 40%; min-width: 300px;"><pre style="white-space: pre-wrap; font-family: var(--font-family-mono); font-size: 0.8125rem; margin: 0;">${escapedName}</pre></td>`;
}
if (currentView === CONSTANTS.VIEWS.PACKAGES) {
const keepRulesCell =
`<td class="text-center border-l border-gray-200 text-sm font-semibold" style="padding: 1rem; width: 12%;">${item.keepRules}</td>`;
const totalKeptCell = renderMatchCell(item.matches.total,
totalLive);
const impactCells = `
${renderScoreCell(item.impact.obfuscation, totalLive)}
${renderScoreCell(item.impact.optimization, totalLive)}
${renderScoreCell(item.impact.shrinking, totalLive)}
`;
return `<tr class="table-row border-b border-gray-200 hover:bg-gray-50">${nameCell}${keepRulesCell}${totalKeptCell}${impactCells}</tr>`;
} else {
const lens = this.state.filters.keepRules[0];
const isIdenticalLens = currentView === CONSTANTS.VIEWS.MODULES &&
lens === "Identical";
const isSubsumedLens = currentView === CONSTANTS.VIEWS.MODULES &&
lens === "Subsumed";
const isUnusedLens = currentView === CONSTANTS.VIEWS.MODULES &&
lens === "Unused";
const customNameCell = `<td class="sticky-name font-medium hover:underline cursor-pointer" title="${escapedName}" style="padding: 1rem; width: 50%; min-width: 300px; font-family: var(--font-family-mono);" data-rule-id="${item.id}">
<pre style="white-space: pre-wrap; font-family: var(--font-family-mono); font-size: 0.8125rem; margin: 0; pointer-events: none;">${highlightRule(cleanedName)}</pre>
</td>`;
if (isIdenticalLens || isSubsumedLens) {
const rules = isIdenticalLens ? item.identicalRules : item
.subsumedByRules;
const rulesHtml = (rules || []).map(r => `
<div style="font-family: var(--font-family-mono); font-size: 0.8125rem; margin-bottom: 0.25rem;">
<pre style="white-space: pre-wrap; margin: 0; color: var(--text-main);">${highlightRule(r.source)}</pre>
</div>
`).join("");
return `<tr class="table-row border-b border-gray-200 hover:bg-gray-50">${customNameCell}<td class="border-l border-gray-200" style="padding: 1rem; vertical-align: top; width: 50%; font-family: var(--font-family-mono);">${rulesHtml}</td></tr>`;
} else if (isUnusedLens) {
const rule = brData.keepRuleKeepRadiusTable.find(r => r.id === item.id) || brData.globalKeepRuleKeepRadiusTable.find(r => r.id === item.id);
const fileOrigin = brData.fileOriginTable.find(f => f.id === rule?.origin?.fileOriginId);
let originStr = "Unknown";
if (fileOrigin) {
const mavenName = formatMavenCoordinate(fileOrigin.mavenCoordinate);
originStr = `${mavenName || fileOrigin.filename}:${rule.origin?.lineNumber || 1}`;
}
return `<tr class="table-row border-b border-gray-200 hover:bg-gray-50">${customNameCell}<td class="border-l border-gray-200" style="padding: 1rem; width: 50%; font-family: var(--font-family-mono);">${escapeHTML(originStr)}</td></tr>`;
}
const impact = item.impact || [];
const getTag = (c, label) => {
const isRestricted = impact.includes(c);
const color = isRestricted ? "oklch(0.446 0.043 257.281)" : "#cbd5e1";
const bgColor = isRestricted ? "oklch(0.984 0.003 247.858)" : "#f8fafc";
const borderColor = isRestricted ? "oklch(0.929 0.013 255.508)" : "#e2e8f0";
return `<span class="impact-tag" style="display: inline-block; color: ${color}; background-color: ${bgColor}; border: 1px solid ${borderColor}; border-radius: 4px; padding: 2px 8px; font-size: 10px; font-weight: 400; height: 21px; line-height: 15px; text-transform: uppercase; letter-spacing: 0.25px; box-sizing: border-box; text-align: center;">${label}</span>`;
};
const impactCell = `
<td style="padding: 1rem; border-left: 1px solid var(--border-color);">
<div class="flex justify-start" style="gap: 0.5rem;">
${getTag('DONT_OBFUSCATE', 'OBFUSCATE')}
${getTag('DONT_OPTIMIZE', 'OPTIMIZE')}
${getTag('DONT_SHRINK', 'SHRINK')}
</div>
</td>
`;
const matchesCells = `
${renderMatchCell(item.matches.total, totalLive)}
${renderMatchCell(item.matches.classes, build.liveClassCount || 0)}
${renderMatchCell(item.matches.fields, build.liveFieldCount || 0)}
${renderMatchCell(item.matches.methods, build.liveMethodCount || 0)}
`;
return `<tr class="table-row border-b border-gray-200 hover:bg-gray-50">${nameCell}${matchesCells}${this.state.showBlockedByRule ? impactCell : ''}</tr>`;
}
}).join("");
},
};
document.addEventListener("DOMContentLoaded", () => {
App.init();
});