Skip to content

Commit 696c062

Browse files
feat(interface): introduce new tree page (#709)
1 parent bac6360 commit 696c062

12 files changed

Lines changed: 1061 additions & 1 deletion

File tree

i18n/english.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,14 @@ const ui = {
239239
emptyHint: "Search the npm registry or enter a spec directly to scan.",
240240
scan: "Scan"
241241
},
242+
tree: {
243+
root: "Root",
244+
depth: "Depth",
245+
deps: "deps",
246+
direct: "direct",
247+
modeDepth: "Depth",
248+
modeTree: "Tree"
249+
},
242250
search_command: {
243251
placeholder: "Search packages...",
244252
placeholder_filter_hint: "or use",

i18n/french.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,14 @@ const ui = {
239239
emptyHint: "Recherchez dans le registre npm ou saisissez une spec directement.",
240240
scan: "Scanner"
241241
},
242+
tree: {
243+
root: "Racine",
244+
depth: "Profondeur",
245+
deps: "dépendances",
246+
direct: "directes",
247+
modeDepth: "Profondeur",
248+
modeTree: "Arbre"
249+
},
242250
search_command: {
243251
placeholder: "Rechercher des packages...",
244252
placeholder_filter_hint: "ou utiliser",

public/components/navigation/navigation.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const kAvailableView = new Set([
88
"home--view",
99
"search--view",
1010
"settings--view",
11+
"tree--view",
1112
"warnings--view"
1213
]);
1314

@@ -59,6 +60,10 @@ export class ViewNavigation {
5960
this.onNavigationSelected(this.menus.get("search--view"));
6061
break;
6162
}
63+
case hotkeys.tree: {
64+
this.onNavigationSelected(this.menus.get("tree--view"));
65+
break;
66+
}
6267
case hotkeys.warnings: {
6368
this.onNavigationSelected(this.menus.get("warnings--view"));
6469
break;

public/components/views/settings/settings.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const kDefaultHotKeys = {
1919
wiki: "W",
2020
lock: "L",
2121
search: "F",
22+
tree: "T",
2223
warnings: "A"
2324
};
2425
const kShortcutInputTargetIds = new Set(Object.keys(kDefaultHotKeys));
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Import Third-party Dependencies
2+
import { html, nothing } from "lit";
3+
import { FLAGS_EMOJIS } from "@nodesecure/vis-network";
4+
import prettyBytes from "pretty-bytes";
5+
6+
// Import Internal Dependencies
7+
import { EVENTS } from "../../../core/events.js";
8+
9+
// CONSTANTS
10+
const kWarningCriticalThreshold = 10;
11+
const kModuleTypeColors = {
12+
esm: "#10b981",
13+
dual: "#06b6d4",
14+
cjs: "#f59e0b",
15+
dts: "#6366f1",
16+
faux: "#6b7280"
17+
};
18+
19+
function renderFlag(flag) {
20+
const ignoredFlags = window.settings.config.ignore.flags ?? [];
21+
const ignoredSet = new Set(ignoredFlags);
22+
if (ignoredSet.has(flag)) {
23+
return nothing;
24+
}
25+
26+
const emoji = FLAGS_EMOJIS[flag];
27+
if (!emoji) {
28+
return nothing;
29+
}
30+
31+
return html`<span class="flag" title="${flag}">${emoji}</span>`;
32+
}
33+
34+
function getVersionData(secureDataSet, name, version) {
35+
return secureDataSet.data.dependencies[name]?.versions[version];
36+
}
37+
38+
export function renderCardContent(secureDataSet, { nodeId, parentId = null, isRoot = false }) {
39+
const entry = secureDataSet.linker.get(nodeId);
40+
const versionData = getVersionData(secureDataSet, entry.name, entry.version);
41+
if (!versionData) {
42+
return nothing;
43+
}
44+
45+
const warningCount = versionData.warnings?.length ?? 0;
46+
47+
let warningClass = "";
48+
if (warningCount > kWarningCriticalThreshold) {
49+
warningClass = "warn-critical";
50+
}
51+
else if (warningCount > 0) {
52+
warningClass = "warn-moderate";
53+
}
54+
55+
const hasProvenance = Boolean(versionData.attestations?.provenance);
56+
const moduleType = versionData.type ?? "cjs";
57+
const typeColor = kModuleTypeColors[moduleType] ?? "#6b7280";
58+
const size = prettyBytes(versionData.size ?? 0);
59+
const licenses = versionData.uniqueLicenseIds?.join(", ") ?? "—";
60+
const depCount = versionData.dependencyCount ?? 0;
61+
const flags = versionData.flags ?? [];
62+
const rootClass = isRoot ? "tree-card--root" : "";
63+
64+
// Show parent label only for packages at depth ≥ 2 (parentId !== null and not root)
65+
let parentName = null;
66+
if (parentId !== null && parentId !== 0) {
67+
const parentEntry = secureDataSet.linker.get(parentId);
68+
if (parentEntry) {
69+
parentName = parentEntry.name;
70+
}
71+
}
72+
73+
return html`
74+
<div
75+
class="tree-card ${warningClass} ${rootClass}"
76+
@click=${() => window.dispatchEvent(new CustomEvent(EVENTS.TREE_NODE_CLICK, { detail: { nodeId } }))}
77+
>
78+
<div class="tree-card--header">
79+
<span class="tree-card--name" title="${entry.name}@${entry.version}">
80+
${entry.name}<span class="tree-card--version">@${entry.version}</span>
81+
</span>
82+
${hasProvenance
83+
? html`<span class="tree-card--provenance" title="Published with npm provenance"></span>`
84+
: nothing
85+
}
86+
</div>
87+
<div class="tree-card--meta">
88+
<span class="tree-card--type" style="--type-color: ${typeColor}">${moduleType}</span>
89+
<span class="tree-card--flags">
90+
${flags.map((flag) => renderFlag(flag))}
91+
</span>
92+
</div>
93+
<div class="tree-card--stats">
94+
<span class="tree-card--size">${size}</span>
95+
<span class="tree-card--separator">·</span>
96+
<span class="tree-card--license">${licenses}</span>
97+
${depCount > 0
98+
? html`<span class="tree-card--separator">·</span><span>${depCount} deps</span>`
99+
: nothing
100+
}
101+
${warningCount > 0
102+
? html`<span class="tree-card--warnings"><i class="icon-warning-empty"></i> ${warningCount}</span>`
103+
: nothing
104+
}
105+
</div>
106+
${parentName === null
107+
? nothing
108+
: html`<div class="tree-card--stats"><span class="tree-card--separator">${parentName}</span></div>`
109+
}
110+
</div>
111+
`;
112+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Import Internal Dependencies
2+
import { CONNECTOR_GAP } from "./tree-layout.js";
3+
4+
export function drawConnectors(renderRoot) {
5+
const grid = renderRoot.querySelector(".tree-grid");
6+
if (!grid) {
7+
return;
8+
}
9+
10+
// Remove existing SVG
11+
grid.querySelector(".connectors-svg")?.remove();
12+
13+
const gridRect = grid.getBoundingClientRect();
14+
if (gridRect.width === 0) {
15+
return;
16+
}
17+
18+
const cells = grid.querySelectorAll(".tree-cell");
19+
20+
// Map each wrapper element to its rects:
21+
// - span bounds (top/bottom) from the stretched wrapper, for parent lookup
22+
// - midY from the inner card, for line anchoring at the visual card center
23+
const elementRects = new Map();
24+
for (const cell of cells) {
25+
const wrapperRaw = cell.getBoundingClientRect();
26+
const cardEl = cell.firstElementChild;
27+
const cardRaw = cardEl ? cardEl.getBoundingClientRect() : wrapperRaw;
28+
29+
elementRects.set(cell, {
30+
left: wrapperRaw.left - gridRect.left,
31+
right: wrapperRaw.right - gridRect.left,
32+
top: wrapperRaw.top - gridRect.top,
33+
bottom: wrapperRaw.bottom - gridRect.top,
34+
midY: cardRaw.top - gridRect.top + (cardRaw.height / 2)
35+
});
36+
}
37+
38+
// Resolve parent element for each child using spatial overlap:
39+
// among all cells matching data-parent-id, pick the one whose vertical
40+
// span (stretched to fill its grid rows) contains the child's midY.
41+
const elementChildren = new Map();
42+
for (const child of cells) {
43+
const rawParentId = child.dataset.parentId;
44+
if (!rawParentId) {
45+
continue;
46+
}
47+
48+
const parentId = Number(rawParentId);
49+
const childMidY = elementRects.get(child).midY;
50+
51+
let bestParent = null;
52+
for (const candidate of cells) {
53+
if (Number(candidate.dataset.nodeId) !== parentId) {
54+
continue;
55+
}
56+
57+
const candidateRect = elementRects.get(candidate);
58+
if (childMidY >= candidateRect.top && childMidY <= candidateRect.bottom) {
59+
bestParent = candidate;
60+
break;
61+
}
62+
}
63+
64+
if (bestParent) {
65+
const children = elementChildren.get(bestParent) ?? [];
66+
children.push(child);
67+
elementChildren.set(bestParent, children);
68+
}
69+
}
70+
71+
const isDark = document.body.classList.contains("dark");
72+
const strokeColor = isDark
73+
? "rgba(164, 148, 255, 0.3)"
74+
: "rgba(55, 34, 175, 0.18)";
75+
76+
const svgNS = "http://www.w3.org/2000/svg";
77+
const svgEl = document.createElementNS(svgNS, "svg");
78+
svgEl.classList.add("connectors-svg");
79+
80+
let hasPath = false;
81+
82+
for (const [parent, children] of elementChildren) {
83+
const parentRect = elementRects.get(parent);
84+
const childRects = children.map((child) => elementRects.get(child));
85+
86+
const midX = parentRect.right + (CONNECTOR_GAP / 2);
87+
const childMidYs = childRects.map((rect) => rect.midY).sort((rectA, rectB) => rectA - rectB);
88+
const firstChildY = childMidYs[0];
89+
const lastChildY = childMidYs.at(-1);
90+
91+
let pathData = `M ${parentRect.right} ${parentRect.midY} H ${midX}`;
92+
93+
// Vertical arm connecting to children's level
94+
if (Math.abs(parentRect.midY - firstChildY) > 1) {
95+
const targetY = childMidYs.length === 1
96+
? firstChildY
97+
: (firstChildY + lastChildY) / 2;
98+
pathData += ` V ${targetY}`;
99+
}
100+
101+
// Vertical bracket if multiple children
102+
if (childMidYs.length > 1) {
103+
pathData += ` M ${midX} ${firstChildY} V ${lastChildY}`;
104+
}
105+
106+
// Horizontal branch to each child
107+
for (const childRect of childRects) {
108+
pathData += ` M ${midX} ${childRect.midY} H ${childRect.left}`;
109+
}
110+
111+
const pathEl = document.createElementNS(svgNS, "path");
112+
pathEl.setAttribute("d", pathData);
113+
pathEl.setAttribute("fill", "none");
114+
pathEl.setAttribute("stroke", strokeColor);
115+
pathEl.setAttribute("stroke-width", "1.5");
116+
pathEl.setAttribute("stroke-linecap", "round");
117+
pathEl.setAttribute("stroke-linejoin", "round");
118+
svgEl.appendChild(pathEl);
119+
hasPath = true;
120+
}
121+
122+
if (hasPath) {
123+
grid.insertBefore(svgEl, grid.firstChild);
124+
}
125+
}

0 commit comments

Comments
 (0)