diff --git a/packages/api/src/extension-development-folders-info.ts b/packages/api/src/extension-development-folders-info.ts new file mode 100644 index 00000000000..35bfe1513a6 --- /dev/null +++ b/packages/api/src/extension-development-folders-info.ts @@ -0,0 +1,26 @@ +/********************************************************************** + * Copyright (C) 2025 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 ExtensionDevelopmentFolderInfo { + path: string; +} + +export enum ExtensionDevelopmentFolderInfoSettings { + SectionName = 'extensions', + DevelopmentExtensionsFolders = 'developmentExtensionsFolders', +} diff --git a/packages/main/src/plugin/extension/extension-development-folders.spec.ts b/packages/main/src/plugin/extension/extension-development-folders.spec.ts new file mode 100644 index 00000000000..ac9e4c6f84a --- /dev/null +++ b/packages/main/src/plugin/extension/extension-development-folders.spec.ts @@ -0,0 +1,275 @@ +/********************************************************************** + * Copyright (C) 2025 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 + ***********************************************************************/ +import type { Configuration } from '@podman-desktop/api'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { ExtensionDevelopmentFolderInfoSettings } from '/@api/extension-development-folders-info.js'; + +import type { ApiSenderType } from '../api.js'; +import type { ConfigurationRegistry } from '../configuration-registry.js'; +import { ExtensionDevelopmentFolders } from './extension-development-folders.js'; +import type { AnalyzedExtension, ExtensionLoader } from './extension-loader.js'; + +const configurationRegistry = { + registerConfigurations: vi.fn(), + getConfiguration: vi.fn(), + onDidChangeConfiguration: vi.fn(), + onDidUpdateConfiguration: vi.fn(), +} as unknown as ConfigurationRegistry; + +const apiSender = { + send: vi.fn(), +} as unknown as ApiSenderType; + +const extensionLoader = { + analyzeExtension: vi.fn(), + loadExtension: vi.fn(), +} as unknown as ExtensionLoader; + +class TestExtensionDevelopmentFolders extends ExtensionDevelopmentFolders { + override refreshFolders(): void { + super.refreshFolders(); + } + + override saveToConfiguration(): Promise { + return super.saveToConfiguration(); + } +} + +let extensionDevelopmentFolders: TestExtensionDevelopmentFolders; +beforeEach(() => { + vi.restoreAllMocks(); + vi.resetAllMocks(); + + extensionDevelopmentFolders = new TestExtensionDevelopmentFolders(configurationRegistry, apiSender); +}); + +test('init', async () => { + // mock refreshFolders method + const refreshFoldersSpy = vi.spyOn(extensionDevelopmentFolders, 'refreshFolders'); + refreshFoldersSpy.mockReturnValue(); + + // call init method + extensionDevelopmentFolders.init(); + + // check we called the onDidChangeConfiguration method + expect(configurationRegistry.onDidChangeConfiguration).toBeCalled(); + + // check we called the refreshFolders method + expect(refreshFoldersSpy).toBeCalled(); + + // now check that if we call onDidChangeConfiguration callback it will call refreshFolders method + const onDidChangeConfigurationCallback = vi.mocked(configurationRegistry.onDidChangeConfiguration).mock.calls[0]?.[0]; + expect(onDidChangeConfigurationCallback).toBeDefined(); + refreshFoldersSpy.mockClear(); + // send dummy event + onDidChangeConfigurationCallback?.({ key: 'fooEvent', value: 'fooValue', scope: 'DEFAULT' }); + // should not trigger a refresh + expect(refreshFoldersSpy).not.toBeCalled(); + // now send the expected property + onDidChangeConfigurationCallback?.({ + key: `${ExtensionDevelopmentFolderInfoSettings.SectionName}.${ExtensionDevelopmentFolderInfoSettings.DevelopmentExtensionsFolders}`, + value: `['foo', 'bar']`, + scope: 'DEFAULT', + }); + // and check that the refresh was called + expect(refreshFoldersSpy).toBeCalled(); + + // check registerConfigurations method + expect(configurationRegistry.registerConfigurations).toBeCalled(); + const configuration = vi.mocked(configurationRegistry.registerConfigurations).mock.calls[0]?.[0]; + expect(configuration?.[0]).toBeDefined(); + expect(configuration?.[0]?.id).toBe('preferences.extensions'); + expect(configuration?.[0]?.title).toBe('Extensions'); + expect(configuration?.[0]?.type).toBe('object'); + expect(configuration?.[0]?.properties).toBeDefined(); + const properties = configuration?.[0]?.properties; + expect(properties).toHaveProperty( + `${ExtensionDevelopmentFolderInfoSettings.SectionName}.${ExtensionDevelopmentFolderInfoSettings.DevelopmentExtensionsFolders}`, + ); + const property = + properties?.[ + `${ExtensionDevelopmentFolderInfoSettings.SectionName}.${ExtensionDevelopmentFolderInfoSettings.DevelopmentExtensionsFolders}` + ]; + expect(property).toHaveProperty('description', 'List of extension folders to monitor'); + expect(property).toHaveProperty('type', 'array'); + expect(property).toHaveProperty('default', []); + expect(property).toHaveProperty('hidden', true); +}); + +test('refreshFolders', async () => { + // mock an empty value first + vi.mocked(configurationRegistry.getConfiguration).mockReturnValueOnce({ + get: vi.fn(() => []), + } as unknown as Configuration); + + const callbackOnDidUpdateDevelopmentFolders = vi.fn(); + extensionDevelopmentFolders.onDidUpdateDevelopmentFolders(callbackOnDidUpdateDevelopmentFolders); + // call init + extensionDevelopmentFolders.init(); + + // check that the callback was called as it is first start + expect(callbackOnDidUpdateDevelopmentFolders).toBeCalledWith([]); + + // grab the value + expect(extensionDevelopmentFolders.getDevelopmentFolders()).toHaveLength(0); + + // now return 2 values + vi.mocked(configurationRegistry.getConfiguration).mockReturnValueOnce({ + get: vi.fn(() => ['foo', 'bar']), + } as unknown as Configuration); + + // call refresh + extensionDevelopmentFolders.refreshFolders(); + + // check that the callback was called as value has changed + expect(callbackOnDidUpdateDevelopmentFolders).toBeCalledWith([{ path: 'foo' }, { path: 'bar' }]); + + // grab the value + expect(extensionDevelopmentFolders.getDevelopmentFolders()).toHaveLength(2); + // check the values + expect(extensionDevelopmentFolders.getDevelopmentFolders()).toStrictEqual([{ path: 'foo' }, { path: 'bar' }]); + + // now return the same value + vi.mocked(configurationRegistry.getConfiguration).mockReturnValueOnce({ + get: vi.fn(() => ['foo', 'bar']), + } as unknown as Configuration); + callbackOnDidUpdateDevelopmentFolders.mockClear(); + + // call refresh + extensionDevelopmentFolders.refreshFolders(); + + // value should be the same + expect(extensionDevelopmentFolders.getDevelopmentFolders()).toHaveLength(2); + // check the values + expect(extensionDevelopmentFolders.getDevelopmentFolders()).toStrictEqual([{ path: 'foo' }, { path: 'bar' }]); + + // no callback should be called + expect(callbackOnDidUpdateDevelopmentFolders).not.toBeCalled(); +}); + +test('saveToConfiguration', async () => { + // mock update function + vi.mocked(configurationRegistry.getConfiguration).mockReturnValue({ + get: vi.fn(() => ['foo', 'bar']), + update: vi.fn(), + } as unknown as Configuration); + + // init the values + extensionDevelopmentFolders.init(); + + // call the method + await extensionDevelopmentFolders.saveToConfiguration(); + + // expect to have called the update method + expect(configurationRegistry.getConfiguration().update).toBeCalledWith( + ExtensionDevelopmentFolderInfoSettings.DevelopmentExtensionsFolders, + ['foo', 'bar'], + ); + + // expect apiSender be called + expect(apiSender.send).toBeCalledWith('extensions-development-folders-changed'); +}); + +test('removeDevelopmentFolder', async () => { + // mock saveToConfiguration method + const saveToConfigurationSpy = vi.spyOn(extensionDevelopmentFolders, 'saveToConfiguration'); + saveToConfigurationSpy.mockResolvedValue(); + + // mock config with 2 values + vi.mocked(configurationRegistry.getConfiguration).mockReturnValue({ + get: vi.fn(() => ['foo', 'bar']), + } as unknown as Configuration); + + const callbackOnDidUpdateDevelopmentFolders = vi.fn(); + extensionDevelopmentFolders.onDidUpdateDevelopmentFolders(callbackOnDidUpdateDevelopmentFolders); + + // init the values + extensionDevelopmentFolders.init(); + + // call the method + await extensionDevelopmentFolders.removeDevelopmentFolder('bar'); + + // expect to have called the update method + expect(saveToConfigurationSpy).toBeCalled(); + + // expect callback to be called with only foo as bar was removed + expect(callbackOnDidUpdateDevelopmentFolders).toBeCalledWith([{ path: 'foo' }]); +}); + +describe('addDevelopmentFolder', () => { + test('no extension loader', async () => { + await expect(extensionDevelopmentFolders.addDevelopmentFolder('foo')).rejects.toThrow( + 'No extension loader available', + ); + }); + + test('check path already exists', async () => { + // mock config with 2 values + vi.mocked(configurationRegistry.getConfiguration).mockReturnValue({ + get: vi.fn(() => ['foo', 'bar']), + } as unknown as Configuration); + + // set loader + extensionDevelopmentFolders.setExtensionLoader(extensionLoader); + + // init the values + extensionDevelopmentFolders.init(); + + await expect(extensionDevelopmentFolders.addDevelopmentFolder('foo')).rejects.toThrow( + 'Path foo already exist in the list', + ); + }); + + test('check error analyzing the extension', async () => { + vi.mocked(extensionLoader.analyzeExtension).mockResolvedValue({ + error: 'foo analyze extension', + } as AnalyzedExtension); + + // set loader + extensionDevelopmentFolders.setExtensionLoader(extensionLoader); + + await expect(extensionDevelopmentFolders.addDevelopmentFolder('foo')).rejects.toThrow('foo analyze extension'); + }); + + test('check working extension', async () => { + const analyzedExtension = { path: 'foo' } as AnalyzedExtension; + vi.mocked(extensionLoader.analyzeExtension).mockResolvedValue(analyzedExtension); + + // set loader + extensionDevelopmentFolders.setExtensionLoader(extensionLoader); + + // mock saveToConfiguration method + const saveToConfigurationSpy = vi.spyOn(extensionDevelopmentFolders, 'saveToConfiguration'); + saveToConfigurationSpy.mockResolvedValue(); + + const callbackOnDidUpdateDevelopmentFolders = vi.fn(); + extensionDevelopmentFolders.onDidUpdateDevelopmentFolders(callbackOnDidUpdateDevelopmentFolders); + + await extensionDevelopmentFolders.addDevelopmentFolder('foo'); + + // expect to have called the saveToConfiguration + expect(saveToConfigurationSpy).toBeCalled(); + + // expect callback to be called with foo + expect(callbackOnDidUpdateDevelopmentFolders).toBeCalledWith([{ path: 'foo' }]); + + // expect to have called the loadExtension + expect(extensionLoader.loadExtension).toBeCalledWith(analyzedExtension); + }); +}); diff --git a/packages/main/src/plugin/extension/extension-development-folders.ts b/packages/main/src/plugin/extension/extension-development-folders.ts new file mode 100644 index 00000000000..e59c48fb31a --- /dev/null +++ b/packages/main/src/plugin/extension/extension-development-folders.ts @@ -0,0 +1,150 @@ +/********************************************************************** + * Copyright (C) 2025 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 + ***********************************************************************/ + +import type { ConfigurationRegistry, IConfigurationNode } from '/@/plugin/configuration-registry.js'; +import { Emitter } from '/@/plugin/events/emitter.js'; +import type { ExtensionDevelopmentFolderInfo } from '/@api/extension-development-folders-info.js'; +import { ExtensionDevelopmentFolderInfoSettings } from '/@api/extension-development-folders-info.js'; + +import type { ApiSenderType } from '../api.js'; +import type { ExtensionLoader } from './extension-loader.js'; + +// Handle the registration / track of all development folders used when developing extensions +export class ExtensionDevelopmentFolders { + #configurationRegistry: ConfigurationRegistry; + + #apiSender: ApiSenderType; + + #firstLaunch = true; + + #developmentFolders: string[] = []; + + #extensionLoader: ExtensionLoader | undefined; + + // event that will be fired + #onDidUpdateDevelopmentFolders: Emitter = new Emitter< + ExtensionDevelopmentFolderInfo[] + >(); + onDidUpdateDevelopmentFolders = this.#onDidUpdateDevelopmentFolders.event; + + constructor(configurationRegistry: ConfigurationRegistry, apiSender: ApiSenderType) { + this.#configurationRegistry = configurationRegistry; + this.#apiSender = apiSender; + } + + setExtensionLoader(extensionLoader: ExtensionLoader): void { + this.#extensionLoader = extensionLoader; + } + + protected refreshFolders(): void { + const updatedFolders = this.#configurationRegistry + .getConfiguration(ExtensionDevelopmentFolderInfoSettings.SectionName) + .get(ExtensionDevelopmentFolderInfoSettings.DevelopmentExtensionsFolders, []); + + // first launch ? send the initial value as well + // or when value is being changed + if (this.#firstLaunch || JSON.stringify(this.#developmentFolders) !== JSON.stringify(updatedFolders)) { + this.#developmentFolders = updatedFolders; + this.#onDidUpdateDevelopmentFolders.fire(this.getDevelopmentFolders()); + } + } + + init(): void { + const developmentExtensionsFoldersConfiguration: IConfigurationNode = { + id: 'preferences.extensions', + title: 'Extensions', + type: 'object', + properties: { + [`${ExtensionDevelopmentFolderInfoSettings.SectionName}.${ExtensionDevelopmentFolderInfoSettings.DevelopmentExtensionsFolders}`]: + { + description: 'List of extension folders to monitor', + type: 'array', + default: [], + hidden: true, + }, + }, + }; + this.#configurationRegistry.registerConfigurations([developmentExtensionsFoldersConfiguration]); + + // refresh the value when the property is changed + this.#configurationRegistry.onDidChangeConfiguration(event => { + if ( + event.key === + `${ExtensionDevelopmentFolderInfoSettings.SectionName}.${ExtensionDevelopmentFolderInfoSettings.DevelopmentExtensionsFolders}` + ) { + this.refreshFolders(); + } + }); + + // initialize and track all development folders + this.refreshFolders(); + + this.#firstLaunch = false; + } + + protected async saveToConfiguration(): Promise { + await this.#configurationRegistry + .getConfiguration(ExtensionDevelopmentFolderInfoSettings.SectionName) + .update(ExtensionDevelopmentFolderInfoSettings.DevelopmentExtensionsFolders, this.#developmentFolders); + + // send an event to refresh the value + this.#apiSender.send('extensions-development-folders-changed'); + } + + async addDevelopmentFolder(path: string): Promise { + // check we have extension loader + if (!this.#extensionLoader) { + throw new Error('No extension loader available'); + } + + // check the path is not already in the list + if (this.#developmentFolders.includes(path)) { + throw new Error(`Path ${path} already exist in the list`); + } + + // before adding the path, check it's a valid extension path + const analyzedExtension = await this.#extensionLoader.analyzeExtension(path, false); + // if there is an error, abort + if (analyzedExtension.error) { + throw new Error(analyzedExtension.error); + } + + this.#developmentFolders.push(path); + + // persist the changes + await this.saveToConfiguration(); + this.#onDidUpdateDevelopmentFolders.fire(this.getDevelopmentFolders()); + + // start the extension + await this.#extensionLoader.loadExtension(analyzedExtension); + } + + async removeDevelopmentFolder(path: string): Promise { + this.#developmentFolders = this.#developmentFolders.filter(folder => folder !== path); + + // persist the changes + await this.saveToConfiguration(); + + // dispatch + this.#onDidUpdateDevelopmentFolders.fire(this.getDevelopmentFolders()); + } + + getDevelopmentFolders(): ExtensionDevelopmentFolderInfo[] { + return this.#developmentFolders.map(path => ({ path })); + } +} diff --git a/packages/main/src/plugin/extension/extension-loader.spec.ts b/packages/main/src/plugin/extension/extension-loader.spec.ts index 1d5d97bc6a2..c16e316b767 100644 --- a/packages/main/src/plugin/extension/extension-loader.spec.ts +++ b/packages/main/src/plugin/extension/extension-loader.spec.ts @@ -72,6 +72,7 @@ import { Disposable } from '../types/disposable.js'; import { Uri } from '../types/uri.js'; import { Exec } from '../util/exec.js'; import type { ViewRegistry } from '../view-registry.js'; +import type { ExtensionDevelopmentFolders } from './extension-development-folders.js'; import type { ActivatedExtension, AnalyzedExtension, RequireCacheDict } from './extension-loader.js'; import { ExtensionLoader } from './extension-loader.js'; import type { ExtensionWatcher } from './extension-watcher.js'; @@ -119,6 +120,10 @@ class TestExtensionLoader extends ExtensionLoader { override reloadExtension(extension: AnalyzedExtension, removable: boolean): Promise { return super.reloadExtension(extension, removable); } + + override loadDevelopmentFolderExtensions(analyzedExtensions: AnalyzedExtension[]): Promise { + return super.loadDevelopmentFolderExtensions(analyzedExtensions); + } } let extensionLoader: TestExtensionLoader; @@ -266,6 +271,10 @@ const extensionWatcher = { reloadExtension: vi.fn(), } as unknown as ExtensionWatcher; +const extensionDevelopmentFolder = { + getDevelopmentFolders: vi.fn(), +} as unknown as ExtensionDevelopmentFolders; + vi.mock('electron', () => { return { app: { @@ -319,6 +328,7 @@ beforeAll(() => { safeStorageRegistry, certificates, extensionWatcher, + extensionDevelopmentFolder, ); }); @@ -2752,3 +2762,43 @@ describe('init', () => { expect(property?.default).toBeTruthy(); }); }); + +describe('loadDevelopmentFolderExtensions', () => { + test('check loading', async () => { + vi.mocked(extensionDevelopmentFolder).getDevelopmentFolders.mockReturnValue([ + { path: 'foo' }, + { path: 'bar' }, + { path: 'baz' }, + ]); + + // spy console.error + const consoleErrorSpy = vi.spyOn(console, 'error'); + + // 1st is non existing + vi.mocked(fs.existsSync).mockReturnValueOnce(false); + // 2nd exists + vi.mocked(fs.existsSync).mockReturnValueOnce(true); + // 3rd exists + vi.mocked(fs.existsSync).mockReturnValueOnce(true); + + const analyzeExtensionSpy = vi.spyOn(extensionLoader, 'analyzeExtension'); + // bar is working + const barAnalyzedExtension = {} as AnalyzedExtension; + analyzeExtensionSpy.mockResolvedValueOnce(barAnalyzedExtension); + // baz has error + const bazAnalyzedExtension = { error: 'dummy error' } as AnalyzedExtension; + analyzeExtensionSpy.mockResolvedValueOnce(bazAnalyzedExtension); + + const analyzedExtensions: AnalyzedExtension[] = []; + await extensionLoader.loadDevelopmentFolderExtensions(analyzedExtensions); + + // check only bar has been added + expect(analyzedExtensions.length).toBe(1); + expect(analyzedExtensions[0]).toBe(barAnalyzedExtension); + + // expect we got a console error for baz + expect(consoleErrorSpy).toBeCalledWith('Error while analyzing extension baz', bazAnalyzedExtension.error); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/packages/main/src/plugin/extension/extension-loader.ts b/packages/main/src/plugin/extension/extension-loader.ts index 0d21e9e13b8..3f291efe3cd 100644 --- a/packages/main/src/plugin/extension/extension-loader.ts +++ b/packages/main/src/plugin/extension/extension-loader.ts @@ -86,6 +86,7 @@ import { TelemetryTrustedValue } from '../types/telemetry.js'; import { Uri } from '../types/uri.js'; import type { Exec } from '../util/exec.js'; import type { ViewRegistry } from '../view-registry.js'; +import type { ExtensionDevelopmentFolders } from './extension-development-folders.js'; import type { ExtensionWatcher } from './extension-watcher.js'; /** @@ -195,6 +196,7 @@ export class ExtensionLoader { private safeStorageRegistry: SafeStorageRegistry, private certificates: Certificates, private extensionWatcher: ExtensionWatcher, + private extensionDevelopmentFolder: ExtensionDevelopmentFolders, ) { this.pluginsDirectory = directories.getPluginsDirectory(); this.pluginsScanDirectory = directories.getPluginsScanDirectory(); @@ -448,6 +450,9 @@ export class ExtensionLoader { analyzedExtensions.push(...analyzedPluginsDirectoryExtensions); } + // load all extensions from developer mode + await this.loadDevelopmentFolderExtensions(analyzedExtensions); + // now we have all extensions, we can load them await this.loadExtensions(analyzedExtensions); @@ -459,6 +464,19 @@ export class ExtensionLoader { }); } + protected async loadDevelopmentFolderExtensions(analyzedExtensions: AnalyzedExtension[]): Promise { + for (const folder of this.extensionDevelopmentFolder.getDevelopmentFolders()) { + if (fs.existsSync(folder.path)) { + const analyzedExtension = await this.analyzeExtension(folder.path, false); + if (!analyzedExtension.error) { + analyzedExtensions.push(analyzedExtension); + } else { + console.error(`Error while analyzing extension ${folder.path}`, analyzedExtension.error); + } + } + } + } + async analyzeExtension(extensionPath: string, removable: boolean): Promise { const resolvedExtensionPath = await realpath(extensionPath); // do nothing if there is no package.json file diff --git a/packages/main/src/plugin/index.ts b/packages/main/src/plugin/index.ts index 53479b8bb41..a2589ce3faf 100644 --- a/packages/main/src/plugin/index.ts +++ b/packages/main/src/plugin/index.ts @@ -79,6 +79,7 @@ import type { ContainerInspectInfo } from '/@api/container-inspect-info.js'; import type { ContainerStatsInfo } from '/@api/container-stats-info.js'; import type { ContributionInfo } from '/@api/contribution-info.js'; import type { DockerSocketMappingStatusInfo } from '/@api/docker-compatibility-info.js'; +import type { ExtensionDevelopmentFolderInfo } from '/@api/extension-development-folders-info.js'; import type { ExtensionInfo } from '/@api/extension-info.js'; import type { GitHubIssue } from '/@api/feedback.js'; import type { HistoryInfo } from '/@api/history-info.js'; @@ -148,6 +149,7 @@ import { EditorInit } from './editor-init.js'; import type { Emitter } from './events/emitter.js'; import { ExtensionsCatalog } from './extension/catalog/extensions-catalog.js'; import type { CatalogExtension } from './extension/catalog/extensions-catalog-api.js'; +import { ExtensionDevelopmentFolders } from './extension/extension-development-folders.js'; import { ExtensionsUpdater } from './extension/updater/extensions-updater.js'; import { Featured } from './featured/featured.js'; import type { FeaturedExtension } from './featured/featured-api.js'; @@ -667,6 +669,8 @@ export class PluginSystem { ); const extensionWatcher = new ExtensionWatcher(fileSystemMonitoring); + const extensionDevelopmentFolders = new ExtensionDevelopmentFolders(configurationRegistry, apiSender); + extensionDevelopmentFolders.init(); this.extensionLoader = new ExtensionLoader( commandRegistry, @@ -705,8 +709,10 @@ export class PluginSystem { safeStorageRegistry, certificates, extensionWatcher, + extensionDevelopmentFolders, ); await this.extensionLoader.init(); + extensionDevelopmentFolders.setExtensionLoader(this.extensionLoader); const feedback = new FeedbackHandler(this.extensionLoader); @@ -2888,6 +2894,27 @@ export class PluginSystem { return path.relative(from, to); }); + this.ipcHandle( + 'extension-development-folders:getDevelopmentFolders', + async (): Promise => { + return extensionDevelopmentFolders.getDevelopmentFolders(); + }, + ); + + this.ipcHandle( + 'extension-development-folders:addDevelopmentFolder', + async (_listener: unknown, path: string): Promise => { + return extensionDevelopmentFolders.addDevelopmentFolder(path); + }, + ); + + this.ipcHandle( + 'extension-development-folders:removeDevelopmentFolder', + async (_listener: unknown, path: string): Promise => { + return extensionDevelopmentFolders.removeDevelopmentFolder(path); + }, + ); + const dockerDesktopInstallation = new DockerDesktopInstallation( apiSender, containerProviderRegistry, diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index 3903412c467..5d18558c481 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -57,6 +57,7 @@ import type { ContainerInspectInfo } from '/@api/container-inspect-info'; import type { ContainerStatsInfo } from '/@api/container-stats-info'; import type { ContributionInfo } from '/@api/contribution-info'; import type { DockerSocketMappingStatusInfo } from '/@api/docker-compatibility-info'; +import type { ExtensionDevelopmentFolderInfo } from '/@api/extension-development-folders-info'; import type { ExtensionInfo } from '/@api/extension-info'; import type { FeedbackProperties, GitHubIssue } from '/@api/feedback'; import type { HistoryInfo } from '/@api/history-info'; @@ -2383,6 +2384,21 @@ export function initExposure(): void { contextBridge.exposeInMainWorld('pathRelative', async (from: string, to: string): Promise => { return ipcInvoke('path:relative', from, to); }); + + contextBridge.exposeInMainWorld( + 'listExtensionDevelopmentFolders', + async (): Promise => { + return ipcInvoke('extension-development-folders:getDevelopmentFolders'); + }, + ); + + contextBridge.exposeInMainWorld('untrackExtensionFolder', async (path: string): Promise => { + return ipcInvoke('extension-development-folders:removeDevelopmentFolder', path); + }); + + contextBridge.exposeInMainWorld('trackExtensionFolder', async (path: string): Promise => { + return ipcInvoke('extension-development-folders:addDevelopmentFolder', path); + }); } // expose methods diff --git a/packages/renderer/src/stores/extensionDevelopmentFolders.spec.ts b/packages/renderer/src/stores/extensionDevelopmentFolders.spec.ts new file mode 100644 index 00000000000..09cd33e3ce9 --- /dev/null +++ b/packages/renderer/src/stores/extensionDevelopmentFolders.spec.ts @@ -0,0 +1,127 @@ +/********************************************************************** + * Copyright (C) 2025 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 + ***********************************************************************/ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { get } from 'svelte/store'; +import { beforeAll, beforeEach, expect, test, vi } from 'vitest'; + +import type { IDisposable } from '../../../main/src/plugin/types/disposable'; +import { + extensionDevelopmentFolders, + extensionDevelopmentFoldersEventStore, + fetchExtensionDevelopmentFolders, +} from './extensionDevelopmentFolders'; + +// first, path window object +const callbacks = new Map void>(); +const eventEmitter = (message: string, func: (...args: unknown[]) => void): IDisposable => { + callbacks.set(message, func); + return {} as IDisposable; +}; + +beforeAll(() => { + Object.defineProperty(window, 'events', { value: { receive: vi.fn() } }); +}); + +beforeEach(() => { + vi.restoreAllMocks(); + vi.resetAllMocks(); + vi.mocked(window.events).receive.mockImplementation((channel, args) => { + return eventEmitter(channel, args); + }); + + vi.spyOn(window, 'addEventListener').mockImplementation((event, callback) => { + callbacks.set(event, callback as () => void); + }); +}); + +test('should be updated in case of an extension is stopped', async () => { + // initial view + vi.mocked(window.listExtensionDevelopmentFolders).mockResolvedValue([ + { + path: 'foo', + }, + ]); + extensionDevelopmentFoldersEventStore.setup(); + + const callback = callbacks.get('extensions-already-started'); + // send 'extensions-already-started' event + expect(callback).toBeDefined(); + callback?.(); + + // now ready to fetch extension folders + await fetchExtensionDevelopmentFolders(); + + // now get list + const extensionDevelopmentFoldersList1 = get(extensionDevelopmentFolders); + expect(extensionDevelopmentFoldersList1.length).toBe(1); + expect(extensionDevelopmentFoldersList1[0].path).toEqual('foo'); + + // ok now mock the listExtensionDevelopmentFolders function to return an empty list + vi.mocked(window.listExtensionDevelopmentFolders).mockResolvedValue([]); + + // call 'extension-stopped' event + const extensionStoppedCallback = callbacks.get('extension-stopped'); + expect(extensionStoppedCallback).toBeDefined(); + + extensionStoppedCallback?.(); + + // check if the onboardings are updated + await vi.waitFor(() => { + const extensionDevelopmentFoldersList2 = get(extensionDevelopmentFolders); + expect(extensionDevelopmentFoldersList2.length).toBe(0); + }); +}); + +test('should be updated in case of an extension is started', async () => { + // initial view + vi.mocked(window.listExtensionDevelopmentFolders).mockResolvedValue([ + { + path: 'foo', + }, + ]); + extensionDevelopmentFoldersEventStore.setup(); + + const callback = callbacks.get('extensions-already-started'); + // send 'extensions-already-started' event + expect(callback).toBeDefined(); + callback?.(); + + // now ready to fetch extension folders + await fetchExtensionDevelopmentFolders(); + + // now get list + const extensionDevelopmentFoldersList1 = get(extensionDevelopmentFolders); + expect(extensionDevelopmentFoldersList1.length).toBe(1); + expect(extensionDevelopmentFoldersList1[0].path).toEqual('foo'); + + // ok now mock the listExtensionDevelopmentFolders function to return an empty list + vi.mocked(window.listExtensionDevelopmentFolders).mockResolvedValue([]); + + // call 'extension-stopped' event + const extensionStoppedCallback = callbacks.get('extension-started'); + expect(extensionStoppedCallback).toBeDefined(); + extensionStoppedCallback?.(); + + // check if the onboardings are updated + await vi.waitFor(() => { + const extensionDevelopmentFoldersList2 = get(extensionDevelopmentFolders); + expect(extensionDevelopmentFoldersList2.length).toBe(0); + }); +}); diff --git a/packages/renderer/src/stores/extensionDevelopmentFolders.ts b/packages/renderer/src/stores/extensionDevelopmentFolders.ts new file mode 100644 index 00000000000..1e622eb144d --- /dev/null +++ b/packages/renderer/src/stores/extensionDevelopmentFolders.ts @@ -0,0 +1,63 @@ +/********************************************************************** + * Copyright (C) 2025 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 + ***********************************************************************/ + +import type { Writable } from 'svelte/store'; +import { writable } from 'svelte/store'; + +import type { ExtensionDevelopmentFolderInfo } from '/@api/extension-development-folders-info'; + +import { EventStore } from './event-store'; + +const windowEvents = [ + 'extension-stopped', + 'extension-started', + 'extensions-started', + 'extensions-development-folders-changed', +]; +const windowListeners = ['extensions-already-started']; + +let readyToUpdate = false; + +export async function checkForUpdate(eventName: string): Promise { + if ('extensions-already-started' === eventName) { + readyToUpdate = true; + } + + // do not fetch until extensions are all started + return readyToUpdate; +} +export const extensionDevelopmentFolders: Writable = writable([]); + +// use helper here as window methods are initialized after the store in tests +const listExtensionDevelopmentFolders = async (): Promise => { + return window.listExtensionDevelopmentFolders(); +}; + +export const extensionDevelopmentFoldersEventStore = new EventStore( + 'extensionDevelopmentFoldes', + extensionDevelopmentFolders, + checkForUpdate, + windowEvents, + windowListeners, + listExtensionDevelopmentFolders, +); +const extensionDevelopmentFoldersEventStoreInfo = extensionDevelopmentFoldersEventStore.setup(); + +export const fetchExtensionDevelopmentFolders = async () => { + await extensionDevelopmentFoldersEventStoreInfo.fetch(); +};