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 } }))}
+ >
+
+
+ ${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 } })
+ )}
+ >
+
+
+
+ `;
+ }
+
+ 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 @@
+
[[=z.token('settings.general.title')]]
@@ -175,6 +180,10 @@ [[=z.token('settings.shortcuts.title')]]
+
+
+
+