From 19d3dbb6fe6413063eec4382e403da4044d08be7 Mon Sep 17 00:00:00 2001 From: Sonia Sandler <66797193+SoniaSandler@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:28:35 -0400 Subject: [PATCH] chore: use product.json command palette search entries (#16708) * chore: use product.json command palette search entries Signed-off-by: Sonia Sandler * chore: add safegaurd for searchOptionsWithShortcuts Signed-off-by: Sonia Sandler * chore: update tests Signed-off-by: Sonia Sandler * chore: add test Signed-off-by: Sonia Sandler * chore: update e2e tests Signed-off-by: Sonia Sandler * chore: apply comments Signed-off-by: Sonia Sandler --------- Signed-off-by: Sonia Sandler --- packages/api/src/index.ts | 1 + packages/api/src/search-option.ts | 23 ++++ .../main/src/plugin/command-registry.spec.ts | 31 +++++ packages/main/src/plugin/command-registry.ts | 8 +- packages/main/src/plugin/index.ts | 5 + packages/preload/src/index.ts | 5 + .../src/lib/dialogs/CommandPalette.spec.ts | 116 ++++++++++++------ .../src/lib/dialogs/CommandPalette.svelte | 30 +++-- product.json | 24 ++++ 9 files changed, 191 insertions(+), 52 deletions(-) create mode 100644 packages/api/src/search-option.ts diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index e578a1763fa..d7baa13d5d6 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -77,6 +77,7 @@ export * from './provider-info.js'; export * from './proxy.js'; export * from './pull-event.js'; export * from './release-notes-info.js'; +export * from './search-option.js'; export * from './status-bar.js'; export * from './system-overview-info.js'; export * from './taskInfo.js'; diff --git a/packages/api/src/search-option.ts b/packages/api/src/search-option.ts new file mode 100644 index 00000000000..7dd9e6bd603 --- /dev/null +++ b/packages/api/src/search-option.ts @@ -0,0 +1,23 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export interface CommandPaletteSearchOption { + category: string; + text: string; + placeholder: string; +} diff --git a/packages/main/src/plugin/command-registry.spec.ts b/packages/main/src/plugin/command-registry.spec.ts index 9193452f608..66890c413fc 100644 --- a/packages/main/src/plugin/command-registry.spec.ts +++ b/packages/main/src/plugin/command-registry.spec.ts @@ -19,11 +19,15 @@ import type { ApiSenderType } from '@podman-desktop/core-api/api-sender'; import { beforeEach, expect, expectTypeOf, test, vi } from 'vitest'; +import product from '/@product.json' with { type: 'json' }; + import { CommandRegistry } from './command-registry.js'; import type { Telemetry } from './telemetry/telemetry.js'; let commandRegistry: CommandRegistry; +vi.mock(import('/@product.json')); + /* eslint-disable @typescript-eslint/no-empty-function */ beforeEach(() => { commandRegistry = new CommandRegistry( @@ -36,6 +40,18 @@ beforeEach(() => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(product).commandPalette.searchOptions = [ + { + category: 'category1', + text: 'Category 1', + placeholder: 'Category 1 text', + }, + { + category: 'category2', + text: 'Category 2', + placeholder: 'Category 2 text', + }, + ]; }); test('Should dispose commands from an extension', async () => { @@ -156,3 +172,18 @@ test('Should include category in the title', async () => { // should have category + title expect(myCommand?.title).toBe(`${category}: ${title1}`); }); + +test('Expect getCommandPaletteSearchOptions to return SearchOptions from product.json', () => { + expect(commandRegistry.getCommandPaletteSearchOptions()).toStrictEqual([ + { + category: 'category1', + text: 'Category 1', + placeholder: 'Category 1 text', + }, + { + category: 'category2', + text: 'Category 2', + placeholder: 'Category 2 text', + }, + ]); +}); diff --git a/packages/main/src/plugin/command-registry.ts b/packages/main/src/plugin/command-registry.ts index 57ca2b1e4fb..6d887f2f6a6 100644 --- a/packages/main/src/plugin/command-registry.ts +++ b/packages/main/src/plugin/command-registry.ts @@ -16,11 +16,13 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import type { CommandInfo } from '@podman-desktop/core-api'; +import type { CommandInfo, CommandPaletteSearchOption } from '@podman-desktop/core-api'; import { ApiSenderType } from '@podman-desktop/core-api/api-sender'; import { inject, injectable } from 'inversify'; import { z } from 'zod'; +import product from '/@product.json' with { type: 'json' }; + import { Telemetry } from './telemetry/telemetry.js'; import { Disposable } from './types/disposable.js'; @@ -137,6 +139,10 @@ export class CommandRegistry { return commandInfos; } + getCommandPaletteSearchOptions(): CommandPaletteSearchOption[] { + return product.commandPalette.searchOptions; + } + registerCommandPalette(...extensionCommands: RawCommand[]): Disposable { const disposables: Disposable[] = []; diff --git a/packages/main/src/plugin/index.ts b/packages/main/src/plugin/index.ts index f00004904b3..dba492239c2 100644 --- a/packages/main/src/plugin/index.ts +++ b/packages/main/src/plugin/index.ts @@ -46,6 +46,7 @@ import type { CliToolInfo, ColorInfo, CommandInfo, + CommandPaletteSearchOption, ContainerCreateOptions, ContainerExportOptions, ContainerImportOptions, @@ -2364,6 +2365,10 @@ export class PluginSystem { return commandRegistry.getCommandPaletteCommands(); }); + this.ipcHandle('commands:getCommandPaletteSearchOptions', async (): Promise => { + return commandRegistry.getCommandPaletteSearchOptions(); + }); + this.ipcHandle( 'extension-loader:stopExtension', async (_listener: Electron.IpcMainInvokeEvent, extensionId: string): Promise => { diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index 5071bde1555..08e9601401f 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -45,6 +45,7 @@ import type { CliToolInfo, ColorInfo, CommandInfo, + CommandPaletteSearchOption, ContainerCreateOptions, ContainerExportOptions, ContainerfileInfo, @@ -1690,6 +1691,10 @@ export function initExposure(): void { return ipcInvoke('commands:getCommandPaletteCommands'); }); + contextBridge.exposeInMainWorld('getCommandPaletteSearchOptions', async (): Promise => { + return ipcInvoke('commands:getCommandPaletteSearchOptions'); + }); + contextBridge.exposeInMainWorld('listExtensions', async (): Promise => { return ipcInvoke('extension-loader:listExtensions'); }); diff --git a/packages/renderer/src/lib/dialogs/CommandPalette.spec.ts b/packages/renderer/src/lib/dialogs/CommandPalette.spec.ts index defc47e51a9..d8666615a0f 100644 --- a/packages/renderer/src/lib/dialogs/CommandPalette.spec.ts +++ b/packages/renderer/src/lib/dialogs/CommandPalette.spec.ts @@ -19,7 +19,7 @@ import '@testing-library/jest-dom/vitest'; import type { ContainerInfo } from '@podman-desktop/core-api'; -import { fireEvent, render, screen } from '@testing-library/svelte'; +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; import userEvent from '@testing-library/user-event'; import { tick } from 'svelte'; import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; @@ -59,12 +59,22 @@ beforeAll(() => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(window.telemetryTrack).mockResolvedValue(undefined); + vi.mocked(window.getCommandPaletteSearchOptions).mockResolvedValue([ + { category: 'category 1', text: 'Category 1 text', placeholder: 'Enter category 1 item' }, + { category: 'category 2', text: 'Category 2 text', placeholder: 'Enter category 2 item' }, + { category: 'category 3', text: 'Category 3 text', placeholder: 'Enter category 3 item' }, + { category: 'category 4', text: 'Category 4 text', placeholder: 'Enter category 4 item' }, + ]); }); describe('Command Palette', () => { test('Expect that F1 key is displaying the widget', async () => { render(CommandPalette); + await waitFor(() => { + expect(window.getCommandPaletteSearchOptions).toHaveBeenCalled(); + }); + // check we have the command palette input field const inputBefore = screen.queryByRole('textbox', { name: COMMAND_PALETTE_ARIA_LABEL }); expect(inputBefore).not.toBeInTheDocument(); @@ -80,6 +90,10 @@ describe('Command Palette', () => { test('Expect that esc key is hiding the widget', async () => { render(CommandPalette, { display: true }); + await waitFor(() => { + expect(window.getCommandPaletteSearchOptions).toHaveBeenCalled(); + }); + // check we have the command palette input field const input = screen.getByRole('textbox', { name: COMMAND_PALETTE_ARIA_LABEL }); expect(input).toBeInTheDocument(); @@ -108,11 +122,15 @@ describe('Command Palette', () => { render(CommandPalette, { display: true }); + await waitFor(() => { + expect(window.getCommandPaletteSearchOptions).toHaveBeenCalled(); + }); + // Wait for component to initialize and items to be rendered await screen.findByRole('textbox', { name: COMMAND_PALETTE_ARIA_LABEL }); - // Switch to Commands mode to ensure we're testing command navigation specifically - const commandsButton = screen.getByRole('button', { name: /Commands/ }); + // Switch to Commands (category 2 in this test case) mode to ensure we're testing command navigation specifically + const commandsButton = screen.getByRole('button', { name: /Category 2/ }); await userEvent.click(commandsButton); // Wait for items to appear @@ -170,11 +188,15 @@ describe('Command Palette', () => { render(CommandPalette, { display: true }); + await waitFor(() => { + expect(window.getCommandPaletteSearchOptions).toHaveBeenCalled(); + }); + // Wait for component to initialize and items to be rendered const input = await screen.findByRole('textbox', { name: COMMAND_PALETTE_ARIA_LABEL }); - // Switch to Commands mode to ensure we're testing command navigation specifically - const commandsButton = screen.getByRole('button', { name: /Commands/ }); + // Switch to Commands (category 2 in this test case) mode to ensure we're testing command navigation specifically + const commandsButton = screen.getByRole('button', { name: /Category 2/ }); await userEvent.click(commandsButton); // Wait for all items to appear @@ -235,11 +257,15 @@ describe('Command Palette', () => { render(CommandPalette, { display: true }); + await waitFor(() => { + expect(window.getCommandPaletteSearchOptions).toHaveBeenCalled(); + }); + // Wait for component to initialize and items to be rendered const input = await screen.findByRole('textbox', { name: COMMAND_PALETTE_ARIA_LABEL }); - // Switch to Commands mode to ensure we're testing command navigation specifically - const commandsButton = screen.getByRole('button', { name: /Commands/ }); + // Switch to Commands (category 2 in this test case) mode to ensure we're testing command navigation specifically + const commandsButton = screen.getByRole('button', { name: /Category 2/ }); await userEvent.click(commandsButton); // Wait for items to appear @@ -290,6 +316,10 @@ describe('Command Palette', () => { render(CommandPalette, { display: true }); + await waitFor(() => { + expect(window.getCommandPaletteSearchOptions).toHaveBeenCalled(); + }); + // check we have the command palette input field const filterInput = screen.getByRole('textbox', { name: COMMAND_PALETTE_ARIA_LABEL }); expect(filterInput).toBeInTheDocument(); @@ -362,12 +392,16 @@ describe('Command Palette', () => { render(CommandPalette, { display: true }); + await waitFor(() => { + expect(window.getCommandPaletteSearchOptions).toHaveBeenCalled(); + }); + // check we have the command palette input field const input = screen.getByRole('textbox', { name: COMMAND_PALETTE_ARIA_LABEL }); expect(input).toBeInTheDocument(); - // Switch to Commands mode to ensure we're testing command navigation specifically - const commandsButton = screen.getByRole('button', { name: /Commands/ }); + // Switch to Commands (category 2 in this test case) mode to ensure we're testing command navigation specifically + const commandsButton = screen.getByRole('button', { name: /Category 2/ }); await userEvent.click(commandsButton); // Wait for items to appear @@ -399,31 +433,31 @@ describe('Command Palette', () => { { description: 'Ctrl+Shift+P', shortcut: '{Control>}{Shift>}p{/Shift}{/Control}', - expectedTabText: 'Ctrl+Shift+P All', + expectedTabText: 'Ctrl+Shift+P Category 1 text', shouldOpen: false, }, { description: 'F1 key', shortcut: '{F1}', - expectedTabText: 'F1 > Commands', + expectedTabText: 'F1 > Category 2 text', shouldOpen: true, }, { description: '> key', shortcut: '>', - expectedTabText: 'F1 > Commands', + expectedTabText: 'F1 > Category 2 text', shouldOpen: false, }, { description: 'Ctrl+K', shortcut: '{Control>}k{/Control}', - expectedTabText: 'Ctrl+K Documentation', + expectedTabText: 'Ctrl+K Category 3 text', shouldOpen: false, }, { description: 'Ctrl+F', shortcut: '{Control>}f{/Control}', - expectedTabText: 'Ctrl+F Go to', + expectedTabText: 'Ctrl+F Category 4 text', shouldOpen: false, }, ]; @@ -434,6 +468,10 @@ describe('Command Palette', () => { }) => { render(CommandPalette, { display: true }); + await waitFor(() => { + expect(window.getCommandPaletteSearchOptions).toHaveBeenCalled(); + }); + // press the shortcut await userEvent.keyboard(shortcut); @@ -445,10 +483,10 @@ describe('Command Palette', () => { expect(expectedTab).toHaveClass('text-[var(--pd-button-tab-text-selected)]'); expect(expectedTab).toHaveClass('border-[var(--pd-button-tab-border-selected)]'); - const allTab = screen.getByRole('button', { name: 'Ctrl+Shift+P All' }); - const commandsTab = screen.getByRole('button', { name: 'F1 > Commands' }); - const docsTab = screen.getByRole('button', { name: 'Ctrl+K Documentation' }); - const gotoTab = screen.getByRole('button', { name: 'Ctrl+F Go to' }); + const allTab = screen.getByRole('button', { name: 'Ctrl+Shift+P Category 1 text' }); + const commandsTab = screen.getByRole('button', { name: 'F1 > Category 2 text' }); + const docsTab = screen.getByRole('button', { name: 'Ctrl+K Category 3 text' }); + const gotoTab = screen.getByRole('button', { name: 'Ctrl+F Category 4 text' }); [allTab, commandsTab, docsTab, gotoTab].forEach(button => { if (button !== expectedTab) { @@ -463,6 +501,10 @@ describe('Command Palette', () => { shouldOpen, }) => { render(CommandPalette); + + await waitFor(() => { + expect(window.getCommandPaletteSearchOptions).toHaveBeenCalled(); + }); // check command palette is not displayed initially const inputBefore = screen.queryByRole('textbox', { name: COMMAND_PALETTE_ARIA_LABEL }); expect(inputBefore).not.toBeInTheDocument(); @@ -490,16 +532,20 @@ describe('Command Palette', () => { render(CommandPalette, { display: true }); + await waitFor(() => { + expect(window.getCommandPaletteSearchOptions).toHaveBeenCalled(); + }); + // check command palette is displayed const input = screen.getByRole('textbox', { name: COMMAND_PALETTE_ARIA_LABEL }); expect(input).toBeInTheDocument(); await screen.findByRole('button', { name: 'Test Command 1' }); - const allTab = screen.getByRole('button', { name: 'Ctrl+Shift+P All' }); - const commandsTab = screen.getByRole('button', { name: 'F1 > Commands' }); - const docsTab = screen.getByRole('button', { name: 'Ctrl+K Documentation' }); - const gotoTab = screen.getByRole('button', { name: 'Ctrl+F Go to' }); + const allTab = screen.getByRole('button', { name: 'Ctrl+Shift+P Category 1 text' }); + const commandsTab = screen.getByRole('button', { name: 'F1 > Category 2 text' }); + const docsTab = screen.getByRole('button', { name: 'Ctrl+K Category 3 text' }); + const gotoTab = screen.getByRole('button', { name: 'Ctrl+F Category 4 text' }); // initially "All" tab should be selected (index 0) expect(allTab).toHaveClass('text-[var(--pd-button-tab-text-selected)]'); @@ -540,14 +586,18 @@ describe('Command Palette', () => { render(CommandPalette, { display: true }); + await waitFor(() => { + expect(window.getCommandPaletteSearchOptions).toHaveBeenCalled(); + }); + // check command palette is displayed const input = screen.getByRole('textbox', { name: COMMAND_PALETTE_ARIA_LABEL }); expect(input).toBeInTheDocument(); - const allTab = screen.getByRole('button', { name: 'Ctrl+Shift+P All' }); - const commandsTab = screen.getByRole('button', { name: 'F1 > Commands' }); - const docsTab = screen.getByRole('button', { name: 'Ctrl+K Documentation' }); - const gotoTab = screen.getByRole('button', { name: 'Ctrl+F Go to' }); + const allTab = screen.getByRole('button', { name: 'Ctrl+Shift+P Category 1 text' }); + const commandsTab = screen.getByRole('button', { name: 'F1 > Category 2 text' }); + const docsTab = screen.getByRole('button', { name: 'Ctrl+K Category 3 text' }); + const gotoTab = screen.getByRole('button', { name: 'Ctrl+F Category 4 text' }); // Test that only one tab is selected at a time expect(allTab).toHaveClass('text-[var(--pd-button-tab-text-selected)]'); @@ -562,27 +612,23 @@ describe('Command Palette', () => { expect(gotoTab).not.toHaveClass('border-[var(--pd-button-tab-border-selected)]'); // Test that placeholder text is correct for each tab - expect(input).toHaveAttribute('placeholder', 'Search Podman Desktop, or type > for commands'); + expect(input).toHaveAttribute('placeholder', 'Enter category 1 item'); // Click Commands tab and verify placeholder changes await userEvent.click(commandsTab); - await vi.waitFor(() => expect(input).toHaveAttribute('placeholder', 'Search and execute commands')); + await vi.waitFor(() => expect(input).toHaveAttribute('placeholder', 'Enter category 2 item')); // Click Documentation tab and verify placeholder changes await userEvent.click(docsTab); - await vi.waitFor(() => expect(input).toHaveAttribute('placeholder', 'Search documentation and tutorials')); + await vi.waitFor(() => expect(input).toHaveAttribute('placeholder', 'Enter category 3 item')); // Click Go to tab and verify placeholder changes await userEvent.click(gotoTab); - await vi.waitFor(() => - expect(input).toHaveAttribute('placeholder', 'Search images, containers, pods, and other resources'), - ); + await vi.waitFor(() => expect(input).toHaveAttribute('placeholder', 'Enter category 4 item')); // Click All tab and verify placeholder changes back await userEvent.click(allTab); - await vi.waitFor(() => - expect(input).toHaveAttribute('placeholder', 'Search Podman Desktop, or type > for commands'), - ); + await vi.waitFor(() => expect(input).toHaveAttribute('placeholder', 'Enter category 1 item')); }); }); diff --git a/packages/renderer/src/lib/dialogs/CommandPalette.svelte b/packages/renderer/src/lib/dialogs/CommandPalette.svelte index c8f8b7049c5..b2aa33f9bed 100644 --- a/packages/renderer/src/lib/dialogs/CommandPalette.svelte +++ b/packages/renderer/src/lib/dialogs/CommandPalette.svelte @@ -10,6 +10,7 @@ import { } from '@fortawesome/free-solid-svg-icons'; import type { CommandInfo, + CommandPaletteSearchOption, ContainerInfo, DocumentationInfo, GoToInfo, @@ -52,12 +53,6 @@ interface Props { onclose?: () => void; } -interface SearchOption { - text: string; - shortCut?: string[]; - helperText?: string; -} - type CommandPaletteItem = CommandInfo | DocumentationInfo | GoToInfo; let { display = false, onclose }: Props = $props(); @@ -75,15 +70,17 @@ let searchIcon = $derived.by(() => { } }); +let searchOptions: CommandPaletteSearchOption[] = $state([]); let isMac: boolean = $state(false); let modifierC: string = $derived(isMac ? '⌘' : 'Ctrl+'); let modifierS: string = $derived(isMac ? '⇧' : 'Shift+'); -let searchOptions: SearchOption[] = $derived([ - { text: 'All', shortCut: [`${modifierC}${modifierS}P`], helperText: 'Search Podman Desktop, or type > for commands' }, - { text: 'Commands', shortCut: [`${F1}`, '>'], helperText: 'Search and execute commands' }, - { text: 'Documentation', shortCut: [`${modifierC}K`], helperText: 'Search documentation and tutorials' }, - { text: 'Go to', shortCut: [`${modifierC}F`], helperText: 'Search images, containers, pods, and other resources' }, -]); +let shortcuts = $derived([[`${modifierC}${modifierS}P`], [`${F1}`, '>'], [`${modifierC}K`], [`${modifierC}F`]]); +let searchOptionsWithShortcuts = $derived( + searchOptions.map((searchOption, index) => { + return { ...searchOption, shortCut: index < shortcuts.length ? shortcuts[index] : undefined }; + }), +); + let searchOptionsSelectedIndex: number = $state(0); let documentationItems: DocumentationInfo[] = $state([]); @@ -95,7 +92,7 @@ let navigationItems: NavigationRegistryEntry[] = $derived($navigationRegistry); let goToItems: GoToInfo[] = $derived( createGoToItems(imageInfos, containerInfos, podInfos, volumInfos, navigationItems), ); -let helperText = $derived(searchOptions[searchOptionsSelectedIndex].helperText); +let helperText = $derived(searchOptionsWithShortcuts[searchOptionsSelectedIndex]?.placeholder); // Keep backward compatibility with existing variable name let filteredCommandInfoItems: CommandInfo[] = $derived( @@ -144,6 +141,7 @@ onMount(async () => { const platform = await window.getOsPlatform(); isMac = platform === 'darwin'; documentationItems = await window.getDocumentationItems(); + searchOptions = await window.getCommandPaletteSearchOptions(); }); // Focus the input when the command palette becomes visible @@ -247,7 +245,7 @@ async function handleKeydown(e: KeyboardEvent): Promise { } function switchSearchOption(direction: 1 | -1): void { - const searchOptionsLength = searchOptions.length; + const searchOptionsLength = searchOptionsWithShortcuts.length; const offset = direction === 1 ? 0 : searchOptionsLength; searchOptionsSelectedIndex = (searchOptionsSelectedIndex + direction + offset) % searchOptionsLength; } @@ -320,7 +318,7 @@ async function executeAction(index: number): Promise { const telemetryOptions = { // All / Commands / Docs / Go To - selectedTab: searchOptions[searchOptionsSelectedIndex].text, + selectedTab: searchOptionsWithShortcuts[searchOptionsSelectedIndex].text, // Pod or Image or Documentation or Command itemType: itemType, pageLink: pageLink, @@ -435,7 +433,7 @@ function getIcon(item: CommandInfo | DocumentationInfo | GoToInfo): IconDefiniti
- {#each searchOptions as searchOption, index (index)} + {#each searchOptionsWithShortcuts as searchOption, index (index)}