Skip to content

Commit 186047a

Browse files
authored
feat: expand plugin architecture with event, persistence, and transform types (#154)
## Summary - Introduce `IPlugin` base interface with shared metadata and lifecycle for all plugin types - Add `IEventPlugin` — headless event handlers that react to host events with no UI - Add `IPersistencePlugin` + `IClipRepository` — pluggable storage backends (cloud API, database, etc.) - Add `IClipTransformPlugin` — modify clip XML during import/export operations - Refactor `ClipRepository` to implement `IClipRepository` with async methods - Refactor `MainWindowViewModel` to use `IClipRepository` abstraction instead of concrete `ClipRepository` - Expand `PluginService` for multi-type plugin discovery and categorization - Generalize Plugin Manager UI to display all plugin types with type labels - Extend `IPluginHost` with `AllClips`, `ClipCollectionChanged`, and `ShowStatus` - Add plugin development documentation in `docs/plugins/`
1 parent 4788877 commit 186047a

27 files changed

+1183
-159
lines changed

docs/plugins/event-plugins.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Event Plugins
2+
3+
Event plugins are headless — they react to host events with no UI panel.
4+
5+
## Interface
6+
7+
```csharp
8+
public interface IEventPlugin : IPlugin { }
9+
```
10+
11+
`IEventPlugin` is a marker interface. All behavior comes from subscribing to `IPluginHost` events in `Initialize()` and unsubscribing in `Dispose()`.
12+
13+
## When to Use
14+
15+
- Auto-formatters that reformat XML on clip selection
16+
- Linters that validate clip structure and post diagnostics
17+
- Sync agents that mirror clips to an external system
18+
- Analytics or telemetry
19+
20+
## Available Events
21+
22+
| Event | When |
23+
|-------|------|
24+
| `SelectedClipChanged` | User selects a different clip |
25+
| `ClipContentChanged` | Clip XML changes (user edit or plugin push) |
26+
| `ClipCollectionChanged` | Clips added, removed, or reloaded |
27+
28+
## Host Capabilities
29+
30+
- `AllClips` — read the full clip collection for bulk operations
31+
- `ShowStatus(message)` — display feedback in the status bar
32+
- `UpdateSelectedClipXml(xml, pluginId)` — push XML changes back to the editor
33+
34+
## Example: Auto-Formatter
35+
36+
```csharp
37+
public class AutoFormatPlugin : IEventPlugin
38+
{
39+
public string Id => "auto-format";
40+
public string DisplayName => "Auto Formatter";
41+
public string Version => "1.0.0";
42+
public IReadOnlyList<PluginKeyBinding> KeyBindings => [];
43+
public IReadOnlyList<PluginMenuAction> MenuActions => [];
44+
45+
private IPluginHost? _host;
46+
47+
public void Initialize(IPluginHost host)
48+
{
49+
_host = host;
50+
_host.SelectedClipChanged += OnClipChanged;
51+
}
52+
53+
private void OnClipChanged(object? sender, ClipInfo? clip)
54+
{
55+
if (clip is null) return;
56+
var formatted = FormatXml(clip.Xml);
57+
if (formatted != clip.Xml)
58+
{
59+
_host!.UpdateSelectedClipXml(formatted, Id);
60+
_host.ShowStatus("Clip auto-formatted");
61+
}
62+
}
63+
64+
private static string FormatXml(string xml) => xml; // your formatting logic
65+
66+
public void Dispose()
67+
{
68+
if (_host is not null)
69+
_host.SelectedClipChanged -= OnClipChanged;
70+
}
71+
}
72+
```

docs/plugins/host-api.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# IPluginHost API Reference
2+
3+
The `IPluginHost` interface is provided to plugins during `Initialize()`. It exposes the host application's state and services.
4+
5+
## Properties
6+
7+
### `ClipInfo? SelectedClip`
8+
9+
The currently selected clip, or `null` if nothing is selected. Returns a snapshot — reading it multiple times may return different instances if the clip changed.
10+
11+
### `IReadOnlyList<ClipInfo> AllClips`
12+
13+
All clips currently loaded in the application. Useful for plugins that operate across the full clip set (linters, search indexers, sync agents).
14+
15+
## Events
16+
17+
### `SelectedClipChanged`
18+
19+
```csharp
20+
event EventHandler<ClipInfo?> SelectedClipChanged;
21+
```
22+
23+
Raised when the user selects a different clip in the list. The argument is the new clip, or `null` if deselected.
24+
25+
### `ClipContentChanged`
26+
27+
```csharp
28+
event EventHandler<ClipContentChangedArgs> ClipContentChanged;
29+
```
30+
31+
Raised when clip content changes. Check `args.Origin` to determine the source:
32+
- `"editor"` — user edited in the structured editor (debounced)
33+
- Plugin ID — a plugin pushed XML changes (immediate)
34+
35+
```csharp
36+
public record ClipContentChangedArgs(ClipInfo Clip, string Origin, bool IsPartial);
37+
```
38+
39+
`IsPartial` is `true` when the XML was produced from an incomplete parse (e.g., user is mid-edit in the script editor).
40+
41+
### `ClipCollectionChanged`
42+
43+
```csharp
44+
event EventHandler? ClipCollectionChanged;
45+
```
46+
47+
Raised when clips are added, removed, or the collection is reloaded.
48+
49+
## Methods
50+
51+
### `UpdateSelectedClipXml(string xml, string originPluginId)`
52+
53+
Replace the XML content of the currently selected clip. The host syncs the new XML back to the structured editor automatically. Pass your plugin's `Id` as `originPluginId` for origin tagging — you'll receive your own change back via `ClipContentChanged` but can skip it by checking `args.Origin == Id`.
54+
55+
### `RefreshSelectedClip() -> ClipInfo?`
56+
57+
Flush the editor's in-progress state to XML and return a fresh snapshot. Use this before reading `SelectedClip` if you need XML that reflects any uncommitted edits in the structured editors.
58+
59+
### `ShowStatus(string message)`
60+
61+
Display a message in the status bar. Use this for user-visible feedback. The message auto-clears after a few seconds.
62+
63+
## ClipInfo
64+
65+
```csharp
66+
public record ClipInfo(string Name, string ClipType, string Xml);
67+
```
68+
69+
A read-only snapshot of clip metadata and content. `ClipType` is the FileMaker clipboard format (e.g., `"Mac-XMSS"` for script steps, `"Mac-XMTB"` for tables).

docs/plugins/overview.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# SharpFM Plugin System
2+
3+
SharpFM supports four types of plugins, all sharing a common base interface (`IPlugin`) for metadata and lifecycle.
4+
5+
## Plugin Types
6+
7+
| Type | Interface | Purpose |
8+
|------|-----------|---------|
9+
| **Panel** | `IPanelPlugin` | Sidebar UI panels that display clip data |
10+
| **Event** | `IEventPlugin` | Headless handlers that react to host events |
11+
| **Persistence** | `IPersistencePlugin` | Alternative storage backends (cloud, database) |
12+
| **Transform** | `IClipTransformPlugin` | Modify clip XML during import/export |
13+
14+
## Getting Started
15+
16+
### 1. Create a Class Library
17+
18+
Create a new .NET 8 class library and reference `SharpFM.Plugin`:
19+
20+
```xml
21+
<Project Sdk="Microsoft.NET.Sdk">
22+
<PropertyGroup>
23+
<TargetFramework>net8.0</TargetFramework>
24+
</PropertyGroup>
25+
<ItemGroup>
26+
<ProjectReference Include="path/to/SharpFM.Plugin.csproj" />
27+
</ItemGroup>
28+
</Project>
29+
```
30+
31+
### 2. Implement a Plugin Interface
32+
33+
Choose the interface that matches your use case and implement it. Every plugin must provide:
34+
35+
- `Id` — unique identifier (e.g., `"my-plugin"`)
36+
- `DisplayName` — shown in the Plugins menu
37+
- `Version` — shown in the Plugin Manager
38+
- `Initialize(IPluginHost host)` — called once at startup
39+
- `Dispose()` — cleanup when unloaded
40+
41+
### 3. Build and Install
42+
43+
Build your plugin as a DLL and install it via the Plugin Manager ("Install from File...") or copy it to the `plugins/` directory next to the SharpFM executable.
44+
45+
## Discovery
46+
47+
SharpFM scans the `plugins/` directory at startup for `.dll` files. Each assembly is loaded in its own `AssemblyLoadContext` and reflected for types implementing `IPlugin` subtypes. Each class is categorized into exactly one plugin type based on the interface it implements.
48+
49+
## Licensing
50+
51+
The `SharpFM.Plugin` project is GPL with a **plugin exception**: plugins that implement these interfaces are not subject to the GPL and may use any license, including proprietary.

docs/plugins/panel-plugins.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Panel Plugins
2+
3+
Panel plugins provide a sidebar UI panel in the SharpFM main window.
4+
5+
## Interface
6+
7+
```csharp
8+
public interface IPanelPlugin : IPlugin
9+
{
10+
Control CreatePanel();
11+
}
12+
```
13+
14+
`CreatePanel()` is called once when the user activates the plugin. Only one panel plugin can be active at a time. Toggling the same plugin closes it; toggling a different one switches the panel content.
15+
16+
## Lifecycle
17+
18+
1. `Initialize(IPluginHost host)` — subscribe to host events here
19+
2. `CreatePanel()` — create your Avalonia `Control` (called when plugin is toggled on)
20+
3. `Dispose()` — unsubscribe from events and clean up
21+
22+
## Subscribing to Events
23+
24+
```csharp
25+
public void Initialize(IPluginHost host)
26+
{
27+
_host = host;
28+
_host.SelectedClipChanged += OnClipChanged;
29+
_host.ClipContentChanged += OnContentChanged;
30+
}
31+
```
32+
33+
## Bidirectional Sync
34+
35+
If your plugin edits clip XML, use origin tagging to avoid feedback loops:
36+
37+
```csharp
38+
// Push changes to the host
39+
_host.UpdateSelectedClipXml(newXml, Id);
40+
41+
// Skip your own updates
42+
private void OnContentChanged(object? sender, ClipContentChangedArgs args)
43+
{
44+
if (args.Origin == Id) return; // I caused this change
45+
_viewModel.LoadClip(args.Clip);
46+
}
47+
```
48+
49+
## Example: Minimal Read-Only Panel
50+
51+
```csharp
52+
public class MyPlugin : IPanelPlugin
53+
{
54+
public string Id => "my-plugin";
55+
public string DisplayName => "My Plugin";
56+
public string Version => "1.0.0";
57+
public IReadOnlyList<PluginKeyBinding> KeyBindings => [];
58+
public IReadOnlyList<PluginMenuAction> MenuActions => [];
59+
60+
private IPluginHost? _host;
61+
62+
public void Initialize(IPluginHost host) => _host = host;
63+
64+
public Control CreatePanel()
65+
{
66+
var text = new TextBlock { Text = _host?.SelectedClip?.Name ?? "No clip" };
67+
_host!.SelectedClipChanged += (_, clip) => text.Text = clip?.Name ?? "No clip";
68+
return text;
69+
}
70+
71+
public void Dispose() { }
72+
}
73+
```
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Persistence Plugins
2+
3+
Persistence plugins provide alternative storage backends for clips.
4+
5+
## Interfaces
6+
7+
### IPersistencePlugin
8+
9+
```csharp
10+
public interface IPersistencePlugin : IPlugin
11+
{
12+
IClipRepository CreateRepository();
13+
}
14+
```
15+
16+
The plugin handles discovery and lifecycle. `CreateRepository()` is called when the user selects this storage provider.
17+
18+
### IClipRepository
19+
20+
```csharp
21+
public interface IClipRepository
22+
{
23+
string ProviderName { get; }
24+
string CurrentLocation { get; }
25+
bool SupportsLocationPicker { get; }
26+
Task<IReadOnlyList<ClipData>> LoadClipsAsync();
27+
Task SaveClipsAsync(IReadOnlyList<ClipData> clips);
28+
Task<string?> PickLocationAsync();
29+
}
30+
```
31+
32+
The repository handles all data operations. Methods are async to support remote backends.
33+
34+
### ClipData
35+
36+
```csharp
37+
public record ClipData(string Name, string ClipType, string Xml);
38+
```
39+
40+
The persistence DTO. Separate from `ClipInfo` (which is used for plugin notifications) so the two can evolve independently.
41+
42+
## Built-In vs Plugin Storage
43+
44+
The built-in file system storage (`ClipRepository`) implements `IClipRepository` directly — it is **not** a plugin. Plugin-provided backends come through `IPersistencePlugin`.
45+
46+
At startup, the host builds a list of available repositories:
47+
1. The built-in file system repository
48+
2. One from each loaded `IPersistencePlugin` via `CreateRepository()`
49+
50+
## Example: Cloud API Storage Plugin
51+
52+
```csharp
53+
public class CloudStoragePlugin : IPersistencePlugin
54+
{
55+
public string Id => "cloud-storage";
56+
public string DisplayName => "Cloud Storage";
57+
public string Version => "1.0.0";
58+
public IReadOnlyList<PluginKeyBinding> KeyBindings => [];
59+
public IReadOnlyList<PluginMenuAction> MenuActions => [];
60+
61+
public void Initialize(IPluginHost host) { }
62+
public IClipRepository CreateRepository() => new CloudRepository();
63+
public void Dispose() { }
64+
}
65+
66+
public class CloudRepository : IClipRepository
67+
{
68+
public string ProviderName => "Cloud API";
69+
public string CurrentLocation => "https://api.example.com/clips";
70+
public bool SupportsLocationPicker => false;
71+
72+
public async Task<IReadOnlyList<ClipData>> LoadClipsAsync()
73+
{
74+
// Fetch clips from your API
75+
return [];
76+
}
77+
78+
public async Task SaveClipsAsync(IReadOnlyList<ClipData> clips)
79+
{
80+
// Push clips to your API
81+
}
82+
83+
public Task<string?> PickLocationAsync() => Task.FromResult<string?>(null);
84+
}
85+
```

0 commit comments

Comments
 (0)