mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 10:00:17 +05:30
9 tasks across 8 chunks, strict TDD: - EventBus with crash-safe handlers - PluginAPI base class - PluginLoader with manifest validation - PluginContext with scoped API - PluginRegistry with lifecycle management - SettingsStore for plugin-scoped settings - Export hooks integration - Sample plugin + renderer wiring Amit Haridas
1227 lines
37 KiB
Markdown
1227 lines
37 KiB
Markdown
# Plugin System Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build a lightweight plugin registry that allows built-in (and future third-party) plugins to register sidebar panels, commands, status bar indicators, settings, and export hooks through a unified context API.
|
|
|
|
**Architecture:** PluginLoader discovers manifests, PluginRegistry validates and stores them, PluginContext provides scoped API to each plugin. Plugins activate lazily — sidebar panels don't execute until clicked. All plugin handlers are wrapped for crash safety. Existing SidebarManager, CommandPalette, and status bar are extended, not replaced.
|
|
|
|
**Tech Stack:** Electron, electron-store (simple JSON store already in main.js), Node.js require() for plugin loading.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-04-14-v5-platform-design.md` — Section 1: Plugin System
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
| File | Responsibility |
|
|
|------|---------------|
|
|
| `src/plugins/plugin-api.js` | Base class with default no-op implementations for all extension points |
|
|
| `src/plugins/plugin-loader.js` | Discovers and validates manifest.json files from built-in + user directories |
|
|
| `src/plugins/plugin-registry.js` | Stores loaded plugins, manages lifecycle (init/activate/deactivate), wraps handlers in try/catch |
|
|
| `src/plugins/plugin-context.js` | Builds scoped context API for each plugin (sidebar, commands, statusBar, settings, editor, events, exports, ipc) |
|
|
| `src/plugins/event-bus.js` | Typed event emitter with versioned payloads, `hasHandler()` for cross-plugin capability checks |
|
|
| `src/plugins/settings-store.js` | Plugin-scoped settings using the existing main.js JSON store via IPC |
|
|
| `src/plugins/built-in/_sample/manifest.json` | Empty sample manifest documenting the schema |
|
|
| `src/plugins/built-in/_sample/index.js` | Minimal plugin that logs init — proves the system works |
|
|
| `tests/plugin-loader.test.js` | Tests for manifest discovery and validation |
|
|
| `tests/plugin-registry.test.js` | Tests for registration, lifecycle, crash safety |
|
|
| `tests/plugin-context.test.js` | Tests for scoped API surface |
|
|
| `tests/event-bus.test.js` | Tests for event emission, versioned payloads, hasHandler |
|
|
|
|
---
|
|
|
|
## Chunk 1: Event Bus
|
|
|
|
The event bus has zero dependencies on other plugin files. Everything else uses it.
|
|
|
|
### Task 1: EventBus class
|
|
|
|
**Files:**
|
|
- Create: `src/plugins/event-bus.js`
|
|
- Test: `tests/event-bus.test.js`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```javascript
|
|
// tests/event-bus.test.js
|
|
const { EventBus } = require('../src/plugins/event-bus');
|
|
|
|
describe('EventBus', () => {
|
|
let bus;
|
|
|
|
beforeEach(() => {
|
|
bus = new EventBus();
|
|
});
|
|
|
|
test('on/emit — listener receives payload', () => {
|
|
const received = [];
|
|
bus.on('document:saved', (payload) => received.push(payload));
|
|
bus.emit('document:saved', { filePath: '/test.md', tabId: 'tab1' });
|
|
expect(received).toEqual([{ filePath: '/test.md', tabId: 'tab1' }]);
|
|
});
|
|
|
|
test('on — ignores events with no listeners', () => {
|
|
expect(() => bus.emit('unknown:event', {})).not.toThrow();
|
|
});
|
|
|
|
test('off — removes specific listener', () => {
|
|
const handler = jest.fn();
|
|
bus.on('test:event', handler);
|
|
bus.off('test:event', handler);
|
|
bus.emit('test:event', {});
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('off — removes all listeners for event when no handler given', () => {
|
|
const h1 = jest.fn();
|
|
const h2 = jest.fn();
|
|
bus.on('test:event', h1);
|
|
bus.on('test:event', h2);
|
|
bus.off('test:event');
|
|
bus.emit('test:event', {});
|
|
expect(h1).not.toHaveBeenCalled();
|
|
expect(h2).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('hasHandler — returns true when listener exists', () => {
|
|
bus.on('ai:analyze', () => {});
|
|
expect(bus.hasHandler('ai:analyze')).toBe(true);
|
|
});
|
|
|
|
test('hasHandler — returns false when no listener exists', () => {
|
|
expect(bus.hasHandler('ai:analyze')).toBe(false);
|
|
});
|
|
|
|
test('handler errors are caught and logged, not thrown', () => {
|
|
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
bus.on('bad:event', () => { throw new Error('boom'); });
|
|
expect(() => bus.emit('bad:event', {})).not.toThrow();
|
|
expect(errorSpy).toHaveBeenCalled();
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
test('multiple listeners all receive the event', () => {
|
|
const results = [];
|
|
bus.on('multi:event', () => results.push('a'));
|
|
bus.on('multi:event', () => results.push('b'));
|
|
bus.emit('multi:event', {});
|
|
expect(results).toEqual(['a', 'b']);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx jest tests/event-bus.test.js --no-cache`
|
|
Expected: FAIL — cannot find module `../src/plugins/event-bus`
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```javascript
|
|
// src/plugins/event-bus.js
|
|
class EventBus {
|
|
constructor() {
|
|
this.listeners = new Map();
|
|
}
|
|
|
|
on(event, handler) {
|
|
if (!this.listeners.has(event)) {
|
|
this.listeners.set(event, []);
|
|
}
|
|
this.listeners.get(event).push(handler);
|
|
}
|
|
|
|
off(event, handler) {
|
|
if (!handler) {
|
|
this.listeners.delete(event);
|
|
return;
|
|
}
|
|
const handlers = this.listeners.get(event);
|
|
if (handlers) {
|
|
const idx = handlers.indexOf(handler);
|
|
if (idx !== -1) handlers.splice(idx, 1);
|
|
}
|
|
}
|
|
|
|
emit(event, payload) {
|
|
const handlers = this.listeners.get(event);
|
|
if (!handlers) return;
|
|
for (const handler of handlers) {
|
|
try {
|
|
handler(payload);
|
|
} catch (err) {
|
|
console.error(`[EventBus] Error in handler for "${event}":`, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
hasHandler(event) {
|
|
const handlers = this.listeners.get(event);
|
|
return handlers != null && handlers.length > 0;
|
|
}
|
|
}
|
|
|
|
module.exports = { EventBus };
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npx jest tests/event-bus.test.js --no-cache`
|
|
Expected: PASS (8 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/plugins/event-bus.js tests/event-bus.test.js
|
|
git commit -m "feat(plugins): add EventBus with typed events and crash-safe handlers"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 2: Plugin API Base Class + Manifest Schema
|
|
|
|
### Task 2: PluginAPI base class
|
|
|
|
**Files:**
|
|
- Create: `src/plugins/plugin-api.js`
|
|
- Test: `tests/plugin-api.test.js`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```javascript
|
|
// tests/plugin-api.test.js
|
|
const { PluginAPI } = require('../src/plugins/plugin-api');
|
|
|
|
describe('PluginAPI', () => {
|
|
test('has default no-op lifecycle methods', () => {
|
|
const plugin = new PluginAPI();
|
|
expect(() => plugin.init({})).not.toThrow();
|
|
expect(() => plugin.activate()).not.toThrow();
|
|
expect(() => plugin.deactivate()).not.toThrow();
|
|
});
|
|
|
|
test('getManifest returns null by default', () => {
|
|
const plugin = new PluginAPI();
|
|
expect(plugin.getManifest()).toBeNull();
|
|
});
|
|
|
|
test('subclass can override init', () => {
|
|
let called = false;
|
|
class MyPlugin extends PluginAPI {
|
|
init(context) {
|
|
called = true;
|
|
this.context = context;
|
|
}
|
|
}
|
|
const p = new MyPlugin();
|
|
p.init({ foo: 'bar' });
|
|
expect(called).toBe(true);
|
|
expect(p.context.foo).toBe('bar');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx jest tests/plugin-api.test.js --no-cache`
|
|
Expected: FAIL — cannot find module
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```javascript
|
|
// src/plugins/plugin-api.js
|
|
class PluginAPI {
|
|
/**
|
|
* Called when the plugin is discovered and loaded.
|
|
* Receives a scoped context object with APIs.
|
|
* @param {object} context - Plugin context (sidebar, commands, settings, etc.)
|
|
*/
|
|
init(context) {
|
|
this.context = context;
|
|
}
|
|
|
|
/** Called when the plugin is activated (e.g., sidebar panel clicked). */
|
|
activate() {}
|
|
|
|
/** Called when the plugin is deactivated. */
|
|
deactivate() {}
|
|
|
|
/** Returns the parsed manifest.json for this plugin. */
|
|
getManifest() {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
module.exports = { PluginAPI };
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npx jest tests/plugin-api.test.js --no-cache`
|
|
Expected: PASS (3 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/plugins/plugin-api.js tests/plugin-api.test.js
|
|
git commit -m "feat(plugins): add PluginAPI base class with no-op lifecycle"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 3: Plugin Loader + Manifest Validation
|
|
|
|
### Task 3: PluginLoader discovers and validates manifests
|
|
|
|
**Files:**
|
|
- Create: `src/plugins/plugin-loader.js`
|
|
- Test: `tests/plugin-loader.test.js`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```javascript
|
|
// tests/plugin-loader.test.js
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const { PluginLoader } = require('../src/plugins/plugin-loader');
|
|
|
|
describe('PluginLoader', () => {
|
|
let tempDir;
|
|
|
|
beforeEach(() => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function writeManifest(dir, manifest) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
}
|
|
|
|
test('discoverPlugins — finds manifests in built-in directory', () => {
|
|
const pluginDir = path.join(tempDir, 'my-plugin');
|
|
writeManifest(pluginDir, {
|
|
id: 'my-plugin',
|
|
name: 'My Plugin',
|
|
version: '1.0.0',
|
|
description: 'Test'
|
|
});
|
|
const loader = new PluginLoader([pluginDir]);
|
|
const plugins = loader.discoverPlugins();
|
|
expect(plugins).toHaveLength(1);
|
|
expect(plugins[0].id).toBe('my-plugin');
|
|
});
|
|
|
|
test('discoverPlugins — skips directories without manifest.json', () => {
|
|
const emptyDir = path.join(tempDir, 'no-manifest');
|
|
fs.mkdirSync(emptyDir, { recursive: true });
|
|
const loader = new PluginLoader([emptyDir]);
|
|
const plugins = loader.discoverPlugins();
|
|
expect(plugins).toHaveLength(0);
|
|
});
|
|
|
|
test('validateManifest — accepts valid manifest', () => {
|
|
const manifest = {
|
|
id: 'test-plugin',
|
|
name: 'Test Plugin',
|
|
version: '1.0.0',
|
|
description: 'A test plugin'
|
|
};
|
|
const loader = new PluginLoader([]);
|
|
expect(loader.validateManifest(manifest)).toBe(true);
|
|
});
|
|
|
|
test('validateManifest — rejects manifest missing id', () => {
|
|
const manifest = { name: 'No ID', version: '1.0.0', description: 'x' };
|
|
const loader = new PluginLoader([]);
|
|
expect(() => loader.validateManifest(manifest)).toThrow(/id/);
|
|
});
|
|
|
|
test('validateManifest — rejects manifest missing name', () => {
|
|
const manifest = { id: 'test', version: '1.0.0', description: 'x' };
|
|
const loader = new PluginLoader([]);
|
|
expect(() => loader.validateManifest(manifest)).toThrow(/name/);
|
|
});
|
|
|
|
test('validateManifest — rejects manifest with duplicate id (already loaded)', () => {
|
|
const loader = new PluginLoader([]);
|
|
loader.loadedIds = new Set(['existing-plugin']);
|
|
expect(() => loader.validateManifest({ id: 'existing-plugin', name: 'Dup', version: '1.0.0', description: 'x' }))
|
|
.toThrow(/duplicate/i);
|
|
});
|
|
|
|
test('discoverPlugins — loads index.js if present', () => {
|
|
const pluginDir = path.join(tempDir, 'with-index');
|
|
writeManifest(pluginDir, { id: 'with-index', name: 'With Index', version: '1.0.0', description: 'Test' });
|
|
fs.writeFileSync(path.join(pluginDir, 'index.js'), 'module.exports = { PluginAPI }; const { PluginAPI } = require("../plugin-api"); class P extends PluginAPI {} module.exports = { Plugin: P };');
|
|
const loader = new PluginLoader([pluginDir]);
|
|
const plugins = loader.discoverPlugins();
|
|
expect(plugins[0].PluginClass).toBeDefined();
|
|
});
|
|
|
|
test('discoverPlugins — continues if one plugin fails to load', () => {
|
|
const badDir = path.join(tempDir, 'bad');
|
|
writeManifest(badDir, { id: 'bad', name: 'Bad', version: '1.0.0', description: 'Broken' });
|
|
fs.writeFileSync(path.join(badDir, 'index.js'), 'throw new Error("broken");');
|
|
|
|
const goodDir = path.join(tempDir, 'good');
|
|
writeManifest(goodDir, { id: 'good', name: 'Good', version: '1.0.0', description: 'Works' });
|
|
|
|
const loader = new PluginLoader([badDir, goodDir]);
|
|
const plugins = loader.discoverPlugins();
|
|
expect(plugins).toHaveLength(1);
|
|
expect(plugins[0].id).toBe('good');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx jest tests/plugin-loader.test.js --no-cache`
|
|
Expected: FAIL — cannot find module
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```javascript
|
|
// src/plugins/plugin-loader.js
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
class PluginLoader {
|
|
/**
|
|
* @param {string[]} searchDirs - Directories to scan for plugin folders
|
|
*/
|
|
constructor(searchDirs = []) {
|
|
this.searchDirs = searchDirs;
|
|
this.loadedIds = new Set();
|
|
}
|
|
|
|
/**
|
|
* Discover plugins by scanning searchDirs for manifest.json files.
|
|
* Returns array of { id, name, version, description, manifest, PluginClass, dir }
|
|
*/
|
|
discoverPlugins() {
|
|
const plugins = [];
|
|
for (const dir of this.searchDirs) {
|
|
if (!fs.existsSync(dir)) continue;
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const pluginDir = path.join(dir, entry.name);
|
|
const manifestPath = path.join(pluginDir, 'manifest.json');
|
|
if (!fs.existsSync(manifestPath)) continue;
|
|
try {
|
|
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
|
const manifest = JSON.parse(raw);
|
|
this.validateManifest(manifest);
|
|
let PluginClass = null;
|
|
const indexPath = path.join(pluginDir, 'index.js');
|
|
if (fs.existsSync(indexPath)) {
|
|
try {
|
|
const loaded = require(indexPath);
|
|
PluginClass = loaded.Plugin || loaded.default || null;
|
|
} catch (err) {
|
|
console.error(`[PluginLoader] Failed to load index.js for "${manifest.id}":`, err.message);
|
|
continue;
|
|
}
|
|
}
|
|
plugins.push({
|
|
id: manifest.id,
|
|
name: manifest.name,
|
|
version: manifest.version,
|
|
description: manifest.description,
|
|
manifest,
|
|
PluginClass,
|
|
dir: pluginDir
|
|
});
|
|
this.loadedIds.add(manifest.id);
|
|
} catch (err) {
|
|
console.error(`[PluginLoader] Skipping plugin in ${pluginDir}:`, err.message);
|
|
}
|
|
}
|
|
}
|
|
return plugins;
|
|
}
|
|
|
|
/**
|
|
* Validate a manifest object. Throws on invalid.
|
|
*/
|
|
validateManifest(manifest) {
|
|
if (!manifest.id) throw new Error('Manifest missing required field: id');
|
|
if (!manifest.name) throw new Error('Manifest missing required field: name');
|
|
if (!manifest.version) throw new Error('Manifest missing required field: version');
|
|
if (!manifest.description) throw new Error('Manifest missing required field: description');
|
|
if (this.loadedIds.has(manifest.id)) {
|
|
throw new Error(`Duplicate plugin id: "${manifest.id}"`);
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
module.exports = { PluginLoader };
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npx jest tests/plugin-loader.test.js --no-cache`
|
|
Expected: PASS (7 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/plugins/plugin-loader.js tests/plugin-loader.test.js
|
|
git commit -m "feat(plugins): add PluginLoader with manifest discovery and validation"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 4: Plugin Context
|
|
|
|
### Task 4: PluginContext builds scoped API for each plugin
|
|
|
|
**Files:**
|
|
- Create: `src/plugins/plugin-context.js`
|
|
- Test: `tests/plugin-context.test.js`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```javascript
|
|
// tests/plugin-context.test.js
|
|
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() }
|
|
};
|
|
context = new PluginContext(mockDeps);
|
|
});
|
|
|
|
test('exposes sidebar.registerPanel', () => {
|
|
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', () => {
|
|
const handler = jest.fn();
|
|
context.commands.register('do-thing', 'Do Thing', handler, 'Ctrl+Alt+T');
|
|
expect(mockDeps.commands.register).toHaveBeenCalledWith('test-plugin:do-thing', 'Do Thing', expect.any(Function), 'Ctrl+Alt+T');
|
|
});
|
|
|
|
test('command handler is wrapped in try/catch', () => {
|
|
const badHandler = () => { throw new Error('boom'); };
|
|
context.commands.register('bad-cmd', 'Bad', badHandler);
|
|
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();
|
|
expect(mockDeps.editor.getContent).toHaveBeenCalled();
|
|
context.editor.getSelection();
|
|
expect(mockDeps.editor.getSelection).toHaveBeenCalled();
|
|
});
|
|
|
|
test('exposes events.on and events.emit 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 and ipc.on', () => {
|
|
context.ipc.invoke('test:channel', { a: 1 });
|
|
expect(mockDeps.ipc.invoke).toHaveBeenCalledWith('test:channel', { a: 1 });
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx jest tests/plugin-context.test.js --no-cache`
|
|
Expected: FAIL — cannot find module
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```javascript
|
|
// src/plugins/plugin-context.js
|
|
class PluginContext {
|
|
/**
|
|
* @param {object} deps - Injected dependencies
|
|
* @param {string} deps.pluginId - Plugin's 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 }
|
|
*/
|
|
constructor(deps) {
|
|
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc } = 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)
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = { PluginContext };
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npx jest tests/plugin-context.test.js --no-cache`
|
|
Expected: PASS (9 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/plugins/plugin-context.js tests/plugin-context.test.js
|
|
git commit -m "feat(plugins): add PluginContext with scoped, crash-safe API"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 5: Plugin Registry
|
|
|
|
### Task 5: PluginRegistry manages plugin lifecycle
|
|
|
|
**Files:**
|
|
- Create: `src/plugins/plugin-registry.js`
|
|
- Test: `tests/plugin-registry.test.js`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```javascript
|
|
// tests/plugin-registry.test.js
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
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', () => {
|
|
const plugin = new TestPlugin();
|
|
registry.register({
|
|
id: 'test',
|
|
name: 'Test',
|
|
version: '1.0.0',
|
|
description: 'desc',
|
|
manifest: {},
|
|
PluginClass: TestPlugin,
|
|
dir: '/tmp/test'
|
|
});
|
|
expect(plugin.initialized).toBe(true);
|
|
expect(registry.getPlugin('test')).toBeDefined();
|
|
});
|
|
|
|
test('register — works without PluginClass (manifest-only plugin)', () => {
|
|
expect(() => {
|
|
registry.register({
|
|
id: 'manifest-only',
|
|
name: 'Manifest Only',
|
|
version: '1.0.0',
|
|
description: 'desc',
|
|
manifest: {},
|
|
PluginClass: null,
|
|
dir: '/tmp/test'
|
|
});
|
|
}).not.toThrow();
|
|
expect(registry.getPlugin('manifest-only')).toBeDefined();
|
|
});
|
|
|
|
test('register — plugin init error does not crash registry', () => {
|
|
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();
|
|
// Plugin should NOT be registered since init failed
|
|
expect(registry.getPlugin('bad')).toBeUndefined();
|
|
});
|
|
|
|
test('getPlugin — returns undefined for unknown plugin', () => {
|
|
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', () => {
|
|
const plugin = new TestPlugin();
|
|
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
|
|
registry.activate('test');
|
|
const instance = registry.getPlugin('test').instance;
|
|
expect(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');
|
|
const instance = registry.getPlugin('test').instance;
|
|
expect(instance.deactivated).toBe(true);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx jest tests/plugin-registry.test.js --no-cache`
|
|
Expected: FAIL — cannot find module
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```javascript
|
|
// src/plugins/plugin-registry.js
|
|
const { PluginContext } = require('./plugin-context');
|
|
const { PluginAPI } = require('./plugin-api');
|
|
|
|
class PluginRegistry {
|
|
constructor(deps) {
|
|
this.deps = deps;
|
|
this.plugins = new Map();
|
|
}
|
|
|
|
/**
|
|
* 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 = null;
|
|
if (PluginClass) {
|
|
instance = new PluginClass();
|
|
instance._manifest = manifest;
|
|
} 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
|
|
});
|
|
|
|
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 };
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npx jest tests/plugin-registry.test.js --no-cache`
|
|
Expected: PASS (7 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/plugins/plugin-registry.js tests/plugin-registry.test.js
|
|
git commit -m "feat(plugins): add PluginRegistry with lifecycle and crash-safe init"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 6: Settings Store (IPC bridge)
|
|
|
|
### Task 6: SettingsStore — plugin-scoped settings via IPC
|
|
|
|
**Files:**
|
|
- Create: `src/plugins/settings-store.js`
|
|
- Test: `tests/settings-store.test.js`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```javascript
|
|
// tests/settings-store.test.js
|
|
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');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx jest tests/settings-store.test.js --no-cache`
|
|
Expected: FAIL — cannot find module
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```javascript
|
|
// src/plugins/settings-store.js
|
|
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) {
|
|
// Future: watch for settings changes via IPC
|
|
// For now, settings are read-once. Change notifications will be added when needed.
|
|
}
|
|
}
|
|
|
|
module.exports = { SettingsStore };
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npx jest tests/settings-store.test.js --no-cache`
|
|
Expected: PASS (4 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/plugins/settings-store.js tests/settings-store.test.js
|
|
git commit -m "feat(plugins): add SettingsStore for plugin-scoped settings"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 6.5: Export Hooks + Settings onChanged
|
|
|
|
### Task 6.5: Export hooks registration and settings change notifications
|
|
|
|
**Files:**
|
|
- Modify: `src/plugins/plugin-context.js` — add exports API
|
|
- Modify: `src/plugins/plugin-registry.js` — store export hooks
|
|
- Modify: `src/renderer.js` — call export hooks during export flow
|
|
- Test: `tests/plugin-context.test.js` — add export hook test
|
|
|
|
- [ ] **Step 1: Add exports API to PluginContext test**
|
|
|
|
Add to `tests/plugin-context.test.js`:
|
|
```javascript
|
|
test('exposes exports.registerPreHook and registerPostHook', () => {
|
|
const exportHooks = { preHooks: [], postHooks: [] };
|
|
const ctx = new PluginContext({ ...mockDeps, exportHooks });
|
|
const handler = jest.fn();
|
|
ctx.exports.registerPreHook(handler);
|
|
expect(exportHooks.preHooks).toContain(handler);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Add exportHooks to PluginContext constructor**
|
|
|
|
In `src/plugins/plugin-context.js`, add to constructor deps and expose:
|
|
```javascript
|
|
this.exports = {
|
|
registerPreHook: (handler) => deps.exportHooks.preHooks.push(handler),
|
|
registerPostHook: (handler) => deps.exportHooks.postHooks.push(handler)
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 3: Add exportHooks to PluginRegistry**
|
|
|
|
In `src/plugins/plugin-registry.js`, initialize and pass to context:
|
|
```javascript
|
|
constructor(deps) {
|
|
// ...existing...
|
|
this.exportHooks = { preHooks: [], postHooks: [] };
|
|
}
|
|
|
|
// In register(), add to context deps:
|
|
const context = new PluginContext({
|
|
// ...existing...
|
|
exportHooks: this.exportHooks
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Wire export hooks into export flow**
|
|
|
|
In `src/renderer.js`, find the export function (search for `export-with-options` IPC send). Before sending the IPC, call pre-hooks. After completion, call post-hooks:
|
|
|
|
```javascript
|
|
// Before export
|
|
pluginRegistry.exportHooks.preHooks.forEach(hook => {
|
|
try { hook({ format, filePath }); } catch (e) { console.error('[ExportHook]', e); }
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Run all tests**
|
|
|
|
Run: `npx jest --no-cache`
|
|
Expected: All existing + new tests pass
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/plugins/plugin-context.js src/plugins/plugin-registry.js src/renderer.js tests/plugin-context.test.js
|
|
git commit -m "feat(plugins): add export hooks and wire into export flow"
|
|
```
|
|
|
|
**Note on settings onChanged:** The `onChanged` API is deferred. For v5.0 MVP, plugins read settings on init and when their panels are activated. Full change notification requires an IPC watcher in main process — add when a plugin actually needs it.
|
|
|
|
---
|
|
|
|
## Chunk 7: Sample Plugin + Integration Wiring
|
|
|
|
### Task 7: Sample built-in plugin that proves the system works
|
|
|
|
**Files:**
|
|
- Create: `src/plugins/built-in/_sample/manifest.json`
|
|
- Create: `src/plugins/built-in/_sample/index.js`
|
|
|
|
- [ ] **Step 1: Create sample manifest**
|
|
|
|
```json
|
|
{
|
|
"id": "_sample",
|
|
"name": "Sample Plugin",
|
|
"version": "1.0.0",
|
|
"description": "Demonstrates the plugin system. Safe to delete.",
|
|
"icon": "puzzle",
|
|
"extensionPoints": {
|
|
"commands": [
|
|
{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }
|
|
]
|
|
},
|
|
"settings": []
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create sample plugin implementation**
|
|
|
|
```javascript
|
|
// src/plugins/built-in/_sample/index.js
|
|
const { PluginAPI } = require('../../plugin-api');
|
|
|
|
class SamplePlugin extends PluginAPI {
|
|
init(context) {
|
|
this.context = context;
|
|
context.commands.register('hello', 'Sample: Hello World', () => {
|
|
console.log('[SamplePlugin] Hello from the plugin system!');
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = { Plugin: SamplePlugin };
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/plugins/built-in/_sample/
|
|
git commit -m "feat(plugins): add sample plugin demonstrating the system"
|
|
```
|
|
|
|
### Task 8: Wire plugin system into renderer.js initialization
|
|
|
|
**Files:**
|
|
- Modify: `src/renderer.js` (around line 1475 where SidebarManager is initialized)
|
|
- Modify: `src/main.js` (add IPC handlers for plugin settings)
|
|
|
|
- [ ] **Step 1: Add plugin initialization function in renderer.js**
|
|
|
|
Find the section around line 1475 where `sidebarManager` and `commandPalette` are created. After the existing panel registrations (around line 1530), add:
|
|
|
|
```javascript
|
|
// --- Plugin System ---
|
|
const { PluginLoader } = require('./plugins/plugin-loader');
|
|
const { PluginRegistry } = require('./plugins/plugin-registry');
|
|
const { EventBus } = require('./plugins/event-bus');
|
|
const { SettingsStore } = require('./plugins/settings-store');
|
|
const path = require('path');
|
|
|
|
const pluginEventBus = new EventBus();
|
|
const pluginSettings = new SettingsStore({
|
|
get: (key) => window.electronAPI.invoke('plugin-settings:get', key),
|
|
set: (key, value) => window.electronAPI.invoke('plugin-settings:set', { key, value })
|
|
});
|
|
|
|
const pluginRegistry = new PluginRegistry({
|
|
sidebar: sidebarManager,
|
|
commands: commandPalette,
|
|
statusBar: {
|
|
registerIndicator: (id, opts) => {
|
|
const statusBarRight = document.querySelector('.status-bar-right');
|
|
if (!statusBarRight) return;
|
|
let indicator = document.getElementById(`plugin-indicator-${id}`);
|
|
if (!indicator) {
|
|
indicator = document.createElement('span');
|
|
indicator.className = 'status-item';
|
|
indicator.id = `plugin-indicator-${id}`;
|
|
// Insert before the last separator or at the end
|
|
statusBarRight.appendChild(document.createTextNode('|'));
|
|
statusBarRight.appendChild(indicator);
|
|
}
|
|
if (opts.render) opts.render(indicator);
|
|
}
|
|
},
|
|
eventBus: pluginEventBus,
|
|
settings: pluginSettings,
|
|
editor: {
|
|
getContent: () => tabManager.getCurrentContent(),
|
|
getSelection: () => tabManager.getSelection(),
|
|
insertAtCursor: (text) => tabManager.insertAtCursor(text),
|
|
onContentChanged: (cb) => pluginEventBus.on('document:changed', cb)
|
|
},
|
|
ipc: {
|
|
invoke: (ch, data) => window.electronAPI.invoke(ch, data),
|
|
on: (ch, cb) => window.electronAPI.on(ch, cb)
|
|
}
|
|
});
|
|
|
|
const builtInDir = path.join(__dirname, 'plugins', 'built-in');
|
|
const loader = new PluginLoader([builtInDir]);
|
|
const discovered = loader.discoverPlugins();
|
|
discovered.forEach(p => pluginRegistry.register(p));
|
|
console.log(`[Plugins] Loaded ${discovered.length} plugins`);
|
|
```
|
|
|
|
- [ ] **Step 2: Add IPC channels in preload.js**
|
|
|
|
Add to `ALLOWED_SEND_CHANNELS`:
|
|
```javascript
|
|
'plugin-settings:get',
|
|
'plugin-settings:set',
|
|
```
|
|
|
|
- [ ] **Step 3: Add IPC handlers in main.js**
|
|
|
|
Find the IPC handler section and add:
|
|
```javascript
|
|
ipcMain.handle('plugin-settings:get', (event, key) => {
|
|
return store.get(key);
|
|
});
|
|
|
|
ipcMain.handle('plugin-settings:set', (event, { key, value }) => {
|
|
store.set(key, value);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Verify app starts without errors**
|
|
|
|
Run: `npm start`
|
|
Expected: App opens normally, console shows `[Plugins] Loaded X plugins`. All existing features work.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/renderer.js src/preload.js src/main.js
|
|
git commit -m "feat(plugins): wire plugin system into app initialization"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 8: Run Full Test Suite
|
|
|
|
### Task 9: Verify all tests pass together
|
|
|
|
- [ ] **Step 1: Run full test suite**
|
|
|
|
Run: `npm test`
|
|
Expected: All existing + new plugin tests pass (no regressions)
|
|
|
|
- [ ] **Step 2: Run linter**
|
|
|
|
Run: `npm run lint`
|
|
Expected: No errors
|
|
|
|
- [ ] **Step 3: Manual smoke test**
|
|
|
|
Run: `npm start`
|
|
Verify:
|
|
- App opens normally
|
|
- All sidebar panels work (Explorer, Git, Snippets, Templates, Outline)
|
|
- Command palette opens (Ctrl+Shift+P)
|
|
- Existing keyboard shortcuts work
|
|
- Console shows plugin system initialized
|
|
|
|
- [ ] **Step 4: Commit any fixes**
|