From a6f9e85f133e9a958c7a8a867d2c25d70fec5ea6 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Wed, 17 Jun 2026 04:19:13 +0800 Subject: [PATCH] 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 --- cmd/smoke-platform/main.go | 178 ++++++++++ frontend/src/App.svelte | 73 ++++- .../src/lib/plugin-manager/PluginCard.svelte | 66 +++- .../lib/plugin-manager/PluginManager.svelte | 197 ++++++++--- frontend/src/lib/shell/VaultSelection.svelte | 309 ++++++++++++++++++ internal/api/app.go | 51 +++ internal/core/appsettings/manager.go | 11 +- main.go | 11 + scripts/smoke-platform.sh | 10 + 9 files changed, 829 insertions(+), 77 deletions(-) create mode 100644 frontend/src/lib/shell/VaultSelection.svelte diff --git a/cmd/smoke-platform/main.go b/cmd/smoke-platform/main.go index f2fb171..f10230b 100644 --- a/cmd/smoke-platform/main.go +++ b/cmd/smoke-platform/main.go @@ -3,15 +3,20 @@ package main import ( + "flag" "fmt" "os" "path/filepath" "github.com/verstak/verstak-desktop/internal/core/capability" "github.com/verstak/verstak-desktop/internal/core/plugin" + "github.com/verstak/verstak-desktop/internal/core/pluginstate" + "github.com/verstak/verstak-desktop/internal/core/vault" ) func main() { + testEnableDisable := flag.Bool("test-enable-disable", false, "Test enable/disable lifecycle") + flag.Parse() exitCode := 0 defer func() { os.Exit(exitCode) @@ -20,6 +25,11 @@ func main() { root, _ := os.Getwd() pluginDir := filepath.Join(root, "plugins") + if *testEnableDisable { + runEnableDisableTest(root) + return + } + fmt.Printf("=== smoke-platform: headless plugin verification ===\n\n") fmt.Printf(" plugin dir: %s\n", pluginDir) @@ -256,3 +266,171 @@ func main() { exitCode = 1 } } + +// runEnableDisableTest tests the enable/disable lifecycle with vault plugin state. +func runEnableDisableTest(root string) { + exitCode := 0 + defer func() { + os.Exit(exitCode) + }() + + fmt.Printf("=== smoke-platform: enable/disable 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) + + // Initialize vault + 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") + + // Open the vault at the path returned by CreateVault (path/VerstakVault) + 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\n") + + // 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) + + // ── Test 1: Disable platform-test ── + fmt.Printf("\n[disable]\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") + + if !psm.IsDisabled("verstak.platform-test") { + fmt.Printf(" ❌ IsDisabled returned false after disable\n") + exitCode = 1 + return + } + fmt.Printf(" ✅ IsDisabled: true\n") + + if psm.IsEnabled("verstak.platform-test") { + fmt.Printf(" ❌ IsEnabled returned true after disable\n") + exitCode = 1 + return + } + fmt.Printf(" ✅ IsEnabled: false\n") + + // Check plugins.json + state := psm.Get() + found := false + for _, dp := range state.DesiredPlugins { + if dp.ID == "verstak.platform-test" { + found = true + break + } + } + if found { + fmt.Printf(" ✅ platform-test in desiredPlugins\n") + } else { + fmt.Printf(" ℹ️ platform-test not in desiredPlugins (ok if not recorded)\n") + } + + // ── Test 2: Enable platform-test ── + fmt.Printf("\n[enable]\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") + + if !psm.IsEnabled("verstak.platform-test") { + fmt.Printf(" ❌ IsEnabled returned false after enable\n") + exitCode = 1 + return + } + fmt.Printf(" ✅ IsEnabled: true\n") + + if psm.IsDisabled("verstak.platform-test") { + fmt.Printf(" ❌ IsDisabled returned true after enable\n") + exitCode = 1 + return + } + fmt.Printf(" ✅ IsDisabled: false\n") + + // ── Test 3: Verify plugins.json on disk ── + fmt.Printf("\n[plugins.json verification]\n") + statePath := filepath.Join(v.GetVaultPath(), ".verstak", "plugins.json") + data, err := os.ReadFile(statePath) + if err != nil { + fmt.Printf(" ❌ read plugins.json: %v\n", err) + exitCode = 1 + return + } + fmt.Printf(" ✅ plugins.json exists on disk\n") + fmt.Printf(" content:\n%s\n", string(data)) + + // ── Summary ── + fmt.Printf("\n=== summary ===\n") + fmt.Printf("✅ enable/disable test passed\n") +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index cda3144..dc0ba03 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -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(); -
- +{#if loading} +
+

Loading Verstak...

+
+{:else if needsVaultSelection} + +{:else} +
+ -
- {#if currentView === 'plugin-manager'} - - {:else} - - {/if} -
-
+
+ {#if currentView === 'plugin-manager'} + + {:else} + + {/if} +
+
+{/if} diff --git a/frontend/src/lib/plugin-manager/PluginCard.svelte b/frontend/src/lib/plugin-manager/PluginCard.svelte index 45c7ab0..8b412ee 100644 --- a/frontend/src/lib/plugin-manager/PluginCard.svelte +++ b/frontend/src/lib/plugin-manager/PluginCard.svelte @@ -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'; -
+
- {m.id || 'unknown'} + {pluginId} v{m.version || '?'}
{p.status} @@ -171,6 +178,20 @@ ⚙ Settings {/if} + {#if vaultOpen && canToggle} + {#if isDisabled} + + {:else} + + {/if} + {/if} + {#if !vaultOpen && canToggle} + Open a vault to manage plugin state + {/if}
@@ -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; + } diff --git a/frontend/src/lib/plugin-manager/PluginManager.svelte b/frontend/src/lib/plugin-manager/PluginManager.svelte index 7015275..9a4af4c 100644 --- a/frontend/src/lib/plugin-manager/PluginManager.svelte +++ b/frontend/src/lib/plugin-manager/PluginManager.svelte @@ -1,7 +1,7 @@ + +
+
+ + + {#if error} +
+ + {error} +
+ {/if} + +
+
+

Create New Vault

+

Create a new vault folder. This will be your workspace.

+
+ + +
+
+ +
+

Open Existing Vault

+

Open a vault that already exists on this computer.

+
+ + +
+
+
+ + {#if recentVaults.length > 0} +
+

Recent Vaults

+
    + {#each recentVaults as path} +
  • + +
  • + {/each} +
+
+ {/if} +
+
+ + diff --git a/internal/api/app.go b/internal/api/app.go index b8e65ae..3026b76 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -191,6 +191,17 @@ func (a *App) ReloadPlugins() (int, string) { if p.Manifest.Contributes != nil { a.contribRegistry.Register(p.Manifest.ID, p.Manifest.Contributes) } + + // Record as desired plugin in vault state (only if vault is open) + if a.pluginState != nil && a.vault != nil && a.vault.GetVaultStatus() == vault.StatusOpen { + source := p.Manifest.Source + if source == "" { + source = "unknown" + } + if err := a.pluginState.RecordDesiredPlugin(p.Manifest.ID, p.Manifest.Version, source); err != nil { + log.Printf("[plugin] %s: failed to record desired: %v", p.Manifest.ID, err) + } + } } a.plugins = plugins @@ -387,6 +398,35 @@ func (a *App) UpdateAppSettings(patch map[string]interface{}) string { return "" } +// SetCurrentVault sets the current vault path in app settings and re-opens the vault. +func (a *App) SetCurrentVault(path string) string { + if a.appSettings == nil { + return "app settings not initialized" + } + if a.vault == nil { + return "vault service not initialized" + } + // Try to open the vault first + if err := a.vault.OpenVault(path); err != nil { + return fmt.Sprintf("failed to open vault: %v", err) + } + // Save to app settings + if err := a.appSettings.SetCurrentVault(path); err != nil { + return fmt.Sprintf("failed to save app settings: %v", err) + } + // Load plugin state for the vault + if a.pluginState != nil { + if err := a.pluginState.Load(); err != nil { + log.Printf("[api] SetCurrentVault: warning loading plugin state: %v", err) + } + } + // Register vault capability + if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil { + log.Printf("[api] SetCurrentVault: failed to register vault capability: %v", err) + } + return "" +} + // ─── Vault Plugin State API ──────────────────────────────── // GetVaultPluginState returns the current vault plugin state. @@ -426,6 +466,17 @@ func (a *App) DisablePlugin(pluginID string) string { return "" } +// RecordDesiredPlugin records a plugin as desired for this vault. +func (a *App) RecordDesiredPlugin(pluginID, version, source string) string { + if a.pluginState == nil { + return "plugin state not initialized" + } + if err := a.pluginState.RecordDesiredPlugin(pluginID, version, source); err != nil { + return err.Error() + } + return "" +} + // ContributionSummary aggregates all contribution types for the frontend. type ContributionSummary struct { Views []contribution.ContributionView `json:"views"` diff --git a/internal/core/appsettings/manager.go b/internal/core/appsettings/manager.go index 69d4dec..2fb026a 100644 --- a/internal/core/appsettings/manager.go +++ b/internal/core/appsettings/manager.go @@ -8,7 +8,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "sync" "time" ) @@ -237,19 +236,11 @@ func addRecent(list []string, path string, max int) []string { filtered = append(filtered, p) } } - // Prepend + // Prepend (most recent first) result := append([]string{path}, filtered...) // Trim if len(result) > max { result = result[:max] } - sort.SliceStable(result, func(i, j int) bool { - return result[i] < result[j] - }) - // Actually keep insertion order: prepend is correct, just trim - result = append([]string{path}, filtered...) - if len(result) > max { - result = result[:max] - } return result } diff --git a/main.go b/main.go index d8d122c..9ba85bf 100644 --- a/main.go +++ b/main.go @@ -170,6 +170,17 @@ func main() { log.Printf("[plugin] %s: contributions registered", p.Manifest.ID) } + // Record as desired plugin in vault state (only if vault is open) + if pluginStateMgr != nil && vaultService.GetVaultStatus() == vault.StatusOpen { + source := p.Manifest.Source + if source == "" { + source = "unknown" + } + if err := pluginStateMgr.RecordDesiredPlugin(p.Manifest.ID, p.Manifest.Version, source); err != nil { + log.Printf("[plugin] %s: failed to record desired: %v", p.Manifest.ID, err) + } + } + log.Printf("[plugin] %s: status=%s", p.Manifest.ID, p.Status) } diff --git a/scripts/smoke-platform.sh b/scripts/smoke-platform.sh index a3719f8..5f7f162 100755 --- a/scripts/smoke-platform.sh +++ b/scripts/smoke-platform.sh @@ -63,5 +63,15 @@ if [ "$SMOKE_EXIT" -ne 0 ]; then exit 1 fi +# ── test enable/disable via Go smoke ── +echo "" +echo "[go smoke: enable/disable]" +(cd "$ROOT" && go run -mod=mod ./cmd/smoke-platform/ -test-enable-disable 2>&1) +SMOKE_ED_EXIT=$? +if [ "$SMOKE_ED_EXIT" -ne 0 ]; then + echo " ❌ smoke-platform: enable/disable test failed" + exit 1 +fi + echo "" echo "✅ smoke-platform done"