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:
mirivlad 2026-06-17 17:07:52 +08:00
parent 9bb35a9fd0
commit 86eeadd2a9
12 changed files with 1108 additions and 122 deletions

View File

@ -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() {

View File

@ -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.

View File

@ -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>

View File

@ -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}

View File

@ -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 => {
WritePluginSettings(settingsPluginId, settingsData).then(err => {
if (err) console.error('WritePluginSettings:', err);
}).catch(e => console.error('WritePluginSettings:', e));
});
} catch (e) {
console.error('saveSettings:', 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>

View File

@ -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>

View File

@ -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>
{#key `${activeViewPluginId}:${activeView}`}
{#if renderError}
<div class="view-container">
{#if activeView}
{#each views.filter(v => v.item.id === activeView) as view}
<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">
<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>
</div>
{:else if activeView}
<div class="view-container empty">
<p>View "{activeView}" not found in contributions</p>
</div>
{:else}
<div class="empty">View "{activeView}" not found in contributions</div>
{/each}
{: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>

View File

@ -93,17 +93,88 @@ func (a *App) GetPermissions() []permissions.Entry {
return entries
}
// GetContributions returns all registered contributions.
func (a *App) GetContributions() ContributionSummary {
return ContributionSummary{
Views: a.contribRegistry.Views(),
Commands: a.contribRegistry.Commands(),
SettingsPanels: a.contribRegistry.SettingsPanels(),
SidebarItems: a.contribRegistry.SidebarItems(),
FileActions: a.contribRegistry.FileActions(),
NoteActions: a.contribRegistry.NoteActions(),
SearchProviders: a.contribRegistry.SearchProviders(),
// ─── Flat contribution types for frontend ─────────────────
// FlatSidebarItem is a flattened sidebar item for the frontend.
type FlatSidebarItem struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
View string `json:"view"`
Position int `json:"position,omitempty"`
}
// FlatView is a flattened view contribution for the frontend.
type FlatView struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Component string `json:"component"`
}
// FlatSettingsPanel is a flattened settings panel for the frontend.
type FlatSettingsPanel struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Component string `json:"component"`
}
// FlatCommand is a flattened command contribution for the frontend.
type FlatCommand struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Handler string `json:"handler,omitempty"`
}
// ContributionSummary aggregates all contribution types for the frontend.
type ContributionSummary struct {
Views []FlatView `json:"views"`
Commands []FlatCommand `json:"commands"`
SettingsPanels []FlatSettingsPanel `json:"settingsPanels"`
SidebarItems []FlatSidebarItem `json:"sidebarItems"`
}
// buildContributionSummary creates a ContributionSummary from the registry.
func buildContributionSummary(r *contribution.Registry) ContributionSummary {
if r == nil {
return ContributionSummary{}
}
regViews := r.Views()
regCmds := r.Commands()
regPanels := r.SettingsPanels()
regSidebar := r.SidebarItems()
views := make([]FlatView, len(regViews))
for i, v := range regViews {
views[i] = FlatView{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, Component: v.Item.Component}
}
cmds := make([]FlatCommand, len(regCmds))
for i, v := range regCmds {
cmds[i] = FlatCommand{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, Handler: v.Item.Handler}
}
panels := make([]FlatSettingsPanel, len(regPanels))
for i, v := range regPanels {
panels[i] = FlatSettingsPanel{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, Component: v.Item.Component}
}
sidebar := make([]FlatSidebarItem, len(regSidebar))
for i, v := range regSidebar {
sidebar[i] = FlatSidebarItem{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, View: v.Item.View, Position: v.Item.Position}
}
return ContributionSummary{Views: views, Commands: cmds, SettingsPanels: panels, SidebarItems: sidebar}
}
// GetContributions returns all registered contributions flattened for the frontend.
func (a *App) GetContributions() ContributionSummary {
if a.contribRegistry == nil {
return ContributionSummary{}
}
return buildContributionSummary(a.contribRegistry)
}
// expandPath resolves "~" to the user's home directory.
@ -203,7 +274,9 @@ func (a *App) ReloadPlugins() (int, string) {
p.Status = plugin.StatusLoaded
}
// Register contributions (unregister first to prevent duplicates)
if p.Manifest.Contributes != nil {
a.contribRegistry.Unregister(p.Manifest.ID)
a.contribRegistry.Register(p.Manifest.ID, p.Manifest.Contributes)
}
@ -638,14 +711,3 @@ func (a *App) SelectVaultForOpen() string {
}
return selected
}
// ContributionSummary aggregates all contribution types for the frontend.
type ContributionSummary struct {
Views []contribution.ContributionView `json:"views"`
Commands []contribution.ContributionCommand `json:"commands"`
SettingsPanels []contribution.ContributionSettingsPanel `json:"settingsPanels"`
SidebarItems []contribution.ContributionSidebarItem `json:"sidebarItems"`
FileActions []contribution.ContributionAction `json:"fileActions"`
NoteActions []contribution.ContributionAction `json:"noteActions"`
SearchProviders []contribution.ContributionSearchProvider `json:"searchProviders"`
}

View File

@ -24,6 +24,73 @@ type Registry struct {
statusBarItems []ContributionStatusBarItem
}
// ContributionPointType defines the type of contribution point.
type ContributionPointType string
const (
PointViews ContributionPointType = "views"
PointCommands ContributionPointType = "commands"
PointSettingsPanels ContributionPointType = "settingsPanels"
PointSidebarItems ContributionPointType = "sidebarItems"
PointFileActions ContributionPointType = "fileActions"
PointNoteActions ContributionPointType = "noteActions"
PointContextMenus ContributionPointType = "contextMenus"
PointSearchProviders ContributionPointType = "searchProviders"
PointActivity ContributionPointType = "activityProviders"
PointStatusBar ContributionPointType = "statusBarItems"
)
// ListByPoint returns all contributions for a given point type.
func (r *Registry) ListByPoint(point ContributionPointType) []interface{} {
r.mu.RLock()
defer r.mu.RUnlock()
var result []interface{}
switch point {
case PointViews:
for _, v := range r.views {
result = append(result, v)
}
case PointCommands:
for _, v := range r.commands {
result = append(result, v)
}
case PointSettingsPanels:
for _, v := range r.settingsPanels {
result = append(result, v)
}
case PointSidebarItems:
for _, v := range r.sidebarItems {
result = append(result, v)
}
case PointFileActions:
for _, v := range r.fileActions {
result = append(result, v)
}
case PointNoteActions:
for _, v := range r.noteActions {
result = append(result, v)
}
case PointContextMenus:
for _, v := range r.contextMenus {
result = append(result, v)
}
case PointSearchProviders:
for _, v := range r.searchProviders {
result = append(result, v)
}
case PointActivity:
for _, v := range r.activityProviders {
result = append(result, v)
}
case PointStatusBar:
for _, v := range r.statusBarItems {
result = append(result, v)
}
}
return result
}
type ContributionView struct {
PluginID string `json:"pluginId"`
Item plugin.ContributionView `json:"item"`
@ -75,10 +142,24 @@ func NewRegistry() *Registry {
}
// Register adds all contributions from a plugin.
// If the plugin already has registered contributions they are replaced
// (supports reload without duplicates).
func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
r.mu.Lock()
defer r.mu.Unlock()
// Remove existing contributions for this plugin to prevent duplicates on reload
r.views = removeViews(r.views, pluginID)
r.commands = removeCommands(r.commands, pluginID)
r.settingsPanels = removeSettingsPanels(r.settingsPanels, pluginID)
r.sidebarItems = removeSidebarItems(r.sidebarItems, pluginID)
r.fileActions = removeActions(r.fileActions, pluginID)
r.noteActions = removeActions(r.noteActions, pluginID)
r.contextMenus = removeContextMenus(r.contextMenus, pluginID)
r.searchProviders = removeSearchProviders(r.searchProviders, pluginID)
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
for _, item := range c.Views {
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
}

View File

@ -0,0 +1,301 @@
package contribution
import (
"testing"
"github.com/verstak/verstak-desktop/internal/core/plugin"
)
// TestRegister_AddsContributions registers sidebar, view, command, settings contributions
// for plugin "test.plugin" and verifies they appear via Views(), Commands(),
// SettingsPanels(), SidebarItems().
func TestRegister_AddsContributions(t *testing.T) {
r := NewRegistry()
contribs := &plugin.Contributions{
Views: []plugin.ContributionView{
{ID: "test.view1", Title: "View 1", Component: "TestComponent"},
},
Commands: []plugin.ContributionCommand{
{ID: "test.cmd1", Title: "Command 1"},
},
SettingsPanels: []plugin.ContributionSettingsPanel{
{ID: "test.settings1", Title: "Settings 1", Component: "SettingsComponent"},
},
SidebarItems: []plugin.ContributionSidebarItem{
{ID: "test.sidebar1", Title: "Sidebar 1", Icon: "icon", View: "test.view1", Position: 1},
},
}
r.Register("test.plugin", contribs)
// Verify counts
if got := len(r.Views()); got != 1 {
t.Errorf("Views(): got %d, want 1", got)
}
if got := len(r.Commands()); got != 1 {
t.Errorf("Commands(): got %d, want 1", got)
}
if got := len(r.SettingsPanels()); got != 1 {
t.Errorf("SettingsPanels(): got %d, want 1", got)
}
if got := len(r.SidebarItems()); got != 1 {
t.Errorf("SidebarItems(): got %d, want 1", got)
}
// Verify the PluginID is set correctly
if r.Views()[0].PluginID != "test.plugin" {
t.Errorf("Views()[0].PluginID = %q, want %q", r.Views()[0].PluginID, "test.plugin")
}
if r.Commands()[0].PluginID != "test.plugin" {
t.Errorf("Commands()[0].PluginID = %q, want %q", r.Commands()[0].PluginID, "test.plugin")
}
if r.SettingsPanels()[0].PluginID != "test.plugin" {
t.Errorf("SettingsPanels()[0].PluginID = %q, want %q", r.SettingsPanels()[0].PluginID, "test.plugin")
}
if r.SidebarItems()[0].PluginID != "test.plugin" {
t.Errorf("SidebarItems()[0].PluginID = %q, want %q", r.SidebarItems()[0].PluginID, "test.plugin")
}
// Verify item data is preserved
if r.Views()[0].Item.Title != "View 1" {
t.Errorf("Views()[0].Item.Title = %q, want %q", r.Views()[0].Item.Title, "View 1")
}
if r.Commands()[0].Item.Title != "Command 1" {
t.Errorf("Commands()[0].Item.Title = %q, want %q", r.Commands()[0].Item.Title, "Command 1")
}
if r.SettingsPanels()[0].Item.Title != "Settings 1" {
t.Errorf("SettingsPanels()[0].Item.Title = %q, want %q", r.SettingsPanels()[0].Item.Title, "Settings 1")
}
if r.SidebarItems()[0].Item.Title != "Sidebar 1" {
t.Errorf("SidebarItems()[0].Item.Title = %q, want %q", r.SidebarItems()[0].Item.Title, "Sidebar 1")
}
}
// TestUnregister_RemovesOwnedContributions registers for two plugins, unregisters one,
// and verifies only that plugin's contributions are removed.
func TestUnregister_RemovesOwnedContributions(t *testing.T) {
r := NewRegistry()
contribA := &plugin.Contributions{
Views: []plugin.ContributionView{
{ID: "a.view1", Title: "A View", Component: "A"},
},
Commands: []plugin.ContributionCommand{
{ID: "a.cmd1", Title: "A Command"},
},
SettingsPanels: []plugin.ContributionSettingsPanel{
{ID: "a.settings1", Title: "A Settings", Component: "A"},
},
SidebarItems: []plugin.ContributionSidebarItem{
{ID: "a.sidebar1", Title: "A Sidebar", View: "a.view1"},
},
}
contribB := &plugin.Contributions{
Views: []plugin.ContributionView{
{ID: "b.view1", Title: "B View", Component: "B"},
},
Commands: []plugin.ContributionCommand{
{ID: "b.cmd1", Title: "B Command"},
},
SettingsPanels: []plugin.ContributionSettingsPanel{
{ID: "b.settings1", Title: "B Settings", Component: "B"},
},
SidebarItems: []plugin.ContributionSidebarItem{
{ID: "b.sidebar1", Title: "B Sidebar", View: "b.view1"},
},
}
r.Register("plugin.a", contribA)
r.Register("plugin.b", contribB)
// Unregister plugin.a
r.Unregister("plugin.a")
// Verify plugin.a contributions are removed
if got := r.Views(); len(got) != 1 || got[0].PluginID != "plugin.b" {
t.Errorf("Views: got %d items (first PluginID=%q), want 1 from plugin.b", len(got), safePluginIDView(got))
}
if got := r.Commands(); len(got) != 1 || got[0].PluginID != "plugin.b" {
t.Errorf("Commands: got %d items (first PluginID=%q), want 1 from plugin.b", len(got), safePluginIDCmd(got))
}
if got := r.SettingsPanels(); len(got) != 1 || got[0].PluginID != "plugin.b" {
t.Errorf("SettingsPanels: got %d items (first PluginID=%q), want 1 from plugin.b", len(got), safePluginIDSettings(got))
}
if got := r.SidebarItems(); len(got) != 1 || got[0].PluginID != "plugin.b" {
t.Errorf("SidebarItems: got %d items (first PluginID=%q), want 1 from plugin.b", len(got), safePluginIDSidebar(got))
}
// Verify plugin.b data is intact
if r.Views()[0].Item.ID != "b.view1" {
t.Errorf("Remaining View ID: got %q, want %q", r.Views()[0].Item.ID, "b.view1")
}
if r.Commands()[0].Item.ID != "b.cmd1" {
t.Errorf("Remaining Command ID: got %q, want %q", r.Commands()[0].Item.ID, "b.cmd1")
}
if r.SettingsPanels()[0].Item.ID != "b.settings1" {
t.Errorf("Remaining SettingsPanel ID: got %q, want %q", r.SettingsPanels()[0].Item.ID, "b.settings1")
}
if r.SidebarItems()[0].Item.ID != "b.sidebar1" {
t.Errorf("Remaining SidebarItem ID: got %q, want %q", r.SidebarItems()[0].Item.ID, "b.sidebar1")
}
}
// safe helpers for error messages when slices are empty
func safePluginIDView(items []ContributionView) string {
if len(items) == 0 {
return "<empty>"
}
return items[0].PluginID
}
func safePluginIDCmd(items []ContributionCommand) string {
if len(items) == 0 {
return "<empty>"
}
return items[0].PluginID
}
func safePluginIDSettings(items []ContributionSettingsPanel) string {
if len(items) == 0 {
return "<empty>"
}
return items[0].PluginID
}
func safePluginIDSidebar(items []ContributionSidebarItem) string {
if len(items) == 0 {
return "<empty>"
}
return items[0].PluginID
}
// TestListByPoint registers various types and calls ListByPoint for each point type,
// verifying correct counts.
func TestListByPoint(t *testing.T) {
r := NewRegistry()
contrib := &plugin.Contributions{
Views: []plugin.ContributionView{{ID: "v1", Title: "V1", Component: "C"}},
Commands: []plugin.ContributionCommand{{ID: "c1", Title: "C1"}},
SettingsPanels: []plugin.ContributionSettingsPanel{{ID: "s1", Title: "S1", Component: "C"}},
SidebarItems: []plugin.ContributionSidebarItem{{ID: "si1", Title: "SI1", View: "v1"}},
FileActions: []plugin.ContributionAction{{ID: "fa1", Label: "FA1"}},
NoteActions: []plugin.ContributionAction{{ID: "na1", Label: "NA1"}},
ContextMenuEntries: []plugin.ContributionContextMenuEntry{{ID: "cm1", Label: "CM1", Context: "file"}},
SearchProviders: []plugin.ContributionSearchProvider{{ID: "sp1", Label: "SP1", Handler: "h"}},
ActivityProviders: []plugin.ContributionActivityProvider{{ID: "ap1", Events: []string{"test"}, Handler: "h"}},
StatusBarItems: []plugin.ContributionStatusBarItem{{ID: "sb1", Label: "SB1"}},
}
r.Register("test.plugin", contrib)
tests := []struct {
point ContributionPointType
want int
}{
{PointViews, 1},
{PointCommands, 1},
{PointSettingsPanels, 1},
{PointSidebarItems, 1},
{PointFileActions, 1},
{PointNoteActions, 1},
{PointContextMenus, 1},
{PointSearchProviders, 1},
{PointActivity, 1},
{PointStatusBar, 1},
}
for _, tt := range tests {
got := r.ListByPoint(tt.point)
if len(got) != tt.want {
t.Errorf("ListByPoint(%q): got %d items, want %d", tt.point, len(got), tt.want)
}
}
}
// TestRegister_DuplicatePrevention calls Register twice for the same plugin
// (simulating reload) and checks contributions appear only once (no duplicates).
// This is the KEY TEST for idempotent re-registration.
func TestRegister_DuplicatePrevention(t *testing.T) {
r := NewRegistry()
contrib := &plugin.Contributions{
Views: []plugin.ContributionView{
{ID: "test.view1", Title: "View 1", Component: "C"},
},
Commands: []plugin.ContributionCommand{
{ID: "test.cmd1", Title: "Cmd 1"},
},
SettingsPanels: []plugin.ContributionSettingsPanel{
{ID: "test.settings1", Title: "Settings 1", Component: "C"},
},
SidebarItems: []plugin.ContributionSidebarItem{
{ID: "test.sidebar1", Title: "Sidebar 1", View: "test.view1"},
},
}
// First registration
r.Register("test.plugin", contrib)
// Second registration — simulates plugin reload
r.Register("test.plugin", contrib)
// Each type should have only 1 entry (no duplicates)
if got := len(r.Views()); got != 1 {
t.Errorf("Views after double Register: got %d, want 1 (no duplicates)", got)
}
if got := len(r.Commands()); got != 1 {
t.Errorf("Commands after double Register: got %d, want 1 (no duplicates)", got)
}
if got := len(r.SettingsPanels()); got != 1 {
t.Errorf("SettingsPanels after double Register: got %d, want 1 (no duplicates)", got)
}
if got := len(r.SidebarItems()); got != 1 {
t.Errorf("SidebarItems after double Register: got %d, want 1 (no duplicates)", got)
}
// Also verify the item data is preserved correctly
if r.Views()[0].Item.ID != "test.view1" {
t.Errorf("View ID after reload: got %q, want %q", r.Views()[0].Item.ID, "test.view1")
}
if r.Commands()[0].Item.ID != "test.cmd1" {
t.Errorf("Command ID after reload: got %q, want %q", r.Commands()[0].Item.ID, "test.cmd1")
}
if r.SettingsPanels()[0].Item.ID != "test.settings1" {
t.Errorf("SettingsPanel ID after reload: got %q, want %q", r.SettingsPanels()[0].Item.ID, "test.settings1")
}
if r.SidebarItems()[0].Item.ID != "test.sidebar1" {
t.Errorf("SidebarItem ID after reload: got %q, want %q", r.SidebarItems()[0].Item.ID, "test.sidebar1")
}
}
// TestUnregister_NoSideEffects verifies that unregistering a non-existent plugin
// doesn't crash or corrupt the registry.
func TestUnregister_NoSideEffects(t *testing.T) {
r := NewRegistry()
// Register a plugin
contrib := &plugin.Contributions{
Views: []plugin.ContributionView{
{ID: "v1", Title: "V1", Component: "C"},
},
}
r.Register("existing.plugin", contrib)
// Unregister a plugin that was never registered — should not panic
r.Unregister("nonexistent.plugin")
// Existing plugin's contributions should still be intact
if got := len(r.Views()); got != 1 {
t.Errorf("Views after unregistering non-existent: got %d, want 1", got)
}
if r.Views()[0].PluginID != "existing.plugin" {
t.Errorf("PluginID after unregistering non-existent: got %q, want %q", r.Views()[0].PluginID, "existing.plugin")
}
// Unregister with empty string — should not panic
r.Unregister("")
// Still intact
if got := len(r.Views()); got != 1 {
t.Errorf("Views after unregistering empty string: got %d, want 1", got)
}
}

View File

@ -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"

View File

@ -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"