diff --git a/packages/api/src/documentation-info.ts b/packages/api/src/documentation-info.ts index a512fd55c8c..c1a81dd2226 100644 --- a/packages/api/src/documentation-info.ts +++ b/packages/api/src/documentation-info.ts @@ -36,4 +36,10 @@ export type GoToInfo = | (PodInfo & { type: 'Pod' }) | (ContainerInfo & { type: 'Container' }) | (ImageInfo & { type: 'Image' }) - | (VolumeInfo & { type: 'Volume' }); + | (VolumeInfo & { type: 'Volume' }) + | (NavigationInfo & { type: 'Navigation' }); + +export interface NavigationInfo { + name: string; + link: string; +} diff --git a/packages/renderer/src/lib/dialogs/CommandPalette.svelte b/packages/renderer/src/lib/dialogs/CommandPalette.svelte index 45939213507..7cf9bfa5aa9 100644 --- a/packages/renderer/src/lib/dialogs/CommandPalette.svelte +++ b/packages/renderer/src/lib/dialogs/CommandPalette.svelte @@ -3,12 +3,14 @@ import { faChevronRight, faMagnifyingGlass } from '@fortawesome/free-solid-svg-i import { Button, Input } from '@podman-desktop/ui-svelte'; import { Icon } from '@podman-desktop/ui-svelte/icons'; import { onMount, tick } from 'svelte'; +import { router } from 'tinro'; import { handleNavigation } from '/@/navigation'; import { commandsInfos } from '/@/stores/commands'; import { containersInfos } from '/@/stores/containers'; import { context } from '/@/stores/context'; import { imagesInfos } from '/@/stores/images'; +import { navigationRegistry, type NavigationRegistryEntry } from '/@/stores/navigation/navigation-registry'; import { podsInfos } from '/@/stores/pods'; import { volumeListInfos } from '/@/stores/volumes'; import type { CommandInfo } from '/@api/command-info'; @@ -68,18 +70,16 @@ let searchOptions: SearchOption[] = $derived([ { text: 'Go to', shortCut: [`${modifierC}F`] }, ]); let searchOptionsSelectedIndex: number = $state(0); -let imageItems: ImageInfo[] = $state([]); -let containerItems: ContainerInfo[] = $state([]); -let podItems: PodInfo[] = $state([]); -let volumeItems: VolumeInfo[] = $state([]); let documentationItems: DocumentationInfo[] = $state([]); let containerInfos: ContainerInfo[] = $derived($containersInfos); let podInfos: PodInfo[] = $derived($podsInfos); let volumInfos: VolumeInfo[] = $derived($volumeListInfos.map(info => info.Volumes).flat()); let imageInfos: ImageInfo[] = $derived($imagesInfos); - -let goToItems: GoToInfo[] = $derived(createGoToItems(imageInfos, containerInfos, podInfos, volumInfos)); +let navigationItems: NavigationRegistryEntry[] = $derived($navigationRegistry); +let goToItems: GoToInfo[] = $derived( + createGoToItems(imageInfos, containerInfos, podInfos, volumInfos, navigationItems), +); // Keep backward compatibility with existing variable name let filteredCommandInfoItems: CommandInfo[] = $derived( @@ -278,6 +278,8 @@ async function executeAction(index: number): Promise { page: NavigationPage.VOLUME, parameters: { name: item.Name, engineId: item.engineId }, }); + } else if (item.type === 'Navigation') { + router.goto(item.link); } } else { // Command item @@ -403,7 +405,11 @@ function isDocItem(item: CommandInfo | DocumentationInfo | GoToInfo): item is Do {#if docItem} {(item.category)}: {(item.name)} {:else if goToItem} - {(item.type)}: {(getGoToDisplayText(item))} + {#if item.type === 'Navigation'} + {item.name} + {:else} + {(item.type)}: {(getGoToDisplayText(item))} + {/if} {:else} {(item.title)} {/if} diff --git a/packages/renderer/src/lib/dialogs/CommandPaletteUtils.ts b/packages/renderer/src/lib/dialogs/CommandPaletteUtils.ts index c35d9369a91..cec0efb7050 100644 --- a/packages/renderer/src/lib/dialogs/CommandPaletteUtils.ts +++ b/packages/renderer/src/lib/dialogs/CommandPaletteUtils.ts @@ -16,8 +16,9 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ +import type { NavigationRegistryEntry } from '/@/stores/navigation/navigation-registry'; import type { ContainerInfo } from '/@api/container-info'; -import type { GoToInfo } from '/@api/documentation-info'; +import type { GoToInfo, NavigationInfo } from '/@api/documentation-info'; import type { ImageInfo } from '/@api/image-info'; import type { PodInfo } from '/@api/pod-info'; import type { VolumeInfo } from '/@api/volume-info'; @@ -42,16 +43,104 @@ export function getGoToDisplayText(goToInfo: GoToInfo): string { return goToInfo.Name; } else if (goToInfo.type === 'Volume') { return goToInfo.Name.substring(0, 12); + } else if (goToInfo.type === 'Navigation') { + return goToInfo.name; } return 'Unknown'; } +// Helper function to extract and capitalize path prefix from link +function extractPathPrefix(link: string, entryName: string): string | undefined { + // Remove leading slash and split by '/' + const pathSegments = link.replace(/^\//, '').split('/'); + + if (pathSegments.length === 0 || pathSegments[0] === '') { + return; + } + + const firstSegment = pathSegments[0]; + + // For submenu items (like Kubernetes Dashboard), use the parent category + if (pathSegments.length > 1) { + const capitalizedSegment = firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1); + return capitalizedSegment; + } + + // For main navigation items, don't add prefix if name matches path + const capitalizedSegment = firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1); + if (entryName.toLowerCase() === firstSegment.toLowerCase()) { + return; + } + + // Capitalize first letter and return + return capitalizedSegment; +} + +// Helper function to process a single navigation entry +function processNavigationEntry(entry: NavigationRegistryEntry, items: NavigationInfo[], parentName = ''): void { + // Skip hidden entries + if (entry.hidden) { + return; + } + + // Determine the display name with appropriate prefix and count + let displayName = entry.name; + + // Add count in parentheses if available + const count = entry.counter || 0; + const countSuffix = count > 0 ? ` (${count})` : ''; + + // Add prefix based on the entry type and parent context + if (parentName) { + // For submenu items, use the parent name as prefix + displayName = `${parentName}: ${entry.name}${countSuffix}`; + } else { + // Extract prefix from the link path dynamically + const pathPrefix = extractPathPrefix(entry.link, entry.name); + if (pathPrefix) { + displayName = `${pathPrefix}> ${entry.name}${countSuffix}`; + } else { + // No prefix needed, just add count + displayName = `${entry.name}${countSuffix}`; + } + } + + // Only add actual navigation entries (type 'entry'), not groups or submenus + if (entry.type === 'entry') { + const navigationInfo: NavigationInfo = { + name: displayName, + link: entry.link, + }; + + items.push(navigationInfo); + } + + // Process submenu items if they exist + if (entry.items && entry.items.length > 0) { + entry.items.forEach(subItem => { + processNavigationEntry(subItem, items, entry.name); + }); + } +} + +// Helper function to extract navigation paths from navigation registry +function extractNavigationPaths(entries: NavigationRegistryEntry[]): NavigationInfo[] { + const items: NavigationInfo[] = []; + + entries.forEach(entry => { + processNavigationEntry(entry, items); + }); + + return items; +} + // Helper function to create GoToInfo items from resources export function createGoToItems( images: ImageInfo[], containers: ContainerInfo[], pods: PodInfo[], volumes: VolumeInfo[], + navigationEntries: NavigationRegistryEntry[] = [], ): GoToInfo[] { const items: GoToInfo[] = []; @@ -75,5 +164,13 @@ export function createGoToItems( items.push({ type: 'Volume', ...volume }); }); + // Add navigation registry entries + if (navigationEntries.length > 0) { + const navigationInfos = extractNavigationPaths(navigationEntries); + navigationInfos.forEach(navigationInfo => { + items.push({ type: 'Navigation', ...navigationInfo }); + }); + } + return items; } diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 41579421766..79f67e7e11e 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -27,9 +27,7 @@ const config = { markdown: { mermaid: true, parseFrontMatter: async params => { - // Only call the release notes parser per-file - const result = await createNotesFiles(params); - return result; + return createNotesFiles(params); }, }, themes: ['@docusaurus/theme-mermaid'], diff --git a/website/navigation-utils.spec.ts b/website/navigation-utils.spec.ts deleted file mode 100644 index 5ecd203bd29..00000000000 --- a/website/navigation-utils.spec.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { writeFile } from 'node:fs/promises'; - -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import { extractNavigationFromSidebar, generateJsonOverviewFile } from './sidebar-content-parser'; - -// Mock the writeFile function -vi.mock('node:fs/promises', () => ({ - writeFile: vi.fn(), -})); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe('extractNavigationFromSidebar', () => { - test('should extract navigation items from simple doc items', () => { - const sidebarItems = [ - { - type: 'doc' as const, - id: 'intro', - label: 'Introduction', - }, - { - type: 'doc' as const, - id: 'running-a-kubernetes-cluster', - label: 'Running a Kubernetes cluster', - }, - ]; - - const result = extractNavigationFromSidebar(sidebarItems, 'https://podman-desktop.io', 'docs'); - - expect(result).toEqual([ - { - name: 'Introduction', - url: 'https://podman-desktop.io/docs/intro', - }, - { - name: 'Running a kubernetes cluster', - url: 'https://podman-desktop.io/docs/running-a-kubernetes-cluster', - }, - ]); - }); - - test('should handle items without labels by using id and formatting it', () => { - const sidebarItems = [ - { - type: 'doc' as const, - id: 'deploying-a-kubernetes-application', - }, - ]; - - const result = extractNavigationFromSidebar(sidebarItems, 'https://podman-desktop.io', 'docs'); - - expect(result).toEqual([ - { - name: 'Deploying a kubernetes application', - url: 'https://podman-desktop.io/docs/deploying-a-kubernetes-application', - }, - ]); - }); - - test('should handle tutorial index case correctly', () => { - const sidebarItems = [ - { - type: 'doc' as const, - id: 'index', - label: 'Introduction', - }, - ]; - - const result = extractNavigationFromSidebar(sidebarItems, 'https://podman-desktop.io', 'tutorial'); - - expect(result).toEqual([ - { - name: 'Introduction', - url: 'https://podman-desktop.io/tutorial', - }, - ]); - }); - - test('should handle category items recursively', () => { - const sidebarItems = [ - { - type: 'category' as const, - label: 'Getting Started', - items: [ - { - type: 'doc' as const, - id: 'intro', - label: 'Introduction', - }, - { - type: 'doc' as const, - id: 'installation', - label: 'Installation', - }, - ], - }, - ]; - - const result = extractNavigationFromSidebar(sidebarItems, 'https://podman-desktop.io', 'docs'); - - expect(result).toEqual([ - { - name: 'Introduction', - url: 'https://podman-desktop.io/docs/intro', - }, - { - name: 'Installation', - url: 'https://podman-desktop.io/docs/installation', - }, - ]); - }); - - test('should format names correctly by replacing hyphens with spaces and capitalizing first letter', () => { - const sidebarItems = [ - { - type: 'doc' as const, - id: 'getting-started-with-compose', - label: 'Getting started with Compose', - }, - { - type: 'doc' as const, - id: 'running-an-ai-application', - }, - ]; - - const result = extractNavigationFromSidebar(sidebarItems, 'https://podman-desktop.io', 'docs'); - - expect(result).toEqual([ - { - name: 'Getting started with compose', - url: 'https://podman-desktop.io/docs/getting-started-with-compose', - }, - { - name: 'Running an AI application', - url: 'https://podman-desktop.io/docs/running-an-ai-application', - }, - ]); - }); - - test('should keep AI capitalized in names', () => { - const sidebarItems = [ - { - type: 'doc' as const, - id: 'ai-lab-start-recipe', - label: 'AI Lab Start Recipe', - }, - { - type: 'doc' as const, - id: 'running-an-ai-application', - }, - { - type: 'doc' as const, - id: 'ai-powered-tools', - }, - ]; - - const result = extractNavigationFromSidebar(sidebarItems, 'https://podman-desktop.io', 'docs'); - - expect(result).toEqual([ - { - name: 'AI lab start recipe', - url: 'https://podman-desktop.io/docs/ai-lab-start-recipe', - }, - { - name: 'Running an AI application', - url: 'https://podman-desktop.io/docs/running-an-ai-application', - }, - { - name: 'AI powered tools', - url: 'https://podman-desktop.io/docs/ai-powered-tools', - }, - ]); - }); - - test('should skip items with empty names', () => { - const sidebarItems = [ - { - type: 'doc' as const, - id: '', - label: '', - }, - { - type: 'doc' as const, - id: 'valid-item', - label: 'Valid Item', - }, - ]; - - const result = extractNavigationFromSidebar(sidebarItems, 'https://podman-desktop.io', 'docs'); - - expect(result).toEqual([ - { - name: 'Valid item', - url: 'https://podman-desktop.io/docs/valid-item', - }, - ]); - }); -}); - -describe('generateJsonOverviewFile', () => { - test('should generate JSON file for docs section', async () => { - const sidebarItems = [ - { - type: 'doc' as const, - id: 'intro', - label: 'Introduction', - }, - ]; - - await generateJsonOverviewFile(sidebarItems, 'docs', 'https://podman-desktop.io', './static/docs.json'); - - expect(writeFile).toHaveBeenCalledWith( - './static/docs.json', - JSON.stringify([ - { - name: 'Introduction', - url: 'https://podman-desktop.io/docs/intro', - }, - ]), - ); - }); - - test('should generate JSON file for tutorial section', async () => { - const sidebarItems = [ - { - type: 'doc' as const, - id: 'index', - label: 'Introduction', - }, - ]; - - await generateJsonOverviewFile(sidebarItems, 'tutorial', 'https://podman-desktop.io', './static/tutorials.json'); - - expect(writeFile).toHaveBeenCalledWith( - './static/tutorials.json', - JSON.stringify([ - { - name: 'Introduction', - url: 'https://podman-desktop.io/tutorial', - }, - ]), - ); - }); - - test('should sort items alphabetically by name', async () => { - const sidebarItems = [ - { - type: 'doc' as const, - id: 'zebra', - label: 'Zebra', - }, - { - type: 'doc' as const, - id: 'apple', - label: 'Apple', - }, - { - type: 'doc' as const, - id: 'banana', - label: 'Banana', - }, - ]; - - await generateJsonOverviewFile(sidebarItems, 'docs', 'https://podman-desktop.io', './static/docs.json'); - - const expectedContent = JSON.stringify([ - { - name: 'Apple', - url: 'https://podman-desktop.io/docs/apple', - }, - { - name: 'Banana', - url: 'https://podman-desktop.io/docs/banana', - }, - { - name: 'Zebra', - url: 'https://podman-desktop.io/docs/zebra', - }, - ]); - - expect(writeFile).toHaveBeenCalledWith('./static/docs.json', expectedContent); - }); - - test('should propagate writeFile errors', async () => { - const mockError = new Error('Write failed'); - vi.mocked(writeFile).mockRejectedValueOnce(mockError); - - const sidebarItems = [ - { - type: 'doc' as const, - id: 'intro', - label: 'Introduction', - }, - ]; - - // Should throw the error - await expect( - generateJsonOverviewFile(sidebarItems, 'docs', 'https://podman-desktop.io', './static/docs.json'), - ).rejects.toThrow('Write failed'); - }); -});