feat(dynamic command palette api): moving the code to command palette folder

This commit is contained in:
kozhuhds
2025-11-21 16:05:25 +01:00
parent 688c51a306
commit 88d4aaba99
2 changed files with 1128 additions and 0 deletions
@@ -0,0 +1,929 @@
import { firstValueFrom } from 'rxjs';
import { PluginExtensionCommandPaletteContext } from '@grafana/data';
import { CommandPaletteDynamicRegistry } from './CommandPaletteDynamicRegistry';
describe('CommandPaletteDynamicRegistry', () => {
const pluginId = 'test-plugin';
describe('Registry Management', () => {
it('should return empty registry when no extensions registered', async () => {
const registry = new CommandPaletteDynamicRegistry();
const observable = registry.asObservable();
const state = await firstValueFrom(observable);
expect(state).toEqual({});
});
it('should be possible to register command palette dynamic providers', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([]);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
},
],
});
const state = await registry.getState();
expect(state).toEqual({
[`${pluginId}/Test Provider`]: [
{
pluginId,
config: {
title: 'Test Provider',
searchProvider: mockSearchProvider,
category: pluginId,
minQueryLength: 2,
debounceMs: 300,
},
},
],
});
});
it('should apply default values for optional config properties', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([]);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
},
],
});
const state = await registry.getState();
const item = state[`${pluginId}/Test Provider`][0];
expect(item.config.category).toBe(pluginId);
expect(item.config.minQueryLength).toBe(2);
expect(item.config.debounceMs).toBe(300);
});
it('should preserve custom config values when provided', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([]);
registry.register({
pluginId,
configs: [
{
title: 'Custom Provider',
searchProvider: mockSearchProvider,
category: 'Custom Category',
minQueryLength: 5,
debounceMs: 500,
},
],
});
const state = await registry.getState();
const item = state[`${pluginId}/Custom Provider`][0];
expect(item.config.category).toBe('Custom Category');
expect(item.config.minQueryLength).toBe(5);
expect(item.config.debounceMs).toBe(500);
});
it('should register multiple providers from the same plugin', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider1 = jest.fn().mockResolvedValue([]);
const mockSearchProvider2 = jest.fn().mockResolvedValue([]);
registry.register({
pluginId,
configs: [
{
title: 'Provider 1',
searchProvider: mockSearchProvider1,
},
{
title: 'Provider 2',
searchProvider: mockSearchProvider2,
},
],
});
const state = await registry.getState();
expect(Object.keys(state)).toHaveLength(2);
expect(state[`${pluginId}/Provider 1`]).toBeDefined();
expect(state[`${pluginId}/Provider 2`]).toBeDefined();
});
it('should notify subscribers when the registry changes', async () => {
const registry = new CommandPaletteDynamicRegistry();
const observable = registry.asObservable();
const subscribeCallback = jest.fn();
observable.subscribe(subscribeCallback);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: jest.fn().mockResolvedValue([]),
},
],
});
expect(subscribeCallback).toHaveBeenCalledTimes(2); // initial empty state + registration
});
it('should not be possible to register on a read-only registry', async () => {
const registry = new CommandPaletteDynamicRegistry();
const readOnlyRegistry = new CommandPaletteDynamicRegistry({
registrySubject: registry['registrySubject'],
});
expect(() => {
readOnlyRegistry.register({
pluginId,
configs: [
{
title: 'Test',
searchProvider: jest.fn(),
},
],
});
}).toThrow('Cannot register to a read-only registry');
});
it('should create a read-only version of the registry', async () => {
const registry = new CommandPaletteDynamicRegistry();
const readOnlyRegistry = registry.readOnly();
expect(() => {
readOnlyRegistry.register({
pluginId,
configs: [
{
title: 'Test',
searchProvider: jest.fn(),
},
],
});
}).toThrow('Cannot register to a read-only registry');
const currentState = await readOnlyRegistry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
});
it('should pass down fresh registrations to the read-only version of the registry', async () => {
const registry = new CommandPaletteDynamicRegistry();
const readOnlyRegistry = registry.readOnly();
const subscribeCallback = jest.fn();
let readOnlyState;
// Should have no providers registered in the beginning
readOnlyState = await readOnlyRegistry.getState();
expect(Object.keys(readOnlyState)).toHaveLength(0);
readOnlyRegistry.asObservable().subscribe(subscribeCallback);
// Register a provider to the original (writable) registry
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: jest.fn().mockResolvedValue([]),
},
],
});
// The read-only registry should have received the new provider
readOnlyState = await readOnlyRegistry.getState();
expect(Object.keys(readOnlyState)).toHaveLength(1);
expect(subscribeCallback).toHaveBeenCalledTimes(2); // initial empty + registration
expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual([`${pluginId}/Test Provider`]);
});
});
describe('Config Validation', () => {
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('should not register provider without title', async () => {
const registry = new CommandPaletteDynamicRegistry();
registry.register({
pluginId,
configs: [
{
// @ts-ignore - testing invalid config
title: '',
searchProvider: jest.fn(),
},
],
});
const state = await registry.getState();
expect(Object.keys(state)).toHaveLength(0);
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('should not register provider with non-string title', async () => {
const registry = new CommandPaletteDynamicRegistry();
registry.register({
pluginId,
configs: [
{
// @ts-ignore - testing invalid config
title: 123,
searchProvider: jest.fn(),
},
],
});
const state = await registry.getState();
expect(Object.keys(state)).toHaveLength(0);
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('should not register provider without searchProvider', async () => {
const registry = new CommandPaletteDynamicRegistry();
registry.register({
pluginId,
configs: [
{
title: 'Test',
// @ts-ignore - testing invalid config
searchProvider: undefined,
},
],
});
const state = await registry.getState();
expect(Object.keys(state)).toHaveLength(0);
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('should not register provider with non-function searchProvider', async () => {
const registry = new CommandPaletteDynamicRegistry();
registry.register({
pluginId,
configs: [
{
title: 'Test',
// @ts-ignore - testing invalid config
searchProvider: 'not-a-function',
},
],
});
const state = await registry.getState();
expect(Object.keys(state)).toHaveLength(0);
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('should log provider registration in dev mode', async () => {
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
const registry = new CommandPaletteDynamicRegistry();
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: jest.fn().mockResolvedValue([]),
},
],
});
await registry.getState();
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('Registered provider: test-plugin/Test Provider')
);
consoleLogSpy.mockRestore();
process.env.NODE_ENV = originalNodeEnv;
});
});
describe('Search Functionality', () => {
it('should execute search across all registered providers', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider1 = jest.fn().mockResolvedValue([{ id: 'result1', title: 'Result 1' }]);
const mockSearchProvider2 = jest.fn().mockResolvedValue([{ id: 'result2', title: 'Result 2' }]);
registry.register({
pluginId: 'plugin1',
configs: [
{
title: 'Provider 1',
searchProvider: mockSearchProvider1,
},
],
});
registry.register({
pluginId: 'plugin2',
configs: [
{
title: 'Provider 2',
searchProvider: mockSearchProvider2,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'test query',
};
const results = await registry.search(context);
expect(mockSearchProvider1).toHaveBeenCalledWith(context);
expect(mockSearchProvider2).toHaveBeenCalledWith(context);
expect(results.size).toBe(2);
});
it('should pass context with AbortSignal to search providers', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([]);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
},
],
});
const abortController = new AbortController();
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'test',
signal: abortController.signal,
};
await registry.search(context);
expect(mockSearchProvider).toHaveBeenCalledWith(
expect.objectContaining({
searchQuery: 'test',
signal: expect.any(Object),
})
);
});
it('should not search when query is below minimum length', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([]);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
minQueryLength: 3,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'ab', // Only 2 characters
};
await registry.search(context);
expect(mockSearchProvider).not.toHaveBeenCalled();
});
it('should search when query meets minimum length', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([]);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
minQueryLength: 2,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'ab', // Exactly 2 characters
};
await registry.search(context);
expect(mockSearchProvider).toHaveBeenCalled();
});
it('should skip inactive providers', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([]);
const isActiveFn = jest.fn().mockReturnValue(false);
registry.register({
pluginId,
configs: [
{
title: 'Inactive Provider',
searchProvider: mockSearchProvider,
isActive: isActiveFn,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'test',
};
await registry.search(context);
expect(isActiveFn).toHaveBeenCalledWith(context);
expect(mockSearchProvider).not.toHaveBeenCalled();
});
it('should execute search for active providers', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([]);
const isActiveFn = jest.fn().mockReturnValue(true);
registry.register({
pluginId,
configs: [
{
title: 'Active Provider',
searchProvider: mockSearchProvider,
isActive: isActiveFn,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'test',
};
await registry.search(context);
expect(isActiveFn).toHaveBeenCalledWith(context);
expect(mockSearchProvider).toHaveBeenCalled();
});
it('should limit results to 5 items per provider', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([
{ id: '1', title: 'Result 1' },
{ id: '2', title: 'Result 2' },
{ id: '3', title: 'Result 3' },
{ id: '4', title: 'Result 4' },
{ id: '5', title: 'Result 5' },
{ id: '6', title: 'Result 6' },
{ id: '7', title: 'Result 7' },
]);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'test',
};
const results = await registry.search(context);
const searchResult = results.get(`${pluginId}/Test Provider`);
expect(searchResult?.items).toHaveLength(5);
expect(searchResult?.items[4].id).toBe('5');
});
});
describe('Result Validation', () => {
let consoleWarnSpy: jest.SpyInstance;
beforeEach(() => {
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
});
afterEach(() => {
consoleWarnSpy.mockRestore();
});
it('should filter out results without id', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([
{ id: 'valid', title: 'Valid Result' },
// @ts-ignore - testing invalid result
{ title: 'Invalid Result' },
]);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'test',
};
const results = await registry.search(context);
const searchResult = results.get(`${pluginId}/Test Provider`);
expect(searchResult?.items).toHaveLength(1);
expect(searchResult?.items[0].id).toBe('valid');
expect(consoleWarnSpy).toHaveBeenCalled();
});
it('should filter out results with non-string id', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([
{ id: 'valid', title: 'Valid Result' },
// @ts-ignore - testing invalid result
{ id: 123, title: 'Invalid Result' },
]);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'test',
};
const results = await registry.search(context);
const searchResult = results.get(`${pluginId}/Test Provider`);
expect(searchResult?.items).toHaveLength(1);
expect(searchResult?.items[0].id).toBe('valid');
expect(consoleWarnSpy).toHaveBeenCalled();
});
it('should filter out results without title', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([
{ id: 'valid', title: 'Valid Result' },
// @ts-ignore - testing invalid result
{ id: 'invalid' },
]);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'test',
};
const results = await registry.search(context);
const searchResult = results.get(`${pluginId}/Test Provider`);
expect(searchResult?.items).toHaveLength(1);
expect(searchResult?.items[0].id).toBe('valid');
expect(consoleWarnSpy).toHaveBeenCalled();
});
it('should filter out results with non-string title', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([
{ id: 'valid', title: 'Valid Result' },
// @ts-ignore - testing invalid result
{ id: 'invalid', title: 123 },
]);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'test',
};
const results = await registry.search(context);
const searchResult = results.get(`${pluginId}/Test Provider`);
expect(searchResult?.items).toHaveLength(1);
expect(searchResult?.items[0].id).toBe('valid');
expect(consoleWarnSpy).toHaveBeenCalled();
});
it('should not include provider in results if no valid items', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([
// @ts-ignore - testing invalid results
{ id: 'invalid' }, // missing title
]);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'test',
};
const results = await registry.search(context);
expect(results.size).toBe(0);
});
it('should warn if provider returns non-array', async () => {
const registry = new CommandPaletteDynamicRegistry();
// @ts-ignore - testing invalid return value
const mockSearchProvider = jest.fn().mockResolvedValue('not-an-array');
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'test',
};
const results = await registry.search(context);
expect(results.size).toBe(0);
expect(consoleWarnSpy).toHaveBeenCalled();
});
});
describe('Error Handling', () => {
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('should handle search provider errors gracefully', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockRejectedValue(new Error('Search failed'));
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'test',
};
const results = await registry.search(context);
expect(results.size).toBe(0);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Search failed'),
expect.objectContaining({
error: 'Error: Search failed',
})
);
});
it('should not log AbortErrors', async () => {
const registry = new CommandPaletteDynamicRegistry();
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
const mockSearchProvider = jest.fn().mockRejectedValue(abortError);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'test',
};
await registry.search(context);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should continue searching other providers if one fails', async () => {
const registry = new CommandPaletteDynamicRegistry();
const failingProvider = jest.fn().mockRejectedValue(new Error('Failed'));
const successProvider = jest.fn().mockResolvedValue([{ id: 'success', title: 'Success Result' }]);
registry.register({
pluginId: 'failing-plugin',
configs: [
{
title: 'Failing Provider',
searchProvider: failingProvider,
},
],
});
registry.register({
pluginId: 'success-plugin',
configs: [
{
title: 'Success Provider',
searchProvider: successProvider,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'test',
};
const results = await registry.search(context);
expect(results.size).toBe(1);
expect(results.get('success-plugin/Success Provider')).toBeDefined();
});
});
describe('Search Context', () => {
it('should handle empty search query', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([]);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
minQueryLength: 0,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: '',
};
await registry.search(context);
expect(mockSearchProvider).toHaveBeenCalled();
});
it('should handle undefined search query', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([]);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
minQueryLength: 0,
},
],
});
const context: PluginExtensionCommandPaletteContext = {};
await registry.search(context);
expect(mockSearchProvider).toHaveBeenCalled();
});
});
describe('Result Structure', () => {
it('should return results with correct structure', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([
{
id: 'test-id',
title: 'Test Title',
description: 'Test Description',
path: '/test/path',
keywords: ['test', 'keyword'],
section: 'Test Section',
data: { custom: 'data' },
},
]);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'test',
};
const results = await registry.search(context);
const searchResult = results.get(`${pluginId}/Test Provider`);
expect(searchResult?.items[0]).toEqual({
id: 'test-id',
title: 'Test Title',
description: 'Test Description',
path: '/test/path',
keywords: ['test', 'keyword'],
section: 'Test Section',
data: { custom: 'data' },
});
});
it('should include config information in search result', async () => {
const registry = new CommandPaletteDynamicRegistry();
const mockSearchProvider = jest.fn().mockResolvedValue([{ id: 'test', title: 'Test' }]);
registry.register({
pluginId,
configs: [
{
title: 'Test Provider',
searchProvider: mockSearchProvider,
category: 'Test Category',
},
],
});
const context: PluginExtensionCommandPaletteContext = {
searchQuery: 'test',
};
const results = await registry.search(context);
const searchResult = results.get(`${pluginId}/Test Provider`);
expect(searchResult?.config.pluginId).toBe(pluginId);
expect(searchResult?.config.config.title).toBe('Test Provider');
expect(searchResult?.config.config.category).toBe('Test Category');
});
});
});
@@ -0,0 +1,199 @@
import { ReplaySubject, Subject, firstValueFrom, map, scan, startWith } from 'rxjs';
import {
PluginExtensionCommandPaletteDynamicConfig,
CommandPaletteDynamicResult,
PluginExtensionCommandPaletteContext,
} from '@grafana/data';
import { deepFreeze } from '../plugins/extensions/utils';
const logPrefix = '[CommandPaletteDynamic]';
export interface CommandPaletteDynamicRegistryItem {
pluginId: string;
config: PluginExtensionCommandPaletteDynamicConfig;
}
export interface CommandPaletteDynamicSearchResult {
items: CommandPaletteDynamicResult[];
config: CommandPaletteDynamicRegistryItem;
}
type PluginExtensionConfigs = {
pluginId: string;
configs: PluginExtensionCommandPaletteDynamicConfig[];
};
type RegistryType = Record<string, CommandPaletteDynamicRegistryItem[]>;
const MSG_CANNOT_REGISTER_READ_ONLY = 'Cannot register to a read-only registry';
export class CommandPaletteDynamicRegistry {
private isReadOnly: boolean;
private resultSubject: Subject<PluginExtensionConfigs>;
private registrySubject: ReplaySubject<RegistryType>;
constructor(
options: {
registrySubject?: ReplaySubject<RegistryType>;
initialState?: RegistryType;
} = {}
) {
this.resultSubject = new Subject<PluginExtensionConfigs>();
this.isReadOnly = false;
// If the registry subject is provided, it's a read-only instance
if (options.registrySubject) {
this.registrySubject = options.registrySubject;
this.isReadOnly = true;
return;
}
this.registrySubject = new ReplaySubject<RegistryType>(1);
this.resultSubject
.pipe(
scan(this.mapToRegistry.bind(this), options.initialState ?? {}),
startWith(options.initialState ?? {}),
map((registry) => deepFreeze(registry))
)
.subscribe(this.registrySubject);
}
private mapToRegistry(registry: RegistryType, item: PluginExtensionConfigs): RegistryType {
const { pluginId, configs } = item;
for (const config of configs) {
const { title, searchProvider, category } = config;
if (!title || typeof title !== 'string') {
console.error(`${logPrefix} Plugin ${pluginId}: title is required and must be a string`);
continue;
}
if (!searchProvider || typeof searchProvider !== 'function') {
console.error(`${logPrefix} Plugin ${pluginId}: searchProvider must be a function`);
continue;
}
const providerId = `${pluginId}/${title}`;
if (!(providerId in registry)) {
registry[providerId] = [];
}
registry[providerId].push({
pluginId,
config: {
...config,
category: category ?? pluginId,
minQueryLength: config.minQueryLength ?? 2,
debounceMs: config.debounceMs ?? 300,
},
});
if (process.env.NODE_ENV === 'development') {
console.log(`${logPrefix} Registered provider: ${providerId}`);
}
}
return registry;
}
register(result: PluginExtensionConfigs): void {
if (this.isReadOnly) {
throw new Error(MSG_CANNOT_REGISTER_READ_ONLY);
}
this.resultSubject.next(result);
}
asObservable() {
return this.registrySubject.asObservable();
}
getState(): Promise<RegistryType> {
return firstValueFrom(this.asObservable());
}
/**
* Execute a search across all registered providers
*/
async search(context: PluginExtensionCommandPaletteContext): Promise<Map<string, CommandPaletteDynamicSearchResult>> {
const registry = await this.getState();
const results = new Map<string, CommandPaletteDynamicSearchResult>();
const searchQuery = context.searchQuery ?? '';
const searchPromises = Object.entries(registry).map(async ([providerId, registryItems]) => {
if (!Array.isArray(registryItems) || registryItems.length === 0) {
return;
}
const item = registryItems[0]; // Take first config per provider
const { config } = item;
// Check minimum query length
if (searchQuery.length < (config.minQueryLength ?? 2)) {
return;
}
// Check if provider is active
if (config.isActive && !config.isActive(context)) {
return;
}
try {
const items = await config.searchProvider(context);
// Validate results
if (!Array.isArray(items)) {
console.warn(`${logPrefix} Provider ${providerId} did not return an array`);
return;
}
// Validate and filter items
const validItems = items
.filter((item) => {
if (!item.id || typeof item.id !== 'string') {
console.warn(`${logPrefix} Provider ${providerId}: result missing id`);
return false;
}
if (!item.title || typeof item.title !== 'string') {
console.warn(`${logPrefix} Provider ${providerId}: result missing title`);
return false;
}
return true;
})
.slice(0, 5); // Limit to 5 items maximum
if (validItems.length > 0) {
results.set(providerId, { items: validItems, config: item });
}
} catch (error) {
// Don't log AbortErrors as they are expected
if (error instanceof Error && error.name === 'AbortError') {
return;
}
console.error(`${logPrefix} Search failed for ${providerId}`, { error: String(error) });
}
});
await Promise.all(searchPromises);
return results;
}
/**
* Returns a read-only version of the registry.
*/
readOnly() {
return new CommandPaletteDynamicRegistry({
registrySubject: this.registrySubject,
});
}
}
/**
* Global instance of the Command Palette Dynamic Registry
* This registry is used to manage dynamic command palette providers from plugins
*/
export const commandPaletteDynamicRegistry = new CommandPaletteDynamicRegistry();