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"
|
||||
|
||||
"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() {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
|
||||
<section class="content">
|
||||
{#if currentView === 'plugin-manager'}
|
||||
<PluginManager />
|
||||
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
|
||||
{:else}
|
||||
<ViewContainer />
|
||||
<ViewContainer {activeView} {activeViewPluginId} />
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<!-- Actions -->
|
||||
<div class="card-actions">
|
||||
{#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
|
||||
</button>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import PluginCard from './PluginCard.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 capabilities = [];
|
||||
|
|
@ -14,6 +14,31 @@
|
|||
let settingsPanel = null;
|
||||
let settingsData = {};
|
||||
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';
|
||||
$: missingInstalled = computeMissingInstalled();
|
||||
|
|
@ -85,30 +110,18 @@
|
|||
$: totalCaps = capabilities.length;
|
||||
$: totalPerms = permissions.length;
|
||||
|
||||
function openSettings(pluginId) {
|
||||
const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId);
|
||||
if (panel) {
|
||||
settingsPanel = panel;
|
||||
settingsPluginId = pluginId;
|
||||
// Load existing settings from Wails backend
|
||||
import('../../../wailsjs/go/api/App').then(mod => {
|
||||
mod.ReadPluginSettings(pluginId).then(data => {
|
||||
settingsData = data || {};
|
||||
}).catch(() => { settingsData = {}; });
|
||||
});
|
||||
}
|
||||
function closeSettings() {
|
||||
settingsPanel = null;
|
||||
settingsPluginId = '';
|
||||
settingsError = null;
|
||||
lastOpenedKey = '';
|
||||
window.dispatchEvent(new CustomEvent('verstak:close-settings'));
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
try {
|
||||
import('../../../wailsjs/go/api/App').then(mod => {
|
||||
mod.WritePluginSettings(settingsPluginId, settingsData).then(err => {
|
||||
if (err) console.error('WritePluginSettings:', err);
|
||||
}).catch(e => console.error('WritePluginSettings:', e));
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('saveSettings:', e);
|
||||
}
|
||||
WritePluginSettings(settingsPluginId, settingsData).then(err => {
|
||||
if (err) console.error('WritePluginSettings:', err);
|
||||
}).catch(e => console.error('WritePluginSettings:', e));
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -160,7 +173,7 @@
|
|||
{:else}
|
||||
<div class="plugin-list">
|
||||
{#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}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -215,12 +228,25 @@
|
|||
{/if}
|
||||
|
||||
<!-- Settings Panel Modal -->
|
||||
{#if settingsPanel}
|
||||
<div class="modal-overlay" on:click|self={() => settingsPanel = null}>
|
||||
{#key `settings-${settingsPluginId}`}
|
||||
{#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-header">
|
||||
<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 class="modal-body">
|
||||
<p class="settings-hint">Plugin: <code>{settingsPluginId}</code></p>
|
||||
|
|
@ -249,6 +275,7 @@
|
|||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
let plugins = [];
|
||||
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
||||
let sidebarItems = [];
|
||||
let errorMessage = '';
|
||||
|
||||
let navItems = [
|
||||
{ id: 'plugin-manager', label: 'Plugin Manager', icon: '🧩' },
|
||||
|
|
@ -14,21 +15,27 @@
|
|||
$: vaultOpen = vaultStatus.status === 'open';
|
||||
|
||||
onMount(async () => {
|
||||
let contribErr = false;
|
||||
try {
|
||||
const [p, v, contribs] = await Promise.all([
|
||||
App.GetPlugins().catch(() => []),
|
||||
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
|
||||
App.GetContributions().catch(() => ({})),
|
||||
App.GetContributions().catch(() => { contribErr = true; return {}; }),
|
||||
]);
|
||||
plugins = p || [];
|
||||
vaultStatus = v;
|
||||
if (contribErr) {
|
||||
errorMessage = 'Failed to load plugin contributions';
|
||||
}
|
||||
sidebarItems = (contribs.sidebarItems || []).filter(item => {
|
||||
const plugin = plugins.find(p => p.manifest?.id === item.pluginId);
|
||||
if (!plugin) return false;
|
||||
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) {
|
||||
console.error('[Sidebar] load error:', e);
|
||||
errorMessage = 'Failed to load sidebar';
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -37,7 +44,7 @@
|
|||
}
|
||||
|
||||
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>
|
||||
|
||||
|
|
@ -70,7 +77,7 @@
|
|||
type="button"
|
||||
>
|
||||
<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>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -81,6 +88,9 @@
|
|||
{/if}
|
||||
|
||||
<div class="sidebar-footer">
|
||||
{#if errorMessage}
|
||||
<span class="sidebar-error">⚠️ Plugin UI error</span>
|
||||
{/if}
|
||||
{#if vaultStatus.status !== 'unknown'}
|
||||
<span class="vault-indicator" class:vault-open={vaultStatus.status === 'open'} class:vault-closed={vaultStatus.status !== 'open'}>
|
||||
● Vault: {vaultStatus.status}
|
||||
|
|
@ -201,4 +211,11 @@
|
|||
.vault-indicator.vault-closed {
|
||||
color: #a0a0b8;
|
||||
}
|
||||
|
||||
.sidebar-error {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: #e94560;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,70 +2,80 @@
|
|||
import { onMount } from 'svelte';
|
||||
import * as App from '../../../wailsjs/go/api/App';
|
||||
|
||||
export let activeView = null;
|
||||
export let activeViewPluginId = null;
|
||||
|
||||
let views = [];
|
||||
let activeView = '';
|
||||
let pluginStates = {};
|
||||
let plugins = [];
|
||||
let renderError = null;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [contribs, pluginList] = await Promise.all([
|
||||
App.GetContributions(),
|
||||
App.GetPlugins(),
|
||||
App.GetContributions().catch(() => ({ views: [] })),
|
||||
App.GetPlugins().catch(() => []),
|
||||
]);
|
||||
views = contribs.views || [];
|
||||
plugins = pluginList;
|
||||
for (const p of pluginList) {
|
||||
pluginStates[p.manifest.id] = p.status;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ViewContainer] load error:', e);
|
||||
}
|
||||
|
||||
window.addEventListener('verstak:open-view', (e) => {
|
||||
activeView = e.detail.viewId;
|
||||
});
|
||||
});
|
||||
|
||||
function getViewStatus(view) {
|
||||
const status = pluginStates[view.pluginId];
|
||||
if (status === 'failed' || status === 'incompatible') return 'error';
|
||||
if (status === 'degraded') return 'degraded';
|
||||
return 'ok';
|
||||
$: currentView = views.find(v => v.id === activeView && v.pluginId === activeViewPluginId);
|
||||
$: currentPlugin = currentView
|
||||
? plugins.find(p => p.manifest?.id === currentView.pluginId)
|
||||
: null;
|
||||
$: pluginStatus = currentPlugin ? currentPlugin.status : 'unknown';
|
||||
|
||||
// Reset render error when view changes
|
||||
$: if (activeView) {
|
||||
renderError = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="view-container">
|
||||
{#if activeView}
|
||||
{#each views.filter(v => v.item.id === activeView) as view}
|
||||
<div class="view" class:degraded={getViewStatus(view) === 'degraded'}>
|
||||
{#key `${activeViewPluginId}:${activeView}`}
|
||||
{#if renderError}
|
||||
<div class="view-container">
|
||||
<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">
|
||||
<span class="view-icon">{view.item.icon || '📦'}</span>
|
||||
<h2>{view.item.title}</h2>
|
||||
{#if getViewStatus(view) === 'degraded'}
|
||||
<span class="badge degraded">degraded</span>
|
||||
{/if}
|
||||
<span class="view-icon">{currentView.icon || '📦'}</span>
|
||||
<h2>{currentView.title}</h2>
|
||||
</div>
|
||||
<div class="view-content">
|
||||
<div class="plugin-view-host" data-view-id={view.item.id} data-component={view.item.component}>
|
||||
<p class="placeholder">
|
||||
Plugin view: <strong>{view.item.component}</strong>
|
||||
<br />
|
||||
<span class="sub">from {view.pluginId}</span>
|
||||
</p>
|
||||
<div class="plugin-view-host" data-view-id={currentView.id} data-component={currentView.component}>
|
||||
<div class="placeholder">
|
||||
<p class="placeholder-label">Plugin View Host</p>
|
||||
<p class="placeholder-info"><span class="placeholder-key">Plugin:</span> <strong>{currentView.pluginId}</strong></p>
|
||||
<p class="placeholder-info"><span class="placeholder-key">View ID:</span> <code>{currentView.id}</code></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>
|
||||
{:else}
|
||||
<div class="empty">View "{activeView}" not found in contributions</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if activeView}
|
||||
<div class="view-container empty">
|
||||
<p>View "{activeView}" not found in contributions</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<p>Select an item from the sidebar</p>
|
||||
<div class="view-container empty">
|
||||
<p>Select a plugin view from the sidebar</p>
|
||||
<p class="sub">Plugin views will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
<style>
|
||||
.view-container {
|
||||
|
|
@ -115,8 +125,68 @@
|
|||
border: 1px dashed #333;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.placeholder strong { color: #4ecca3; }
|
||||
.placeholder .sub { font-size: 0.85rem; color: #555; }
|
||||
.placeholder-label {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -127,11 +197,4 @@
|
|||
font-size: 1rem;
|
||||
}
|
||||
.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>
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
VERSTAK_ROOT="$(cd "$ROOT/.." && pwd)"
|
||||
FAILED=0
|
||||
GLOBAL_ERRORS=""
|
||||
|
||||
report() {
|
||||
if [ "$2" -eq 0 ]; then
|
||||
|
|
@ -13,6 +15,71 @@ report() {
|
|||
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() {
|
||||
local dir="$1"
|
||||
if [ ! -f "$dir/package.json" ]; then
|
||||
|
|
@ -31,6 +98,10 @@ ensure_npm_deps() {
|
|||
}
|
||||
|
||||
echo "=== verstak-desktop build ==="
|
||||
echo ""
|
||||
|
||||
# ── Global update (best-effort — errors collected, not fatal) ──
|
||||
global_update
|
||||
|
||||
# ── Dependency checks ──
|
||||
echo "[deps]"
|
||||
|
|
@ -146,9 +217,14 @@ else
|
|||
fi
|
||||
|
||||
echo ""
|
||||
if [ "$FAILED" -eq 0 ]; then
|
||||
if [ "$FAILED" -eq 0 ] && [ -z "$GLOBAL_ERRORS" ]; then
|
||||
echo "✅ build passed"
|
||||
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
|
||||
exit "$FAILED"
|
||||
|
|
|
|||
|
|
@ -86,5 +86,15 @@ if [ "$SMOKE_WS_EXIT" -ne 0 ]; then
|
|||
exit 1
|
||||
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 "✅ smoke-platform all tests done"
|
||||
|
|
|
|||
Loading…
Reference in New Issue