diff --git a/packages/backend/src/extension.spec.ts b/packages/backend/src/extension.spec.ts index 1abcf1b8..072f0abb 100644 --- a/packages/backend/src/extension.spec.ts +++ b/packages/backend/src/extension.spec.ts @@ -19,8 +19,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; -import type * as podmanDesktopApi from '@podman-desktop/api'; -import { activate, deactivate } from './extension'; +import * as podmanDesktopApi from '@podman-desktop/api'; +import { activate, deactivate, openBuildPage } from './extension'; import * as fs from 'node:fs'; import os from 'node:os'; @@ -48,6 +48,10 @@ vi.mock('@podman-desktop/api', async () => { }, onDidChangeViewState: vi.fn(), }), + listWebviews: vi.fn().mockReturnValue([{ viewType: 'a' }, { id: 'test', viewType: 'bootc' }, { viewType: 'b' }]), + }, + navigation: { + navigateToWebview: vi.fn(), }, fs: { createFileSystemWatcher: () => ({ @@ -89,3 +93,19 @@ test('check deactivate', async () => { expect(consoleLogMock).toBeCalledWith('stopping bootc extension'); }); + +test('check command triggers webview and redirects', async () => { + const postMessageMock = vi.fn(); + const panel = { + webview: { + postMessage: postMessageMock, + }, + } as unknown as podmanDesktopApi.WebviewPanel; + + const image = { name: 'build', tag: 'latest' }; + + await openBuildPage(panel, image); + + expect(podmanDesktopApi.navigation.navigateToWebview).toHaveBeenCalled(); + expect(postMessageMock).toHaveBeenCalledWith({ body: 'build/latest', id: 'navigate-build' }); +}); diff --git a/packages/backend/src/extension.ts b/packages/backend/src/extension.ts index 192db40d..43c2e25e 100644 --- a/packages/backend/src/extension.ts +++ b/packages/backend/src/extension.ts @@ -18,14 +18,12 @@ import type { ExtensionContext } from '@podman-desktop/api'; import * as extensionApi from '@podman-desktop/api'; -import { buildDiskImage } from './build-disk-image'; import { History } from './history'; import fs from 'node:fs'; -import { bootcBuildOptionSelection } from './quickpicks'; import { RpcExtension } from '/@shared/src/messages/MessageProxy'; import { BootcApiImpl } from './api-impl'; import { HistoryNotifier } from './history/historyNotifier'; -import type { BuildType } from '/@shared/src/models/bootc'; +import { Messages } from '/@shared/src/messages/Messages'; export async function activate(extensionContext: ExtensionContext): Promise { console.log('starting bootc extension'); @@ -33,30 +31,7 @@ export async function activate(extensionContext: ExtensionContext): Promise { - const selections = await bootcBuildOptionSelection(history); - if (selections) { - // Get a unique name for the build - const name = await history.getUnusedHistoryName(image.name); - - await buildDiskImage( - { - id: name, - image: image.name, - tag: image.tag, - engineId: image.engineId, - type: [selections.type as BuildType], - folder: selections.folder, - arch: selections.arch, - }, - history, - ); - } - }), - ); - - const panel = extensionApi.window.createWebviewPanel('bootc', 'Bootc', { + const panel = extensionApi.window.createWebviewPanel('bootc', 'Bootable Containers', { localResourceRoots: [extensionApi.Uri.joinPath(extensionContext.extensionUri, 'media')], }); extensionContext.subscriptions.push(panel); @@ -106,6 +81,37 @@ export async function activate(extensionContext: ExtensionContext): Promise { + await openBuildPage(panel, image); + }), + ); +} + +export async function openBuildPage( + panel: extensionApi.WebviewPanel, + image: { name: string; tag: string }, +): Promise { + // this should use webview reveal function in the future + const webviews = extensionApi.window.listWebviews(); + const bootcWebView = (await webviews).find(webview => webview.viewType === 'bootc'); + + if (!bootcWebView) { + console.error('Could not find bootc webview'); + return; + } + + await extensionApi.navigation.navigateToWebview(bootcWebView.id); + + // if we trigger immediately, the webview hasn't loaded yet and can't redirect + // if we trigger too slow, there's a visible flash as the homepage appears first + await new Promise(r => setTimeout(r, 100)); + + await panel.webview.postMessage({ + id: Messages.MSG_NAVIGATE_BUILD, + body: encodeURIComponent(image.name) + '/' + encodeURIComponent(image.tag), + }); } export async function deactivate(): Promise { diff --git a/packages/backend/src/quickpicks.spec.ts b/packages/backend/src/quickpicks.spec.ts deleted file mode 100644 index a7857518..00000000 --- a/packages/backend/src/quickpicks.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, test, expect, vi, beforeAll } from 'vitest'; -import { bootcBuildOptionSelection } from './quickpicks'; // Update with the actual new file name -import * as extensionApi from '@podman-desktop/api'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { History } from './history'; // Assuming this is the correct path - -vi.mock('@podman-desktop/api', async () => { - return { - window: { - showQuickPick: vi.fn(), - showInputBox: vi.fn(), - }, - }; -}); - -// "Fake" the history file -vi.mock('node:fs', async () => { - return { - existsSync: vi.fn().mockImplementation(() => true), - readFile: vi.fn().mockImplementation(() => '[]'), - writeFile: vi.fn().mockImplementation(() => Promise.resolve()), - mkdir: vi.fn().mockImplementation(() => Promise.resolve()), - }; -}); - -beforeAll(async () => {}); - -describe('bootcBuildOptionSelection', () => { - test('Check that selections are shown correctly for the first pick (selecting images)', async () => { - // Before all, add an example "entry" to the history - // so we can test the getLastFolder function - - // Create a temporary file to use for the example history - const tempDir = os.tmpdir(); - const tempFilePath = path.join(tempDir, `tempfile-${Date.now()}`); - - const history = new History(tempFilePath); - await history.addOrUpdateBuildInfo({ - id: 'name1', - image: 'exampleImage', - tag: 'exampleTag', - engineId: 'exampleEngineId', - type: ['iso'], - folder: '/example/fake/folder', - arch: 'exampleArch', - status: 'success', - }); - - const showQuickPickMock = vi.spyOn(extensionApi.window, 'showQuickPick'); - - // First call to showQuickPick (disk image type selection) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - showQuickPickMock.mockResolvedValueOnce({ label: 'QCOW2', detail: 'QEMU image (.qcow2)', format: 'qcow2' } as any); - - // Second call to showQuickPick (architecture selection) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - showQuickPickMock.mockResolvedValueOnce({ label: 'ARM64', detail: 'ARM® aarch64 systems', arch: 'arm64' } as any); - - // Third call to showInputBox (folder selection) - vi.spyOn(extensionApi.window, 'showInputBox').mockResolvedValueOnce('/path/to/fake/storage'); - - // This function is asynchronous, so make sure to await it - await bootcBuildOptionSelection(history); - - // Check the first call for selecting the disk image type - expect(showQuickPickMock).toHaveBeenNthCalledWith( - 1, // This indicates the first call - [ - { label: 'QCOW2', detail: 'QEMU image (.qcow2)', format: 'qcow2' }, - { label: 'AMI', detail: 'Amazon Machine Image (.ami)', format: 'ami' }, - { label: 'RAW', detail: 'Raw image (.raw) with an MBR or GPT partition table', format: 'raw' }, - { label: 'VMDK', detail: 'Virtual Machine Disk image (.vmdk)', format: 'vmdk' }, - { label: 'ISO', detail: 'ISO standard disk image (.iso) for flashing media and using EFI', format: 'iso' }, - ], - { - title: 'Select the type of disk image to create', - }, - ); - - // Check the second call for selecting the architecture - expect(showQuickPickMock).toHaveBeenNthCalledWith( - 2, // This indicates the second call - [ - { label: 'ARM64', detail: 'ARM® aarch64 systems', arch: 'arm64' }, - { label: 'AMD64', detail: 'Intel and AMD x86_64 systems', arch: 'amd64' }, - ], - { - title: 'Select the architecture', - }, - ); - - // Check the third call for selecting the folder - expect(extensionApi.window.showInputBox).toHaveBeenCalledWith({ - prompt: 'Select the folder to generate disk qcow2 into', - value: '/example/fake/folder', - ignoreFocusOut: true, - }); - }); -}); diff --git a/packages/backend/src/quickpicks.ts b/packages/backend/src/quickpicks.ts deleted file mode 100644 index 7d5433bf..00000000 --- a/packages/backend/src/quickpicks.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { History } from './history'; -import * as extensionApi from '@podman-desktop/api'; -import * as os from 'node:os'; -import { resolve } from 'node:path'; - -export interface BootcBuildOptionSelection { - type: string; - folder: string; - path: string; - arch: string; -} - -export async function bootcBuildOptionSelection(history: History): Promise { - const selection = await extensionApi.window.showQuickPick( - [ - { label: 'QCOW2', detail: 'QEMU image (.qcow2)', format: 'qcow2' }, - { label: 'AMI', detail: 'Amazon Machine Image (.ami)', format: 'ami' }, - { label: 'RAW', detail: 'Raw image (.raw) with an MBR or GPT partition table', format: 'raw' }, - { label: 'VMDK', detail: 'Virtual Machine Disk image (.vmdk)', format: 'vmdk' }, - { label: 'ISO', detail: 'ISO standard disk image (.iso) for flashing media and using EFI', format: 'iso' }, - ], - { - title: 'Select the type of disk image to create', - }, - ); - - if (!selection) { - return; - } - - const selectedType = selection.format; - - const selectionArch = await extensionApi.window.showQuickPick( - [ - { label: 'ARM64', detail: 'ARM® aarch64 systems', arch: 'arm64' }, - { label: 'AMD64', detail: 'Intel and AMD x86_64 systems', arch: 'amd64' }, - ], - { - title: 'Select the architecture', - }, - ); - if (!selectionArch) { - return; - } - const selectedArch = selectionArch.arch; - - const location = history.getLastFolder() ?? os.homedir(); - const selectedFolder = await extensionApi.window.showInputBox({ - prompt: `Select the folder to generate disk ${selectedType} into`, - value: location, - ignoreFocusOut: true, - }); - - if (!selectedFolder) { - return; - } - - const imageNameMap = { - qcow2: 'qcow2/disk.qcow2', - ami: 'image/disk.raw', - raw: 'image/disk.raw', - vmdk: 'image/disk.vmdk', - iso: 'bootiso/disk.iso', - }; - - const imagePath = resolve(selectedFolder, imageNameMap[selectedType]); - - return { - type: selectedType, - folder: selectedFolder, - path: imagePath, - arch: selectedArch, - }; -} diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index 7a2a8e9e..9d3fb3db 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -7,6 +7,8 @@ import Build from './Build.svelte'; import { onMount } from 'svelte'; import { getRouterState } from './api/client'; import Homepage from './Homepage.svelte'; +import { rpcBrowser } from '/@/api/client'; +import { Messages } from '/@shared/src/messages/Messages'; router.mode.hash(); @@ -17,6 +19,10 @@ onMount(() => { const state = getRouterState(); router.goto(state.url); isMounted = true; + + return rpcBrowser.subscribe(Messages.MSG_NAVIGATE_BUILD, (x: string) => { + router.goto(`/build/${x}`); + }); }); @@ -29,6 +35,9 @@ onMount(() => { + + + diff --git a/packages/frontend/src/Build.spec.ts b/packages/frontend/src/Build.spec.ts index fdf3d58a..43a6b907 100644 --- a/packages/frontend/src/Build.spec.ts +++ b/packages/frontend/src/Build.spec.ts @@ -94,7 +94,7 @@ vi.mock('./api/client', async () => { }; }); -async function waitRender(customProperties: object): Promise { +async function waitRender(customProperties?: object): Promise { const result = render(Build, { ...customProperties }); // wait that result.component.$$.ctx[2] is set while (result.component.$$.ctx[2] === undefined) { @@ -105,7 +105,7 @@ async function waitRender(customProperties: object): Promise { test('Render shows correct images and history', async () => { vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo); vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockBootcImages); - await waitRender(Build); + await waitRender(); // Wait until children length is 2 meaning it's fully rendered / propagated the changes while (screen.getByLabelText('image-select')?.children.length !== 2) { @@ -139,7 +139,30 @@ test('Render shows correct images and history', async () => { }); test('Check that VMDK option is there', async () => { - await waitRender(Build); + await waitRender(); const vmdk = screen.getByLabelText('vmdk-select'); expect(vmdk).toBeDefined(); }); + +test('Check that preselecting an image works', async () => { + vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo); + vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockBootcImages); + await waitRender({ imageName: 'image2', imageTag: 'latest' }); + + // Wait until children length is 2 meaning it's fully rendered / propagated the changes + while (screen.getByLabelText('image-select')?.children.length !== 2) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + const select = screen.getByLabelText('image-select') as HTMLSelectElement; + expect(select).toBeDefined(); + expect(select.children.length).toEqual(2); + + // Expect image:1 to be first since it's the last one in the history + expect(select.children[0].textContent).toEqual('image1:latest'); + expect(select.children[1].textContent).toEqual('image2:latest'); + + // Expect the one we passed in to be selected + const selectedImage = select.value as unknown as any[]; + expect(selectedImage).toBeDefined(); + expect(selectedImage).toEqual('image2:latest'); +}); diff --git a/packages/frontend/src/Build.svelte b/packages/frontend/src/Build.svelte index 54e29c4c..6be44f6b 100644 --- a/packages/frontend/src/Build.svelte +++ b/packages/frontend/src/Build.svelte @@ -12,8 +12,11 @@ import { Input } from '@podman-desktop/ui-svelte'; import EmptyScreen from './lib/upstream/EmptyScreen.svelte'; import { router } from 'tinro'; +export let imageName: string | undefined = undefined; +export let imageTag: string | undefined = undefined; + // Image variables -let selectedImage: ImageInfo; +let selectedImage: string | undefined; let buildImageName: string; let buildTag: string; let buildEngineId: string; @@ -23,28 +26,44 @@ let buildFolder: string; let buildType: BuildType[]; let buildArch: string | undefined; -// Other variabler +// Other variable let success = false; let buildInProgress = false; -let bootcAvailableImages: any[] = []; +let bootcAvailableImages: ImageInfo[] = []; let errorMessage = ''; let errorFormValidation = ''; +function findImage(repoTag: string): ImageInfo | undefined { + return bootcAvailableImages.find( + image => image.RepoTags && image.RepoTags.length > 0 && image.RepoTags[0] === repoTag, + ); +} // Function that will use listHistoryInfo, if there is anything in the list, pick the first one in the list (as it's the most recent) // and fill buildFolder, buildType and buildArch with the values from the selected image. async function fillBuildOptions() { + // Fill the build options from history const historyInfo = await bootcClient.listHistoryInfo(); if (historyInfo.length > 0) { const latestBuild = historyInfo[0]; buildFolder = latestBuild.folder; buildType = latestBuild.type; buildArch = latestBuild.arch; + } + + // If an image name and tag were passed in, try to use it as the initially selected image + let initialImage: ImageInfo | undefined; + if (imageName && imageTag) { + initialImage = findImage(`${imageName}:${imageTag}`); + } + + // If not, use the last image from history if it is valid + if (!initialImage && historyInfo.length > 0 && historyInfo[0].image && historyInfo[0].tag) { + // Find the image that matches the latest build's name and tag + initialImage = findImage(`${historyInfo[0].image}:${historyInfo[0].tag}`); + } - // Find the image that matches the latest build's name and tag and set selectedImage to that value - selectedImage = bootcAvailableImages.find( - image => - image.RepoTags && image.RepoTags.length > 0 && image.RepoTags[0] === `${latestBuild.image}:${latestBuild.tag}`, - ); + if (initialImage && initialImage.RepoTags && initialImage.RepoTags.length > 0) { + selectedImage = initialImage.RepoTags[0]; } } @@ -141,7 +160,10 @@ function cleanup() { } onMount(async () => { - bootcAvailableImages = await bootcClient.listBootcImages(); + const imageInfos = await bootcClient.listBootcImages(); + + // filter to images that have a repo tag here, to avoid doing it everywhere + bootcAvailableImages = imageInfos.filter(image => image.RepoTags && image.RepoTags.length > 0); // Fills the build options with the last options await fillBuildOptions(); @@ -150,10 +172,11 @@ onMount(async () => { // each time imageName updated, "split" it between : to image and tag $: { if (selectedImage !== undefined) { - if (selectedImage.RepoTags && selectedImage.RepoTags.length > 0) { - buildImageName = selectedImage.RepoTags[0].split(':')[0]; - buildTag = selectedImage.RepoTags[0].split(':')[1]; - buildEngineId = selectedImage.engineId; + const image = findImage(selectedImage); + if (image) { + buildImageName = selectedImage.split(':')[0]; + buildTag = selectedImage.split(':')[1]; + buildEngineId = image.engineId; } } } @@ -210,7 +233,7 @@ $: { {#each bootcAvailableImages as image} {#if image.RepoTags && image.RepoTags.length > 0} - + {/if} {/each} {/if} diff --git a/packages/shared/src/messages/Messages.ts b/packages/shared/src/messages/Messages.ts index a2334046..a59e8e46 100644 --- a/packages/shared/src/messages/Messages.ts +++ b/packages/shared/src/messages/Messages.ts @@ -19,4 +19,5 @@ export enum Messages { MSG_HISTORY_UPDATE = 'history-update', MSG_IMAGE_PULL_UPDATE = 'image-pull-update', // Responsible for any pull updates + MSG_NAVIGATE_BUILD = 'navigate-build', }