mirror of
https://github.com/podman-desktop/podman-desktop
synced 2026-04-21 17:47:22 +00:00
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:
parent
52b1f7ca7c
commit
8d30d8a4e5
9 changed files with 752 additions and 0 deletions
26
packages/api/src/extension-development-folders-info.ts
Normal file
26
packages/api/src/extension-development-folders-info.ts
Normal 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',
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
127
packages/renderer/src/stores/extensionDevelopmentFolders.spec.ts
Normal file
127
packages/renderer/src/stores/extensionDevelopmentFolders.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
63
packages/renderer/src/stores/extensionDevelopmentFolders.ts
Normal file
63
packages/renderer/src/stores/extensionDevelopmentFolders.ts
Normal 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();
|
||||
};
|
||||
Loading…
Reference in a new issue