diff --git a/public/app/features/commandPalette/CommandPaletteDynamicRegistry.test.ts b/public/app/features/commandPalette/CommandPaletteDynamicRegistry.test.ts new file mode 100644 index 00000000000..e6dc60c775b --- /dev/null +++ b/public/app/features/commandPalette/CommandPaletteDynamicRegistry.test.ts @@ -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'); + }); + }); +}); diff --git a/public/app/features/commandPalette/CommandPaletteDynamicRegistry.ts b/public/app/features/commandPalette/CommandPaletteDynamicRegistry.ts new file mode 100644 index 00000000000..b462a3bf3be --- /dev/null +++ b/public/app/features/commandPalette/CommandPaletteDynamicRegistry.ts @@ -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; + +const MSG_CANNOT_REGISTER_READ_ONLY = 'Cannot register to a read-only registry'; + +export class CommandPaletteDynamicRegistry { + private isReadOnly: boolean; + private resultSubject: Subject; + private registrySubject: ReplaySubject; + + constructor( + options: { + registrySubject?: ReplaySubject; + initialState?: RegistryType; + } = {} + ) { + this.resultSubject = new Subject(); + 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(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 { + return firstValueFrom(this.asObservable()); + } + + /** + * Execute a search across all registered providers + */ + async search(context: PluginExtensionCommandPaletteContext): Promise> { + const registry = await this.getState(); + const results = new Map(); + 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();