feat(m4b): add vault selection UI, enable/disable toggle, missing-installed UI

- Add VaultSelection.svelte: first-run vault create/open/recent UI
- Update App.svelte: vault check on startup, show VaultSelection when needed
- Update PluginCard.svelte: enable/disable buttons, vault state awareness
- Update PluginManager.svelte: enable/disable handlers, missing-installed section
- Add SetCurrentVault Wails API binding
- Add RecordDesiredPlugin Wails API binding
- Record desired plugins on discovery (only when vault open)
- Fix addRecent: remove duplicate sort, clean up unused import
- Update smoke-platform.sh: enable/disable lifecycle test
- Add runEnableDisableTest: vault create/open, disable/enable, plugins.json verify
This commit is contained in:
2026-06-17 04:19:13 +08:00
parent c8d2560bb2
commit a6f9e85f13
9 changed files with 829 additions and 77 deletions
+63 -10
View File
@@ -2,23 +2,75 @@
import PluginManager from './lib/plugin-manager/PluginManager.svelte';
import Sidebar from './lib/shell/Sidebar.svelte';
import ViewContainer from './lib/shell/ViewContainer.svelte';
import VaultSelection from './lib/shell/VaultSelection.svelte';
import * as App from '../wailsjs/go/api/App';
let currentView = 'plugin-manager';
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let needsVaultSelection = false;
let loading = true;
async function checkVault() {
loading = true;
try {
const settings = await App.GetAppSettings();
vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' };
if (!settings.currentVaultPath || vaultStatus.status !== 'open') {
needsVaultSelection = true;
} else {
needsVaultSelection = false;
}
} catch (e) {
console.error('[App] startup check failed:', e);
needsVaultSelection = true;
}
loading = false;
}
function onVaultOpened() {
needsVaultSelection = false;
vaultStatus = { status: 'open', path: '', vaultId: '' };
}
// Listen for vault-opened event from VaultSelection
if (typeof window !== 'undefined') {
window.addEventListener('verstak:vault-opened', onVaultOpened);
}
checkVault();
</script>
<main>
<Sidebar />
{#if loading}
<div class="app-loading">
<p>Loading Verstak...</p>
</div>
{:else if needsVaultSelection}
<VaultSelection />
{:else}
<main>
<Sidebar />
<section class="content">
{#if currentView === 'plugin-manager'}
<PluginManager />
{:else}
<ViewContainer />
{/if}
</section>
</main>
<section class="content">
{#if currentView === 'plugin-manager'}
<PluginManager />
{:else}
<ViewContainer />
{/if}
</section>
</main>
{/if}
<style>
.app-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: #1a1a2e;
color: #a0a0b8;
font-size: 1rem;
}
main {
display: flex;
height: 100vh;
@@ -29,5 +81,6 @@
display: flex;
flex-direction: column;
overflow: hidden;
padding: 1.5rem;
}
</style>
@@ -2,8 +2,14 @@
export let p = {};
export let capabilities = [];
export let permissions = [];
export let contributions = {};
export let vaultOpen = false;
export let onSettings = () => {};
export let onEnable = () => {};
export let onDisable = () => {};
$: m = p.manifest || {};
$: pluginId = m.id || 'unknown';
$: hasSettingsPanel = (contributions.settingsPanels || []).some(sp => sp.pluginId === pluginId);
$: hasUIPermission = (m.permissions || []).includes('ui.register');
$: hasStoragePermission = (m.permissions || []).includes('storage.namespace');
@@ -20,8 +26,6 @@
discovered: '#a0a0b8',
}[p.status] || '#a0a0b8');
$: pluginId = m.id || 'unknown';
$: contribCounts = {
views: (contributions.views || []).filter(v => v.pluginId === pluginId).length,
commands: (contributions.commands || []).filter(c => c.pluginId === pluginId).length,
@@ -54,13 +58,16 @@
$: missingOptional = (m.optionalRequires || []).filter(opt =>
!capabilities.some(c => c.name === opt)
);
$: isDisabled = p.status === 'disabled' || !p.enabled;
$: canToggle = p.status !== 'failed' && p.status !== 'incompatible' && p.status !== 'missing-required-capability' && p.status !== 'discovered';
</script>
<div class="plugin-card" class:disabled={!p.enabled} class:failed={p.status === 'failed'}>
<div class="plugin-card" class:disabled={isDisabled} class:failed={p.status === 'failed'}>
<div class="card-header">
<div class="plugin-id">
<span class="status-dot" style="background: {statusColor}"></span>
<strong>{m.id || 'unknown'}</strong>
<strong>{pluginId}</strong>
<span class="version">v{m.version || '?'}</span>
</div>
<span class="status-badge" style="color: {statusColor}">{p.status}</span>
@@ -171,6 +178,20 @@
⚙ Settings
</button>
{/if}
{#if vaultOpen && canToggle}
{#if isDisabled}
<button class="btn-enable" on:click={() => onEnable(m.id)} type="button">
▶ Enable
</button>
{:else}
<button class="btn-disable" on:click={() => onDisable(m.id)} type="button">
⏸ Disable
</button>
{/if}
{/if}
{#if !vaultOpen && canToggle}
<span class="vault-hint">Open a vault to manage plugin state</span>
{/if}
</div>
<!-- Permission warnings -->
@@ -347,6 +368,7 @@
.card-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.5rem;
@@ -366,4 +388,40 @@
.btn-settings:hover {
background: #1a3a5c;
}
.btn-enable {
background: #4ecca3;
color: #1a1a2e;
border: none;
padding: 0.3rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
}
.btn-enable:hover {
background: #3dbb92;
}
.btn-disable {
background: #533483;
color: #e0e0f0;
border: none;
padding: 0.3rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
}
.btn-disable:hover {
background: #6b44a0;
}
.vault-hint {
color: #666;
font-size: 0.75rem;
font-style: italic;
}
</style>
@@ -1,7 +1,7 @@
<script>
import PluginCard from './PluginCard.svelte';
import { onMount } from 'svelte';
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus } from '../../../wailsjs/go/api/App';
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin } from '../../../wailsjs/go/api/App';
let plugins = [];
let capabilities = [];
@@ -10,10 +10,20 @@
let loading = true;
let error = '';
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let vaultPluginState = { enabledPlugins: [], disabledPlugins: [], desiredPlugins: [] };
let settingsPanel = null;
let settingsData = {};
let settingsPluginId = '';
$: vaultOpen = vaultStatus.status === 'open';
$: missingInstalled = computeMissingInstalled();
function computeMissingInstalled() {
if (!vaultPluginState.desiredPlugins) return [];
const installedIDs = new Set(plugins.map(p => p.manifest?.id).filter(Boolean));
return (vaultPluginState.desiredPlugins || []).filter(dp => !installedIDs.has(dp.id));
}
async function loadAll() {
error = '';
loading = true;
@@ -27,6 +37,10 @@
}
// Vault status — non-critical
GetVaultStatus().then(v => { vaultStatus = v || { status: 'unknown', path: '', vaultId: '' }; }).catch(() => {});
// Vault plugin state
if (vaultStatus.status === 'open') {
GetVaultPluginState().then(s => { vaultPluginState = s || { enabledPlugins: [], disabledPlugins: [], desiredPlugins: [] }; }).catch(() => {});
}
// Capabilities and permissions are non-critical — load async
GetCapabilities().then(c => { capabilities = c || []; }).catch(() => {});
GetPermissions().then(p => { permissions = p || []; }).catch(() => {});
@@ -49,6 +63,24 @@
await loadAll();
}
async function enablePlugin(pluginId) {
const err = await EnablePlugin(pluginId);
if (err) {
error = 'Enable: ' + err;
return;
}
await reload();
}
async function disablePlugin(pluginId) {
const err = await DisablePlugin(pluginId);
if (err) {
error = 'Disable: ' + err;
return;
}
await reload();
}
$: totalPlugins = plugins.length;
$: totalCaps = capabilities.length;
$: totalPerms = permissions.length;
@@ -58,24 +90,22 @@
if (panel) {
settingsPanel = panel;
settingsPluginId = pluginId;
// Load existing settings
try {
const data = JSON.parse(localStorage.getItem('verstak-settings-' + pluginId) || '{}');
settingsData = data;
} catch (e) {
settingsData = {};
}
// Load existing settings from Wails backend
import('../../../wailsjs/go/api/App').then(mod => {
mod.ReadPluginSettings(pluginId).then(data => {
settingsData = data || {};
}).catch(() => { 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(() => {});
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);
}
@@ -112,13 +142,14 @@
<span class="badge">{totalPerms} permissions known</span>
</div>
{#if plugins.length === 0}
{#if plugins.length === 0 && missingInstalled.length === 0}
<div class="empty">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</div> <p>No plugins found</p>
</div>
<p>No plugins found</p>
<p class="hint">Plugin directories scanned:</p>
<ul class="hint-list">
<li><code>~/.config/verstak/plugins/</code> — user plugins</li>
@@ -129,11 +160,38 @@
{:else}
<div class="plugin-list">
{#each plugins as p}
<PluginCard {p} {capabilities} {permissions} {contributions} onSettings={openSettings} />
<PluginCard {p} {capabilities} {permissions} {contributions} {vaultOpen} onSettings={openSettings} onEnable={enablePlugin} onDisable={disablePlugin} />
{/each}
</div>
{/if}
{#if missingInstalled.length > 0}
<div class="missing-section">
<h3>Missing Installed Plugins</h3>
<p class="missing-hint">These plugins are required by this vault but their packages are not installed locally.</p>
<div class="plugin-list">
{#each missingInstalled as mp}
<div class="plugin-card missing-card">
<div class="card-header">
<div class="plugin-id">
<span class="status-dot" style="background: #e94560"></span>
<strong>{mp.id}</strong>
{#if mp.version}<span class="version">v{mp.version}</span>{/if}
</div>
<span class="status-badge" style="color: #e94560">missing</span>
</div>
<p class="missing-text">
This plugin is listed in the vault's desired plugins but the package is not installed.
{#if mp.source && mp.source !== 'unknown'}
<span class="source-hint">Source: {mp.source}</span>
{/if}
</p>
</div>
{/each}
</div>
</div>
{/if}
{#if capabilities.length > 0}
<details class="registry-section">
<summary>Capability Registry ({totalCaps})</summary>
@@ -154,47 +212,49 @@
</table>
</details>
{/if}
{/if}
{/if}
<!-- 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>
<!-- 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>
{#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>
{:else}
<p class="placeholder">Settings component: {settingsPanel.item.component}</p>
{/if}
</div>
</div>
</div>
{/if}
<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; }
<style>
.plugin-manager {
max-width: 900px;
}
header {
display: flex;
align-items: center;
@@ -270,6 +330,37 @@
.hint-list li { margin: 0.25rem 0; }
.hint code { background: #0f3460; padding: 0.1rem 0.3rem; border-radius: 3px; }
.plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; }
/* Missing installed section */
.missing-section {
margin-bottom: 1.5rem;
}
.missing-section h3 {
color: #e94560;
font-size: 1rem;
margin: 0 0 0.25rem;
}
.missing-hint {
color: #a0a0b8;
font-size: 0.8rem;
margin: 0 0 0.75rem;
}
.missing-card {
border-color: #e94560;
opacity: 0.8;
}
.missing-text {
color: #a0a0b8;
font-size: 0.85rem;
margin: 0.5rem 0 0;
}
.source-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
color: #666;
}
.registry-section {
background: #16213e; border: 1px solid #0f3460;
border-radius: 8px; padding: 0.75rem; margin-top: 1rem;
@@ -0,0 +1,309 @@
<script>
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
let appSettings = {};
let recentVaults = [];
let error = '';
let loading = true;
let creating = false;
let opening = false;
let newVaultPath = '';
let openVaultPath = '';
onMount(async () => {
try {
appSettings = await App.GetAppSettings() || {};
recentVaults = appSettings.recentVaults || [];
} catch (e) {
error = 'Failed to load app settings: ' + String(e);
}
loading = false;
});
async function createVault() {
error = '';
if (!newVaultPath.trim()) {
error = 'Please enter a path for the new vault';
return;
}
creating = true;
try {
// Create the vault
const createErr = await App.CreateVault(newVaultPath.trim());
if (createErr) {
error = 'Create vault: ' + createErr;
creating = false;
return;
}
// Open it and save to app settings
const setErr = await App.SetCurrentVault(newVaultPath.trim());
if (setErr) {
error = 'Set current vault: ' + setErr;
creating = false;
return;
}
// Success — dispatch event for app to transition
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) {
error = String(e);
creating = false;
}
}
async function openExistingVault() {
error = '';
if (!openVaultPath.trim()) {
error = 'Please enter a path to an existing vault';
return;
}
opening = true;
try {
const setErr = await App.SetCurrentVault(openVaultPath.trim());
if (setErr) {
error = setErr;
opening = false;
return;
}
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) {
error = String(e);
opening = false;
}
}
async function openRecent(path) {
error = '';
opening = true;
try {
const setErr = await App.SetCurrentVault(path);
if (setErr) {
error = setErr;
opening = false;
return;
}
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) {
error = String(e);
opening = false;
}
}
</script>
<div class="vault-selection">
<div class="vault-selection-inner">
<div class="logo">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#4ecca3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="12" y1="11" x2="12" y2="17"/>
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
<h1>Verstak</h1>
<p class="subtitle">Choose a vault to get started</p>
</div>
{#if error}
<div class="error-box">
<span class="error-icon"></span>
<span class="error-text">{error}</span>
</div>
{/if}
<div class="actions">
<div class="action-card">
<h3>Create New Vault</h3>
<p class="hint">Create a new vault folder. This will be your workspace.</p>
<div class="input-row">
<input
type="text"
bind:value={newVaultPath}
placeholder="~/Documents/MyVault"
disabled={creating}
/>
<button class="btn-primary" on:click={createVault} type="button" disabled={creating}>
{creating ? 'Creating...' : 'Create & Open'}
</button>
</div>
</div>
<div class="action-card">
<h3>Open Existing Vault</h3>
<p class="hint">Open a vault that already exists on this computer.</p>
<div class="input-row">
<input
type="text"
bind:value={openVaultPath}
placeholder="~/Documents/ExistingVault"
disabled={opening}
/>
<button class="btn-primary" on:click={openExistingVault} type="button" disabled={opening}>
{opening ? 'Opening...' : 'Open'}
</button>
</div>
</div>
</div>
{#if recentVaults.length > 0}
<div class="recent-section">
<h3>Recent Vaults</h3>
<ul class="recent-list">
{#each recentVaults as path}
<li>
<button class="recent-item" on:click={() => openRecent(path)} type="button" disabled={opening}>
<span class="recent-icon">📁</span>
<span class="recent-path">{path}</span>
</button>
</li>
{/each}
</ul>
</div>
{/if}
</div>
</div>
<style>
.vault-selection {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: #1a1a2e;
padding: 2rem;
}
.vault-selection-inner {
max-width: 520px;
width: 100%;
}
.logo {
text-align: center;
margin-bottom: 2rem;
}
.logo h1 {
color: #e0e0f0;
font-size: 1.8rem;
margin: 0.5rem 0 0.25rem;
}
.subtitle {
color: #a0a0b8;
font-size: 0.95rem;
margin: 0;
}
.error-box {
background: rgba(233, 69, 96, 0.1);
border: 1px solid #e94560;
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.85rem;
color: #e94560;
}
.error-icon { flex-shrink: 0; }
.error-text { word-break: break-word; }
.actions {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.action-card {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 8px;
padding: 1.25rem;
}
.action-card h3 {
color: #e0e0f0;
font-size: 1rem;
margin: 0 0 0.25rem;
}
.hint {
color: #a0a0b8;
font-size: 0.8rem;
margin: 0 0 0.75rem;
}
.input-row {
display: flex;
gap: 0.5rem;
}
.input-row input {
flex: 1;
background: #0f3460;
border: 1px solid #1a3a5c;
color: #e0e0f0;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.9rem;
}
.input-row input:focus {
outline: none;
border-color: #4ecca3;
}
.input-row input::placeholder {
color: #666;
}
.btn-primary {
background: #4ecca3;
color: #1a1a2e;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
}
.btn-primary:hover:not(:disabled) {
background: #3dbb92;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.recent-section {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 8px;
padding: 1rem 1.25rem;
}
.recent-section h3 {
color: #a0a0b8;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.5rem;
}
.recent-list {
list-style: none;
padding: 0;
margin: 0;
}
.recent-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
background: none;
border: none;
color: #e0e0f0;
padding: 0.4rem 0;
cursor: pointer;
text-align: left;
font-size: 0.85rem;
}
.recent-item:hover {
color: #4ecca3;
}
.recent-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.recent-icon { flex-shrink: 0; }
.recent-path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>