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:
+28
-3
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user