| // 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(); |
| }); |