feat: add plugin UI host (sidebar, view container, settings panel) + storage API

- internal/core/storage/api.go — plugin namespace JSON storage (settings/data/cache)
- internal/core/storage/api_test.go — 8 tests (write/read, path traversal, atomic)
- internal/api/app.go — Wails bindings for storage (Read/WritePluginSettings, Read/WritePluginDataJSON)
- main.go — initialize storage service, pass to NewApp
- Sidebar.svelte — plugin sidebar items from contributions (filtered by ui.register)
- ViewContainer.svelte — plugin view host with degraded status
- PluginCard.svelte — Settings button + permission warnings
- PluginManager.svelte — settings panel modal with test form
- App.svelte — integrated sidebar + view container layout
This commit is contained in:
2026-06-17 03:01:37 +08:00
parent 70d4c75d7e
commit ca7eb79a40
9 changed files with 983 additions and 69 deletions
+10 -61
View File
@@ -1,27 +1,19 @@
<script>
import PluginManager from './lib/plugin-manager/PluginManager.svelte';
import Sidebar from './lib/shell/Sidebar.svelte';
import ViewContainer from './lib/shell/ViewContainer.svelte';
let view = 'plugin-manager';
let currentView = 'plugin-manager';
</script>
<main>
<nav>
<h1>Verstak</h1>
<div class="nav-items">
<button
class="nav-item"
class:active={view === 'plugin-manager'}
on:click={() => view = 'plugin-manager'}
type="button"
>
⚙ Plugin Manager
</button>
</div>
</nav>
<Sidebar />
<section class="content">
{#if view === 'plugin-manager'}
{#if currentView === 'plugin-manager'}
<PluginManager />
{:else}
<ViewContainer />
{/if}
</section>
</main>
@@ -32,53 +24,10 @@
height: 100vh;
background: #1a1a2e;
}
nav {
width: 220px;
background: #16213e;
border-right: 1px solid #0f3460;
padding: 1rem;
display: flex;
flex-direction: column;
}
nav h1 {
font-size: 1.2rem;
color: #e94560;
margin-bottom: 1.5rem;
}
.nav-items {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.nav-item {
background: transparent;
border: none;
color: #a0a0b8;
padding: 0.5rem 0.75rem;
border-radius: 6px;
cursor: pointer;
text-align: left;
font-size: 0.9rem;
transition: all 0.15s;
}
.nav-item:hover {
background: #0f3460;
color: #e0e0e0;
}
.nav-item.active {
background: #e94560;
color: #fff;
}
.content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>
@@ -2,9 +2,12 @@
export let p = {};
export let capabilities = [];
export let permissions = [];
export let contributions = {};
export let onSettings = () => {};
$: m = p.manifest || {};
$: hasSettingsPanel = (contributions.settingsPanels || []).some(sp => sp.pluginId === pluginId);
$: hasUIPermission = (m.permissions || []).includes('ui.register');
$: hasStoragePermission = (m.permissions || []).includes('storage.namespace');
$: hasCommandsPermission = (m.permissions || []).includes('commands.register');
$: statusColor = ({
loaded: '#4ecca3',
@@ -160,6 +163,20 @@
{#if p.error}
<div class="error-box">{p.error}</div>
{/if}
<!-- Actions -->
<div class="card-actions">
{#if hasSettingsPanel}
<button class="btn-settings" on:click={() => onSettings(m.id)} type="button">
⚙ Settings
</button>
{/if}
</div>
<!-- Permission warnings -->
{#if !hasUIPermission && (m.contributes && (m.contributes.views || m.contributes.sidebarItems || m.contributes.settingsPanels).length > 0)}
<p class="warning">⚠ Plugin has UI contributions but lacks ui.register permission</p>
{/if}
</div>
<style>
@@ -327,4 +344,26 @@
color: #e94560;
font-family: monospace;
}
.card-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid #0f3460;
}
.btn-settings {
background: #0f3460;
border: 1px solid #1a3a5c;
color: #e0e0f0;
padding: 0.3rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.btn-settings:hover {
background: #1a3a5c;
}
</style>
@@ -10,6 +10,9 @@
let loading = true;
let error = '';
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let settingsPanel = null;
let settingsData = {};
let settingsPluginId = '';
async function loadAll() {
error = '';
@@ -49,6 +52,34 @@
$: totalPlugins = plugins.length;
$: 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
try {
const data = JSON.parse(localStorage.getItem('verstak-settings-' + pluginId) || '{}');
settingsData = data;
} catch (e) {
settingsData = {};
}
}
}
function saveSettings() {
try {
localStorage.setItem('verstak-settings-' + settingsPluginId, JSON.stringify(settingsData));
// Also try Wails backend
const { WritePluginSettings } = require('../../../wailsjs/go/api/App');
WritePluginSettings(settingsPluginId, settingsData).then(err => {
if (err) console.error('WritePluginSettings:', err);
}).catch(() => {});
} catch (e) {
console.error('saveSettings:', e);
}
}
</script>
<div class="plugin-manager">
@@ -98,7 +129,7 @@
{:else}
<div class="plugin-list">
{#each plugins as p}
<PluginCard {p} {capabilities} {permissions} {contributions} />
<PluginCard {p} {capabilities} {permissions} {contributions} onSettings={openSettings} />
{/each}
</div>
{/if}
@@ -123,11 +154,47 @@
</table>
</details>
{/if}
{/if}
</div>
{/if}
<style>
.plugin-manager { max-width: 900px; }
<!-- Settings Panel Modal -->
{#if settingsPanel}
<div class="modal-overlay" on:click|self={() => settingsPanel = null}>
<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>
</div>
<div class="modal-body">
<p class="settings-hint">Plugin: <code>{settingsPluginId}</code></p>
<p class="settings-hint">Component: <code>{settingsPanel.item.component}</code></p>
{#if settingsPanel.item.id === 'verstak.platform-test.settings'}
<div class="settings-form">
<h4>Test Settings</h4>
<div class="form-row">
<label for="test-name">Test Name</label>
<input id="test-name" type="text" bind:value={settingsData.testName} placeholder="Enter test name" />
</div>
<div class="form-row">
<label for="test-interval">Test Interval (seconds)</label>
<input id="test-interval" type="number" bind:value={settingsData.testInterval} min="1" max="300" />
</div>
<div class="form-row">
<label><input type="checkbox" bind:checked={settingsData.autoRun} /> Auto-run on startup</label>
</div>
<button class="btn-save" on:click={() => saveSettings()} type="button">Save Settings</button>
</div>
{:else}
<p class="placeholder">Settings component: {settingsPanel.item.component}</p>
{/if}
</div>
</div>
</div>
{/if}
</div>
<style>
.plugin-manager { max-width: 900px; }
header {
display: flex;
align-items: center;
@@ -235,4 +302,86 @@
color: #a0a0b8;
border: 1px solid #533483;
}
/* ── Modal ── */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
}
.modal {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 8px;
width: 480px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid #0f3460;
}
.modal-header h3 { margin: 0; color: #e0e0f0; font-size: 1.1rem; }
.modal-close {
background: none; border: none; color: #a0a0b8;
font-size: 1.2rem; cursor: pointer; padding: 0.2rem 0.5rem;
}
.modal-close:hover { color: #e94560; }
.modal-body { padding: 1rem; overflow-y: auto; }
.settings-hint { color: #666; font-size: 0.8rem; margin: 0.25rem 0; }
.settings-hint code { color: #4ecca3; }
/* ── Settings Form ── */
.settings-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.settings-form h4 {
margin: 0 0 0.5rem 0;
color: #e0e0f0;
font-size: 1rem;
}
.form-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-row label {
color: #a0a0b8;
font-size: 0.85rem;
}
.form-row input[type="text"],
.form-row input[type="number"] {
background: #0f3460;
border: 1px solid #1a3a5c;
color: #e0e0f0;
padding: 0.4rem 0.6rem;
border-radius: 4px;
font-size: 0.9rem;
}
.form-row input:focus {
outline: none;
border-color: #4ecca3;
}
.btn-save {
background: #4ecca3;
color: #1a1a2e;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
margin-top: 0.5rem;
}
.btn-save:hover {
background: #3dbb92;
}
</style>
+75
View File
@@ -0,0 +1,75 @@
<script>
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
let sidebarItems = [];
let activeView = '';
let plugins = [];
let contributions = { sidebarItems: [], views: [], commands: [], settingsPanels: [] };
onMount(async () => {
try {
const [contribs, pluginList] = await Promise.all([
App.GetContributions(),
App.GetPlugins(),
]);
contributions = contribs;
plugins = pluginList;
const pluginMap = new Map(pluginList.map(p => [p.manifest.id, p]));
sidebarItems = (contribs.sidebarItems || []).filter(item => {
const plugin = pluginMap.get(item.pluginId);
return plugin && plugin.manifest.permissions.includes('ui.register');
});
} catch (e) {
console.error('[Sidebar] load error:', e);
}
});
function openView(viewId) {
activeView = viewId;
window.dispatchEvent(new CustomEvent('verstak:open-view', { detail: { viewId } }));
}
</script>
<nav class="sidebar">
{#each sidebarItems as item}
<button
class="sidebar-item"
class:active={activeView === item.item.view}
on:click={() => openView(item.item.view)}
type="button"
>
{#if item.item.icon}<span class="icon">{item.item.icon}</span>{/if}
<span class="label">{item.item.title}</span>
</button>
{/each}
</nav>
<style>
.sidebar {
width: 200px;
background: #16213e;
border-right: 1px solid #0f3460;
display: flex;
flex-direction: column;
padding: 0.5rem 0;
overflow-y: auto;
}
.sidebar-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: none;
border: none;
color: #a0a0b0;
cursor: pointer;
text-align: left;
font-size: 0.9rem;
width: 100%;
}
.sidebar-item:hover { background: #0f3460; color: #e0e0f0; }
.sidebar-item.active { background: #0f3460; color: #4ecca3; }
.icon { font-size: 1.1rem; }
.label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>
+137
View File
@@ -0,0 +1,137 @@
<script>
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
let views = [];
let activeView = '';
let pluginStates = {};
let plugins = [];
onMount(async () => {
try {
const [contribs, pluginList] = await Promise.all([
App.GetContributions(),
App.GetPlugins(),
]);
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';
}
</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'}>
<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}
</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>
</div>
</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>
<p class="sub">Plugin views will appear here</p>
</div>
{/if}
</div>
<style>
.view-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #1a1a2e;
}
.view {
flex: 1;
display: flex;
flex-direction: column;
padding: 1.5rem;
}
.view.degraded {
border-left: 3px solid #ffc857;
}
.view-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #16213e;
}
.view-header h2 {
margin: 0;
font-size: 1.2rem;
color: #e0e0f0;
flex: 1;
}
.view-icon { font-size: 1.3rem; }
.view-content {
flex: 1;
overflow: auto;
}
.plugin-view-host {
width: 100%;
min-height: 200px;
}
.placeholder {
color: #666;
font-style: italic;
padding: 2rem;
text-align: center;
border: 1px dashed #333;
border-radius: 8px;
}
.placeholder strong { color: #4ecca3; }
.placeholder .sub { font-size: 0.85rem; color: #555; }
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #555;
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>