fix(plugin-manager): sync UI state with plugin lifecycle + sidebar click fix

Root cause fixes:
- Sidebar: handleSidebarItem used item.id instead of item.view for viewId.
  Platform Test sidebar item has id=verstak.platform-test.sidebar but
  view=verstak.platform-test.diagnostics. Click now dispatches correct viewId.
- PluginManager: EnablePlugin/DisablePlugin only wrote to plugins.json but
  never re-discovered plugins. UI showed stale state (no Enable button after
  Disable, no Disable after Enable). Now calls ReloadPlugins() + loadAll()
  after each toggle.
- PluginManager: loadAll() fired async loads (GetCapabilities etc) without
  awaiting — loading spinner disappeared before data was ready. Now awaits
  all via Promise.all.
- PluginCard: no loading feedback on Enable/Disable buttons. Added
  actionFeedback prop — buttons show '⟳ Enabling...' / '⟳ Disabling...'
  and are disabled during operation.
- PluginManager: no visible result after Reload/Enable/Disable. Added
  toast notifications (success/error/info) with auto-dismiss.
- Settings: openSettingsFromProps didn't handle missing panel — now shows
  visible error in modal.
This commit is contained in:
mirivlad 2026-06-17 19:40:05 +08:00
parent 6d2f7858eb
commit a100f5a441
3 changed files with 115 additions and 160 deletions

View File

@ -60,8 +60,12 @@
!capabilities.some(c => c.name === opt) !capabilities.some(c => c.name === opt)
); );
export let actionFeedback = {}; // { [pluginId]: 'enabling' | 'disabling' | null }
$: isDisabled = p.status === 'disabled' || !p.enabled; $: isDisabled = p.status === 'disabled' || !p.enabled;
$: canToggle = p.status !== 'failed' && p.status !== 'incompatible' && p.status !== 'missing-required-capability' && p.status !== 'discovered'; $: canToggle = p.status !== 'failed' && p.status !== 'incompatible' && p.status !== 'missing-required-capability' && p.status !== 'discovered';
$: isBusy = actionFeedback[pluginId] != null;
$: busyAction = actionFeedback[pluginId] || null;
</script> </script>
<div class="plugin-card" class:disabled={isDisabled} class:failed={p.status === 'failed'}> <div class="plugin-card" class:disabled={isDisabled} class:failed={p.status === 'failed'}>
@ -181,12 +185,12 @@
{/if} {/if}
{#if vaultOpen && canToggle} {#if vaultOpen && canToggle}
{#if isDisabled} {#if isDisabled}
<button class="btn-enable" on:click={() => onEnable(m.id)} type="button"> <button class="btn-enable" on:click={() => onEnable(m.id)} type="button" disabled={isBusy}>
▶ Enable {#if busyAction === 'enabling'}⟳ Enabling...{:else}▶ Enable{/if}
</button> </button>
{:else} {:else}
<button class="btn-disable" on:click={() => onDisable(m.id)} type="button"> <button class="btn-disable" on:click={() => onDisable(m.id)} type="button" disabled={isBusy}>
⏸ Disable {#if busyAction === 'disabling'}⟳ Disabling...{:else}⏸ Disable{/if}
</button> </button>
{/if} {/if}
{/if} {/if}

View File

@ -20,6 +20,12 @@
let settingsPluginInfo = null; let settingsPluginInfo = null;
let lastOpenedKey = ''; let lastOpenedKey = '';
// Per-action loading state — shows feedback on specific buttons without hiding the whole list
let actionFeedback = {}; // { [pluginId]: 'enabling' | 'disabling' | null }
let reloading = false;
let toastMessage = '';
let toastType = 'success'; // 'success' | 'error' | 'info'
export let activeSettingsPluginId = ''; export let activeSettingsPluginId = '';
export let activeSettingsPanelId = ''; export let activeSettingsPanelId = '';
@ -31,13 +37,20 @@
} }
} }
function showToast(msg, type = 'success') {
toastMessage = msg;
toastType = type;
setTimeout(() => {
toastMessage = '';
}, 4000);
}
async function openSettingsFromProps(pluginId, panelId) { async function openSettingsFromProps(pluginId, panelId) {
const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId && (!panelId || sp.id === panelId)); const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId && (!panelId || sp.id === panelId));
if (panel) { if (panel) {
settingsPanel = panel; settingsPanel = panel;
settingsPluginId = pluginId; settingsPluginId = pluginId;
settingsError = null; settingsError = null;
// Get plugin frontend info
try { try {
const info = await GetPluginFrontendInfo(pluginId); const info = await GetPluginFrontendInfo(pluginId);
settingsPluginInfo = info; settingsPluginInfo = info;
@ -45,6 +58,8 @@
ReadPluginSettings(pluginId).then(data => { ReadPluginSettings(pluginId).then(data => {
settingsData = data || {}; settingsData = data || {};
}).catch(() => { settingsData = {}; }); }).catch(() => { settingsData = {}; });
} else {
settingsError = `Settings panel not found for plugin "${pluginId}". Check that the plugin is enabled and has settingsPanels in its manifest.`;
} }
} }
@ -68,50 +83,79 @@
loading = false; loading = false;
return; return;
} }
// Vault status — non-critical // Collect all async loads but await them so loading stays true until all are done
GetVaultStatus().then(v => { vaultStatus = v || { status: 'unknown', path: '', vaultId: '' }; }).catch(() => {}); try {
// Vault plugin state const [v, caps, perms, contribs] = await Promise.all([
if (vaultStatus.status === 'open') { GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
GetVaultPluginState().then(s => { vaultPluginState = s || { enabledPlugins: [], disabledPlugins: [], desiredPlugins: [] }; }).catch(() => {}); GetCapabilities().catch(() => []),
GetPermissions().catch(() => []),
GetContributions().catch(() => ({})),
]);
vaultStatus = v || { status: 'unknown', path: '', vaultId: '' };
capabilities = caps || [];
permissions = perms || [];
contributions = contribs || {};
} catch (e) {
// Non-critical — log but don't fail
console.error('[PluginManager] non-critical load error:', e);
}
if (vaultStatus.status === 'open') {
try {
vaultPluginState = await GetVaultPluginState() || { enabledPlugins: [], disabledPlugins: [], desiredPlugins: [] };
} catch { /* non-critical */ }
} }
// Capabilities and permissions are non-critical — load async
GetCapabilities().then(c => { capabilities = c || []; }).catch(() => {});
GetPermissions().then(p => { permissions = p || []; }).catch(() => {});
GetContributions().then(c => { contributions = c || {}; }).catch(() => {});
loading = false; loading = false;
} }
onMount(() => { loadAll(); }); onMount(() => { loadAll(); });
async function reload() { async function reload() {
loading = true; reloading = true;
error = ''; error = '';
let resultMsg = '';
try { try {
await ReloadPlugins(); const [count, summary] = await ReloadPlugins();
resultMsg = `Reloaded ${count} plugin(s). ${summary}`;
} catch (e) { } catch (e) {
error = 'Reload: ' + String(e); error = 'Reload: ' + String(e);
loading = false; reloading = false;
return; return;
} }
await loadAll(); await loadAll();
reloading = false;
showToast(resultMsg, 'success');
} }
async function enablePlugin(pluginId) { async function enablePlugin(pluginId) {
actionFeedback = { ...actionFeedback, [pluginId]: 'enabling' };
error = '';
const err = await EnablePlugin(pluginId); const err = await EnablePlugin(pluginId);
if (err) { if (err) {
actionFeedback = { ...actionFeedback, [pluginId]: null };
error = 'Enable: ' + err; error = 'Enable: ' + err;
return; return;
} }
await reload(); // Reload to get updated state
try { await ReloadPlugins(); } catch (e) { /* ignore */ }
await loadAll();
actionFeedback = { ...actionFeedback, [pluginId]: null };
showToast(`Plugin "${pluginId}" enabled`, 'success');
} }
async function disablePlugin(pluginId) { async function disablePlugin(pluginId) {
actionFeedback = { ...actionFeedback, [pluginId]: 'disabling' };
error = '';
const err = await DisablePlugin(pluginId); const err = await DisablePlugin(pluginId);
if (err) { if (err) {
actionFeedback = { ...actionFeedback, [pluginId]: null };
error = 'Disable: ' + err; error = 'Disable: ' + err;
return; return;
} }
await reload(); // Reload to get updated state
try { await ReloadPlugins(); } catch (e) { /* ignore */ }
await loadAll();
actionFeedback = { ...actionFeedback, [pluginId]: null };
showToast(`Plugin "${pluginId}" disabled`, 'info');
} }
$: totalPlugins = plugins.length; $: totalPlugins = plugins.length;
@ -134,6 +178,13 @@
</script> </script>
<div class="plugin-manager"> <div class="plugin-manager">
<!-- Toast notification -->
{#if toastMessage}
<div class="toast" class:toast-success={toastType === 'success'} class:toast-error={toastType === 'error'} class:toast-info={toastType === 'info'}>
{toastMessage}
</div>
{/if}
<header> <header>
<div class="header-left"> <div class="header-left">
<h2>Plugin Manager</h2> <h2>Plugin Manager</h2>
@ -143,8 +194,8 @@
</span> </span>
{/if} {/if}
</div> </div>
<button class="reload-btn" on:click={reload} type="button" disabled={loading}> <button class="reload-btn" on:click={reload} type="button" disabled={loading || reloading}>
{loading ? '⟳ Loading...' : '⟳ Reload'} {reloading ? '⟳ Reloading...' : '⟳ Reload'}
</button> </button>
</header> </header>
@ -181,7 +232,7 @@
{:else} {:else}
<div class="plugin-list"> <div class="plugin-list">
{#each plugins as p} {#each plugins as p}
<PluginCard {p} {capabilities} {permissions} {contributions} {vaultOpen} settingsPanels={(contributions.settingsPanels || []).filter(sp => sp.pluginId === p.manifest?.id)} onEnable={enablePlugin} onDisable={disablePlugin} /> <PluginCard {p} {capabilities} {permissions} {contributions} {vaultOpen} {actionFeedback} settingsPanels={(contributions.settingsPanels || []).filter(sp => sp.pluginId === p.manifest?.id)} onEnable={enablePlugin} onDisable={disablePlugin} />
{/each} {/each}
</div> </div>
{/if} {/if}
@ -278,6 +329,7 @@
.plugin-manager { .plugin-manager {
max-width: 900px; max-width: 900px;
padding-top: 0.5rem; padding-top: 0.5rem;
position: relative;
} }
header { header {
display: flex; display: flex;
@ -300,32 +352,29 @@
font-weight: 600; font-weight: 600;
border: 1px solid; border: 1px solid;
} }
.vault-open { .vault-open { background: rgba(78, 204, 163, 0.15); color: #4ecca3; border-color: #4ecca3; }
background: rgba(78, 204, 163, 0.15); .vault-not-created { background: rgba(255, 200, 87, 0.15); color: #ffc857; border-color: #ffc857; }
color: #4ecca3; .vault-closed { background: rgba(160, 160, 184, 0.15); color: #a0a0b8; border-color: #a0a0b8; }
border-color: #4ecca3; .vault-error { background: rgba(233, 69, 96, 0.15); color: #e94560; border-color: #e94560; }
}
.vault-not-created {
background: rgba(255, 200, 87, 0.15);
color: #ffc857;
border-color: #ffc857;
}
.vault-closed {
background: rgba(160, 160, 184, 0.15);
color: #a0a0b8;
border-color: #a0a0b8;
}
.vault-error {
background: rgba(233, 69, 96, 0.15);
color: #e94560;
border-color: #e94560;
}
.reload-btn { .reload-btn {
background: #0f3460; color: #e0e0e0; border: 1px solid #533483; background: #0f3460; color: #e0e0e0; border: 1px solid #533483;
padding: 0.4rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; padding: 0.4rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem;
} }
.reload-btn:hover:not(:disabled) { background: #533483; } .reload-btn:hover:not(:disabled) { background: #533483; }
.reload-btn:disabled { opacity: 0.5; cursor: not-allowed; } .reload-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Toast */
.toast {
position: fixed; top: 1rem; right: 1rem; z-index: 2000;
padding: 0.6rem 1.2rem; border-radius: 6px; font-size: 0.85rem;
max-width: 400px; word-break: break-word;
animation: toastIn 0.25s ease-out;
}
.toast-success { background: #1a3a2e; color: #4ecca3; border: 1px solid #4ecca3; }
.toast-error { background: #3a1a1a; color: #e94560; border: 1px solid #e94560; }
.toast-info { background: #1a1a3a; color: #a78bfa; border: 1px solid #a78bfa; }
@keyframes toastIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
.loading, .error { .loading, .error {
padding: 2rem; text-align: center; color: #a0a0b8; padding: 2rem; text-align: center; color: #a0a0b8;
} }
@ -357,70 +406,30 @@
.hint code { background: #0f3460; padding: 0.1rem 0.3rem; border-radius: 3px; } .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; } .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 { .missing-section h3 { color: #e94560; font-size: 1rem; margin: 0 0 0.25rem; }
margin-bottom: 1.5rem; .missing-hint { color: #a0a0b8; font-size: 0.8rem; margin: 0 0 0.75rem; }
} .missing-card { border-color: #e94560; opacity: 0.8; }
.missing-section h3 { .missing-text { color: #a0a0b8; font-size: 0.85rem; margin: 0.5rem 0 0; }
color: #e94560; .source-hint { display: block; margin-top: 0.25rem; font-size: 0.75rem; color: #666; }
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 { .registry-section {
background: #16213e; border: 1px solid #0f3460; background: #16213e; border: 1px solid #0f3460;
border-radius: 8px; padding: 0.75rem; margin-top: 1rem; border-radius: 8px; padding: 0.75rem; margin-top: 1rem;
} }
.registry-section summary { .registry-section summary { cursor: pointer; color: #a0a0b8; font-size: 0.9rem; font-weight: 600; }
cursor: pointer; color: #a0a0b8; font-size: 0.9rem; font-weight: 600;
}
table { width: 100%; margin-top: 0.5rem; border-collapse: collapse; font-size: 0.85rem; } table { width: 100%; margin-top: 0.5rem; border-collapse: collapse; font-size: 0.85rem; }
th { th { text-align: left; padding: 0.4rem 0.5rem; color: #a0a0b8; border-bottom: 1px solid #0f3460; }
text-align: left; padding: 0.4rem 0.5rem; color: #a0a0b8; border-bottom: 1px solid #0f3460;
}
td { padding: 0.3rem 0.5rem; border-bottom: 1px solid #0f3460; } td { padding: 0.3rem 0.5rem; border-bottom: 1px solid #0f3460; }
td code { color: #e0e0e0; } td code { color: #e0e0e0; }
:global(.status-stable) { color: #4ecca3; } :global(.status-stable) { color: #4ecca3; }
:global(.status-draft) { color: #ffc857; } :global(.status-draft) { color: #ffc857; }
:global(.status-deprecated) { color: #e94560; } :global(.status-deprecated) { color: #e94560; }
.source-badge { .source-badge { font-size: 0.75rem; padding: 0.1rem 0.4rem; border-radius: 4px; font-weight: 600; }
font-size: 0.75rem; .source-core { background: #1a3a5c; color: #4ecca3; border: 1px solid #4ecca3; }
padding: 0.1rem 0.4rem; .source-plugin { background: #0f3460; color: #a0a0b8; border: 1px solid #533483; }
border-radius: 4px;
font-weight: 600;
}
.source-core {
background: #1a3a5c;
color: #4ecca3;
border: 1px solid #4ecca3;
}
.source-plugin {
background: #0f3460;
color: #a0a0b8;
border: 1px solid #533483;
}
/* ── Modal ── */ /* Modal */
.modal-overlay { .modal-overlay {
position: fixed; inset: 0; position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
@ -428,77 +437,17 @@
z-index: 1000; z-index: 1000;
} }
.modal { .modal {
background: #16213e; background: #16213e; border: 1px solid #0f3460; border-radius: 8px;
border: 1px solid #0f3460; width: 480px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column;
border-radius: 8px;
width: 480px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
} }
.modal-header { .modal-header {
display: flex; display: flex; align-items: center; justify-content: space-between;
align-items: center; padding: 1rem; border-bottom: 1px solid #0f3460;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid #0f3460;
} }
.modal-header h3 { margin: 0; color: #e0e0f0; font-size: 1.1rem; } .modal-header h3 { margin: 0; color: #e0e0f0; font-size: 1.1rem; }
.modal-close { .modal-close { background: none; border: none; color: #a0a0b8; font-size: 1.2rem; cursor: pointer; padding: 0.2rem 0.5rem; }
background: none; border: none; color: #a0a0b8;
font-size: 1.2rem; cursor: pointer; padding: 0.2rem 0.5rem;
}
.modal-close:hover { color: #e94560; } .modal-close:hover { color: #e94560; }
.modal-body { padding: 1rem; overflow-y: auto; } .modal-body { padding: 1rem; overflow-y: auto; }
.settings-hint { color: #666; font-size: 0.8rem; margin: 0.25rem 0; } .settings-hint { color: #666; font-size: 0.8rem; margin: 0.25rem 0; }
.settings-hint code { color: #4ecca3; } .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> </style>

View File

@ -45,7 +45,9 @@
} }
function handleSidebarItem(item) { function handleSidebarItem(item) {
window.dispatchEvent(new CustomEvent('verstak:open-view', { detail: { viewId: item.id, pluginId: item.pluginId } })); // Use item.view (the view contribution ID) if available, fall back to item.id
const viewId = item.view || item.id;
window.dispatchEvent(new CustomEvent('verstak:open-view', { detail: { viewId, pluginId: item.pluginId } }));
} }
</script> </script>