feat(plugins): add PluginContext, PluginRegistry, and SettingsStore

- PluginContext: scoped API with crash-safe command wrappers
- PluginRegistry: lifecycle management with graceful init failure
- SettingsStore: plugin-scoped key/value via IPC backend
- Export hooks: pre/post hooks on registry for cross-plugin integration

Amit Haridas
This commit is contained in:
2026-04-23 22:55:12 +05:30
parent 54c9484cb5
commit a816b6ec32
6 changed files with 373 additions and 0 deletions
+70
View File
@@ -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 };
+80
View File
@@ -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 };
+23
View File
@@ -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 };
+79
View File
@@ -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);
});
});
+87
View File
@@ -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);
});
});
+34
View File
@@ -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');
});
});