chore: use product.json command palette search entries (#16708)

* chore: use product.json command palette search entries
Signed-off-by: Sonia Sandler <ssandler@redhat.com>

* chore: add safegaurd for searchOptionsWithShortcuts
Signed-off-by: Sonia Sandler <ssandler@redhat.com>

* chore: update tests
Signed-off-by: Sonia Sandler <ssandler@redhat.com>

* chore: add test
Signed-off-by: Sonia Sandler <ssandler@redhat.com>

* chore: update e2e tests

Signed-off-by: Sonia Sandler <ssandler@redhat.com>

* chore: apply comments
Signed-off-by: Sonia Sandler <ssandler@redhat.com>

---------

Signed-off-by: Sonia Sandler <ssandler@redhat.com>
This commit is contained in:
Sonia Sandler 2026-04-17 10:28:35 -04:00 committed by GitHub
parent 72cf8f2e95
commit 19d3dbb6fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 191 additions and 52 deletions

View file

@ -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';

View file

@ -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;
}

View file

@ -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',
},
]);
});

View file

@ -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[] = [];

View file

@ -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<CommandPaletteSearchOption[]> => {
return commandRegistry.getCommandPaletteSearchOptions();
});
this.ipcHandle(
'extension-loader:stopExtension',
async (_listener: Electron.IpcMainInvokeEvent, extensionId: string): Promise<void> => {

View file

@ -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<CommandPaletteSearchOption[]> => {
return ipcInvoke('commands:getCommandPaletteSearchOptions');
});
contextBridge.exposeInMainWorld('listExtensions', async (): Promise<ExtensionInfo[]> => {
return ipcInvoke('extension-loader:listExtensions');
});

View file

@ -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'));
});
});

View file

@ -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<void> {
}
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<void> {
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
</div>
<div class="flex flex-row m-2">
{#each searchOptions as searchOption, index (index)}
{#each searchOptionsWithShortcuts as searchOption, index (index)}
<Button
type="tab"
class="focus:outline-hidden"

View file

@ -133,5 +133,29 @@
"category": "Documentation"
}
]
},
"commandPalette": {
"searchOptions": [
{
"category": "ALL",
"text": "All",
"placeholder": "Search Podman Desktop, or type > for commands"
},
{
"category": "COMMANDS",
"text": "Commands",
"placeholder": "Search and execute commands"
},
{
"category": "DOCUMENTATION",
"text": "Documentation",
"placeholder": "Search documentation and tutorials"
},
{
"category": "GOTO",
"text": "Go to",
"placeholder": "Search images, containers, pods, and other resources"
}
]
}
}