diff --git a/packages/extension-api/src/extension-api.d.ts b/packages/extension-api/src/extension-api.d.ts index 71dff4167995b..f188c32704578 100644 --- a/packages/extension-api/src/extension-api.d.ts +++ b/packages/extension-api/src/extension-api.d.ts @@ -1193,6 +1193,39 @@ declare module '@podman-desktop/api' { * button. */ cancellable?: boolean; + + /** + * You may specify a navigation object, making the task having a + * navigate action that the user can trigger. + * @example + * ```ts + * import { window, type ProgressLocation } from '@podman-desktop/api'; + * + * await window.withProgress( + * { + * location: ProgressLocation.TASK_WIDGET, + * title: 'My task', + * details: { + * routeId: 'dummy-route-id', + * routeArgs: ['hello', 'world'], + * } + * }, + * async () => { + * return 'dummy result'; + * }, + * ); + * ``` + */ + details?: { + /** + * The routeId used in {@link navigation.register} + */ + routeId: string; + /** + * The arguments to provide the route + */ + routeArgs: string[]; + }; } /** diff --git a/packages/main/src/plugin/extension-loader.spec.ts b/packages/main/src/plugin/extension-loader.spec.ts index 25d8b7850637a..f88f5018e539d 100644 --- a/packages/main/src/plugin/extension-loader.spec.ts +++ b/packages/main/src/plugin/extension-loader.spec.ts @@ -64,7 +64,7 @@ import type { Proxy } from './proxy.js'; import type { ExtensionSecretStorage, SafeStorageRegistry } from './safe-storage/safe-storage-registry.js'; import type { StatusBarRegistry } from './statusbar/statusbar-registry.js'; import type { NotificationRegistry } from './tasks/notification-registry.js'; -import type { ProgressImpl } from './tasks/progress-impl.js'; +import { type ProgressImpl, ProgressLocation } from './tasks/progress-impl.js'; import type { Telemetry } from './telemetry/telemetry.js'; import type { TrayMenuRegistry } from './tray-menu-registry.js'; import type { IDisposable } from './types/disposable.js'; @@ -141,7 +141,9 @@ const trayMenuRegistry: TrayMenuRegistry = {} as unknown as TrayMenuRegistry; const messageBox: MessageBox = {} as MessageBox; -const progress: ProgressImpl = {} as ProgressImpl; +const progress: ProgressImpl = { + withProgress: vi.fn(), +} as unknown as ProgressImpl; const statusBarRegistry: StatusBarRegistry = {} as unknown as StatusBarRegistry; @@ -2286,6 +2288,45 @@ test('when registering a navigation route, should be pushed to disposables', () expect(disposables.length).toBe(1); }); +test('withProgress should add the extension id to the routeId', async () => { + vi.mocked(progress.withProgress).mockResolvedValue(undefined); + const disposables: IDisposable[] = []; + + const api = extensionLoader.createApi( + '/path', + { + publisher: 'pub', + name: 'dummy', + }, + disposables, + ); + expect(api).toBeDefined(); + + await api.window.withProgress( + { + location: ProgressLocation.TASK_WIDGET, + title: 'Dummy title', + details: { + routeId: 'dummy-route-id', + routeArgs: ['hello', 'world'], + }, + }, + async () => {}, + ); + + expect(progress.withProgress).toHaveBeenCalledWith( + { + location: ProgressLocation.TASK_WIDGET, + title: 'Dummy title', + details: { + routeId: 'pub.dummy.dummy-route-id', + routeArgs: ['hello', 'world'], + }, + }, + expect.any(Function), + ); +}); + describe('loading extension folders', () => { const fileEntry = { isDirectory: () => false, diff --git a/packages/main/src/plugin/extension-loader.ts b/packages/main/src/plugin/extension-loader.ts index 28d85672d7737..f5039f8c21a7b 100644 --- a/packages/main/src/plugin/extension-loader.ts +++ b/packages/main/src/plugin/extension-loader.ts @@ -983,7 +983,18 @@ export class ExtensionLoader { token: containerDesktopAPI.CancellationToken, ) => Promise, ): Promise => { - return progress.withProgress(options, task); + return progress.withProgress( + { + ...options, + details: options.details + ? { + routeArgs: options.details.routeArgs, + routeId: `${extensionInfo.id}.${options.details.routeId}`, + } + : undefined, + }, + task, + ); }, showNotification: (notificationInfo: containerDesktopAPI.NotificationOptions): containerDesktopAPI.Disposable => { diff --git a/packages/main/src/plugin/index.ts b/packages/main/src/plugin/index.ts index 3626b0ea748f8..e738708fbef72 100644 --- a/packages/main/src/plugin/index.ts +++ b/packages/main/src/plugin/index.ts @@ -645,7 +645,7 @@ export class PluginSystem { apiSender, trayMenuRegistry, messageBox, - new ProgressImpl(taskManager), + new ProgressImpl(taskManager, navigationManager), statusBarRegistry, kubernetesClient, fileSystemMonitoring, diff --git a/packages/main/src/plugin/tasks/progress-impl.spec.ts b/packages/main/src/plugin/tasks/progress-impl.spec.ts index c3f7a58c91d55..93569fb819303 100644 --- a/packages/main/src/plugin/tasks/progress-impl.spec.ts +++ b/packages/main/src/plugin/tasks/progress-impl.spec.ts @@ -21,6 +21,7 @@ import type { Event } from '@podman-desktop/api'; import { beforeEach, expect, test, vi } from 'vitest'; +import type { NavigationManager } from '/@/plugin/navigation/navigation-manager.js'; import type { Task, TaskAction, TaskUpdateEvent } from '/@/plugin/tasks/tasks.js'; import type { TaskState, TaskStatus } from '/@api/taskInfo.js'; @@ -31,6 +32,11 @@ const taskManager = { createTask: vi.fn(), } as unknown as TaskManager; +const navigationManager = { + hasRoute: vi.fn(), + navigateToRoute: vi.fn(), +} as unknown as NavigationManager; + class TestTaskImpl implements Task { constructor( public readonly id: string, @@ -62,7 +68,7 @@ test('Should create a task and report update', async () => { const task = new TestTaskImpl('test-task-id', 'test-title', 'running', 'in-progress'); vi.mocked(taskManager.createTask).mockReturnValue(task); - const progress = new ProgressImpl(taskManager); + const progress = new ProgressImpl(taskManager, navigationManager); await progress.withProgress({ location: ProgressLocation.TASK_WIDGET, title: 'My task' }, async () => 0); expect(task.status).toBe('success'); @@ -72,7 +78,7 @@ test('Should create a task and report progress', async () => { const task = new TestTaskImpl('test-task-id', 'test-title', 'running', 'in-progress'); vi.mocked(taskManager.createTask).mockReturnValue(task); - const progress = new ProgressImpl(taskManager); + const progress = new ProgressImpl(taskManager, navigationManager); await progress.withProgress({ location: ProgressLocation.TASK_WIDGET, title: 'My task' }, async progress => { progress.report({ increment: 50 }); }); @@ -85,7 +91,7 @@ test('Should create a task and propagate the exception', async () => { const task = new TestTaskImpl('test-task-id', 'test-title', 'running', 'in-progress'); vi.mocked(taskManager.createTask).mockReturnValue(task); - const progress = new ProgressImpl(taskManager); + const progress = new ProgressImpl(taskManager, navigationManager); await expect( progress.withProgress({ location: ProgressLocation.TASK_WIDGET, title: 'My task' }, async () => { @@ -101,7 +107,7 @@ test('Should create a task and propagate the result', async () => { const task = new TestTaskImpl('test-task-id', 'test-title', 'running', 'in-progress'); vi.mocked(taskManager.createTask).mockReturnValue(task); - const progress = new ProgressImpl(taskManager); + const progress = new ProgressImpl(taskManager, navigationManager); const result: string = await progress.withProgress( { location: ProgressLocation.TASK_WIDGET, title: 'My task' }, @@ -118,7 +124,7 @@ test('Should update the task name', async () => { const task = new TestTaskImpl('test-task-id', 'test-title', 'running', 'in-progress'); vi.mocked(taskManager.createTask).mockReturnValue(task); - const progress = new ProgressImpl(taskManager); + const progress = new ProgressImpl(taskManager, navigationManager); await progress.withProgress({ location: ProgressLocation.TASK_WIDGET, title: 'My task' }, async progress => { progress.report({ message: 'New title' }); @@ -127,3 +133,43 @@ test('Should update the task name', async () => { expect(task.name).toBe('New title'); expect(task.status).toBe('success'); }); + +test('Should create a task with a navigation action', async () => { + vi.mocked(navigationManager.hasRoute).mockReturnValue(true); + + const task = new TestTaskImpl('test-task-id', 'test-title', 'running', 'in-progress'); + const progress = new ProgressImpl(taskManager, navigationManager); + + let taskAction: TaskAction | undefined; + vi.mocked(taskManager.createTask).mockImplementation(options => { + taskAction = options?.action; + return task; + }); + + await progress.withProgress( + { + location: ProgressLocation.TASK_WIDGET, + title: 'My task', + details: { + routeId: 'dummy-route-id', + routeArgs: ['hello', 'world'], + }, + }, + async () => { + return 'dummy result'; + }, + ); + + await vi.waitFor(() => { + expect(taskAction).toBeDefined(); + }); + + expect(taskAction?.name).toBe('View'); + expect(taskAction?.execute).toBeInstanceOf(Function); + + // execute the task action + taskAction?.execute(task); + + // ensure the arguments and routeId is properly used + expect(navigationManager.navigateToRoute).toHaveBeenCalledWith('dummy-route-id', 'hello', 'world'); +}); diff --git a/packages/main/src/plugin/tasks/progress-impl.ts b/packages/main/src/plugin/tasks/progress-impl.ts index d37c4a0501f41..034de0b2ff874 100644 --- a/packages/main/src/plugin/tasks/progress-impl.ts +++ b/packages/main/src/plugin/tasks/progress-impl.ts @@ -18,6 +18,8 @@ import type * as extensionApi from '@podman-desktop/api'; import { findWindow } from '/@/electron-util.js'; +import type { NavigationManager } from '/@/plugin/navigation/navigation-manager.js'; +import type { TaskAction } from '/@/plugin/tasks/tasks.js'; import { CancellationTokenImpl } from '../cancellation-token.js'; import type { TaskManager } from './task-manager.js'; @@ -35,7 +37,10 @@ export enum ProgressLocation { } export class ProgressImpl { - constructor(private taskManager: TaskManager) {} + constructor( + private taskManager: TaskManager, + private navigationManager: NavigationManager, + ) {} /** * Execute a task with progress, based on the provided options and task function. @@ -78,6 +83,23 @@ export class ProgressImpl { ); } + protected getTaskAction(options: extensionApi.ProgressOptions): TaskAction | undefined { + if (!options.details) return undefined; + + if (!this.navigationManager.hasRoute(options.details.routeId)) { + console.warn(`cannot created task action for unknown routeId ${options.details.routeId}`); + return undefined; + } + + return { + name: 'View', + execute: (): unknown => { + if (!options.details) return; + return this.navigationManager.navigateToRoute(options.details.routeId, ...options.details.routeArgs); + }, + }; + } + async withWidget( options: extensionApi.ProgressOptions, task: ( @@ -85,7 +107,10 @@ export class ProgressImpl { token: extensionApi.CancellationToken, ) => Promise, ): Promise { - const t = this.taskManager.createTask({ title: options.title }); + const t = this.taskManager.createTask({ + title: options.title, + action: this.getTaskAction(options), + }); return task( {