diff --git a/cmd/smoke-platform/main.go b/cmd/smoke-platform/main.go index 90cc948..abeb330 100644 --- a/cmd/smoke-platform/main.go +++ b/cmd/smoke-platform/main.go @@ -9,6 +9,7 @@ import ( "path/filepath" "github.com/verstak/verstak-desktop/internal/core/capability" + "github.com/verstak/verstak-desktop/internal/core/contribution" "github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/pluginstate" "github.com/verstak/verstak-desktop/internal/core/vault" @@ -18,6 +19,7 @@ import ( func main() { testEnableDisable := flag.Bool("test-enable-disable", false, "Test enable/disable lifecycle") testWorkspace := flag.Bool("test-workspace", false, "Test workspace/cases lifecycle") + testContributions := flag.Bool("test-contributions", false, "Test contribution registry lifecycle") flag.Parse() exitCode := 0 defer func() { @@ -32,6 +34,11 @@ func main() { return } + if *testContributions { + runContributionsTest(root) + return + } + if *testEnableDisable { runEnableDisableTest(root) return @@ -459,7 +466,259 @@ func runEnableDisableTest(root string) { fmt.Printf("✅ enable/disable test passed\n") } -// runWorkspaceTest tests the workspace/cases lifecycle. +// runContributionsTest tests the contribution registry lifecycle: +// 1. Creates a vault, discovers platform-test +// 2. Registers capabilities + contributions +// 3. Verifies contributions appear by name +// 4. Disables plugin → unregisters contributions → verifies gone +// 5. Re-enables → contributions return +// 6. Checks no duplicates after reload +func runContributionsTest(root string) { + exitCode := 0 + defer func() { + os.Exit(exitCode) + }() + + fmt.Printf("=== smoke-platform: contribution registry test ===\n\n") + + // Create a temp vault + tmpDir, err := os.MkdirTemp("", "verstak-smoke-*") + if err != nil { + fmt.Printf(" ❌ failed to create temp dir: %v\n", err) + exitCode = 1 + return + } + defer os.RemoveAll(tmpDir) + + vaultPath := filepath.Join(tmpDir, "testvault") + fmt.Printf(" vault path: %s\n", vaultPath) + + v := vault.NewVault(nil) + if err := v.CreateVault(vaultPath); err != nil { + fmt.Printf(" ❌ create vault: %v\n", err) + exitCode = 1 + return + } + fmt.Printf(" ✅ vault created\n") + + openedPath := v.GetVaultPath() + if err := v.OpenVault(openedPath); err != nil { + fmt.Printf(" ❌ open vault: %v\n", err) + exitCode = 1 + return + } + fmt.Printf(" ✅ vault opened at %s\n", openedPath) + + // Initialize plugin state + psm := pluginstate.NewManager(v) + if err := psm.Load(); err != nil { + fmt.Printf(" ❌ load plugin state: %v\n", err) + exitCode = 1 + return + } + fmt.Printf(" ✅ plugin state loaded\n") + + // Discover plugins + pluginDir := filepath.Join(root, "plugins") + plugins, _ := plugin.DiscoverPlugins([]string{pluginDir}) + if len(plugins) == 0 { + fmt.Printf(" ❌ no plugins discovered\n") + exitCode = 1 + return + } + fmt.Printf(" ✅ discovered %d plugin(s)\n", len(plugins)) + + // Find platform-test + var target *plugin.Plugin + for i, p := range plugins { + if p.Manifest.ID == "verstak.platform-test" { + target = &plugins[i] + break + } + } + if target == nil { + fmt.Printf(" ❌ platform-test not found\n") + exitCode = 1 + return + } + fmt.Printf(" ✅ platform-test found: %s@%s\n", target.Manifest.ID, target.Manifest.Version) + + // Register capabilities + reg := capability.NewRegistry() + coreCaps := []string{ + "verstak/core/plugin-manager/v1", + "verstak/core/capability-registry/v1", + "verstak/core/contribution-registry/v1", + "verstak/core/permissions/v1", + "verstak/core/events/v1", + } + _ = reg.Register("verstak-desktop", coreCaps) + _ = reg.Register("verstak-desktop", []string{"verstak/core/vault/v1"}) + for _, cap := range target.Manifest.Provides { + _ = reg.Register(target.Manifest.ID, []string{cap}) + } + totalCaps := len(reg.List()) + fmt.Printf(" ✅ registered %d capabilities (core + plugin)\n", totalCaps) + + // ── 1. Register contributions from platform-test ── + fmt.Printf("\n[register contributions]\n") + contribReg := contribution.NewRegistry() + if target.Manifest.Contributes == nil { + fmt.Printf(" ❌ platform-test has no contributions in manifest\n") + exitCode = 1 + return + } + contribReg.Register(target.Manifest.ID, target.Manifest.Contributes) + fmt.Printf(" ✅ contributions registered for %s\n", target.Manifest.ID) + + // ── 2. Verify contributions appear by name ── + fmt.Printf("\n[verify contributions]\n") + allGood := true + + // SidebarItems + sidebarItems := contribReg.SidebarItems() + sidebarNames := make([]string, len(sidebarItems)) + for i, item := range sidebarItems { + sidebarNames[i] = item.Item.Title + } + fmt.Printf(" sidebarItems (%d): %v\n", len(sidebarItems), sidebarNames) + if len(sidebarItems) == 0 { + fmt.Printf(" ❌ no sidebarItems registered\n") + allGood = false + } + + // Views + views := contribReg.Views() + viewNames := make([]string, len(views)) + for i, v := range views { + viewNames[i] = v.Item.Title + } + fmt.Printf(" views (%d): %v\n", len(views), viewNames) + if len(views) == 0 { + fmt.Printf(" ❌ no views registered\n") + allGood = false + } + + // SettingsPanels + settingsPanels := contribReg.SettingsPanels() + settingsNames := make([]string, len(settingsPanels)) + for i, s := range settingsPanels { + settingsNames[i] = s.Item.Title + } + fmt.Printf(" settingsPanels (%d): %v\n", len(settingsPanels), settingsNames) + if len(settingsPanels) == 0 { + fmt.Printf(" ❌ no settingsPanels registered\n") + allGood = false + } + + // Commands + commands := contribReg.Commands() + cmdNames := make([]string, len(commands)) + for i, c := range commands { + cmdNames[i] = c.Item.Title + } + fmt.Printf(" commands (%d): %v\n", len(commands), cmdNames) + if len(commands) == 0 { + fmt.Printf(" ❌ no commands registered\n") + allGood = false + } + + if !allGood { + fmt.Printf(" ❌ some contribution types are missing\n") + exitCode = 1 + return + } + fmt.Printf(" ✅ all contribution types present\n") + + // ── 3. Disable plugin → contributions unregistered ── + fmt.Printf("\n[disable plugin → unregister contributions]\n") + if err := psm.DisablePlugin("verstak.platform-test"); err != nil { + fmt.Printf(" ❌ disable: %v\n", err) + exitCode = 1 + return + } + fmt.Printf(" ✅ disabled platform-test\n") + + contribReg.Unregister("verstak.platform-test") + remainingViews := len(contribReg.Views()) + remainingSidebar := len(contribReg.SidebarItems()) + remainingCommands := len(contribReg.Commands()) + remainingSettings := len(contribReg.SettingsPanels()) + fmt.Printf(" remaining: views=%d sidebar=%d commands=%d settings=%d\n", + remainingViews, remainingSidebar, remainingCommands, remainingSettings) + + if remainingViews+remainingSidebar+remainingCommands+remainingSettings != 0 { + fmt.Printf(" ❌ some contributions not unregistered\n") + exitCode = 1 + return + } + fmt.Printf(" ✅ all contributions unregistered\n") + + // ── 4. Re-enable → contributions return ── + fmt.Printf("\n[re-enable plugin → register contributions]\n") + if err := psm.EnablePlugin("verstak.platform-test"); err != nil { + fmt.Printf(" ❌ enable: %v\n", err) + exitCode = 1 + return + } + fmt.Printf(" ✅ enabled platform-test\n") + + contribReg.Register(target.Manifest.ID, target.Manifest.Contributes) + views2 := contribReg.Views() + sidebar2 := contribReg.SidebarItems() + commands2 := contribReg.Commands() + settings2 := contribReg.SettingsPanels() + + fmt.Printf(" after re-register: views=%d sidebar=%d commands=%d settings=%d\n", + len(views2), len(sidebar2), len(commands2), len(settings2)) + + if len(views2) == 0 || len(sidebar2) == 0 || len(commands2) == 0 || len(settings2) == 0 { + fmt.Printf(" ❌ contributions did not return after re-enable\n") + exitCode = 1 + return + } + fmt.Printf(" ✅ contributions returned after re-register\n") + + // ── 5. Re-register (simulate reload) → no duplicates ── + fmt.Printf("\n[re-register (reload) → no duplicates]\n") + contribReg.Register(target.Manifest.ID, target.Manifest.Contributes) + views3 := contribReg.Views() + sidebar3 := contribReg.SidebarItems() + commands3 := contribReg.Commands() + settings3 := contribReg.SettingsPanels() + + fmt.Printf(" after reload: views=%d sidebar=%d commands=%d settings=%d\n", + len(views3), len(sidebar3), len(commands3), len(settings3)) + + if len(views3) != len(views2) { + fmt.Printf(" ❌ duplicate views after reload: before=%d, after=%d\n", len(views2), len(views3)) + allGood = false + } + if len(sidebar3) != len(sidebar2) { + fmt.Printf(" ❌ duplicate sidebarItems after reload: before=%d, after=%d\n", len(sidebar2), len(sidebar3)) + allGood = false + } + if len(commands3) != len(commands2) { + fmt.Printf(" ❌ duplicate commands after reload: before=%d, after=%d\n", len(commands2), len(commands3)) + allGood = false + } + if len(settings3) != len(settings2) { + fmt.Printf(" ❌ duplicate settingsPanels after reload: before=%d, after=%d\n", len(settings2), len(settings3)) + allGood = false + } + + if !allGood { + fmt.Printf(" ❌ some duplicates detected\n") + exitCode = 1 + return + } + fmt.Printf(" ✅ no duplicates after reload\n") + + // ── Summary ── + fmt.Printf("\n=== summary ===\n") + fmt.Printf("✅ contribution registry test passed\n") +} + func runWorkspaceTest(root string) { exitCode := 0 defer func() { diff --git a/docs/PLUGIN_RUNTIME.md b/docs/PLUGIN_RUNTIME.md index 98aaa14..b2a69b8 100644 --- a/docs/PLUGIN_RUNTIME.md +++ b/docs/PLUGIN_RUNTIME.md @@ -161,30 +161,95 @@ foreach plugin: ## Contribution Points -Плагины могут регистрировать UI contributions через поле `contributes`: +Плагины могут регистрировать UI contributions через поле `contributes` в `plugin.json`. -| Тип | Описание | -|---|---| -| `views` | Панели/страницы (component — Svelte) | -| `commands` | Команды command palette | -| `settingsPanels` | Панели в Settings | -| `sidebarItems` | Элементы боковой панели | -| `fileActions` | Действия над файлами | -| `noteActions` | Действия над заметками | -| `contextMenuEntries` | Пункты контекстного меню | -| `searchProviders` | Провайдеры поиска | -| `activityProviders` | Провайдеры активности | -| `statusBarItems` | Элементы status bar | +### Реализованные contribution points (Milestone 5a) + +| Тип | Поле manifest | Описание | Frontend host | +|---|---|---|---| +| Боковая панель | `sidebarItems` | Элементы в sidebar слева | ✅ Sidebar.svelte (из ContributionRegistry) | +| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (placeholder — frontend bundle host not implemented) | +| Панели настроек | `settingsPanels` | Панели в Plugin Manager | ✅ PluginManager.svelte (кнопка Settings, открывает modal) | +| Команды | `commands` | Команды для command palette | ✅ ContributionRegistry (UI command palette не реализован) | + +### Планируемые contribution points + +| Тип | Поле manifest | Статус | +|---|---|---| +| Действия над файлами | `fileActions` | Registry готов, UI не реализован | +| Действия над заметками | `noteActions` | Registry готов, UI не реализован | +| Контекстное меню | `contextMenuEntries` | Registry готов, UI не реализован | +| Провайдеры поиска | `searchProviders` | Registry готов, UI не реализован | +| Провайдеры активности | `activityProviders` | Registry готов, UI не реализован | +| Элементы status bar | `statusBarItems` | Registry готов, UI не реализован | + +### Структура contribution points в manifest + +```json +{ + "contributes": { + "sidebarItems": [ + { + "id": "mypanel.sidebar", + "title": "My Panel", + "icon": "📌", + "view": "mypanel.view", + "position": 100 + } + ], + "views": [ + { + "id": "mypanel.view", + "title": "My Panel View", + "icon": "📌", + "component": "MyPanelComponent" + } + ], + "settingsPanels": [ + { + "id": "mypanel.settings", + "title": "My Settings", + "component": "MySettingsPanel" + } + ], + "commands": [ + { + "id": "mypanel.cmd", + "title": "Do Something", + "icon": "⚡", + "handler": "doSomething" + } + ] + } +} +``` + +### Contribution lifecycle + +1. Plugin `Register(pluginID, contributions)` — все contributions регистрируются +2. `Unregister(pluginID)` — удаляет все contributions указанного plugin +3. Reload: `Unregister → Register` (предотвращает дублирование) +4. Disable plugin → `Unregister` (contributions исчезают из UI) +5. Enable plugin → `Register` при следующем Reload +6. Registry idempotent: Register удаляет старые записи перед добавлением новых + +### Error boundary + +- Ошибка в plugin view/settings placeholder не роняет shell +- ViewContainer показывает "⚠️ Plugin UI failed" fallback +- Error канал: `console.error` + видимый fallback в UI ## Reload `ReloadPlugins()` в `internal/api/app.go` позволяет перезагрузить plugins без перезапуска приложения: -1. Unregister all capabilities (кроме core). -2. Re-register core capabilities. +1. Unregister all non-core capabilities. +2. Re-register core capabilities + vault + workspace (если открыт). 3. Re-scan discovery directories. -4. Re-run capability resolution. -5. Re-register contributions. +4. For each plugin: re-run capability resolution. +5. **Unregister contributions** before re-registering (предотвращает дубли). +6. Register contributions for loaded/degraded plugins (disabled/failed — не регистрируются). +7. Update plugins list. Frontend вызывает это при нажатии "Reload" в Plugin Manager. diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 6d1c264..5af2804 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -10,6 +10,11 @@ let needsVaultSelection = false; let loading = true; + let activeView = null; + let activeViewPluginId = ''; + let activeSettingsPluginId = ''; + let activeSettingsPanelId = ''; + async function checkVault() { loading = true; try { @@ -37,10 +42,30 @@ currentView = e.detail.viewId; } - // Listen for vault-opened event from VaultSelection + function onOpenView(e) { + activeView = e.detail.viewId; + activeViewPluginId = e.detail.pluginId || ''; + currentView = 'plugin-view'; + } + + function onOpenSettings(e) { + activeSettingsPluginId = e.detail.pluginId; + activeSettingsPanelId = e.detail.panelId || ''; + currentView = 'plugin-manager'; + } + + function onCloseSettings() { + activeSettingsPluginId = ''; + activeSettingsPanelId = ''; + } + + // Listen for events if (typeof window !== 'undefined') { window.addEventListener('verstak:vault-opened', onVaultOpened); window.addEventListener('verstak:nav', onNav); + window.addEventListener('verstak:open-view', onOpenView); + window.addEventListener('verstak:open-settings', onOpenSettings); + window.addEventListener('verstak:close-settings', onCloseSettings); } checkVault(); @@ -58,9 +83,9 @@
{#if currentView === 'plugin-manager'} - + {:else} - + {/if}
diff --git a/frontend/src/lib/plugin-manager/PluginCard.svelte b/frontend/src/lib/plugin-manager/PluginCard.svelte index 8b412ee..7aad3e9 100644 --- a/frontend/src/lib/plugin-manager/PluginCard.svelte +++ b/frontend/src/lib/plugin-manager/PluginCard.svelte @@ -4,13 +4,13 @@ export let permissions = []; export let contributions = {}; export let vaultOpen = false; - export let onSettings = () => {}; + export let settingsPanels = []; export let onEnable = () => {}; export let onDisable = () => {}; $: m = p.manifest || {}; $: pluginId = m.id || 'unknown'; - $: hasSettingsPanel = (contributions.settingsPanels || []).some(sp => sp.pluginId === pluginId); + $: hasSettingsPanel = settingsPanels.length > 0; $: hasUIPermission = (m.permissions || []).includes('ui.register'); $: hasStoragePermission = (m.permissions || []).includes('storage.namespace'); $: hasCommandsPermission = (m.permissions || []).includes('commands.register'); @@ -174,7 +174,7 @@
{#if hasSettingsPanel} - {/if} diff --git a/frontend/src/lib/plugin-manager/PluginManager.svelte b/frontend/src/lib/plugin-manager/PluginManager.svelte index 97ecdd8..a15d9e2 100644 --- a/frontend/src/lib/plugin-manager/PluginManager.svelte +++ b/frontend/src/lib/plugin-manager/PluginManager.svelte @@ -1,7 +1,7 @@ @@ -160,7 +173,7 @@ {:else}
{#each plugins as p} - + sp.pluginId === p.manifest?.id)} onEnable={enablePlugin} onDisable={disablePlugin} /> {/each}
{/if} @@ -215,12 +228,25 @@ {/if} - {#if settingsPanel} -