From adb486fedc3df8ff846bfd53548dab39bce99335 Mon Sep 17 00:00:00 2001 From: PierreDemailly Date: Fri, 10 Apr 2026 22:59:31 +0200 Subject: [PATCH] feat(interface): add dep filter to command palette --- i18n/english.js | 4 +- i18n/french.js | 4 +- .../command-palette/command-palette-panels.js | 3 +- public/components/command-palette/filters.js | 76 ++++++++- test/e2e/command-palette.spec.js | 82 ++++++++++ test/e2e/fixtures/nsecure-result.json | 145 ++++++++++++++++-- test/ui/command-palette-filters.test.js | 66 +++++++- 7 files changed, 363 insertions(+), 17 deletions(-) diff --git a/i18n/english.js b/i18n/english.js index a394941a..e4b26546 100644 --- a/i18n/english.js +++ b/i18n/english.js @@ -299,6 +299,7 @@ const ui = { section_extensions: "File extensions", section_builtins: "Node.js core modules", section_authors: "Authors", + section_dep: "Packages depending on", hint_size: "e.g. >50kb, 10kb..200kb", hint_version: "e.g. ^1.0.0, >=2.0.0", empty: "No results found", @@ -323,7 +324,8 @@ const ui = { ext: "file extension", builtin: "node.js module", size: "e.g. >50kb", - highlighted: "all" + highlighted: "all", + dep: "package name" } }, legend: { diff --git a/i18n/french.js b/i18n/french.js index 6afe9754..09d25908 100644 --- a/i18n/french.js +++ b/i18n/french.js @@ -299,6 +299,7 @@ const ui = { section_extensions: "Extensions de fichiers", section_builtins: "Modules Node.js natifs", section_authors: "Auteurs", + section_dep: "Packages dépendant de", hint_size: "ex. >50kb, 10kb..200kb", hint_version: "ex. ^1.0.0, >=2.0.0", empty: "Aucun résultat trouvé", @@ -323,7 +324,8 @@ const ui = { ext: "extension de fichier", builtin: "module node.js", size: "ex. >50kb", - highlighted: "all" + highlighted: "all", + dep: "nom du package" } }, legend: { diff --git a/public/components/command-palette/command-palette-panels.js b/public/components/command-palette/command-palette-panels.js index 1bc8fe74..b2003d6f 100644 --- a/public/components/command-palette/command-palette-panels.js +++ b/public/components/command-palette/command-palette-panels.js @@ -18,7 +18,8 @@ const kListTitleKeys = { license: "section_licenses", ext: "section_extensions", builtin: "section_builtins", - author: "section_authors" + author: "section_authors", + dep: "section_dep" }; /** diff --git a/public/components/command-palette/filters.js b/public/components/command-palette/filters.js index ff3b7618..abc2a9cb 100644 --- a/public/components/command-palette/filters.js +++ b/public/components/command-palette/filters.js @@ -20,7 +20,18 @@ export const VERSION_PRESETS = [ { label: "≥ 1.0", value: ">=1.0.0" }, { label: "< 1.0", value: "<1.0.0" } ]; -export const FILTERS_NAME = new Set(["package", "version", "flag", "license", "author", "ext", "builtin", "size", "highlighted"]); +export const FILTERS_NAME = new Set([ + "package", + "version", + "flag", + "license", + "author", + "ext", + "builtin", + "size", + "highlighted", + "dep" +]); export const PRESETS = [ { id: "has_vulnerabilities", filter: "flag", value: "hasVulnerabilities" }, { id: "has_scripts", filter: "flag", value: "hasScript" }, @@ -29,7 +40,7 @@ export const PRESETS = [ { id: "large", filter: "size", value: ">100kb" } ]; // Filters that use a searchable text-based list (not a rich visual panel) -export const FILTER_HAS_HELPERS = new Set(["license", "ext", "builtin", "author"]); +export const FILTER_HAS_HELPERS = new Set(["license", "ext", "builtin", "author", "dep"]); // Filters where the mode persists after selection (multi-select) export const FILTER_MULTI_SELECT = new Set(["flag"]); // Filters that auto-confirm immediately on selection (no text input needed) @@ -60,6 +71,18 @@ export function getFlagCounts(linker) { * @returns {Map} */ export function getFilterValueCounts(linker, filterName) { + if (filterName === "dep") { + const counts = new Map(); + for (const opt of linker.values()) { + const dependentCount = Object.keys(opt.usedBy).length; + if (dependentCount > 0) { + counts.set(opt.name, (counts.get(opt.name) ?? 0) + dependentCount); + } + } + + return counts; + } + const counts = new Map(); for (const opt of linker.values()) { for (const value of getValuesForCount(opt, filterName)) { @@ -155,6 +178,16 @@ export function getHelperValues(linker, filterName) { return { display: name, value: name }; }); } + case "dep": { + const items = new Set(); + for (const { name } of linker.values()) { + items.add(name); + } + + return [...items].sort().map((name) => { + return { display: name, value: name }; + }); + } default: return []; } @@ -169,6 +202,10 @@ export function getHelperValues(linker, filterName) { * @returns {Set} */ export function computeMatches(linker, filterName, inputValue) { + if (filterName === "dep") { + return computeDepMatches(linker, inputValue); + } + const matchingIds = new Set(); for (const [id, opt] of linker) { @@ -180,6 +217,41 @@ export function computeMatches(linker, filterName, inputValue) { return matchingIds; } +/** + * Collect packages that depend on package matching inputValue + * + * @param {Map} linker + * @param {string} inputValue + * @returns {Set} + */ +function computeDepMatches(linker, inputValue) { + const matchingIds = new Set(); + + try { + const regex = new RegExp(inputValue, "i"); + + const dependentNames = new Set(); + for (const opt of linker.values()) { + if (regex.test(opt.name)) { + for (const dependency of Object.keys(opt.usedBy)) { + dependentNames.add(dependency); + } + } + } + + for (const [id, opt] of linker) { + if (dependentNames.has(opt.name)) { + matchingIds.add(String(id)); + } + } + } + catch { + // invalid regex + } + + return matchingIds; +} + function matchesFilter(opt, filterName, inputValue) { switch (filterName) { case "package": { diff --git a/test/e2e/command-palette.spec.js b/test/e2e/command-palette.spec.js index d1ad0708..fa9d965b 100644 --- a/test/e2e/command-palette.spec.js +++ b/test/e2e/command-palette.spec.js @@ -152,6 +152,88 @@ test.describe("[command-palette] presets and actions", () => { }); }); +test.describe("[command-palette] dep filter", () => { + let i18n; + + test.beforeEach(async({ page }) => { + await page.goto("/"); + await page.waitForSelector(`[data-menu="network--view"].active`); + + i18n = await page.evaluate(() => { + const lang = document.getElementById("lang").dataset.lang; + const activeLang = lang in window.i18n ? lang : "english"; + + return window.i18n[activeLang].search_command; + }); + + await page.locator("body").click(); + await page.keyboard.press("Control+k"); + + await expect(page.locator(".backdrop")).toBeVisible(); + }); + + test("dep appears in the filter hint list", async({ page }) => { + const filterRow = page + .locator(".helper-item") + .filter({ hasText: "dep:" }); + + await expect(filterRow).toBeVisible(); + await expect(filterRow).toContainText(i18n.filter_hints.dep); + }); + + test("typing dep: activates the filter and shows the dep panel", async({ page }) => { + await page.locator("#cmd-input").fill("dep:"); + + await expect( + page + .locator(".section-title").filter({ hasText: i18n.section_dep })).toBeVisible(); + }); + + test("dep list panel shows all package names as helpers", async({ page }) => { + await page.locator("#cmd-input").fill("dep:"); + + await expect(page.locator(".list-item").filter({ hasText: "debug" })).toBeVisible(); + await expect(page.locator(".list-item").filter({ hasText: "ms" })).toBeVisible(); + }); + + test("typing dep:ms and pressing Enter adds a chip and shows debug as result", async({ page }) => { + await page.locator("#cmd-input").fill("dep:ms"); + await page.keyboard.press("Enter"); + + await expect(page.locator("search-chip")).toBeVisible(); + await expect(page.locator(".result-item")).toHaveCount(1); + await expect(page.locator(".result-item")).toContainText("debug"); + }); + + test("typing dep:debug and pressing Enter shows empty results", async({ page }) => { + await page.locator("#cmd-input").fill("dep:debug"); + await page.keyboard.press("Enter"); + + await expect(page.locator("search-chip")).toBeVisible(); + await expect(page.locator(".dialog .empty-state")).toHaveText(i18n.empty_after_filter); + }); + + test("dep chip label shows filter and value", async({ page }) => { + await page.locator("#cmd-input").fill("dep:ms"); + await page.keyboard.press("Enter"); + + const chip = page.locator("search-chip"); + await expect(chip).toHaveAttribute("filter", "dep"); + await expect(chip).toHaveAttribute("value", "ms"); + }); + + test("removing the dep chip clears the results", async({ page }) => { + await page.locator("#cmd-input").fill("dep:ms"); + await page.keyboard.press("Enter"); + + await expect(page.locator("search-chip")).toBeVisible(); + + await page.keyboard.press("Backspace"); + + await expect(page.locator("search-chip")).not.toBeVisible(); + }); +}); + test.describe("[command-palette] ignore flags and warnings", () => { let i18n; diff --git a/test/e2e/fixtures/nsecure-result.json b/test/e2e/fixtures/nsecure-result.json index b145bd91..14006252 100644 --- a/test/e2e/fixtures/nsecure-result.json +++ b/test/e2e/fixtures/nsecure-result.json @@ -1,9 +1,9 @@ { "id": "j6vlz7", "rootDependency": { - "name": "ms", - "version": "2.1.3", - "integrity": "sha512-EY5JVSmS/ZEMr9kLaphuJfQMLLbK87KibiJPcp3GB4YQHlFK6Ynk9mr3WRVyYqKw8akoxIoZfzLWnBTREgvbmw==" + "name": "debug", + "version": "4.3.4", + "integrity": "sha512-PRWFHuSU/p2NUD9BubleR5hiIMBEcMsE+M7BxsqWBf2a0L2PjRaRBQzBGJm9nz5M2Yw02bUBvwDBzn8eDLYiQ==" }, "scannerVersion": "10.8.0", "vulnerabilityStrategy": "none", @@ -14,14 +14,126 @@ "identifiers": [] }, "dependencies": { - "ms": { + "debug": { "versions": { - "2.1.3": { + "4.3.4": { "id": 0, "type": "cjs", "usedBy": {}, "isDevDependency": false, "existOnRemoteRegistry": true, + "flags": [], + "warnings": [], + "dependencyCount": 1, + "gitUrl": null, + "alias": {}, + "description": "small debugging utility", + "size": 14823, + "author": { + "name": "TJ Holowaychuk", + "email": "tj@vision-media.ca" + }, + "scripts": { + "test": "xo && npm run unit && npm run lint-readme", + "lint-readme": "node ./scripts/support-list.js readme.md" + }, + "licenses": [ + { + "licenses": { + "MIT": "https://spdx.org/licenses/MIT.html#licenseText" + }, + "spdx": { + "osi": true, + "fsf": true, + "fsfAndOsi": true, + "includesDeprecated": false + }, + "fileName": "package.json" + } + ], + "uniqueLicenseIds": [ + "MIT" + ], + "composition": { + "extensions": [ + ".js", + ".json", + ".md" + ], + "files": [ + "src/browser.js", + "src/common.js", + "src/index.js", + "src/node.js", + "package.json", + "README.md", + "LICENSE" + ], + "minified": [], + "unused": [], + "missing": [], + "required_files": [], + "required_nodejs": [], + "required_thirdparty": [ + "ms" + ], + "required_subpath": {} + }, + "repository": "debug-js/debug", + "integrity": "a1e82ce6e7e9742e0a3a62ebb22fe83f47b9d5fb", + "links": { + "npm": "https://www.npmjs.com/package/debug/v/4.3.4", + "homepage": "https://github.com/debug-js/debug#readme", + "repository": "https://github.com/debug-js/debug" + } + } + }, + "vulnerabilities": [], + "metadata": { + "homepage": "https://github.com/debug-js/debug#readme", + "publishedCount": 54, + "lastVersion": "4.3.4", + "lastUpdateAt": "2022-02-22T12:00:00.000Z", + "hasReceivedUpdateInOneYear": false, + "hasChangedAuthor": false, + "integrity": { + "4.3.4": "a1e82ce6e7e9742e0a3a62ebb22fe83f47b9d5fb" + }, + "author": { + "name": "TJ Holowaychuk", + "email": "tj@vision-media.ca" + }, + "publishers": [ + { + "name": "tootallnate", + "email": "nathan@tootallnate.net", + "version": "4.3.4", + "at": "2022-02-22T12:00:00.000Z" + } + ], + "maintainers": [ + { + "email": "tj@vision-media.ca", + "name": "tjholowaychuk" + }, + { + "email": "nathan@tootallnate.net", + "name": "tootallnate" + } + ], + "hasManyPublishers": false + } + }, + "ms": { + "versions": { + "2.1.3": { + "id": 1, + "type": "cjs", + "usedBy": { + "debug": "4.3.4" + }, + "isDevDependency": false, + "existOnRemoteRegistry": true, "flags": [ "hasWarnings" ], @@ -203,22 +315,37 @@ "executionTime": 1250, "apiCalls": [ { - "name": "pacote.manifest ms@2.1.3", + "name": "pacote.manifest debug@4.3.4", "startedAt": 1774800760809, "executionTime": 7 }, { - "name": "pacote.extract ms@2.1.3", + "name": "pacote.extract debug@4.3.4", "startedAt": 1774800760821, "executionTime": 114 }, { - "name": "tarball.scanDirOrArchive ms@2.1.3", + "name": "tarball.scanDirOrArchive debug@4.3.4", "startedAt": 1774800760938, "executionTime": 330 + }, + { + "name": "pacote.manifest ms@2.1.3", + "startedAt": 1774800761270, + "executionTime": 6 + }, + { + "name": "pacote.extract ms@2.1.3", + "startedAt": 1774800761280, + "executionTime": 89 + }, + { + "name": "tarball.scanDirOrArchive ms@2.1.3", + "startedAt": 1774800761370, + "executionTime": 265 } ], - "apiCallsCount": 3, + "apiCallsCount": 6, "errorCount": 0, "errors": [] } diff --git a/test/ui/command-palette-filters.test.js b/test/ui/command-palette-filters.test.js index 9a5d6766..142760ee 100644 --- a/test/ui/command-palette-filters.test.js +++ b/test/ui/command-palette-filters.test.js @@ -19,7 +19,8 @@ const kLinker = new Map([ composition: { extensions: [".js", ".ts"], required_nodejs: ["fs", "path"] }, author: { name: "TJ Holowaychuk" }, size: 102_400, - isHighlighted: true + isHighlighted: true, + usedBy: {} }], [1, { name: "lodash", @@ -29,7 +30,8 @@ const kLinker = new Map([ composition: { extensions: [".js", ""], required_nodejs: ["path"] }, author: "John-David Dalton", size: 5_000, - isHighlighted: true + isHighlighted: true, + usedBy: { express: "4.18.2" } }], [2, { name: "semver", @@ -38,7 +40,8 @@ const kLinker = new Map([ uniqueLicenseIds: ["ISC"], composition: { extensions: [".js"], required_nodejs: [] }, author: null, - size: 20_000 + size: 20_000, + usedBy: { express: "4.18.2" } }] ]); @@ -209,6 +212,44 @@ describe("computeMatches", () => { }); }); + describe("filter: dep", () => { + it("should return packages that depend on the given package", () => { + const result = computeMatches(kLinker, "dep", "lodash"); + + assert.deepEqual(result, new Set(["0"])); + }); + + it("should match against multiple packages when regex hits several names", () => { + const result = computeMatches(kLinker, "dep", "lodash|semver"); + + assert.deepEqual(result, new Set(["0"])); + }); + + it("should return empty set when no package depends on the target", () => { + const result = computeMatches(kLinker, "dep", "express"); + + assert.deepEqual(result, new Set()); + }); + + it("should return empty set when the target package does not exist", () => { + const result = computeMatches(kLinker, "dep", "unknown-package"); + + assert.deepEqual(result, new Set()); + }); + + it("should support partial regex matching", () => { + const result = computeMatches(kLinker, "dep", "sem"); + + assert.deepEqual(result, new Set(["0"])); + }); + + it("should return empty set on invalid regex", () => { + const result = computeMatches(kLinker, "dep", "[invalid"); + + assert.deepEqual(result, new Set()); + }); + }); + describe("filter: author", () => { it("should match packages whose author is an object with a matching name", () => { const result = computeMatches(kLinker, "author", "TJ"); @@ -310,6 +351,15 @@ describe("getFilterValueCounts", () => { ])); }); + it("should count dependent packages per dependency name", () => { + const result = getFilterValueCounts(kLinker, "dep"); + + assert.deepEqual(result, new Map([ + ["lodash", 1], + ["semver", 1] + ])); + }); + it("should return empty map for unknown filter name", () => { const result = getFilterValueCounts(kLinker, "unknown"); @@ -354,6 +404,16 @@ describe("getHelperValues", () => { ]); }); + it("should return all package names sorted alphabetically for dep filter", () => { + const result = getHelperValues(kLinker, "dep"); + + assert.deepEqual(result, [ + { display: "express", value: "express" }, + { display: "lodash", value: "lodash" }, + { display: "semver", value: "semver" } + ]); + }); + it("should return empty array for unknown filter name", () => { const result = getHelperValues(kLinker, "unknown");