diff --git a/src/plugins/plugin-context.js b/src/plugins/plugin-context.js new file mode 100644 index 0000000..2d0e0d1 --- /dev/null +++ b/src/plugins/plugin-context.js @@ -0,0 +1,70 @@ +class PluginContext { + /** + * @param {object} deps - Injected dependencies + * @param {string} deps.pluginId - Plugin unique ID (for namespacing) + * @param {object} deps.sidebar - SidebarManager.registerPanel + * @param {object} deps.commands - CommandPalette.register + * @param {object} deps.statusBar - StatusBar.registerIndicator + * @param {object} deps.eventBus - EventBus instance + * @param {object} deps.settings - { get, set, onChanged } + * @param {object} deps.editor - { getContent, getSelection, insertAtCursor, onContentChanged } + * @param {object} deps.ipc - { invoke, on } + * @param {object} deps.exportHooks - { preHooks: [], postHooks: [] } + */ + constructor(deps) { + const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } = deps; + + this.sidebar = { + registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts) + }; + + this.commands = { + register: (id, label, handler, shortcut) => { + const safeHandler = (...args) => { + try { + handler(...args); + } catch (err) { + console.error(`[Plugin:${pluginId}] Command "${id}" error:`, err); + } + }; + commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut); + } + }; + + this.statusBar = { + registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts) + }; + + this.settings = { + get: (key) => settings.get(`plugins.${pluginId}.${key}`), + set: (key, value) => settings.set(`plugins.${pluginId}.${key}`, value), + onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb) + }; + + this.editor = { + getContent: () => editor.getContent(), + getSelection: () => editor.getSelection(), + insertAtCursor: (text) => editor.insertAtCursor(text), + onContentChanged: (cb) => editor.onContentChanged(cb) + }; + + this.events = { + on: (event, handler) => eventBus.on(event, handler), + off: (event, handler) => eventBus.off(event, handler), + emit: (event, payload) => eventBus.emit(event, payload), + hasHandler: (event) => eventBus.hasHandler(event) + }; + + this.ipc = { + invoke: (channel, ...args) => ipc.invoke(channel, ...args), + on: (channel, handler) => ipc.on(channel, handler) + }; + + this.exports = { + registerPreHook: (handler) => { if (exportHooks) exportHooks.preHooks.push(handler); }, + registerPostHook: (handler) => { if (exportHooks) exportHooks.postHooks.push(handler); } + }; + } +} + +module.exports = { PluginContext }; diff --git a/src/plugins/plugin-registry.js b/src/plugins/plugin-registry.js new file mode 100644 index 0000000..5482478 --- /dev/null +++ b/src/plugins/plugin-registry.js @@ -0,0 +1,80 @@ +const { PluginContext } = require('./plugin-context'); +const { PluginAPI } = require('./plugin-api'); + +class PluginRegistry { + constructor(deps) { + this.deps = deps; + this.plugins = new Map(); + this.exportHooks = { preHooks: [], postHooks: [] }; + } + + /** + * Register a discovered plugin. Creates instance, builds context, calls init. + * If init throws, plugin is NOT registered. + */ + register(pluginInfo) { + const { id, name, version, description, manifest, PluginClass, dir } = pluginInfo; + + let instance; + if (PluginClass) { + instance = new PluginClass(); + } else { + instance = new PluginAPI(); + } + instance._manifest = manifest; + + const context = new PluginContext({ + pluginId: id, + sidebar: this.deps.sidebar, + commands: this.deps.commands, + statusBar: this.deps.statusBar, + eventBus: this.deps.eventBus, + settings: this.deps.settings, + editor: this.deps.editor, + ipc: this.deps.ipc, + exportHooks: this.exportHooks + }); + + try { + instance.init(context); + } catch (err) { + console.error(`[PluginRegistry] Plugin "${id}" init failed:`, err.message); + return; + } + + this.plugins.set(id, { id, name, version, description, manifest, instance, dir, context }); + console.log(`[PluginRegistry] Registered plugin: ${name} v${version}`); + } + + getPlugin(id) { + return this.plugins.get(id); + } + + getAll() { + return Array.from(this.plugins.values()); + } + + activate(id) { + const plugin = this.plugins.get(id); + if (plugin?.instance) { + try { + plugin.instance.activate(); + } catch (err) { + console.error(`[PluginRegistry] Plugin "${id}" activate error:`, err.message); + } + } + } + + deactivate(id) { + const plugin = this.plugins.get(id); + if (plugin?.instance) { + try { + plugin.instance.deactivate(); + } catch (err) { + console.error(`[PluginRegistry] Plugin "${id}" deactivate error:`, err.message); + } + } + } +} + +module.exports = { PluginRegistry }; diff --git a/src/plugins/settings-store.js b/src/plugins/settings-store.js new file mode 100644 index 0000000..5e07150 --- /dev/null +++ b/src/plugins/settings-store.js @@ -0,0 +1,23 @@ +class SettingsStore { + /** + * @param {object} backend - { get(key), set(key, value) } backed by main process store + */ + constructor(backend) { + this.backend = backend; + } + + get(key) { + return this.backend.get(key); + } + + set(key, value) { + this.backend.set(key, value); + } + + onChanged(key, callback) { + // Deferred: plugins read settings on init/activate for MVP. + // Full change notification requires IPC watcher in main process. + } +} + +module.exports = { SettingsStore }; diff --git a/tests/plugin-context.test.js b/tests/plugin-context.test.js new file mode 100644 index 0000000..138f5be --- /dev/null +++ b/tests/plugin-context.test.js @@ -0,0 +1,79 @@ +const { PluginContext } = require('../src/plugins/plugin-context'); +const { EventBus } = require('../src/plugins/event-bus'); + +describe('PluginContext', () => { + let context; + let mockDeps; + + beforeEach(() => { + mockDeps = { + pluginId: 'test-plugin', + sidebar: { registerPanel: jest.fn() }, + commands: { register: jest.fn() }, + statusBar: { registerIndicator: jest.fn() }, + eventBus: new EventBus(), + settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() }, + editor: { getContent: jest.fn(), getSelection: jest.fn(), insertAtCursor: jest.fn(), onContentChanged: jest.fn() }, + ipc: { invoke: jest.fn(), on: jest.fn() }, + exportHooks: { preHooks: [], postHooks: [] } + }; + context = new PluginContext(mockDeps); + }); + + test('exposes sidebar.registerPanel with namespaced id', () => { + const handler = jest.fn(); + context.sidebar.registerPanel('my-panel', { icon: 'test', title: 'Test', render: handler }); + expect(mockDeps.sidebar.registerPanel).toHaveBeenCalledWith('test-plugin:my-panel', { icon: 'test', title: 'Test', render: handler }); + }); + + test('exposes commands.register with crash-safe wrapper', () => { + const badHandler = () => { throw new Error('boom'); }; + context.commands.register('bad-cmd', 'Bad', badHandler, 'Ctrl+Alt+T'); + const registeredHandler = mockDeps.commands.register.mock.calls[0][2]; + expect(() => registeredHandler()).not.toThrow(); + }); + + test('exposes settings.get/set scoped to plugin', () => { + context.settings.get('myKey'); + context.settings.set('myKey', 'myValue'); + expect(mockDeps.settings.get).toHaveBeenCalledWith('plugins.test-plugin.myKey'); + expect(mockDeps.settings.set).toHaveBeenCalledWith('plugins.test-plugin.myKey', 'myValue'); + }); + + test('exposes editor methods', () => { + context.editor.getContent(); + context.editor.getSelection(); + expect(mockDeps.editor.getContent).toHaveBeenCalled(); + expect(mockDeps.editor.getSelection).toHaveBeenCalled(); + }); + + test('exposes events via event bus', () => { + const handler = jest.fn(); + context.events.on('test:event', handler); + context.events.emit('test:event', { data: 1 }); + expect(handler).toHaveBeenCalledWith({ data: 1 }); + }); + + test('exposes events.hasHandler', () => { + expect(context.events.hasHandler('no:such')).toBe(false); + context.events.on('exists:event', () => {}); + expect(context.events.hasHandler('exists:event')).toBe(true); + }); + + test('exposes ipc.invoke', () => { + context.ipc.invoke('test:channel', { a: 1 }); + expect(mockDeps.ipc.invoke).toHaveBeenCalledWith('test:channel', { a: 1 }); + }); + + test('exposes exports.registerPreHook', () => { + const handler = jest.fn(); + context.exports.registerPreHook(handler); + expect(mockDeps.exportHooks.preHooks).toContain(handler); + }); + + test('exposes exports.registerPostHook', () => { + const handler = jest.fn(); + context.exports.registerPostHook(handler); + expect(mockDeps.exportHooks.postHooks).toContain(handler); + }); +}); diff --git a/tests/plugin-registry.test.js b/tests/plugin-registry.test.js new file mode 100644 index 0000000..02d4cf4 --- /dev/null +++ b/tests/plugin-registry.test.js @@ -0,0 +1,87 @@ +const { PluginRegistry } = require('../src/plugins/plugin-registry'); +const { PluginAPI } = require('../src/plugins/plugin-api'); +const { EventBus } = require('../src/plugins/event-bus'); + +class TestPlugin extends PluginAPI { + init(context) { this.initialized = true; this.ctx = context; } + activate() { this.activated = true; } + deactivate() { this.deactivated = true; } +} + +describe('PluginRegistry', () => { + let registry; + let mockDeps; + + beforeEach(() => { + mockDeps = { + sidebar: { registerPanel: jest.fn() }, + commands: { register: jest.fn() }, + statusBar: { registerIndicator: jest.fn() }, + eventBus: new EventBus(), + settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() }, + editor: { getContent: jest.fn(() => ''), getSelection: jest.fn(() => ''), insertAtCursor: jest.fn(), onContentChanged: jest.fn() }, + ipc: { invoke: jest.fn(), on: jest.fn() } + }; + registry = new PluginRegistry(mockDeps); + }); + + test('register — stores plugin and calls init', () => { + registry.register({ + id: 'test', name: 'Test', version: '1.0.0', description: 'desc', + manifest: {}, PluginClass: TestPlugin, dir: '/tmp/test' + }); + const entry = registry.getPlugin('test'); + expect(entry).toBeDefined(); + expect(entry.instance.initialized).toBe(true); + }); + + test('register — works without PluginClass', () => { + registry.register({ + id: 'manifest-only', name: 'Manifest Only', version: '1.0.0', description: 'desc', + manifest: {}, PluginClass: null, dir: '/tmp/test' + }); + expect(registry.getPlugin('manifest-only')).toBeDefined(); + }); + + test('register — init error does not crash, plugin not registered', () => { + class BadPlugin extends PluginAPI { + init() { throw new Error('init fail'); } + } + expect(() => { + registry.register({ + id: 'bad', name: 'Bad', version: '1.0.0', description: 'desc', + manifest: {}, PluginClass: BadPlugin, dir: '/tmp/test' + }); + }).not.toThrow(); + expect(registry.getPlugin('bad')).toBeUndefined(); + }); + + test('getPlugin — returns undefined for unknown', () => { + expect(registry.getPlugin('nope')).toBeUndefined(); + }); + + test('getAll — returns all registered plugins', () => { + registry.register({ id: 'a', name: 'A', version: '1', description: '', manifest: {}, PluginClass: null, dir: '' }); + registry.register({ id: 'b', name: 'B', version: '1', description: '', manifest: {}, PluginClass: null, dir: '' }); + expect(registry.getAll()).toHaveLength(2); + }); + + test('activate — calls activate on plugin instance', () => { + registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' }); + registry.activate('test'); + expect(registry.getPlugin('test').instance.activated).toBe(true); + }); + + test('deactivate — calls deactivate on plugin instance', () => { + registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' }); + registry.deactivate('test'); + expect(registry.getPlugin('test').instance.deactivated).toBe(true); + }); + + test('exportHooks are available and populated', () => { + registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' }); + const handler = jest.fn(); + registry.getPlugin('test').instance.ctx.exports.registerPreHook(handler); + expect(registry.exportHooks.preHooks).toContain(handler); + }); +}); diff --git a/tests/settings-store.test.js b/tests/settings-store.test.js new file mode 100644 index 0000000..91203b7 --- /dev/null +++ b/tests/settings-store.test.js @@ -0,0 +1,34 @@ +const { SettingsStore } = require('../src/plugins/settings-store'); + +describe('SettingsStore', () => { + let store; + let data; + + beforeEach(() => { + data = {}; + store = new SettingsStore({ + get: (key) => data[key], + set: (key, value) => { data[key] = value; } + }); + }); + + test('get returns value for full key', () => { + data['plugins.my-plugin.myKey'] = 'myValue'; + expect(store.get('plugins.my-plugin.myKey')).toBe('myValue'); + }); + + test('get returns undefined for missing key', () => { + expect(store.get('plugins.my-plugin.missing')).toBeUndefined(); + }); + + test('set stores value', () => { + store.set('plugins.my-plugin.myKey', 42); + expect(data['plugins.my-plugin.myKey']).toBe(42); + }); + + test('set overwrites existing value', () => { + data['plugins.my-plugin.myKey'] = 'old'; + store.set('plugins.my-plugin.myKey', 'new'); + expect(data['plugins.my-plugin.myKey']).toBe('new'); + }); +});