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:
parent
9bb35a9fd0
commit
86eeadd2a9
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/verstak/verstak-desktop/internal/core/capability"
|
"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/plugin"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||||
|
|
@ -18,6 +19,7 @@ import (
|
||||||
func main() {
|
func main() {
|
||||||
testEnableDisable := flag.Bool("test-enable-disable", false, "Test enable/disable lifecycle")
|
testEnableDisable := flag.Bool("test-enable-disable", false, "Test enable/disable lifecycle")
|
||||||
testWorkspace := flag.Bool("test-workspace", false, "Test workspace/cases lifecycle")
|
testWorkspace := flag.Bool("test-workspace", false, "Test workspace/cases lifecycle")
|
||||||
|
testContributions := flag.Bool("test-contributions", false, "Test contribution registry lifecycle")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
exitCode := 0
|
exitCode := 0
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
@ -32,6 +34,11 @@ func main() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *testContributions {
|
||||||
|
runContributionsTest(root)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if *testEnableDisable {
|
if *testEnableDisable {
|
||||||
runEnableDisableTest(root)
|
runEnableDisableTest(root)
|
||||||
return
|
return
|
||||||
|
|
@ -459,7 +466,259 @@ func runEnableDisableTest(root string) {
|
||||||
fmt.Printf("✅ enable/disable test passed\n")
|
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) {
|
func runWorkspaceTest(root string) {
|
||||||
exitCode := 0
|
exitCode := 0
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
|
||||||
|
|
@ -161,30 +161,95 @@ foreach plugin:
|
||||||
|
|
||||||
## Contribution Points
|
## Contribution Points
|
||||||
|
|
||||||
Плагины могут регистрировать UI contributions через поле `contributes`:
|
Плагины могут регистрировать UI contributions через поле `contributes` в `plugin.json`.
|
||||||
|
|
||||||
| Тип | Описание |
|
### Реализованные contribution points (Milestone 5a)
|
||||||
|---|---|
|
|
||||||
| `views` | Панели/страницы (component — Svelte) |
|
| Тип | Поле manifest | Описание | Frontend host |
|
||||||
| `commands` | Команды command palette |
|
|---|---|---|---|
|
||||||
| `settingsPanels` | Панели в Settings |
|
| Боковая панель | `sidebarItems` | Элементы в sidebar слева | ✅ Sidebar.svelte (из ContributionRegistry) |
|
||||||
| `sidebarItems` | Элементы боковой панели |
|
| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (placeholder — frontend bundle host not implemented) |
|
||||||
| `fileActions` | Действия над файлами |
|
| Панели настроек | `settingsPanels` | Панели в Plugin Manager | ✅ PluginManager.svelte (кнопка Settings, открывает modal) |
|
||||||
| `noteActions` | Действия над заметками |
|
| Команды | `commands` | Команды для command palette | ✅ ContributionRegistry (UI command palette не реализован) |
|
||||||
| `contextMenuEntries` | Пункты контекстного меню |
|
|
||||||
| `searchProviders` | Провайдеры поиска |
|
### Планируемые contribution points
|
||||||
| `activityProviders` | Провайдеры активности |
|
|
||||||
| `statusBarItems` | Элементы status bar |
|
| Тип | Поле 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
|
## Reload
|
||||||
|
|
||||||
`ReloadPlugins()` в `internal/api/app.go` позволяет перезагрузить plugins без перезапуска приложения:
|
`ReloadPlugins()` в `internal/api/app.go` позволяет перезагрузить plugins без перезапуска приложения:
|
||||||
|
|
||||||
1. Unregister all capabilities (кроме core).
|
1. Unregister all non-core capabilities.
|
||||||
2. Re-register core capabilities.
|
2. Re-register core capabilities + vault + workspace (если открыт).
|
||||||
3. Re-scan discovery directories.
|
3. Re-scan discovery directories.
|
||||||
4. Re-run capability resolution.
|
4. For each plugin: re-run capability resolution.
|
||||||
5. Re-register contributions.
|
5. **Unregister contributions** before re-registering (предотвращает дубли).
|
||||||
|
6. Register contributions for loaded/degraded plugins (disabled/failed — не регистрируются).
|
||||||
|
7. Update plugins list.
|
||||||
|
|
||||||
Frontend вызывает это при нажатии "Reload" в Plugin Manager.
|
Frontend вызывает это при нажатии "Reload" в Plugin Manager.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@
|
||||||
let needsVaultSelection = false;
|
let needsVaultSelection = false;
|
||||||
let loading = true;
|
let loading = true;
|
||||||
|
|
||||||
|
let activeView = null;
|
||||||
|
let activeViewPluginId = '';
|
||||||
|
let activeSettingsPluginId = '';
|
||||||
|
let activeSettingsPanelId = '';
|
||||||
|
|
||||||
async function checkVault() {
|
async function checkVault() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
|
|
@ -37,10 +42,30 @@
|
||||||
currentView = e.detail.viewId;
|
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') {
|
if (typeof window !== 'undefined') {
|
||||||
window.addEventListener('verstak:vault-opened', onVaultOpened);
|
window.addEventListener('verstak:vault-opened', onVaultOpened);
|
||||||
window.addEventListener('verstak:nav', onNav);
|
window.addEventListener('verstak:nav', onNav);
|
||||||
|
window.addEventListener('verstak:open-view', onOpenView);
|
||||||
|
window.addEventListener('verstak:open-settings', onOpenSettings);
|
||||||
|
window.addEventListener('verstak:close-settings', onCloseSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkVault();
|
checkVault();
|
||||||
|
|
@ -58,9 +83,9 @@
|
||||||
|
|
||||||
<section class="content">
|
<section class="content">
|
||||||
{#if currentView === 'plugin-manager'}
|
{#if currentView === 'plugin-manager'}
|
||||||
<PluginManager />
|
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
|
||||||
{:else}
|
{:else}
|
||||||
<ViewContainer />
|
<ViewContainer {activeView} {activeViewPluginId} />
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@
|
||||||
export let permissions = [];
|
export let permissions = [];
|
||||||
export let contributions = {};
|
export let contributions = {};
|
||||||
export let vaultOpen = false;
|
export let vaultOpen = false;
|
||||||
export let onSettings = () => {};
|
export let settingsPanels = [];
|
||||||
export let onEnable = () => {};
|
export let onEnable = () => {};
|
||||||
export let onDisable = () => {};
|
export let onDisable = () => {};
|
||||||
|
|
||||||
$: m = p.manifest || {};
|
$: m = p.manifest || {};
|
||||||
$: pluginId = m.id || 'unknown';
|
$: pluginId = m.id || 'unknown';
|
||||||
$: hasSettingsPanel = (contributions.settingsPanels || []).some(sp => sp.pluginId === pluginId);
|
$: hasSettingsPanel = settingsPanels.length > 0;
|
||||||
$: hasUIPermission = (m.permissions || []).includes('ui.register');
|
$: hasUIPermission = (m.permissions || []).includes('ui.register');
|
||||||
$: hasStoragePermission = (m.permissions || []).includes('storage.namespace');
|
$: hasStoragePermission = (m.permissions || []).includes('storage.namespace');
|
||||||
$: hasCommandsPermission = (m.permissions || []).includes('commands.register');
|
$: hasCommandsPermission = (m.permissions || []).includes('commands.register');
|
||||||
|
|
@ -174,7 +174,7 @@
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
{#if hasSettingsPanel}
|
{#if hasSettingsPanel}
|
||||||
<button class="btn-settings" on:click={() => onSettings(m.id)} type="button">
|
<button class="btn-settings" on:click={() => window.dispatchEvent(new CustomEvent('verstak:open-settings', { detail: { pluginId: m.id, panelId: settingsPanels[0]?.id } }))} type="button" disabled={isDisabled || p.status === 'failed'}>
|
||||||
⚙ Settings
|
⚙ Settings
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import PluginCard from './PluginCard.svelte';
|
import PluginCard from './PluginCard.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin } from '../../../wailsjs/go/api/App';
|
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin, ReadPluginSettings, WritePluginSettings } from '../../../wailsjs/go/api/App';
|
||||||
|
|
||||||
let plugins = [];
|
let plugins = [];
|
||||||
let capabilities = [];
|
let capabilities = [];
|
||||||
|
|
@ -14,6 +14,31 @@
|
||||||
let settingsPanel = null;
|
let settingsPanel = null;
|
||||||
let settingsData = {};
|
let settingsData = {};
|
||||||
let settingsPluginId = '';
|
let settingsPluginId = '';
|
||||||
|
let settingsError = null;
|
||||||
|
let lastOpenedKey = '';
|
||||||
|
|
||||||
|
export let activeSettingsPluginId = '';
|
||||||
|
export let activeSettingsPanelId = '';
|
||||||
|
|
||||||
|
$: if (activeSettingsPluginId && activeSettingsPanelId) {
|
||||||
|
const key = `${activeSettingsPluginId}:${activeSettingsPanelId}`;
|
||||||
|
if (key !== lastOpenedKey) {
|
||||||
|
lastOpenedKey = key;
|
||||||
|
openSettingsFromProps(activeSettingsPluginId, activeSettingsPanelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettingsFromProps(pluginId, panelId) {
|
||||||
|
const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId && (!panelId || sp.id === panelId));
|
||||||
|
if (panel) {
|
||||||
|
settingsPanel = panel;
|
||||||
|
settingsPluginId = pluginId;
|
||||||
|
settingsError = null;
|
||||||
|
ReadPluginSettings(pluginId).then(data => {
|
||||||
|
settingsData = data || {};
|
||||||
|
}).catch(() => { settingsData = {}; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$: vaultOpen = vaultStatus.status === 'open';
|
$: vaultOpen = vaultStatus.status === 'open';
|
||||||
$: missingInstalled = computeMissingInstalled();
|
$: missingInstalled = computeMissingInstalled();
|
||||||
|
|
@ -85,30 +110,18 @@
|
||||||
$: totalCaps = capabilities.length;
|
$: totalCaps = capabilities.length;
|
||||||
$: totalPerms = permissions.length;
|
$: totalPerms = permissions.length;
|
||||||
|
|
||||||
function openSettings(pluginId) {
|
function closeSettings() {
|
||||||
const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId);
|
settingsPanel = null;
|
||||||
if (panel) {
|
settingsPluginId = '';
|
||||||
settingsPanel = panel;
|
settingsError = null;
|
||||||
settingsPluginId = pluginId;
|
lastOpenedKey = '';
|
||||||
// Load existing settings from Wails backend
|
window.dispatchEvent(new CustomEvent('verstak:close-settings'));
|
||||||
import('../../../wailsjs/go/api/App').then(mod => {
|
|
||||||
mod.ReadPluginSettings(pluginId).then(data => {
|
|
||||||
settingsData = data || {};
|
|
||||||
}).catch(() => { settingsData = {}; });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSettings() {
|
function saveSettings() {
|
||||||
try {
|
WritePluginSettings(settingsPluginId, settingsData).then(err => {
|
||||||
import('../../../wailsjs/go/api/App').then(mod => {
|
if (err) console.error('WritePluginSettings:', err);
|
||||||
mod.WritePluginSettings(settingsPluginId, settingsData).then(err => {
|
}).catch(e => console.error('WritePluginSettings:', e));
|
||||||
if (err) console.error('WritePluginSettings:', err);
|
|
||||||
}).catch(e => console.error('WritePluginSettings:', e));
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('saveSettings:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -160,7 +173,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<div class="plugin-list">
|
<div class="plugin-list">
|
||||||
{#each plugins as p}
|
{#each plugins as p}
|
||||||
<PluginCard {p} {capabilities} {permissions} {contributions} {vaultOpen} onSettings={openSettings} onEnable={enablePlugin} onDisable={disablePlugin} />
|
<PluginCard {p} {capabilities} {permissions} {contributions} {vaultOpen} settingsPanels={(contributions.settingsPanels || []).filter(sp => sp.pluginId === p.manifest?.id)} onEnable={enablePlugin} onDisable={disablePlugin} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -215,12 +228,25 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Settings Panel Modal -->
|
<!-- Settings Panel Modal -->
|
||||||
{#if settingsPanel}
|
{#key `settings-${settingsPluginId}`}
|
||||||
<div class="modal-overlay" on:click|self={() => settingsPanel = null}>
|
{#if settingsError}
|
||||||
|
<div class="modal-overlay" on:click|self={closeSettings} on:keydown|self={(e) => e.key === 'Escape' && closeSettings()} role="presentation">
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-label="Settings Error">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Settings Error</h3>
|
||||||
|
<button class="modal-close" on:click={closeSettings} type="button">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="error" style="color: #e94560;">{settingsError}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if settingsPanel}
|
||||||
|
<div class="modal-overlay" on:click|self={closeSettings} on:keydown|self={(e) => e.key === 'Escape' && closeSettings()} role="presentation">
|
||||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Plugin Settings">
|
<div class="modal" role="dialog" aria-modal="true" aria-label="Plugin Settings">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>{settingsPanel.item.title}</h3>
|
<h3>{settingsPanel.item.title}</h3>
|
||||||
<button class="modal-close" on:click={() => settingsPanel = null} type="button">✕</button>
|
<button class="modal-close" on:click={closeSettings} type="button">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p class="settings-hint">Plugin: <code>{settingsPluginId}</code></p>
|
<p class="settings-hint">Plugin: <code>{settingsPluginId}</code></p>
|
||||||
|
|
@ -249,6 +275,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
let plugins = [];
|
let plugins = [];
|
||||||
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
||||||
let sidebarItems = [];
|
let sidebarItems = [];
|
||||||
|
let errorMessage = '';
|
||||||
|
|
||||||
let navItems = [
|
let navItems = [
|
||||||
{ id: 'plugin-manager', label: 'Plugin Manager', icon: '🧩' },
|
{ id: 'plugin-manager', label: 'Plugin Manager', icon: '🧩' },
|
||||||
|
|
@ -14,21 +15,27 @@
|
||||||
$: vaultOpen = vaultStatus.status === 'open';
|
$: vaultOpen = vaultStatus.status === 'open';
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
let contribErr = false;
|
||||||
try {
|
try {
|
||||||
const [p, v, contribs] = await Promise.all([
|
const [p, v, contribs] = await Promise.all([
|
||||||
App.GetPlugins().catch(() => []),
|
App.GetPlugins().catch(() => []),
|
||||||
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
|
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
|
||||||
App.GetContributions().catch(() => ({})),
|
App.GetContributions().catch(() => { contribErr = true; return {}; }),
|
||||||
]);
|
]);
|
||||||
plugins = p || [];
|
plugins = p || [];
|
||||||
vaultStatus = v;
|
vaultStatus = v;
|
||||||
|
if (contribErr) {
|
||||||
|
errorMessage = 'Failed to load plugin contributions';
|
||||||
|
}
|
||||||
sidebarItems = (contribs.sidebarItems || []).filter(item => {
|
sidebarItems = (contribs.sidebarItems || []).filter(item => {
|
||||||
const plugin = plugins.find(p => p.manifest?.id === item.pluginId);
|
const plugin = plugins.find(p => p.manifest?.id === item.pluginId);
|
||||||
if (!plugin) return false;
|
if (!plugin) return false;
|
||||||
return plugin.status !== 'disabled' && plugin.status !== 'failed' && plugin.status !== 'incompatible' && plugin.status !== 'missing-required-capability';
|
return plugin.status !== 'disabled' && plugin.status !== 'failed' && plugin.status !== 'incompatible' && plugin.status !== 'missing-required-capability';
|
||||||
});
|
});
|
||||||
|
sidebarItems.sort((a, b) => (a.position || 100) - (b.position || 100));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Sidebar] load error:', e);
|
console.error('[Sidebar] load error:', e);
|
||||||
|
errorMessage = 'Failed to load sidebar';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -37,7 +44,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSidebarItem(item) {
|
function handleSidebarItem(item) {
|
||||||
window.dispatchEvent(new CustomEvent('verstak:open-view', { detail: { viewId: item.id } }));
|
window.dispatchEvent(new CustomEvent('verstak:open-view', { detail: { viewId: item.id, pluginId: item.pluginId } }));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -70,7 +77,7 @@
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span class="nav-icon">{item.icon || '📌'}</span>
|
<span class="nav-icon">{item.icon || '📌'}</span>
|
||||||
<span class="nav-label">{item.label || item.id}</span>
|
<span class="nav-label">{item.title || item.id}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -81,6 +88,9 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
|
{#if errorMessage}
|
||||||
|
<span class="sidebar-error">⚠️ Plugin UI error</span>
|
||||||
|
{/if}
|
||||||
{#if vaultStatus.status !== 'unknown'}
|
{#if vaultStatus.status !== 'unknown'}
|
||||||
<span class="vault-indicator" class:vault-open={vaultStatus.status === 'open'} class:vault-closed={vaultStatus.status !== 'open'}>
|
<span class="vault-indicator" class:vault-open={vaultStatus.status === 'open'} class:vault-closed={vaultStatus.status !== 'open'}>
|
||||||
● Vault: {vaultStatus.status}
|
● Vault: {vaultStatus.status}
|
||||||
|
|
@ -201,4 +211,11 @@
|
||||||
.vault-indicator.vault-closed {
|
.vault-indicator.vault-closed {
|
||||||
color: #a0a0b8;
|
color: #a0a0b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-error {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #e94560;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,70 +2,80 @@
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
|
||||||
|
export let activeView = null;
|
||||||
|
export let activeViewPluginId = null;
|
||||||
|
|
||||||
let views = [];
|
let views = [];
|
||||||
let activeView = '';
|
|
||||||
let pluginStates = {};
|
|
||||||
let plugins = [];
|
let plugins = [];
|
||||||
|
let renderError = null;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
const [contribs, pluginList] = await Promise.all([
|
const [contribs, pluginList] = await Promise.all([
|
||||||
App.GetContributions(),
|
App.GetContributions().catch(() => ({ views: [] })),
|
||||||
App.GetPlugins(),
|
App.GetPlugins().catch(() => []),
|
||||||
]);
|
]);
|
||||||
views = contribs.views || [];
|
views = contribs.views || [];
|
||||||
plugins = pluginList;
|
plugins = pluginList;
|
||||||
for (const p of pluginList) {
|
|
||||||
pluginStates[p.manifest.id] = p.status;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[ViewContainer] load error:', e);
|
console.error('[ViewContainer] load error:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('verstak:open-view', (e) => {
|
|
||||||
activeView = e.detail.viewId;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function getViewStatus(view) {
|
$: currentView = views.find(v => v.id === activeView && v.pluginId === activeViewPluginId);
|
||||||
const status = pluginStates[view.pluginId];
|
$: currentPlugin = currentView
|
||||||
if (status === 'failed' || status === 'incompatible') return 'error';
|
? plugins.find(p => p.manifest?.id === currentView.pluginId)
|
||||||
if (status === 'degraded') return 'degraded';
|
: null;
|
||||||
return 'ok';
|
$: pluginStatus = currentPlugin ? currentPlugin.status : 'unknown';
|
||||||
|
|
||||||
|
// Reset render error when view changes
|
||||||
|
$: if (activeView) {
|
||||||
|
renderError = null;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="view-container">
|
{#key `${activeViewPluginId}:${activeView}`}
|
||||||
{#if activeView}
|
{#if renderError}
|
||||||
{#each views.filter(v => v.item.id === activeView) as view}
|
<div class="view-container">
|
||||||
<div class="view" class:degraded={getViewStatus(view) === 'degraded'}>
|
<div class="error-boundary">
|
||||||
|
<div class="error-fallback">
|
||||||
|
<span class="error-icon">⚠</span>
|
||||||
|
<p class="error-title">Plugin UI failed</p>
|
||||||
|
<p class="error-text">{renderError}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if currentView}
|
||||||
|
<div class="view-container">
|
||||||
|
<div class="view" class:degraded={pluginStatus === 'degraded'}>
|
||||||
<div class="view-header">
|
<div class="view-header">
|
||||||
<span class="view-icon">{view.item.icon || '📦'}</span>
|
<span class="view-icon">{currentView.icon || '📦'}</span>
|
||||||
<h2>{view.item.title}</h2>
|
<h2>{currentView.title}</h2>
|
||||||
{#if getViewStatus(view) === 'degraded'}
|
|
||||||
<span class="badge degraded">degraded</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="view-content">
|
<div class="view-content">
|
||||||
<div class="plugin-view-host" data-view-id={view.item.id} data-component={view.item.component}>
|
<div class="plugin-view-host" data-view-id={currentView.id} data-component={currentView.component}>
|
||||||
<p class="placeholder">
|
<div class="placeholder">
|
||||||
Plugin view: <strong>{view.item.component}</strong>
|
<p class="placeholder-label">Plugin View Host</p>
|
||||||
<br />
|
<p class="placeholder-info"><span class="placeholder-key">Plugin:</span> <strong>{currentView.pluginId}</strong></p>
|
||||||
<span class="sub">from {view.pluginId}</span>
|
<p class="placeholder-info"><span class="placeholder-key">View ID:</span> <code>{currentView.id}</code></p>
|
||||||
</p>
|
<p class="placeholder-info"><span class="placeholder-key">Component:</span> <code>{currentView.component}</code></p>
|
||||||
|
<p class="placeholder-badge">frontend bundle host not implemented yet</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
</div>
|
||||||
<div class="empty">View "{activeView}" not found in contributions</div>
|
{:else if activeView}
|
||||||
{/each}
|
<div class="view-container empty">
|
||||||
|
<p>View "{activeView}" not found in contributions</p>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="empty">
|
<div class="view-container empty">
|
||||||
<p>Select an item from the sidebar</p>
|
<p>Select a plugin view from the sidebar</p>
|
||||||
<p class="sub">Plugin views will appear here</p>
|
<p class="sub">Plugin views will appear here</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{/key}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.view-container {
|
.view-container {
|
||||||
|
|
@ -115,8 +125,68 @@
|
||||||
border: 1px dashed #333;
|
border: 1px dashed #333;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.placeholder strong { color: #4ecca3; }
|
.placeholder-label {
|
||||||
.placeholder .sub { font-size: 0.85rem; color: #555; }
|
font-size: 1rem;
|
||||||
|
color: #a0a0b8;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.placeholder-info {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.placeholder-key {
|
||||||
|
color: #a0a0b8;
|
||||||
|
}
|
||||||
|
.placeholder-info strong { color: #4ecca3; }
|
||||||
|
.placeholder-info code {
|
||||||
|
color: #e0e0f0;
|
||||||
|
background: #16213e;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.placeholder-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
background: #533483;
|
||||||
|
color: #e0e0f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.error-boundary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.error-fallback {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.error-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #e94560;
|
||||||
|
}
|
||||||
|
.error-title {
|
||||||
|
color: #e94560;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.error-text {
|
||||||
|
color: #a0a0b8;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: monospace;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
.empty {
|
.empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -127,11 +197,4 @@
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
.empty .sub { font-size: 0.85rem; color: #444; margin-top: 0.5rem; }
|
.empty .sub { font-size: 0.85rem; color: #444; margin-top: 0.5rem; }
|
||||||
.badge {
|
|
||||||
padding: 0.15rem 0.5rem;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.badge.degraded { background: #ffc857; color: #1a1a2e; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -93,17 +93,88 @@ func (a *App) GetPermissions() []permissions.Entry {
|
||||||
return entries
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetContributions returns all registered contributions.
|
// ─── Flat contribution types for frontend ─────────────────
|
||||||
func (a *App) GetContributions() ContributionSummary {
|
|
||||||
return ContributionSummary{
|
// FlatSidebarItem is a flattened sidebar item for the frontend.
|
||||||
Views: a.contribRegistry.Views(),
|
type FlatSidebarItem struct {
|
||||||
Commands: a.contribRegistry.Commands(),
|
PluginID string `json:"pluginId"`
|
||||||
SettingsPanels: a.contribRegistry.SettingsPanels(),
|
ID string `json:"id"`
|
||||||
SidebarItems: a.contribRegistry.SidebarItems(),
|
Title string `json:"title"`
|
||||||
FileActions: a.contribRegistry.FileActions(),
|
Icon string `json:"icon,omitempty"`
|
||||||
NoteActions: a.contribRegistry.NoteActions(),
|
View string `json:"view"`
|
||||||
SearchProviders: a.contribRegistry.SearchProviders(),
|
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.
|
// expandPath resolves "~" to the user's home directory.
|
||||||
|
|
@ -203,7 +274,9 @@ func (a *App) ReloadPlugins() (int, string) {
|
||||||
p.Status = plugin.StatusLoaded
|
p.Status = plugin.StatusLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register contributions (unregister first to prevent duplicates)
|
||||||
if p.Manifest.Contributes != nil {
|
if p.Manifest.Contributes != nil {
|
||||||
|
a.contribRegistry.Unregister(p.Manifest.ID)
|
||||||
a.contribRegistry.Register(p.Manifest.ID, p.Manifest.Contributes)
|
a.contribRegistry.Register(p.Manifest.ID, p.Manifest.Contributes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -638,14 +711,3 @@ func (a *App) SelectVaultForOpen() string {
|
||||||
}
|
}
|
||||||
return selected
|
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"`
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,73 @@ type Registry struct {
|
||||||
statusBarItems []ContributionStatusBarItem
|
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 {
|
type ContributionView struct {
|
||||||
PluginID string `json:"pluginId"`
|
PluginID string `json:"pluginId"`
|
||||||
Item plugin.ContributionView `json:"item"`
|
Item plugin.ContributionView `json:"item"`
|
||||||
|
|
@ -75,10 +142,24 @@ func NewRegistry() *Registry {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register adds all contributions from a plugin.
|
// 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) {
|
func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
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 {
|
for _, item := range c.Views {
|
||||||
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
|
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
VERSTAK_ROOT="$(cd "$ROOT/.." && pwd)"
|
||||||
FAILED=0
|
FAILED=0
|
||||||
|
GLOBAL_ERRORS=""
|
||||||
|
|
||||||
report() {
|
report() {
|
||||||
if [ "$2" -eq 0 ]; then
|
if [ "$2" -eq 0 ]; then
|
||||||
|
|
@ -13,6 +15,71 @@ report() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Global update: pull all repos, build official plugins ─────────────────
|
||||||
|
global_update() {
|
||||||
|
local repos=("verstak-desktop" "verstak-sdk" "verstak-official-plugins" "verstak-sync-server" "verstak-browser-extension" "verstak-docs")
|
||||||
|
local errors=""
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== global update: pull all repos ==="
|
||||||
|
for repo in "${repos[@]}"; do
|
||||||
|
local repo_path="$VERSTAK_ROOT/$repo"
|
||||||
|
if [ ! -d "$repo_path" ]; then
|
||||||
|
errors="$errors ⚠️ $repo: directory not found at $repo_path\n"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "[$repo]"
|
||||||
|
(cd "$repo_path" && git pull --ff-only 2>&1) && echo " ✅ pulled" || {
|
||||||
|
errors="$errors ❌ $repo: git pull failed\n"
|
||||||
|
echo " ❌ git pull failed"
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
# Build official plugins
|
||||||
|
local official_plugins="$VERSTAK_ROOT/verstak-official-plugins"
|
||||||
|
if [ -d "$official_plugins" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "=== build official plugins ==="
|
||||||
|
(cd "$official_plugins" && if [ -f "package.json" ] && [ -d "plugins" ]; then
|
||||||
|
if [ ! -d "node_modules" ]; then
|
||||||
|
echo "[official-plugins] installing npm deps..."
|
||||||
|
npm install --no-audit --no-fund 2>&1 || true
|
||||||
|
fi
|
||||||
|
# Build each plugin that has a frontend
|
||||||
|
for plugin_dir in plugins/*/; do
|
||||||
|
local plugin_name=$(basename "$plugin_dir")
|
||||||
|
local fe_dir="$plugin_dir/frontend"
|
||||||
|
if [ -d "$fe_dir" ] && [ -f "$fe_dir/package.json" ]; then
|
||||||
|
echo "[$plugin_name] building frontend..."
|
||||||
|
(cd "$fe_dir" && [ ! -d "node_modules" ] && npm install --no-audit --no-fund 2>&1 || true; npm run build 2>&1 || true)
|
||||||
|
fi
|
||||||
|
# Build backend if main.go exists
|
||||||
|
local backend_dir="$plugin_dir/backend"
|
||||||
|
if [ -f "$backend_dir/main.go" ]; then
|
||||||
|
echo "[$plugin_name] building backend..."
|
||||||
|
(cd "$backend_dir" && go build -o "$(basename "$backend_dir")" . 2>&1 || true)
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo " ✅ official plugins built"
|
||||||
|
else
|
||||||
|
echo " ℹ️ no plugins to build"
|
||||||
|
fi)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy official plugins to desktop
|
||||||
|
local dest="$ROOT/plugins"
|
||||||
|
if [ -d "$official_plugins/plugins" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "=== install official plugins to desktop ==="
|
||||||
|
rm -rf "$dest"
|
||||||
|
mkdir -p "$dest"
|
||||||
|
cp -r "$official_plugins/plugins/"* "$dest/" 2>/dev/null || true
|
||||||
|
echo " ✅ plugins installed to $dest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
GLOBAL_ERRORS="$errors"
|
||||||
|
}
|
||||||
|
|
||||||
ensure_npm_deps() {
|
ensure_npm_deps() {
|
||||||
local dir="$1"
|
local dir="$1"
|
||||||
if [ ! -f "$dir/package.json" ]; then
|
if [ ! -f "$dir/package.json" ]; then
|
||||||
|
|
@ -31,6 +98,10 @@ ensure_npm_deps() {
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "=== verstak-desktop build ==="
|
echo "=== verstak-desktop build ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Global update (best-effort — errors collected, not fatal) ──
|
||||||
|
global_update
|
||||||
|
|
||||||
# ── Dependency checks ──
|
# ── Dependency checks ──
|
||||||
echo "[deps]"
|
echo "[deps]"
|
||||||
|
|
@ -146,9 +217,14 @@ else
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
if [ "$FAILED" -eq 0 ]; then
|
if [ "$FAILED" -eq 0 ] && [ -z "$GLOBAL_ERRORS" ]; then
|
||||||
echo "✅ build passed"
|
echo "✅ build passed"
|
||||||
else
|
else
|
||||||
echo "❌ build failed"
|
echo "❌ build completed with issues"
|
||||||
|
if [ -n "$GLOBAL_ERRORS" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "Global update errors:"
|
||||||
|
echo -e "$GLOBAL_ERRORS"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
exit "$FAILED"
|
exit "$FAILED"
|
||||||
|
|
|
||||||
|
|
@ -86,5 +86,15 @@ if [ "$SMOKE_WS_EXIT" -ne 0 ]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── test contributions via Go smoke ──
|
||||||
|
echo ""
|
||||||
|
echo "[go smoke: contributions]"
|
||||||
|
(cd "$ROOT" && go run -mod=mod ./cmd/smoke-platform/ -test-contributions 2>&1)
|
||||||
|
SMOKE_CONT_EXIT=$?
|
||||||
|
if [ "$SMOKE_CONT_EXIT" -ne 0 ]; then
|
||||||
|
echo " ❌ smoke-platform: contributions test failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ smoke-platform all tests done"
|
echo "✅ smoke-platform all tests done"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue