feat: milestone 5a — frontend plugin host, contribution lifecycle, UI shell

- Contribution Registry: ListByPoint, idempotent Register (Unregister-before-add)
- Flat ContributionSummary types for frontend (no nested .item.)
- Sidebar.svelte: items from ContributionRegistry, sort by position, error boundary
- ViewContainer.svelte: declarative placeholder host with error boundary
- PluginManager.svelte: settings panels from registry, knoppka only with settingsPanel
- PluginCard.svelte: settingsPanels prop, disabled state for Settings button
- Error boundary: ViewContainer + PluginManager catch errors, shell stays stable
- ReloadPlugins: Unregister before Register contributions (no duplicates)
- Smoke: -test-contributions flag, enable/disable/reload lifecycle verification
- Build: global_update() — pull all repos, build official plugins, install to desktop
This commit is contained in:
2026-06-17 17:07:52 +08:00
parent 9bb35a9fd0
commit 86eeadd2a9
12 changed files with 1108 additions and 122 deletions
+83 -21
View File
@@ -93,17 +93,88 @@ func (a *App) GetPermissions() []permissions.Entry {
return entries
}
// GetContributions returns all registered contributions.
func (a *App) GetContributions() ContributionSummary {
return ContributionSummary{
Views: a.contribRegistry.Views(),
Commands: a.contribRegistry.Commands(),
SettingsPanels: a.contribRegistry.SettingsPanels(),
SidebarItems: a.contribRegistry.SidebarItems(),
FileActions: a.contribRegistry.FileActions(),
NoteActions: a.contribRegistry.NoteActions(),
SearchProviders: a.contribRegistry.SearchProviders(),
// ─── Flat contribution types for frontend ─────────────────
// FlatSidebarItem is a flattened sidebar item for the frontend.
type FlatSidebarItem struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
View string `json:"view"`
Position int `json:"position,omitempty"`
}
// FlatView is a flattened view contribution for the frontend.
type FlatView struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Component string `json:"component"`
}
// FlatSettingsPanel is a flattened settings panel for the frontend.
type FlatSettingsPanel struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Component string `json:"component"`
}
// FlatCommand is a flattened command contribution for the frontend.
type FlatCommand struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Handler string `json:"handler,omitempty"`
}
// ContributionSummary aggregates all contribution types for the frontend.
type ContributionSummary struct {
Views []FlatView `json:"views"`
Commands []FlatCommand `json:"commands"`
SettingsPanels []FlatSettingsPanel `json:"settingsPanels"`
SidebarItems []FlatSidebarItem `json:"sidebarItems"`
}
// buildContributionSummary creates a ContributionSummary from the registry.
func buildContributionSummary(r *contribution.Registry) ContributionSummary {
if r == nil {
return ContributionSummary{}
}
regViews := r.Views()
regCmds := r.Commands()
regPanels := r.SettingsPanels()
regSidebar := r.SidebarItems()
views := make([]FlatView, len(regViews))
for i, v := range regViews {
views[i] = FlatView{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, Component: v.Item.Component}
}
cmds := make([]FlatCommand, len(regCmds))
for i, v := range regCmds {
cmds[i] = FlatCommand{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, Handler: v.Item.Handler}
}
panels := make([]FlatSettingsPanel, len(regPanels))
for i, v := range regPanels {
panels[i] = FlatSettingsPanel{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, Component: v.Item.Component}
}
sidebar := make([]FlatSidebarItem, len(regSidebar))
for i, v := range regSidebar {
sidebar[i] = FlatSidebarItem{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, View: v.Item.View, Position: v.Item.Position}
}
return ContributionSummary{Views: views, Commands: cmds, SettingsPanels: panels, SidebarItems: sidebar}
}
// GetContributions returns all registered contributions flattened for the frontend.
func (a *App) GetContributions() ContributionSummary {
if a.contribRegistry == nil {
return ContributionSummary{}
}
return buildContributionSummary(a.contribRegistry)
}
// expandPath resolves "~" to the user's home directory.
@@ -203,7 +274,9 @@ func (a *App) ReloadPlugins() (int, string) {
p.Status = plugin.StatusLoaded
}
// Register contributions (unregister first to prevent duplicates)
if p.Manifest.Contributes != nil {
a.contribRegistry.Unregister(p.Manifest.ID)
a.contribRegistry.Register(p.Manifest.ID, p.Manifest.Contributes)
}
@@ -638,14 +711,3 @@ func (a *App) SelectVaultForOpen() string {
}
return selected
}
// ContributionSummary aggregates all contribution types for the frontend.
type ContributionSummary struct {
Views []contribution.ContributionView `json:"views"`
Commands []contribution.ContributionCommand `json:"commands"`
SettingsPanels []contribution.ContributionSettingsPanel `json:"settingsPanels"`
SidebarItems []contribution.ContributionSidebarItem `json:"sidebarItems"`
FileActions []contribution.ContributionAction `json:"fileActions"`
NoteActions []contribution.ContributionAction `json:"noteActions"`
SearchProviders []contribution.ContributionSearchProvider `json:"searchProviders"`
}
+81
View File
@@ -24,6 +24,73 @@ type Registry struct {
statusBarItems []ContributionStatusBarItem
}
// ContributionPointType defines the type of contribution point.
type ContributionPointType string
const (
PointViews ContributionPointType = "views"
PointCommands ContributionPointType = "commands"
PointSettingsPanels ContributionPointType = "settingsPanels"
PointSidebarItems ContributionPointType = "sidebarItems"
PointFileActions ContributionPointType = "fileActions"
PointNoteActions ContributionPointType = "noteActions"
PointContextMenus ContributionPointType = "contextMenus"
PointSearchProviders ContributionPointType = "searchProviders"
PointActivity ContributionPointType = "activityProviders"
PointStatusBar ContributionPointType = "statusBarItems"
)
// ListByPoint returns all contributions for a given point type.
func (r *Registry) ListByPoint(point ContributionPointType) []interface{} {
r.mu.RLock()
defer r.mu.RUnlock()
var result []interface{}
switch point {
case PointViews:
for _, v := range r.views {
result = append(result, v)
}
case PointCommands:
for _, v := range r.commands {
result = append(result, v)
}
case PointSettingsPanels:
for _, v := range r.settingsPanels {
result = append(result, v)
}
case PointSidebarItems:
for _, v := range r.sidebarItems {
result = append(result, v)
}
case PointFileActions:
for _, v := range r.fileActions {
result = append(result, v)
}
case PointNoteActions:
for _, v := range r.noteActions {
result = append(result, v)
}
case PointContextMenus:
for _, v := range r.contextMenus {
result = append(result, v)
}
case PointSearchProviders:
for _, v := range r.searchProviders {
result = append(result, v)
}
case PointActivity:
for _, v := range r.activityProviders {
result = append(result, v)
}
case PointStatusBar:
for _, v := range r.statusBarItems {
result = append(result, v)
}
}
return result
}
type ContributionView struct {
PluginID string `json:"pluginId"`
Item plugin.ContributionView `json:"item"`
@@ -75,10 +142,24 @@ func NewRegistry() *Registry {
}
// Register adds all contributions from a plugin.
// If the plugin already has registered contributions they are replaced
// (supports reload without duplicates).
func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
r.mu.Lock()
defer r.mu.Unlock()
// Remove existing contributions for this plugin to prevent duplicates on reload
r.views = removeViews(r.views, pluginID)
r.commands = removeCommands(r.commands, pluginID)
r.settingsPanels = removeSettingsPanels(r.settingsPanels, pluginID)
r.sidebarItems = removeSidebarItems(r.sidebarItems, pluginID)
r.fileActions = removeActions(r.fileActions, pluginID)
r.noteActions = removeActions(r.noteActions, pluginID)
r.contextMenus = removeContextMenus(r.contextMenus, pluginID)
r.searchProviders = removeSearchProviders(r.searchProviders, pluginID)
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
for _, item := range c.Views {
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
}
+301
View File
@@ -0,0 +1,301 @@
package contribution
import (
"testing"
"github.com/verstak/verstak-desktop/internal/core/plugin"
)
// TestRegister_AddsContributions registers sidebar, view, command, settings contributions
// for plugin "test.plugin" and verifies they appear via Views(), Commands(),
// SettingsPanels(), SidebarItems().
func TestRegister_AddsContributions(t *testing.T) {
r := NewRegistry()
contribs := &plugin.Contributions{
Views: []plugin.ContributionView{
{ID: "test.view1", Title: "View 1", Component: "TestComponent"},
},
Commands: []plugin.ContributionCommand{
{ID: "test.cmd1", Title: "Command 1"},
},
SettingsPanels: []plugin.ContributionSettingsPanel{
{ID: "test.settings1", Title: "Settings 1", Component: "SettingsComponent"},
},
SidebarItems: []plugin.ContributionSidebarItem{
{ID: "test.sidebar1", Title: "Sidebar 1", Icon: "icon", View: "test.view1", Position: 1},
},
}
r.Register("test.plugin", contribs)
// Verify counts
if got := len(r.Views()); got != 1 {
t.Errorf("Views(): got %d, want 1", got)
}
if got := len(r.Commands()); got != 1 {
t.Errorf("Commands(): got %d, want 1", got)
}
if got := len(r.SettingsPanels()); got != 1 {
t.Errorf("SettingsPanels(): got %d, want 1", got)
}
if got := len(r.SidebarItems()); got != 1 {
t.Errorf("SidebarItems(): got %d, want 1", got)
}
// Verify the PluginID is set correctly
if r.Views()[0].PluginID != "test.plugin" {
t.Errorf("Views()[0].PluginID = %q, want %q", r.Views()[0].PluginID, "test.plugin")
}
if r.Commands()[0].PluginID != "test.plugin" {
t.Errorf("Commands()[0].PluginID = %q, want %q", r.Commands()[0].PluginID, "test.plugin")
}
if r.SettingsPanels()[0].PluginID != "test.plugin" {
t.Errorf("SettingsPanels()[0].PluginID = %q, want %q", r.SettingsPanels()[0].PluginID, "test.plugin")
}
if r.SidebarItems()[0].PluginID != "test.plugin" {
t.Errorf("SidebarItems()[0].PluginID = %q, want %q", r.SidebarItems()[0].PluginID, "test.plugin")
}
// Verify item data is preserved
if r.Views()[0].Item.Title != "View 1" {
t.Errorf("Views()[0].Item.Title = %q, want %q", r.Views()[0].Item.Title, "View 1")
}
if r.Commands()[0].Item.Title != "Command 1" {
t.Errorf("Commands()[0].Item.Title = %q, want %q", r.Commands()[0].Item.Title, "Command 1")
}
if r.SettingsPanels()[0].Item.Title != "Settings 1" {
t.Errorf("SettingsPanels()[0].Item.Title = %q, want %q", r.SettingsPanels()[0].Item.Title, "Settings 1")
}
if r.SidebarItems()[0].Item.Title != "Sidebar 1" {
t.Errorf("SidebarItems()[0].Item.Title = %q, want %q", r.SidebarItems()[0].Item.Title, "Sidebar 1")
}
}
// TestUnregister_RemovesOwnedContributions registers for two plugins, unregisters one,
// and verifies only that plugin's contributions are removed.
func TestUnregister_RemovesOwnedContributions(t *testing.T) {
r := NewRegistry()
contribA := &plugin.Contributions{
Views: []plugin.ContributionView{
{ID: "a.view1", Title: "A View", Component: "A"},
},
Commands: []plugin.ContributionCommand{
{ID: "a.cmd1", Title: "A Command"},
},
SettingsPanels: []plugin.ContributionSettingsPanel{
{ID: "a.settings1", Title: "A Settings", Component: "A"},
},
SidebarItems: []plugin.ContributionSidebarItem{
{ID: "a.sidebar1", Title: "A Sidebar", View: "a.view1"},
},
}
contribB := &plugin.Contributions{
Views: []plugin.ContributionView{
{ID: "b.view1", Title: "B View", Component: "B"},
},
Commands: []plugin.ContributionCommand{
{ID: "b.cmd1", Title: "B Command"},
},
SettingsPanels: []plugin.ContributionSettingsPanel{
{ID: "b.settings1", Title: "B Settings", Component: "B"},
},
SidebarItems: []plugin.ContributionSidebarItem{
{ID: "b.sidebar1", Title: "B Sidebar", View: "b.view1"},
},
}
r.Register("plugin.a", contribA)
r.Register("plugin.b", contribB)
// Unregister plugin.a
r.Unregister("plugin.a")
// Verify plugin.a contributions are removed
if got := r.Views(); len(got) != 1 || got[0].PluginID != "plugin.b" {
t.Errorf("Views: got %d items (first PluginID=%q), want 1 from plugin.b", len(got), safePluginIDView(got))
}
if got := r.Commands(); len(got) != 1 || got[0].PluginID != "plugin.b" {
t.Errorf("Commands: got %d items (first PluginID=%q), want 1 from plugin.b", len(got), safePluginIDCmd(got))
}
if got := r.SettingsPanels(); len(got) != 1 || got[0].PluginID != "plugin.b" {
t.Errorf("SettingsPanels: got %d items (first PluginID=%q), want 1 from plugin.b", len(got), safePluginIDSettings(got))
}
if got := r.SidebarItems(); len(got) != 1 || got[0].PluginID != "plugin.b" {
t.Errorf("SidebarItems: got %d items (first PluginID=%q), want 1 from plugin.b", len(got), safePluginIDSidebar(got))
}
// Verify plugin.b data is intact
if r.Views()[0].Item.ID != "b.view1" {
t.Errorf("Remaining View ID: got %q, want %q", r.Views()[0].Item.ID, "b.view1")
}
if r.Commands()[0].Item.ID != "b.cmd1" {
t.Errorf("Remaining Command ID: got %q, want %q", r.Commands()[0].Item.ID, "b.cmd1")
}
if r.SettingsPanels()[0].Item.ID != "b.settings1" {
t.Errorf("Remaining SettingsPanel ID: got %q, want %q", r.SettingsPanels()[0].Item.ID, "b.settings1")
}
if r.SidebarItems()[0].Item.ID != "b.sidebar1" {
t.Errorf("Remaining SidebarItem ID: got %q, want %q", r.SidebarItems()[0].Item.ID, "b.sidebar1")
}
}
// safe helpers for error messages when slices are empty
func safePluginIDView(items []ContributionView) string {
if len(items) == 0 {
return "<empty>"
}
return items[0].PluginID
}
func safePluginIDCmd(items []ContributionCommand) string {
if len(items) == 0 {
return "<empty>"
}
return items[0].PluginID
}
func safePluginIDSettings(items []ContributionSettingsPanel) string {
if len(items) == 0 {
return "<empty>"
}
return items[0].PluginID
}
func safePluginIDSidebar(items []ContributionSidebarItem) string {
if len(items) == 0 {
return "<empty>"
}
return items[0].PluginID
}
// TestListByPoint registers various types and calls ListByPoint for each point type,
// verifying correct counts.
func TestListByPoint(t *testing.T) {
r := NewRegistry()
contrib := &plugin.Contributions{
Views: []plugin.ContributionView{{ID: "v1", Title: "V1", Component: "C"}},
Commands: []plugin.ContributionCommand{{ID: "c1", Title: "C1"}},
SettingsPanels: []plugin.ContributionSettingsPanel{{ID: "s1", Title: "S1", Component: "C"}},
SidebarItems: []plugin.ContributionSidebarItem{{ID: "si1", Title: "SI1", View: "v1"}},
FileActions: []plugin.ContributionAction{{ID: "fa1", Label: "FA1"}},
NoteActions: []plugin.ContributionAction{{ID: "na1", Label: "NA1"}},
ContextMenuEntries: []plugin.ContributionContextMenuEntry{{ID: "cm1", Label: "CM1", Context: "file"}},
SearchProviders: []plugin.ContributionSearchProvider{{ID: "sp1", Label: "SP1", Handler: "h"}},
ActivityProviders: []plugin.ContributionActivityProvider{{ID: "ap1", Events: []string{"test"}, Handler: "h"}},
StatusBarItems: []plugin.ContributionStatusBarItem{{ID: "sb1", Label: "SB1"}},
}
r.Register("test.plugin", contrib)
tests := []struct {
point ContributionPointType
want int
}{
{PointViews, 1},
{PointCommands, 1},
{PointSettingsPanels, 1},
{PointSidebarItems, 1},
{PointFileActions, 1},
{PointNoteActions, 1},
{PointContextMenus, 1},
{PointSearchProviders, 1},
{PointActivity, 1},
{PointStatusBar, 1},
}
for _, tt := range tests {
got := r.ListByPoint(tt.point)
if len(got) != tt.want {
t.Errorf("ListByPoint(%q): got %d items, want %d", tt.point, len(got), tt.want)
}
}
}
// TestRegister_DuplicatePrevention calls Register twice for the same plugin
// (simulating reload) and checks contributions appear only once (no duplicates).
// This is the KEY TEST for idempotent re-registration.
func TestRegister_DuplicatePrevention(t *testing.T) {
r := NewRegistry()
contrib := &plugin.Contributions{
Views: []plugin.ContributionView{
{ID: "test.view1", Title: "View 1", Component: "C"},
},
Commands: []plugin.ContributionCommand{
{ID: "test.cmd1", Title: "Cmd 1"},
},
SettingsPanels: []plugin.ContributionSettingsPanel{
{ID: "test.settings1", Title: "Settings 1", Component: "C"},
},
SidebarItems: []plugin.ContributionSidebarItem{
{ID: "test.sidebar1", Title: "Sidebar 1", View: "test.view1"},
},
}
// First registration
r.Register("test.plugin", contrib)
// Second registration — simulates plugin reload
r.Register("test.plugin", contrib)
// Each type should have only 1 entry (no duplicates)
if got := len(r.Views()); got != 1 {
t.Errorf("Views after double Register: got %d, want 1 (no duplicates)", got)
}
if got := len(r.Commands()); got != 1 {
t.Errorf("Commands after double Register: got %d, want 1 (no duplicates)", got)
}
if got := len(r.SettingsPanels()); got != 1 {
t.Errorf("SettingsPanels after double Register: got %d, want 1 (no duplicates)", got)
}
if got := len(r.SidebarItems()); got != 1 {
t.Errorf("SidebarItems after double Register: got %d, want 1 (no duplicates)", got)
}
// Also verify the item data is preserved correctly
if r.Views()[0].Item.ID != "test.view1" {
t.Errorf("View ID after reload: got %q, want %q", r.Views()[0].Item.ID, "test.view1")
}
if r.Commands()[0].Item.ID != "test.cmd1" {
t.Errorf("Command ID after reload: got %q, want %q", r.Commands()[0].Item.ID, "test.cmd1")
}
if r.SettingsPanels()[0].Item.ID != "test.settings1" {
t.Errorf("SettingsPanel ID after reload: got %q, want %q", r.SettingsPanels()[0].Item.ID, "test.settings1")
}
if r.SidebarItems()[0].Item.ID != "test.sidebar1" {
t.Errorf("SidebarItem ID after reload: got %q, want %q", r.SidebarItems()[0].Item.ID, "test.sidebar1")
}
}
// TestUnregister_NoSideEffects verifies that unregistering a non-existent plugin
// doesn't crash or corrupt the registry.
func TestUnregister_NoSideEffects(t *testing.T) {
r := NewRegistry()
// Register a plugin
contrib := &plugin.Contributions{
Views: []plugin.ContributionView{
{ID: "v1", Title: "V1", Component: "C"},
},
}
r.Register("existing.plugin", contrib)
// Unregister a plugin that was never registered — should not panic
r.Unregister("nonexistent.plugin")
// Existing plugin's contributions should still be intact
if got := len(r.Views()); got != 1 {
t.Errorf("Views after unregistering non-existent: got %d, want 1", got)
}
if r.Views()[0].PluginID != "existing.plugin" {
t.Errorf("PluginID after unregistering non-existent: got %q, want %q", r.Views()[0].PluginID, "existing.plugin")
}
// Unregister with empty string — should not panic
r.Unregister("")
// Still intact
if got := len(r.Views()); got != 1 {
t.Errorf("Views after unregistering empty string: got %d, want 1", got)
}
}