diff --git a/packages/api/src/taskInfo.ts b/packages/api/src/taskInfo.ts index 63986a8cff4d3..86708499738ad 100644 --- a/packages/api/src/taskInfo.ts +++ b/packages/api/src/taskInfo.ts @@ -35,4 +35,6 @@ export interface TaskInfo { error?: string; progress?: number; action?: string; + cancellable: boolean; + cancellationTokenSourceId?: number; } diff --git a/packages/main/src/plugin/index.ts b/packages/main/src/plugin/index.ts index ebd7c2cef1cf7..29557fb762ab4 100644 --- a/packages/main/src/plugin/index.ts +++ b/packages/main/src/plugin/index.ts @@ -674,7 +674,7 @@ export class PluginSystem { apiSender, trayMenuRegistry, messageBox, - new ProgressImpl(taskManager, navigationManager), + new ProgressImpl(taskManager, navigationManager, cancellationTokenRegistry), 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 b9fd639014916..553d994d37979 100644 --- a/packages/main/src/plugin/tasks/progress-impl.spec.ts +++ b/packages/main/src/plugin/tasks/progress-impl.spec.ts @@ -24,6 +24,8 @@ import type { NavigationManager } from '/@/plugin/navigation/navigation-manager. import type { TaskAction } from '/@/plugin/tasks/tasks.js'; import type { TaskState, TaskStatus } from '/@api/taskInfo.js'; +import { CancellationTokenSource } from '../cancellation-token.js'; +import type { CancellationTokenRegistry } from '../cancellation-token-registry.js'; import { ProgressImpl, ProgressLocation } from './progress-impl.js'; import { TaskImpl } from './task-impl.js'; import type { TaskManager } from './task-manager.js'; @@ -37,6 +39,11 @@ const navigationManager = { navigateToRoute: vi.fn(), } as unknown as NavigationManager; +const cancellationTokenRegistry = { + createCancellationTokenSource: vi.fn(), + getCancellationTokenSource: vi.fn(), +} as unknown as CancellationTokenRegistry; + class TestTaskImpl extends TaskImpl { constructor(id: string, name: string, state: TaskState, status: TaskStatus) { super(id, name); @@ -48,7 +55,7 @@ class TestTaskImpl extends TaskImpl { let progress: ProgressImpl; beforeEach(() => { vi.clearAllMocks(); - progress = new ProgressImpl(taskManager, navigationManager); + progress = new ProgressImpl(taskManager, navigationManager, cancellationTokenRegistry); }); test('Should create a task and report update', async () => { @@ -151,3 +158,58 @@ test('Should create a task with a navigation action', async () => { // ensure the arguments and routeId is properly used expect(navigationManager.navigateToRoute).toHaveBeenCalledWith('dummy-route-id', 'hello', 'world'); }); + +test('Should create a cancellable task with a source id if cancellable option provided ', async () => { + const dummyTask = new TestTaskImpl('test-task-id', 'test-title', 'running', 'in-progress'); + vi.mocked(taskManager.createTask).mockReturnValue(dummyTask); + + const tokenSourceId = 1234; + // get id for the token source + vi.mocked(cancellationTokenRegistry.createCancellationTokenSource).mockReturnValue(tokenSourceId); + + //get the token source + vi.mocked(cancellationTokenRegistry.getCancellationTokenSource).mockReturnValue(new CancellationTokenSource()); + + await progress.withProgress( + { location: ProgressLocation.TASK_WIDGET, title: 'My task', cancellable: true }, + async progress => { + progress.report({ increment: 50 }); + }, + ); + + // grab the options passed to createTask + const options = vi.mocked(taskManager.createTask).mock.calls[0]?.[0]; + expect(options).toBeDefined(); + // expect callable has been set + expect(options?.cancellable).toBeTruthy(); + // expect the token source id to be set + expect(options?.cancellationTokenSourceId).toBe(tokenSourceId); + + // check that the token source was created + expect(cancellationTokenRegistry.createCancellationTokenSource).toHaveBeenCalled(); + expect(cancellationTokenRegistry.getCancellationTokenSource).toHaveBeenCalled(); +}); + +test('Should not provide cancellable and source id if cancellable option is omitted ', async () => { + const dummyTask = new TestTaskImpl('test-task-id', 'test-title', 'running', 'in-progress'); + vi.mocked(taskManager.createTask).mockReturnValue(dummyTask); + + await progress.withProgress( + { location: ProgressLocation.TASK_WIDGET, title: 'My task', cancellable: false }, + async progress => { + progress.report({ increment: 50 }); + }, + ); + + // grab the options passed to createTask + const options = vi.mocked(taskManager.createTask).mock.calls[0]?.[0]; + expect(options).toBeDefined(); + // expect callable not provided + expect(options?.cancellable).toBeFalsy(); + // expect the token source id not being set + expect(options?.cancellationTokenSourceId).toBeUndefined(); + + // check that the cancellationTokenRegistry was never called + expect(cancellationTokenRegistry.createCancellationTokenSource).not.toHaveBeenCalled(); + expect(cancellationTokenRegistry.getCancellationTokenSource).not.toHaveBeenCalled(); +}); diff --git a/packages/main/src/plugin/tasks/progress-impl.ts b/packages/main/src/plugin/tasks/progress-impl.ts index 034de0b2ff874..f727ad5edad47 100644 --- a/packages/main/src/plugin/tasks/progress-impl.ts +++ b/packages/main/src/plugin/tasks/progress-impl.ts @@ -22,6 +22,7 @@ import type { NavigationManager } from '/@/plugin/navigation/navigation-manager. import type { TaskAction } from '/@/plugin/tasks/tasks.js'; import { CancellationTokenImpl } from '../cancellation-token.js'; +import type { CancellationTokenRegistry } from '../cancellation-token-registry.js'; import type { TaskManager } from './task-manager.js'; export enum ProgressLocation { @@ -40,6 +41,7 @@ export class ProgressImpl { constructor( private taskManager: TaskManager, private navigationManager: NavigationManager, + private cancellationTokenRegistry: CancellationTokenRegistry, ) {} /** @@ -107,8 +109,29 @@ export class ProgressImpl { token: extensionApi.CancellationToken, ) => Promise, ): Promise { + const isCancellable = options.cancellable ?? false; + let cancellationToken: extensionApi.CancellationToken; + let cancellationTokenSourceId: number | undefined; + + // if cancellable, register the token source and provides the source id to the task so frontend can cancel the task + if (isCancellable) { + cancellationTokenSourceId = this.cancellationTokenRegistry.createCancellationTokenSource(); + const cancellationTokenSource = + this.cancellationTokenRegistry.getCancellationTokenSource(cancellationTokenSourceId); + // no token, error + if (!cancellationTokenSource) { + throw new Error('Failed to create CancellationTokenSource'); + } + cancellationToken = cancellationTokenSource.token; + } else { + cancellationToken = new CancellationTokenImpl(); + } + const t = this.taskManager.createTask({ title: options.title, + // if the task is cancellable, we set the token source id + cancellable: isCancellable, + cancellationTokenSourceId, action: this.getTaskAction(options), }); @@ -123,7 +146,7 @@ export class ProgressImpl { } }, }, - new CancellationTokenImpl(), + cancellationToken, ) .then(value => { // Middleware to capture the success of the task diff --git a/packages/main/src/plugin/tasks/task-impl.spec.ts b/packages/main/src/plugin/tasks/task-impl.spec.ts index 95fefdc1a7643..889795b36332a 100644 --- a/packages/main/src/plugin/tasks/task-impl.spec.ts +++ b/packages/main/src/plugin/tasks/task-impl.spec.ts @@ -104,6 +104,34 @@ describe('update field should send an update event', () => { }), }); }); + + test('cancellable', () => { + const onUpdateListenerMock = vi.fn<(e: TaskUpdateEvent) => void>(); + const task = new TaskImpl('test-id', 'Test name'); + task.onUpdate(onUpdateListenerMock); + + task.cancellable = true; + expect(onUpdateListenerMock).toHaveBeenCalledWith({ + action: 'update', + task: expect.objectContaining({ + cancellable: true, + }), + }); + }); + + test('cancellationTokenSourceId', () => { + const onUpdateListenerMock = vi.fn<(e: TaskUpdateEvent) => void>(); + const task = new TaskImpl('test-id', 'Test name'); + task.onUpdate(onUpdateListenerMock); + + task.cancellationTokenSourceId = 123; + expect(onUpdateListenerMock).toHaveBeenCalledWith({ + action: 'update', + task: expect.objectContaining({ + cancellationTokenSourceId: 123, + }), + }); + }); }); test('dispose should send a delete TaskUpdateEvent', () => { diff --git a/packages/main/src/plugin/tasks/task-impl.ts b/packages/main/src/plugin/tasks/task-impl.ts index 5fa04cdbc67d8..7bf830874fe0f 100644 --- a/packages/main/src/plugin/tasks/task-impl.ts +++ b/packages/main/src/plugin/tasks/task-impl.ts @@ -32,6 +32,8 @@ export class TaskImpl implements Task { protected mState: TaskState; protected mStatus: TaskStatus; protected mName: string; + protected mCancellable = false; + protected mCancellationTokenSourceId: number | undefined; constructor( public readonly id: string, @@ -114,6 +116,24 @@ export class TaskImpl implements Task { this.emitter?.fire({ action: action, task: this }); } + set cancellable(value: boolean) { + this.mCancellable = value; + this.notify(); + } + + get cancellable(): boolean { + return this.mCancellable; + } + + set cancellationTokenSourceId(value: number) { + this.mCancellationTokenSourceId = value; + this.notify(); + } + + get cancellationTokenSourceId(): number | undefined { + return this.mCancellationTokenSourceId; + } + dispose(): void { this.notify('delete'); this.emitter?.dispose(); diff --git a/packages/main/src/plugin/tasks/task-manager.spec.ts b/packages/main/src/plugin/tasks/task-manager.spec.ts index 20b1a16945290..b6bd02cd4c7c2 100644 --- a/packages/main/src/plugin/tasks/task-manager.spec.ts +++ b/packages/main/src/plugin/tasks/task-manager.spec.ts @@ -298,6 +298,42 @@ test('clear tasks should clear task not in running state', async () => { ); }); +test('create task being cancellable', async () => { + const taskManager = new TaskManager(apiSender, statusBarRegistry, commandRegistry, configurationRegistry); + const task = taskManager.createTask({ cancellable: true, cancellationTokenSourceId: 1 }); + expect(task.id).equal('task-1'); + expect(task.name).equal('Task 1'); + expect(task.state).equal('running'); + expect(task.cancellable).toBeTruthy(); + expect(task.cancellationTokenSourceId).toEqual(1); + expect(apiSenderSendMock).toBeCalledWith( + 'task-created', + expect.objectContaining({ + id: task.id, + name: task.name, + state: task.state, + cancellable: true, + cancellationTokenSourceId: 1, + }), + ); +}); + +test('create task being cancellable throw error if missing cancellationTokenSourceId', async () => { + const taskManager = new TaskManager(apiSender, statusBarRegistry, commandRegistry, configurationRegistry); + + expect(() => taskManager.createTask({ cancellable: true })).toThrow( + 'cancellable task requires a cancellationTokenSourceId', + ); +}); + +test('create task having cancellationTokenSourceId without being cancellable throw error', async () => { + const taskManager = new TaskManager(apiSender, statusBarRegistry, commandRegistry, configurationRegistry); + + expect(() => taskManager.createTask({ cancellationTokenSourceId: 4 })).toThrow( + 'cancellationTokenSourceId is only allowed for cancellable tasks', + ); +}); + describe('execute', () => { test('execute should throw an error if the task does not exist', async () => { const taskManager = new TaskManager(apiSender, statusBarRegistry, commandRegistry, configurationRegistry); diff --git a/packages/main/src/plugin/tasks/task-manager.ts b/packages/main/src/plugin/tasks/task-manager.ts index 06b407083d803..263eaf035d72a 100644 --- a/packages/main/src/plugin/tasks/task-manager.ts +++ b/packages/main/src/plugin/tasks/task-manager.ts @@ -113,11 +113,26 @@ export class TaskManager { this.setStatusBarEntry(true); } - public createTask(options?: { title?: string; action?: TaskAction }): Task { + public createTask(options?: { + title?: string; + action?: TaskAction; + cancellable?: boolean; + cancellationTokenSourceId?: number; + }): Task { this.taskId++; const task = new TaskImpl(`task-${this.taskId}`, options?.title ? options.title : `Task ${this.taskId}`); task.action = options?.action; + task.cancellable = options?.cancellable ?? false; + if (task.cancellable && !options?.cancellationTokenSourceId) { + throw new Error('cancellable task requires a cancellationTokenSourceId'); + } + if (options?.cancellationTokenSourceId && !task.cancellable) { + throw new Error('cancellationTokenSourceId is only allowed for cancellable tasks'); + } + if (options?.cancellationTokenSourceId) { + task.cancellationTokenSourceId = options?.cancellationTokenSourceId; + } this.registerTask(task); return task; } @@ -162,6 +177,8 @@ export class TaskManager { body: task.body ?? '', state: 'completed', error: undefined, + cancellable: task.cancellable, + cancellationTokenSourceId: task.cancellationTokenSourceId, }; } @@ -174,6 +191,8 @@ export class TaskManager { progress: task.progress, error: task.error, action: task.action?.name, + cancellable: task.cancellable, + cancellationTokenSourceId: task.cancellationTokenSourceId, }; } diff --git a/packages/main/src/plugin/tasks/tasks.ts b/packages/main/src/plugin/tasks/tasks.ts index 87424d886ed19..10d0125f744b0 100644 --- a/packages/main/src/plugin/tasks/tasks.ts +++ b/packages/main/src/plugin/tasks/tasks.ts @@ -39,5 +39,7 @@ export interface Task extends Disposable { error?: string; progress?: number; action?: TaskAction; + cancellable: boolean; + cancellationTokenSourceId?: number; readonly onUpdate: Event; } diff --git a/packages/renderer/src/lib/statusbar/StatusBar.spec.ts b/packages/renderer/src/lib/statusbar/StatusBar.spec.ts index b314be3fb5829..468e61f8ec135 100644 --- a/packages/renderer/src/lib/statusbar/StatusBar.spec.ts +++ b/packages/renderer/src/lib/statusbar/StatusBar.spec.ts @@ -36,6 +36,7 @@ beforeEach(() => { status: 'in-progress', started: 0, id: 'dummy-task', + cancellable: false, }, ]); }); diff --git a/packages/renderer/src/lib/statusbar/TaskIndicator.spec.ts b/packages/renderer/src/lib/statusbar/TaskIndicator.spec.ts index d58f1498b1022..8565cd6bc7766 100644 --- a/packages/renderer/src/lib/statusbar/TaskIndicator.spec.ts +++ b/packages/renderer/src/lib/statusbar/TaskIndicator.spec.ts @@ -46,6 +46,7 @@ test('clicking on task should send task manager toggle event', async () => { status: 'in-progress', started: 0, id: 'dummy-task', + cancellable: false, }, ]); @@ -68,6 +69,7 @@ test('one task running should display it', async () => { status: 'in-progress', started: 0, id: 'dummy-task', + cancellable: false, }, ]); @@ -85,6 +87,7 @@ test('multiple tasks running should display them', async () => { status: 'in-progress', started: 0, id: 'foo-task', + cancellable: false, }, { name: 'Bar Task', @@ -92,6 +95,7 @@ test('multiple tasks running should display them', async () => { status: 'in-progress', started: 0, id: 'foo-task', + cancellable: false, }, ]); @@ -110,6 +114,7 @@ test('task with defined progress value should display it', async () => { started: 0, id: 'dummy-task', progress: 50, + cancellable: false, }, ]); @@ -127,6 +132,7 @@ test('task with undefined progress value should show indeterminate progress', as started: 0, id: 'dummy-task', progress: undefined, // indeterminate + cancellable: false, }, ]); diff --git a/packages/renderer/src/lib/task-manager/TaskManager.spec.ts b/packages/renderer/src/lib/task-manager/TaskManager.spec.ts index 2550120181211..becf172e423ae 100644 --- a/packages/renderer/src/lib/task-manager/TaskManager.spec.ts +++ b/packages/renderer/src/lib/task-manager/TaskManager.spec.ts @@ -43,8 +43,16 @@ const IN_PROGRESS_TASK: TaskInfo = { state: 'running', status: 'in-progress', started, + cancellable: false, +}; +const SUCCEED_TASK: TaskInfo = { + id: '1', + name: 'Running Task 1', + state: 'completed', + status: 'success', + started, + cancellable: false, }; -const SUCCEED_TASK: TaskInfo = { id: '1', name: 'Running Task 1', state: 'completed', status: 'success', started }; const NOTIFICATION_TASK: NotificationTaskInfo = { id: '1', name: 'Notification Task 1', @@ -52,6 +60,7 @@ const NOTIFICATION_TASK: NotificationTaskInfo = { status: 'success', state: 'completed', started, + cancellable: false, }; test('Expect that the tasks manager is hidden by default', async () => { diff --git a/packages/renderer/src/lib/task-manager/TaskManagerItem.spec.ts b/packages/renderer/src/lib/task-manager/TaskManagerItem.spec.ts index 7a3dd439b75c1..a9625fca97c82 100644 --- a/packages/renderer/src/lib/task-manager/TaskManagerItem.spec.ts +++ b/packages/renderer/src/lib/task-manager/TaskManagerItem.spec.ts @@ -33,6 +33,7 @@ const IN_PROGRESS_TASK: TaskInfo = { status: 'in-progress', started, action: 'Task action', + cancellable: false, }; const IN_PROGRESS_TASK_2: TaskInfo = { @@ -41,6 +42,7 @@ const IN_PROGRESS_TASK_2: TaskInfo = { state: 'running', status: 'in-progress', started, + cancellable: false, }; test('Expect that the action button is visible', async () => { diff --git a/packages/renderer/src/lib/toast/ToastCustomUi.spec.ts b/packages/renderer/src/lib/toast/ToastCustomUi.spec.ts index eb3e6631339cd..d67856b37809e 100644 --- a/packages/renderer/src/lib/toast/ToastCustomUi.spec.ts +++ b/packages/renderer/src/lib/toast/ToastCustomUi.spec.ts @@ -35,6 +35,7 @@ const IN_PROGRESS_TASK: TaskInfo = { status: 'in-progress', started, action: 'Task action', + cancellable: false, }; const SUCCESS_TASK: TaskInfo = { @@ -44,6 +45,7 @@ const SUCCESS_TASK: TaskInfo = { status: 'success', started, action: 'Success action', + cancellable: false, }; const failureTaskError = 'this is the error'; @@ -55,6 +57,7 @@ const FAILURE_TASK: TaskInfo = { started, error: failureTaskError, action: 'failure action', + cancellable: false, }; beforeAll(() => { diff --git a/packages/renderer/src/lib/toast/ToastTaskNotifications.spec.ts b/packages/renderer/src/lib/toast/ToastTaskNotifications.spec.ts index 29c6d523d492c..02fe8b7e39241 100644 --- a/packages/renderer/src/lib/toast/ToastTaskNotifications.spec.ts +++ b/packages/renderer/src/lib/toast/ToastTaskNotifications.spec.ts @@ -52,6 +52,7 @@ const IN_PROGRESS_TASK: TaskInfo = { status: 'in-progress', started, action: 'Task action', + cancellable: false, }; beforeEach(() => { diff --git a/packages/renderer/src/stores/tasks.spec.ts b/packages/renderer/src/stores/tasks.spec.ts index c08ef2173a617..144dbcf49fcc7 100644 --- a/packages/renderer/src/stores/tasks.spec.ts +++ b/packages/renderer/src/stores/tasks.spec.ts @@ -24,7 +24,14 @@ import { clearNotifications, isNotificationTask } from './tasks'; const started = new Date().getTime(); -const SUCCEED_TASK: TaskInfo = { id: '1', name: 'Running Task 1', status: 'success', state: 'completed', started }; +const SUCCEED_TASK: TaskInfo = { + id: '1', + name: 'Running Task 1', + status: 'success', + state: 'completed', + started, + cancellable: false, +}; const NOTIFICATION_TASK: NotificationTaskInfo = { id: '1', @@ -33,6 +40,7 @@ const NOTIFICATION_TASK: NotificationTaskInfo = { state: 'completed', status: 'success', started, + cancellable: false, }; beforeEach(() => {