chore: add a property to store folders used as extension folders (#10712)

* chore: add a property to store folders used as extension folders

no UI for now it's just adding backend way on adding/editing/removing
and the store keeping the values

related to https://github.com/podman-desktop/podman-desktop/issues/8616
Signed-off-by: Florent Benoit <fbenoit@redhat.com>
This commit is contained in:
Florent BENOIT 2025-01-20 09:18:07 +01:00 committed by GitHub
parent 52b1f7ca7c
commit 8d30d8a4e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 752 additions and 0 deletions

View file

@ -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',
}

View file

@ -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<void> {
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);
});
});

View file

@ -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<ExtensionDevelopmentFolderInfo[]> = 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<string[]>(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<void> {
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<void> {
// 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<void> {
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 }));
}
}

View file

@ -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<void> {
return super.reloadExtension(extension, removable);
}
override loadDevelopmentFolderExtensions(analyzedExtensions: AnalyzedExtension[]): Promise<void> {
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();
});
});

View file

@ -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<void> {
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<AnalyzedExtension> {
const resolvedExtensionPath = await realpath(extensionPath);
// do nothing if there is no package.json file

View file

@ -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<ExtensionDevelopmentFolderInfo[]> => {
return extensionDevelopmentFolders.getDevelopmentFolders();
},
);
this.ipcHandle(
'extension-development-folders:addDevelopmentFolder',
async (_listener: unknown, path: string): Promise<void> => {
return extensionDevelopmentFolders.addDevelopmentFolder(path);
},
);
this.ipcHandle(
'extension-development-folders:removeDevelopmentFolder',
async (_listener: unknown, path: string): Promise<void> => {
return extensionDevelopmentFolders.removeDevelopmentFolder(path);
},
);
const dockerDesktopInstallation = new DockerDesktopInstallation(
apiSender,
containerProviderRegistry,

View file

@ -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<string> => {
return ipcInvoke('path:relative', from, to);
});
contextBridge.exposeInMainWorld(
'listExtensionDevelopmentFolders',
async (): Promise<ExtensionDevelopmentFolderInfo[]> => {
return ipcInvoke('extension-development-folders:getDevelopmentFolders');
},
);
contextBridge.exposeInMainWorld('untrackExtensionFolder', async (path: string): Promise<void> => {
return ipcInvoke('extension-development-folders:removeDevelopmentFolder', path);
});
contextBridge.exposeInMainWorld('trackExtensionFolder', async (path: string): Promise<void> => {
return ipcInvoke('extension-development-folders:addDevelopmentFolder', path);
});
}
// expose methods

View file

@ -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<string, () => 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);
});
});

View file

@ -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<boolean> {
if ('extensions-already-started' === eventName) {
readyToUpdate = true;
}
// do not fetch until extensions are all started
return readyToUpdate;
}
export const extensionDevelopmentFolders: Writable<ExtensionDevelopmentFolderInfo[]> = writable([]);
// use helper here as window methods are initialized after the store in tests
const listExtensionDevelopmentFolders = async (): Promise<ExtensionDevelopmentFolderInfo[]> => {
return window.listExtensionDevelopmentFolders();
};
export const extensionDevelopmentFoldersEventStore = new EventStore<ExtensionDevelopmentFolderInfo[]>(
'extensionDevelopmentFoldes',
extensionDevelopmentFolders,
checkForUpdate,
windowEvents,
windowListeners,
listExtensionDevelopmentFolders,
);
const extensionDevelopmentFoldersEventStoreInfo = extensionDevelopmentFoldersEventStore.setup();
export const fetchExtensionDevelopmentFolders = async () => {
await extensionDevelopmentFoldersEventStoreInfo.fetch();
};