diff --git a/.gitignore b/.gitignore index addefcdf..efa13fe9 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,7 @@ reports # playwright test results test-results + +# IDEs +.vscode +.idea diff --git a/i18n/english.js b/i18n/english.js index 16d63945..19e7f410 100644 --- a/i18n/english.js +++ b/i18n/english.js @@ -281,6 +281,9 @@ const ui = { section_actions: "Actions", action_toggle_theme_to_dark: "Switch to dark theme", action_toggle_theme_to_light: "Switch to light theme", + action_reset_view: "Reset view", + action_copy_packages: "Copy packages", + action_export_payload: "Export payload", section_presets: "Quick filters", preset_has_vulnerabilities: "Has vulnerabilities", preset_has_scripts: "Has install scripts", diff --git a/i18n/french.js b/i18n/french.js index 27ac48cc..08523dd2 100644 --- a/i18n/french.js +++ b/i18n/french.js @@ -281,6 +281,9 @@ const ui = { section_actions: "Actions", action_toggle_theme_to_dark: "Passer en thème sombre", action_toggle_theme_to_light: "Passer en thème clair", + action_reset_view: "Réinitialiser la vue", + action_copy_packages: "Copier les packages", + action_export_payload: "Exporter le payload", section_presets: "Filtres rapides", preset_has_vulnerabilities: "Contient des vulnérabilités", preset_has_scripts: "Scripts d'installation", diff --git a/public/components/command-palette/command-palette.js b/public/components/command-palette/command-palette.js index 0c9a9d70..b22f8dcc 100644 --- a/public/components/command-palette/command-palette.js +++ b/public/components/command-palette/command-palette.js @@ -30,7 +30,10 @@ import "./search-chip.js"; // CONSTANTS const kActions = [ - { id: "toggle_theme", shortcut: "t" } + { id: "toggle_theme", shortcut: "t" }, + { id: "reset_view", shortcut: "r" }, + { id: "copy_packages", shortcut: "c" }, + { id: "export_payload", shortcut: "e" } ]; const kWarningItems = Object.keys(warnings) .map((id) => { @@ -364,12 +367,50 @@ class CommandPalette extends LitElement { this.#close(); } - #executeAction(action) { - if (action.id === "toggle_theme") { - const nextTheme = window.settings.config.theme === "dark" ? "light" : "dark"; - window.dispatchEvent(new CustomEvent(EVENTS.SETTINGS_SAVED, { - detail: { ...window.settings.config, theme: nextTheme } - })); + async #executeAction(action) { + switch (action.id) { + case "toggle_theme": { + const nextTheme = window.settings.config.theme === "dark" ? "light" : "dark"; + window.dispatchEvent(new CustomEvent(EVENTS.SETTINGS_SAVED, { + detail: { ...window.settings.config, theme: nextTheme } + })); + break; + } + case "reset_view": + this.#network.network.emit("click", { nodes: [], edges: [] }); + this.#network.network.focus(0, { + animation: true, + scale: 0.35, + offset: { x: 150, y: 0 } + }); + break; + case "copy_packages": { + const packages = this.results.length > 0 ? this.results : this.#packages; + const text = packages.map((pkg) => `${pkg.name}@${pkg.version}`).join("\n"); + try { + await navigator.clipboard.writeText(text); + } + catch (error) { + console.error(error); + } + break; + } + case "export_payload": { + try { + const res = await fetch("/data"); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = "nsecure-result.json"; + anchor.click(); + URL.revokeObjectURL(url); + } + catch (error) { + console.error(error); + } + break; + } } this.#close(); @@ -498,13 +539,28 @@ class CommandPalette extends LitElement { const i18n = window.i18n[currentLang()].search_command; const currentTheme = window.settings?.config?.theme ?? "light"; const targetTheme = currentTheme === "dark" ? "light" : "dark"; + const copyCount = this.results.length > 0 ? this.results.length : this.#packages.length; return kActions.map((action) => { - return { - ...action, - label: i18n[`action_${action.id}_to_${targetTheme}`], - kbd: resolveKbd(action.shortcut) - }; + let label; + switch (action.id) { + case "toggle_theme": + label = i18n[`action_toggle_theme_to_${targetTheme}`]; + break; + case "reset_view": + label = i18n.action_reset_view; + break; + case "copy_packages": + label = `${i18n.action_copy_packages} (${copyCount})`; + break; + case "export_payload": + label = i18n.action_export_payload; + break; + default: + label = action.id; + } + + return { ...action, label, kbd: resolveKbd(action.shortcut) }; }); } @@ -584,7 +640,7 @@ class CommandPalette extends LitElement { presets: PRESETS, onApply: (preset) => this.#addQuery(preset.filter, preset.value) }) : nothing} - ${showRichPlaceholder ? renderActions({ + ${(showRichPlaceholder || showRefinePlaceholder) ? renderActions({ actions: this.#resolveActions(), onExecute: (action) => this.#executeAction(action) }) : nothing} diff --git a/test/e2e/command-palette.spec.js b/test/e2e/command-palette.spec.js index 76e0a4b1..6ca9cc34 100644 --- a/test/e2e/command-palette.spec.js +++ b/test/e2e/command-palette.spec.js @@ -30,9 +30,9 @@ test.describe("[command-palette] presets and actions", () => { await expect(presetsSection.locator(".range-preset")).toHaveCount(5); }); - test("renders the theme toggle action button", async({ page }) => { + test("renders all four action buttons", async({ page }) => { const actionsSection = page.locator(".section").filter({ hasText: i18n.section_actions }); - await expect(actionsSection.locator(".range-preset")).toHaveCount(1); + await expect(actionsSection.locator(".range-preset")).toHaveCount(4); }); test("clicking a preset adds a chip and hides the presets section", async({ page }) => { @@ -66,7 +66,8 @@ test.describe("[command-palette] presets and actions", () => { const expectedTheme = initialTheme === "dark" ? "light" : "dark"; const actionsSection = page.locator(".section").filter({ hasText: i18n.section_actions }); - await actionsSection.locator(".range-preset").click(); + const toggleLabel = i18n[`action_toggle_theme_to_${expectedTheme}`]; + await actionsSection.locator(".range-preset").filter({ hasText: toggleLabel }).click(); await expect(page.locator(".backdrop")).not.toBeVisible(); const newTheme = await page.evaluate(() => window.settings.config.theme); @@ -89,6 +90,66 @@ test.describe("[command-palette] presets and actions", () => { await expect(page.locator(".backdrop")).not.toBeVisible(); }); + + test("actions section remains visible after a filter chip is applied", async({ page }) => { + await page.locator(".range-preset").filter({ hasText: i18n.preset_deprecated }).click(); + + await expect(page.locator(".section").filter({ hasText: i18n.section_actions })).toBeVisible(); + }); + + test("clicking reset view closes the palette", async({ page }) => { + const actionsSection = page.locator(".section").filter({ hasText: i18n.section_actions }); + await actionsSection.locator(".range-preset").filter({ hasText: i18n.action_reset_view }).click(); + + await expect(page.locator(".backdrop")).not.toBeVisible(); + }); + + test("Alt+R triggers reset view and closes the palette", async({ page }) => { + await page.keyboard.press("Alt+r"); + + await expect(page.locator(".backdrop")).not.toBeVisible(); + }); + + test("clicking copy packages closes the palette and writes specs to clipboard", async({ page }) => { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + + const actionsSection = page.locator(".section").filter({ hasText: i18n.section_actions }); + await actionsSection.locator(".range-preset").filter({ hasText: i18n.action_copy_packages }).click(); + + await expect(page.locator(".backdrop")).not.toBeVisible(); + + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); + expect(clipboardText.length).toBeGreaterThan(0); + expect(clipboardText).toContain("@"); + }); + + test("Alt+C triggers copy packages and closes the palette", async({ page }) => { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await page.keyboard.press("Alt+c"); + + await expect(page.locator(".backdrop")).not.toBeVisible(); + }); + + test("clicking export payload closes the palette and triggers a download", async({ page }) => { + const actionsSection = page.locator(".section").filter({ hasText: i18n.section_actions }); + const [download] = await Promise.all([ + page.waitForEvent("download"), + actionsSection.locator(".range-preset").filter({ hasText: i18n.action_export_payload }).click() + ]); + + await expect(page.locator(".backdrop")).not.toBeVisible(); + expect(download.suggestedFilename()).toBe("nsecure-result.json"); + }); + + test("Alt+E triggers export payload and closes the palette", async({ page }) => { + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.keyboard.press("Alt+e") + ]); + + await expect(page.locator(".backdrop")).not.toBeVisible(); + expect(download.suggestedFilename()).toBe("nsecure-result.json"); + }); }); test.describe("[command-palette] ignore flags and warnings", () => {