Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions i18n/english.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions i18n/french.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions public/components/navigation/navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const kAvailableView = new Set([
"home--view",
"search--view",
"settings--view",
"tree--view",
"warnings--view"
]);

Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions public/components/views/settings/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const kDefaultHotKeys = {
wiki: "W",
lock: "L",
search: "F",
tree: "T",
warnings: "A"
};
const kShortcutInputTargetIds = new Set(Object.keys(kDefaultHotKeys));
Expand Down
112 changes: 112 additions & 0 deletions public/components/views/tree/tree-card.js
Original file line number Diff line number Diff line change
@@ -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`<span class="flag" title="${flag}">${emoji}</span>`;
}

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`
<div
class="tree-card ${warningClass} ${rootClass}"
@click=${() => window.dispatchEvent(new CustomEvent(EVENTS.TREE_NODE_CLICK, { detail: { nodeId } }))}
>
<div class="tree-card--header">
<span class="tree-card--name" title="${entry.name}@${entry.version}">
${entry.name}<span class="tree-card--version">@${entry.version}</span>
</span>
${hasProvenance
? html`<span class="tree-card--provenance" title="Published with npm provenance">✓</span>`
: nothing
}
</div>
<div class="tree-card--meta">
<span class="tree-card--type" style="--type-color: ${typeColor}">${moduleType}</span>
<span class="tree-card--flags">
${flags.map((flag) => renderFlag(flag))}
</span>
</div>
<div class="tree-card--stats">
<span class="tree-card--size">${size}</span>
<span class="tree-card--separator">·</span>
<span class="tree-card--license">${licenses}</span>
${depCount > 0
? html`<span class="tree-card--separator">·</span><span>${depCount} deps</span>`
: nothing
}
${warningCount > 0
? html`<span class="tree-card--warnings"><i class="icon-warning-empty"></i> ${warningCount}</span>`
: nothing
}
</div>
${parentName === null
? nothing
: html`<div class="tree-card--stats"><span class="tree-card--separator">↳ ${parentName}</span></div>`
}
</div>
`;
}
125 changes: 125 additions & 0 deletions public/components/views/tree/tree-connectors.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading