From a79f37120d2617fb3d0390fceadac5d915ed4528 Mon Sep 17 00:00:00 2001 From: PierreDemailly Date: Sat, 28 Mar 2026 21:41:24 +0100 Subject: [PATCH] feat(interface): introduce new tree page --- i18n/english.js | 8 + i18n/french.js | 8 + public/components/navigation/navigation.js | 5 + public/components/views/settings/settings.js | 1 + public/components/views/tree/tree-card.js | 112 +++++ .../components/views/tree/tree-connectors.js | 125 +++++ public/components/views/tree/tree-layout.js | 135 ++++++ public/components/views/tree/tree-styles.js | 431 ++++++++++++++++++ public/components/views/tree/tree.js | 194 ++++++++ public/core/events.js | 3 +- public/main.js | 31 ++ views/index.html | 9 + 12 files changed, 1061 insertions(+), 1 deletion(-) create mode 100644 public/components/views/tree/tree-card.js create mode 100644 public/components/views/tree/tree-connectors.js create mode 100644 public/components/views/tree/tree-layout.js create mode 100644 public/components/views/tree/tree-styles.js create mode 100644 public/components/views/tree/tree.js diff --git a/i18n/english.js b/i18n/english.js index cf854f10..c71b8bed 100644 --- a/i18n/english.js +++ b/i18n/english.js @@ -232,6 +232,14 @@ const ui = { emptyHint: "Search the npm registry or enter a spec directly to scan.", scan: "Scan" }, + tree: { + root: "Root", + depth: "Depth", + deps: "deps", + direct: "direct", + modeDepth: "Depth", + modeTree: "Tree" + }, search_command: { placeholder: "Search packages...", placeholder_filter_hint: "or use", diff --git a/i18n/french.js b/i18n/french.js index b0b151e6..5ebbb74c 100644 --- a/i18n/french.js +++ b/i18n/french.js @@ -232,6 +232,14 @@ const ui = { emptyHint: "Recherchez dans le registre npm ou saisissez une spec directement.", scan: "Scanner" }, + tree: { + root: "Racine", + depth: "Profondeur", + deps: "dépendances", + direct: "directes", + modeDepth: "Profondeur", + modeTree: "Arbre" + }, search_command: { placeholder: "Rechercher des packages...", placeholder_filter_hint: "ou utiliser", diff --git a/public/components/navigation/navigation.js b/public/components/navigation/navigation.js index e6b8ff99..99cd89db 100644 --- a/public/components/navigation/navigation.js +++ b/public/components/navigation/navigation.js @@ -8,6 +8,7 @@ const kAvailableView = new Set([ "home--view", "search--view", "settings--view", + "tree--view", "warnings--view" ]); @@ -59,6 +60,10 @@ export class ViewNavigation { this.onNavigationSelected(this.menus.get("search--view")); break; } + case hotkeys.tree: { + this.onNavigationSelected(this.menus.get("tree--view")); + break; + } case hotkeys.warnings: { this.onNavigationSelected(this.menus.get("warnings--view")); break; diff --git a/public/components/views/settings/settings.js b/public/components/views/settings/settings.js index abae9ae9..fcffb3f7 100644 --- a/public/components/views/settings/settings.js +++ b/public/components/views/settings/settings.js @@ -19,6 +19,7 @@ const kDefaultHotKeys = { wiki: "W", lock: "L", search: "F", + tree: "T", warnings: "A" }; const kShortcutInputTargetIds = new Set(Object.keys(kDefaultHotKeys)); diff --git a/public/components/views/tree/tree-card.js b/public/components/views/tree/tree-card.js new file mode 100644 index 00000000..85bd5199 --- /dev/null +++ b/public/components/views/tree/tree-card.js @@ -0,0 +1,112 @@ +// Import Third-party Dependencies +import { html, nothing } from "lit"; +import { FLAGS_EMOJIS } from "@nodesecure/vis-network"; +import prettyBytes from "pretty-bytes"; + +// Import Internal Dependencies +import { EVENTS } from "../../../core/events.js"; + +// CONSTANTS +const kWarningCriticalThreshold = 10; +const kModuleTypeColors = { + esm: "#10b981", + dual: "#06b6d4", + cjs: "#f59e0b", + dts: "#6366f1", + faux: "#6b7280" +}; + +function renderFlag(flag) { + const ignoredFlags = window.settings.config.ignore.flags ?? []; + const ignoredSet = new Set(ignoredFlags); + if (ignoredSet.has(flag)) { + return nothing; + } + + const emoji = FLAGS_EMOJIS[flag]; + if (!emoji) { + return nothing; + } + + return html`${emoji}`; +} + +function getVersionData(secureDataSet, name, version) { + return secureDataSet.data.dependencies[name]?.versions[version]; +} + +export function renderCardContent(secureDataSet, { nodeId, parentId = null, isRoot = false }) { + const entry = secureDataSet.linker.get(nodeId); + const versionData = getVersionData(secureDataSet, entry.name, entry.version); + if (!versionData) { + return nothing; + } + + const warningCount = versionData.warnings?.length ?? 0; + + let warningClass = ""; + if (warningCount > kWarningCriticalThreshold) { + warningClass = "warn-critical"; + } + else if (warningCount > 0) { + warningClass = "warn-moderate"; + } + + const hasProvenance = Boolean(versionData.attestations?.provenance); + const moduleType = versionData.type ?? "cjs"; + const typeColor = kModuleTypeColors[moduleType] ?? "#6b7280"; + const size = prettyBytes(versionData.size ?? 0); + const licenses = versionData.uniqueLicenseIds?.join(", ") ?? "—"; + const depCount = versionData.dependencyCount ?? 0; + const flags = versionData.flags ?? []; + const rootClass = isRoot ? "tree-card--root" : ""; + + // Show parent label only for packages at depth ≥ 2 (parentId !== null and not root) + let parentName = null; + if (parentId !== null && parentId !== 0) { + const parentEntry = secureDataSet.linker.get(parentId); + if (parentEntry) { + parentName = parentEntry.name; + } + } + + return html` +
window.dispatchEvent(new CustomEvent(EVENTS.TREE_NODE_CLICK, { detail: { nodeId } }))} + > +
+ + ${entry.name}@${entry.version} + + ${hasProvenance + ? html`` + : nothing + } +
+
+ ${moduleType} + + ${flags.map((flag) => renderFlag(flag))} + +
+
+ ${size} + · + ${licenses} + ${depCount > 0 + ? html`·${depCount} deps` + : nothing + } + ${warningCount > 0 + ? html` ${warningCount}` + : nothing + } +
+ ${parentName === null + ? nothing + : html`
↳ ${parentName}
` + } +
+ `; +} diff --git a/public/components/views/tree/tree-connectors.js b/public/components/views/tree/tree-connectors.js new file mode 100644 index 00000000..7ef52b06 --- /dev/null +++ b/public/components/views/tree/tree-connectors.js @@ -0,0 +1,125 @@ +// Import Internal Dependencies +import { CONNECTOR_GAP } from "./tree-layout.js"; + +export function drawConnectors(renderRoot) { + const grid = renderRoot.querySelector(".tree-grid"); + if (!grid) { + return; + } + + // Remove existing SVG + grid.querySelector(".connectors-svg")?.remove(); + + const gridRect = grid.getBoundingClientRect(); + if (gridRect.width === 0) { + return; + } + + const cells = grid.querySelectorAll(".tree-cell"); + + // Map each wrapper element to its rects: + // - span bounds (top/bottom) from the stretched wrapper, for parent lookup + // - midY from the inner card, for line anchoring at the visual card center + const elementRects = new Map(); + for (const cell of cells) { + const wrapperRaw = cell.getBoundingClientRect(); + const cardEl = cell.firstElementChild; + const cardRaw = cardEl ? cardEl.getBoundingClientRect() : wrapperRaw; + + elementRects.set(cell, { + left: wrapperRaw.left - gridRect.left, + right: wrapperRaw.right - gridRect.left, + top: wrapperRaw.top - gridRect.top, + bottom: wrapperRaw.bottom - gridRect.top, + midY: cardRaw.top - gridRect.top + (cardRaw.height / 2) + }); + } + + // Resolve parent element for each child using spatial overlap: + // among all cells matching data-parent-id, pick the one whose vertical + // span (stretched to fill its grid rows) contains the child's midY. + const elementChildren = new Map(); + for (const child of cells) { + const rawParentId = child.dataset.parentId; + if (!rawParentId) { + continue; + } + + const parentId = Number(rawParentId); + const childMidY = elementRects.get(child).midY; + + let bestParent = null; + for (const candidate of cells) { + if (Number(candidate.dataset.nodeId) !== parentId) { + continue; + } + + const candidateRect = elementRects.get(candidate); + if (childMidY >= candidateRect.top && childMidY <= candidateRect.bottom) { + bestParent = candidate; + break; + } + } + + if (bestParent) { + const children = elementChildren.get(bestParent) ?? []; + children.push(child); + elementChildren.set(bestParent, children); + } + } + + const isDark = document.body.classList.contains("dark"); + const strokeColor = isDark + ? "rgba(164, 148, 255, 0.3)" + : "rgba(55, 34, 175, 0.18)"; + + const svgNS = "http://www.w3.org/2000/svg"; + const svgEl = document.createElementNS(svgNS, "svg"); + svgEl.classList.add("connectors-svg"); + + let hasPath = false; + + for (const [parent, children] of elementChildren) { + const parentRect = elementRects.get(parent); + const childRects = children.map((child) => elementRects.get(child)); + + const midX = parentRect.right + (CONNECTOR_GAP / 2); + const childMidYs = childRects.map((rect) => rect.midY).sort((rectA, rectB) => rectA - rectB); + const firstChildY = childMidYs[0]; + const lastChildY = childMidYs.at(-1); + + let pathData = `M ${parentRect.right} ${parentRect.midY} H ${midX}`; + + // Vertical arm connecting to children's level + if (Math.abs(parentRect.midY - firstChildY) > 1) { + const targetY = childMidYs.length === 1 + ? firstChildY + : (firstChildY + lastChildY) / 2; + pathData += ` V ${targetY}`; + } + + // Vertical bracket if multiple children + if (childMidYs.length > 1) { + pathData += ` M ${midX} ${firstChildY} V ${lastChildY}`; + } + + // Horizontal branch to each child + for (const childRect of childRects) { + pathData += ` M ${midX} ${childRect.midY} H ${childRect.left}`; + } + + const pathEl = document.createElementNS(svgNS, "path"); + pathEl.setAttribute("d", pathData); + pathEl.setAttribute("fill", "none"); + pathEl.setAttribute("stroke", strokeColor); + pathEl.setAttribute("stroke-width", "1.5"); + pathEl.setAttribute("stroke-linecap", "round"); + pathEl.setAttribute("stroke-linejoin", "round"); + svgEl.appendChild(pathEl); + hasPath = true; + } + + if (hasPath) { + grid.insertBefore(svgEl, grid.firstChild); + } +} diff --git a/public/components/views/tree/tree-layout.js b/public/components/views/tree/tree-layout.js new file mode 100644 index 00000000..05f191fd --- /dev/null +++ b/public/components/views/tree/tree-layout.js @@ -0,0 +1,135 @@ +// CONSTANTS +export const CARD_WIDTH = 250; +export const CONNECTOR_GAP = 16; +export const GAP_ROW_HEIGHT = 16; + +export function getSortedChildren(nodeId, childrenByParent, linker) { + return (childrenByParent.get(nodeId) ?? []) + .sort((idA, idB) => linker.get(idA).name.localeCompare(linker.get(idB).name)); +} + +export function buildChildrenMap(rawEdgesData) { + const childrenByParent = new Map(); + for (const edge of rawEdgesData) { + const children = childrenByParent.get(edge.to) ?? []; + children.push(edge.from); + childrenByParent.set(edge.to, children); + } + + return childrenByParent; +} + +export function computeDepthGroups(rawEdgesData) { + const childrenByParent = buildChildrenMap(rawEdgesData); + + const depthMap = new Map(); + depthMap.set(0, 0); + const queue = [0]; + + while (queue.length > 0) { + const current = queue.shift(); + const currentDepth = depthMap.get(current); + + for (const childId of (childrenByParent.get(current) ?? [])) { + if (!depthMap.has(childId)) { + depthMap.set(childId, currentDepth + 1); + queue.push(childId); + } + } + } + + const byDepth = new Map(); + for (const [nodeId, depth] of depthMap) { + const group = byDepth.get(depth) ?? []; + group.push(nodeId); + byDepth.set(depth, group); + } + + return new Map([...byDepth.entries()].sort((entryA, entryB) => entryA[0] - entryB[0])); +} + +/** + * Recursively builds grid cells for a subtree. + * Returns the total number of rows used. + * Appends cells to the `cells` array (children before parent). + */ +function buildSubtree({ nodeId, col, startRow, parentId, ancestors, childrenByParent, linker, cells }) { + if (ancestors.has(nodeId)) { + cells.push({ nodeId, col, row: startRow, rowSpan: 1, parentId, isCyclic: true }); + + return 1; + } + + const newAncestors = new Set(ancestors); + newAncestors.add(nodeId); + + const children = getSortedChildren(nodeId, childrenByParent, linker); + + if (children.length === 0) { + cells.push({ nodeId, col, row: startRow, rowSpan: 1, parentId, isCyclic: false }); + + return 1; + } + + let totalRows = 0; + let childRow = startRow; + + for (const childId of children) { + const childRows = buildSubtree({ + nodeId: childId, col: col + 1, + startRow: childRow, + parentId: nodeId, + ancestors: newAncestors, + childrenByParent, + linker, + cells + }); + childRow += childRows; + totalRows += childRows; + } + + cells.push({ nodeId, col, row: startRow, rowSpan: totalRows, parentId, isCyclic: false }); + + return totalRows; +} + +/** + * Builds the full tree layout as a unified CSS grid. + * Returns cells[] and the total row count (including gap rows). + */ +export function computeTreeLayout(rawEdgesData, linker) { + const childrenByParent = buildChildrenMap(rawEdgesData); + const cells = []; + let currentRow = 1; + + const rootChildren = getSortedChildren(0, childrenByParent, linker); + + for (let index = 0; index < rootChildren.length; index++) { + const childId = rootChildren[index]; + const rowsUsed = buildSubtree({ + nodeId: childId, col: 1, startRow: currentRow, parentId: 0, + ancestors: new Set([0]), childrenByParent, linker, cells + }); + currentRow += rowsUsed; + + if (index < rootChildren.length - 1) { + cells.push({ isGap: true, row: currentRow }); + currentRow++; + } + } + + const totalRows = currentRow - 1; + + // Root at col 0, spanning all rows (including gap rows) + cells.push({ + nodeId: 0, + col: 0, + row: 1, + rowSpan: totalRows, + parentId: null, + isCyclic: false, + isRoot: true + }); + + return { cells, totalRows }; +} diff --git a/public/components/views/tree/tree-styles.js b/public/components/views/tree/tree-styles.js new file mode 100644 index 00000000..24101a11 --- /dev/null +++ b/public/components/views/tree/tree-styles.js @@ -0,0 +1,431 @@ +// Import Third-party Dependencies +import { css } from "lit"; + +export const treeStyles = css` + :host { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; + font-family: mononoki, monospace; + } + + [class^="icon-"]::before, [class*=" icon-"]::before { + font-family: fontello; + font-style: normal; + font-weight: normal; + display: inline-block; + text-decoration: inherit; + text-align: center; + font-variant: normal; + text-transform: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + .icon-warning-empty::before { content: '\\e80f'; } + + .page-header { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 40px; + border-bottom: 1px solid rgb(55 34 175 / 15%); + flex-shrink: 0; + } + + :host-context(body.dark) .page-header { + border-bottom-color: rgb(164 148 255 / 12%); + } + + .page-header--title { + display: flex; + align-items: baseline; + gap: 4px; + font-size: 23px; + font-weight: 700; + } + + .page-header--pkg { + color: var(--primary-lighter, #5a44da); + } + + :host-context(body.dark) .page-header--pkg { + color: var(--secondary, #00d1ff); + } + + .page-header--version { + font-size: 17px; + font-weight: 400; + color: var(--secondary-darker, #1976d2); + } + + :host-context(body.dark) .page-header--version { + color: var(--dark-theme-secondary-color, #4f9ad1); + } + + .page-header--stats { + display: flex; + align-items: center; + gap: 6px; + } + + .page-header--stat-badge { + background: rgb(55 34 175 / 7%); + border: 1px solid rgb(55 34 175 / 15%); + border-radius: 12px; + padding: 2px 10px; + font-size: 14px; + color: var(--primary-lighter, #5a44da); + } + + :host-context(body.dark) .page-header--stat-badge { + background: rgb(164 148 255 / 7%); + border-color: rgb(164 148 255 / 15%); + color: var(--secondary, #00d1ff); + } + + .page-header--modes { + margin-left: auto; + display: flex; + border: 1px solid rgb(55 34 175 / 25%); + border-radius: 6px; + overflow: hidden; + } + + :host-context(body.dark) .page-header--modes { + border-color: rgb(164 148 255 / 20%); + } + + .mode-btn { + background: transparent; + border: none; + padding: 5px 14px; + font-family: mononoki, monospace; + font-size: 15px; + color: #7a7595; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + + .mode-btn:hover { + background: rgb(55 34 175 / 6%); + color: var(--primary-lighter, #5a44da); + } + + .mode-btn.active { + background: var(--primary, #3722af); + color: white; + } + + :host-context(body.dark) .mode-btn.active { + background: var(--dark-theme-secondary-darker, #262981); + color: var(--secondary, #00d1ff); + } + + .tree-card { + border-radius: 6px; + padding: 10px 12px; + border: 1px solid rgb(55 34 175 / 20%); + background: rgb(55 34 175 / 2%); + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; + box-sizing: border-box; + position: relative; + z-index: 1; + } + + .tree-card:hover { + border-color: var(--primary-lighter, #5a44da); + background: rgb(55 34 175 / 7%); + } + + :host-context(body.dark) .tree-card { + border-color: rgb(164 148 255 / 15%); + background: rgb(255 255 255 / 2%); + } + + :host-context(body.dark) .tree-card:hover { + border-color: rgb(164 148 255 / 40%); + background: rgb(90 68 218 / 10%); + } + + .tree-card.warn-moderate { + background: rgb(249 115 22 / 8%); + border-color: rgb(249 115 22 / 35%); + } + + .tree-card.warn-moderate:hover { + background: rgb(249 115 22 / 14%); + border-color: rgb(249 115 22 / 55%); + } + + :host-context(body.dark) .tree-card.warn-moderate { + background: rgb(249 115 22 / 10%); + border-color: rgb(249 115 22 / 30%); + } + + .tree-card.warn-critical { + background: rgb(220 38 38 / 10%); + border-color: rgb(220 38 38 / 35%); + } + + .tree-card.warn-critical:hover { + background: rgb(220 38 38 / 16%); + border-color: rgb(220 38 38 / 55%); + } + + :host-context(body.dark) .tree-card.warn-critical { + background: rgb(220 38 38 / 12%); + border-color: rgb(220 38 38 / 30%); + } + + .tree-card--root { + border-style: dashed; + align-self: start; + } + + .tree-card--header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 4px; + } + + .tree-card--name { + font-size: 15px; + font-weight: 600; + color: var(--primary-darker, #261877); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; + } + + :host-context(body.dark) .tree-card--name { + color: rgb(255 255 255 / 90%); + } + + .tree-card--version { + color: var(--secondary-darker, #1976d2); + font-weight: 400; + font-size: 14px; + } + + :host-context(body.dark) .tree-card--version { + color: var(--dark-theme-secondary-color, #4f9ad1); + } + + .tree-card--provenance { + display: inline-flex; + align-items: center; + color: #10b981; + font-size: 14px; + font-weight: 700; + flex-shrink: 0; + cursor: help; + } + + .tree-card--meta { + display: flex; + align-items: center; + gap: 5px; + flex-wrap: wrap; + } + + .tree-card--type { + display: inline-block; + font-size: 12px; + font-weight: 700; + padding: 1px 5px; + border-radius: 3px; + background: var(--type-color, #6b7280); + color: white; + letter-spacing: 0.5px; + flex-shrink: 0; + text-transform: uppercase; + } + + .tree-card--flags { + display: flex; + gap: 2px; + } + + .flag { + cursor: help; + font-size: 15px; + line-height: 1; + } + + .tree-card--stats { + display: flex; + align-items: center; + gap: 5px; + font-size: 14px; + color: #7a7595; + flex-wrap: wrap; + } + + :host-context(body.dark) .tree-card--stats { + color: rgb(255 255 255 / 45%); + } + + .tree-card--separator { + opacity: 0.4; + user-select: none; + } + + .tree-card--warnings { + margin-left: auto; + color: #f97316; + font-weight: 600; + } + + .tree-card.warn-critical .tree-card--warnings { + color: #ef4444; + } + + .tree-card--cyclic { + font-size: 13px; + color: #a855f7; + margin-left: auto; + } + + .depth-container { + display: flex; + flex-direction: row; + gap: 16px; + padding: 24px 40px 40px; + overflow: auto hidden; + flex: 1; + align-items: flex-start; + } + + .depth-container::-webkit-scrollbar { height: 6px; } + .depth-container::-webkit-scrollbar-track { background: transparent; } + + .depth-container::-webkit-scrollbar-thumb { + background: rgb(55 34 175 / 30%); + border-radius: 3px; + } + + :host-context(body.dark) .depth-container::-webkit-scrollbar-thumb { + background: rgb(164 148 255 / 30%); + } + + .depth-column { + flex-shrink: 0; + width: 250px; + display: flex; + flex-direction: column; + height: 100%; + } + + .depth-column--header { + display: flex; + align-items: center; + gap: 8px; + padding-bottom: 10px; + border-bottom: 2px solid var(--primary, #3722af); + margin-bottom: 10px; + } + + :host-context(body.dark) .depth-column--header { + border-bottom-color: var(--dark-theme-secondary-color, #4f9ad1); + } + + .depth-column--label { + font-size: 16px; + font-weight: 600; + color: var(--primary-lighter, #5a44da); + text-transform: uppercase; + letter-spacing: 1px; + } + + :host-context(body.dark) .depth-column--label { + color: var(--secondary, #00d1ff); + } + + .depth-column--count { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--primary, #3722af); + color: white; + border-radius: 10px; + padding: 1px 7px; + font-size: 14px; + font-weight: bold; + } + + .depth-column--cards { + display: flex; + flex-direction: column; + gap: 6px; + overflow-y: auto; + flex: 1; + padding-right: 4px; + } + + .depth-column--cards::-webkit-scrollbar { width: 4px; } + .depth-column--cards::-webkit-scrollbar-track { background: transparent; } + + .depth-column--cards::-webkit-scrollbar-thumb { + background: rgb(55 34 175 / 30%); + border-radius: 2px; + } + + :host-context(body.dark) .depth-column--cards::-webkit-scrollbar-thumb { + background: rgb(164 148 255 / 30%); + } + + .tree-body { + overflow: auto; + flex: 1; + padding: 24px 40px 40px; + } + + .tree-body::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + .tree-body::-webkit-scrollbar-track { + background: transparent; + } + + .tree-body::-webkit-scrollbar-thumb { + background: rgb(55 34 175 / 30%); + border-radius: 3px; + } + + :host-context(body.dark) .tree-body::-webkit-scrollbar-thumb { + background: rgb(164 148 255 / 30%); + } + + .tree-grid { + display: grid; + position: relative; + } + + .tree-gap { + pointer-events: none; + } + + .connectors-svg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; + z-index: 0; + } +`; diff --git a/public/components/views/tree/tree.js b/public/components/views/tree/tree.js new file mode 100644 index 00000000..7d0ca258 --- /dev/null +++ b/public/components/views/tree/tree.js @@ -0,0 +1,194 @@ +// Import Third-party Dependencies +import { LitElement, html, nothing } from "lit"; + +// Import Internal Dependencies +import { currentLang } from "../../../common/utils.js"; +import { EVENTS } from "../../../core/events.js"; +import { treeStyles } from "./tree-styles.js"; +import { CARD_WIDTH, CONNECTOR_GAP, GAP_ROW_HEIGHT, computeDepthGroups, computeTreeLayout } from "./tree-layout.js"; +import { renderCardContent } from "./tree-card.js"; +import { drawConnectors } from "./tree-connectors.js"; +import "../../../components/root-selector/root-selector.js"; + +class TreeView extends LitElement { + static styles = treeStyles; + + static properties = { + secureDataSet: { attribute: false }, + _mode: { state: true } + }; + + constructor() { + super(); + this.secureDataSet = null; + this._mode = "depth"; + } + + updated() { + if (this._mode === "tree") { + requestAnimationFrame(() => drawConnectors(this.renderRoot)); + } + } + + #renderDepthColumn(depth, nodeIds) { + const i18n = window.i18n[currentLang()]; + const label = depth === 0 + ? i18n.tree.root + : `${i18n.tree.depth} ${depth}`; + + const sortedNodeIds = [...nodeIds].sort((idA, idB) => { + const entryA = this.secureDataSet.linker.get(idA); + const entryB = this.secureDataSet.linker.get(idB); + + return entryA.name.localeCompare(entryB.name); + }); + + return html` +
+
+ ${label} + ${sortedNodeIds.length} +
+
+ ${sortedNodeIds.map((nodeId) => renderCardContent(this.secureDataSet, { nodeId }))} +
+
+ `; + } + + #renderDepthMode(depthGroups) { + return html` +
+ ${[...depthGroups.entries()].map( + ([depth, nodeIds]) => this.#renderDepthColumn(depth, nodeIds) + )} +
+ `; + } + + #renderTreeMode(maxDepth) { + const { cells, totalRows } = computeTreeLayout(this.secureDataSet.rawEdgesData, this.secureDataSet.linker); + + const colWidth = CARD_WIDTH + CONNECTOR_GAP; + // +1 for root col + const numCols = maxDepth + 1; + + return html` +
+
+ ${cells.map((cell) => { + if (cell.isGap) { + return html` +
+ `; + } + + if (cell.isCyclic) { + return html` +
+
window.dispatchEvent( + new CustomEvent(EVENTS.TREE_NODE_CLICK, { detail: { nodeId: cell.nodeId } }) + )} + > +
+ ${this.secureDataSet.linker.get(cell.nodeId).name} + ↺ cyclic +
+
+
+ `; + } + + return html` +
+ ${renderCardContent(this.secureDataSet, { + nodeId: cell.nodeId, + parentId: cell.parentId, + isRoot: cell.isRoot ?? false + })} +
+ `; + })} +
+
+ `; + } + + #renderHeader(depthGroups) { + const totalDeps = Object.keys(this.secureDataSet.data.dependencies).length; + const directDeps = (depthGroups.get(1) ?? []).length; + const maxDepth = Math.max(...depthGroups.keys()); + const i18n = window.i18n[currentLang()]; + + return html` + + `; + } + + render() { + if (!this.secureDataSet?.data) { + return nothing; + } + + const depthGroups = computeDepthGroups( + this.secureDataSet.rawEdgesData, + this.secureDataSet.linker + ); + const maxDepth = Math.max(...depthGroups.keys()); + + return html` + ${this.#renderHeader(depthGroups)} + ${this._mode === "tree" + ? this.#renderTreeMode(maxDepth) + : this.#renderDepthMode(depthGroups) + } + `; + } +} + +customElements.define("tree-view", TreeView); diff --git a/public/core/events.js b/public/core/events.js index 441ee458..3602f101 100644 --- a/public/core/events.js +++ b/public/core/events.js @@ -16,5 +16,6 @@ export const EVENTS = { DRILL_SWITCH: "drill-switch", ROOT_SWITCH: "root-switch", ROOT_REMOVE: "root-remove", - WARNINGS_PACKAGE_CLICK: "warnings-package-click" + WARNINGS_PACKAGE_CLICK: "warnings-package-click", + TREE_NODE_CLICK: "tree-node-click" }; diff --git a/public/main.js b/public/main.js index c74423fd..e8eb26e8 100644 --- a/public/main.js +++ b/public/main.js @@ -13,6 +13,7 @@ import "./components/search-command/search-command.js"; import { Settings } from "./components/views/settings/settings.js"; import { HomeView } from "./components/views/home/home.js"; import "./components/views/search/search.js"; +import "./components/views/tree/tree.js"; import "./components/views/warnings/warnings.js"; import "./components/root-selector/root-selector.js"; import "./components/network-breadcrumb/network-breadcrumb.js"; @@ -29,6 +30,7 @@ let secureDataSet; let nsn; let homeView; let searchview; +let treeView; let warningsView; let viewAfterSwitch = null; let drillBreadcrumb; @@ -37,6 +39,7 @@ const drillStack = []; document.addEventListener("DOMContentLoaded", async() => { searchview = document.querySelector("search-view"); + treeView = document.querySelector("tree-view"); warningsView = document.querySelector("warnings-view"); window.cachedSpecs = []; @@ -95,12 +98,22 @@ document.addEventListener("DOMContentLoaded", async() => { else { window.navigation.hideMenu("network--view"); window.navigation.hideMenu("home--view"); + window.navigation.hideMenu("tree--view"); window.navigation.hideMenu("warnings--view"); window.navigation.setNavByName("search--view"); } window.socket.commands.remove(specToRemove); }); + treeView.addEventListener("click", (event) => { + const clickedCard = event.composedPath().find( + (el) => el instanceof Element && el.classList.contains("tree-card") + ); + if (!clickedCard) { + PackageInfo.close(); + } + }); + warningsView.addEventListener("click", (event) => { const clickedRow = event.composedPath().find( (el) => el instanceof Element && el.classList.contains("pkg-row") @@ -124,6 +137,21 @@ document.addEventListener("DOMContentLoaded", async() => { }, 25); }); + window.addEventListener(EVENTS.TREE_NODE_CLICK, (event) => { + console.log(event); + if (!secureDataSet) { + return; + } + + const { nodeId } = event.detail; + const selectedNode = secureDataSet.linker.get(nodeId); + if (!selectedNode) { + return; + } + + new PackageInfo(selectedNode, nodeId, secureDataSet.data.dependencies[selectedNode.name], nsn); + }); + await init(); window.dispatchEvent( new CustomEvent(EVENTS.SETTINGS_SAVED, { @@ -333,7 +361,9 @@ async function init(options = {}) { window.navigation.showMenu("network--view"); window.navigation.showMenu("home--view"); + window.navigation.showMenu("tree--view"); window.navigation.showMenu("warnings--view"); + treeView.secureDataSet = secureDataSet; warningsView.secureDataSet = secureDataSet; window.vulnerabilityStrategy = secureDataSet.data.vulnerabilityStrategy; @@ -403,6 +433,7 @@ async function loadDataSet() { if (secureDataSet.data === null) { window.navigation.hideMenu("network--view"); window.navigation.hideMenu("home--view"); + window.navigation.hideMenu("tree--view"); window.navigation.hideMenu("warnings--view"); window.navigation.setNavByName("search--view"); diff --git a/views/index.html b/views/index.html index a791694a..66cef677 100644 --- a/views/index.html +++ b/views/index.html @@ -58,6 +58,10 @@ +
  • + + +
  • @@ -93,6 +97,7 @@ + +
    + + +